1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: use Unleash React SDK in Admin UI (#9723)

In this PR I integrate the Unleash React SDK with the Admin UI. 

We also take advantage of Unleash Hosted Edge behind the scenes with
multiple regions to get the evaluations close to the end user.
This commit is contained in:
Ivar Conradi Østhus 2025-04-10 08:26:30 +02:00 committed by GitHub
parent 09c3f1ab40
commit e63b28c1b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 163 additions and 22 deletions

View File

@ -7,6 +7,7 @@
<meta name="baseUriPath" content="::baseUriPath::" /> <meta name="baseUriPath" content="::baseUriPath::" />
<meta name="cdnPrefix" content="::cdnPrefix::" /> <meta name="cdnPrefix" content="::cdnPrefix::" />
<meta name="uiFlags" content="::uiFlags::" /> <meta name="uiFlags" content="::uiFlags::" />
<meta name="unleashToken" content="::unleashToken::" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="unleash" /> <meta name="description" content="unleash" />
<title>Unleash</title> <title>Unleash</title>

View File

@ -69,6 +69,7 @@
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"@uiw/codemirror-theme-duotone": "4.23.10", "@uiw/codemirror-theme-duotone": "4.23.10",
"@uiw/react-codemirror": "4.23.10", "@uiw/react-codemirror": "4.23.10",
"@unleash/proxy-client-react": "^5.0.0",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"cartesian": "^1.0.1", "cartesian": "^1.0.1",
"chart.js": "3.9.1", "chart.js": "3.9.1",
@ -118,6 +119,7 @@
"swr": "2.3.3", "swr": "2.3.3",
"tss-react": "4.9.15", "tss-react": "4.9.15",
"typescript": "5.4.5", "typescript": "5.4.5",
"unleash-proxy-client": "^3.7.3",
"use-query-params": "^2.2.1", "use-query-params": "^2.2.1",
"vanilla-jsoneditor": "^0.23.0", "vanilla-jsoneditor": "^0.23.0",
"vite": "5.4.16", "vite": "5.4.16",

View File

@ -28,8 +28,9 @@ import { ReactComponent as CelebatoryUnleashLogo } from 'assets/img/unleashHolid
import { ReactComponent as CelebatoryUnleashLogoWhite } from 'assets/img/unleashHolidayDark.svg'; import { ReactComponent as CelebatoryUnleashLogoWhite } from 'assets/img/unleashHolidayDark.svg';
import { ReactComponent as LogoOnlyWhite } from 'assets/img/logo.svg'; import { ReactComponent as LogoOnlyWhite } from 'assets/img/logo.svg';
import { ReactComponent as LogoOnly } from 'assets/img/logoDark.svg'; import { ReactComponent as LogoOnly } from 'assets/img/logoDark.svg';
import { useUiFlag } from 'hooks/useUiFlag';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useFlag } from '@unleash/proxy-client-react';
import { useUiFlag } from 'hooks/useUiFlag';
export const MobileNavigationSidebar: FC<{ export const MobileNavigationSidebar: FC<{
onClick: () => void; onClick: () => void;
@ -110,7 +111,7 @@ export const NavigationSidebar: FC<{ NewInUnleash?: typeof NewInUnleash }> = ({
NewInUnleash, NewInUnleash,
}) => { }) => {
const { routes } = useRoutes(); const { routes } = useRoutes();
const celebatoryUnleash = useUiFlag('celebrateUnleash'); const celebrateUnleashFrontend = useFlag('celebrateUnleashFrontend');
const [mode, setMode] = useNavigationMode(); const [mode, setMode] = useNavigationMode();
const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>(); const [expanded, changeExpanded] = useExpanded<'configure' | 'admin'>();
@ -138,7 +139,7 @@ export const NavigationSidebar: FC<{ NewInUnleash?: typeof NewInUnleash }> = ({
<ThemeMode <ThemeMode
darkmode={ darkmode={
<ConditionallyRender <ConditionallyRender
condition={celebatoryUnleash} condition={celebrateUnleashFrontend}
show={<CelebatoryUnleashLogoWhite />} show={<CelebatoryUnleashLogoWhite />}
elseShow={ elseShow={
<StyledUnleashLogoWhite aria-label='Unleash logo' /> <StyledUnleashLogoWhite aria-label='Unleash logo' />
@ -147,7 +148,7 @@ export const NavigationSidebar: FC<{ NewInUnleash?: typeof NewInUnleash }> = ({
} }
lightmode={ lightmode={
<ConditionallyRender <ConditionallyRender
condition={celebatoryUnleash} condition={celebrateUnleashFrontend}
show={<StyledCelebatoryLogo />} show={<StyledCelebatoryLogo />}
elseShow={ elseShow={
<StyledUnleashLogo aria-label='Unleash logo' /> <StyledUnleashLogo aria-label='Unleash logo' />

View File

@ -0,0 +1,56 @@
import type React from 'react';
import { type FC, useEffect } from 'react';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import FlagProvider, { UnleashClient } from '@unleash/proxy-client-react';
const UNLEASH_API = 'https://hosted.edge.getunleash.io/api/frontend';
const DEV_TOKEN = '';
let client: UnleashClient;
let token: string;
let started: boolean = false;
export const UnleashFlagProvider: FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const getUnleashFrontendToken = (): string => {
const el = document.querySelector<HTMLMetaElement>(
'meta[name="unleashToken"]',
);
const content = el?.content ?? '::unleashToken::';
return content === '::unleashToken::' ? DEV_TOKEN : content;
};
// We only want to create a single client.
if (!client) {
token = getUnleashFrontendToken();
client = new UnleashClient({
url: UNLEASH_API,
clientKey: token || 'offline',
refreshInterval: 1,
appName: 'Unleash Cloud UI',
});
}
const { uiConfig } = useUiConfig();
useEffect(() => {
if (uiConfig.unleashContext && token) {
client.updateContext(uiConfig.unleashContext);
if (!started) {
started = true;
client.start();
}
} else {
// nothing
}
}, [uiConfig.unleashContext]);
return (
<FlagProvider unleashClient={client} startClient={false}>
{children}
</FlagProvider>
);
};

View File

@ -22,6 +22,7 @@ import { Error as LayoutError } from './component/layout/Error/Error';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi'; import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi';
import { HighlightProvider } from 'component/common/Highlight/HighlightProvider'; import { HighlightProvider } from 'component/common/Highlight/HighlightProvider';
import { UnleashFlagProvider } from 'component/providers/UnleashFlagProvider/UnleashFlagProvider';
window.global ||= window; window.global ||= window;
@ -50,23 +51,25 @@ const ApplicationRoot = () => {
<ThemeProvider> <ThemeProvider>
<AnnouncerProvider> <AnnouncerProvider>
<PlausibleProvider> <PlausibleProvider>
<ErrorBoundary <UnleashFlagProvider>
FallbackComponent={LayoutError} <ErrorBoundary
onError={sendErrorToApi} FallbackComponent={LayoutError}
> onError={sendErrorToApi}
<FeedbackProvider> >
<FeedbackCESProvider> <FeedbackProvider>
<StickyProvider> <FeedbackCESProvider>
<HighlightProvider> <StickyProvider>
<InstanceStatus> <HighlightProvider>
<ScrollTop /> <InstanceStatus>
<App /> <ScrollTop />
</InstanceStatus> <App />
</HighlightProvider> </InstanceStatus>
</StickyProvider> </HighlightProvider>
</FeedbackCESProvider> </StickyProvider>
</FeedbackProvider> </FeedbackCESProvider>
</ErrorBoundary> </FeedbackProvider>
</ErrorBoundary>
</UnleashFlagProvider>
</PlausibleProvider> </PlausibleProvider>
</AnnouncerProvider> </AnnouncerProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -1,6 +1,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Variant } from 'utils/variants'; import type { Variant } from 'utils/variants';
import type { ResourceLimitsSchema } from '../openapi'; import type { ResourceLimitsSchema } from '../openapi';
import {} from '@unleash/proxy-client-react/dist/FlagContext';
import type { IMutableContext } from 'unleash-proxy-client';
export interface IUiConfig { export interface IUiConfig {
authenticationType?: string; authenticationType?: string;
@ -34,6 +36,7 @@ export interface IUiConfig {
oidcConfiguredThroughEnv?: boolean; oidcConfiguredThroughEnv?: boolean;
samlConfiguredThroughEnv?: boolean; samlConfiguredThroughEnv?: boolean;
maxSessionsCount?: number; maxSessionsCount?: number;
unleashContext?: IMutableContext;
} }
export interface IProclamationToast { export interface IProclamationToast {

View File

@ -3371,6 +3371,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@unleash/proxy-client-react@npm:^5.0.0":
version: 5.0.0
resolution: "@unleash/proxy-client-react@npm:5.0.0"
peerDependencies:
unleash-proxy-client: ^3.7.3
checksum: 10c0/cf8d227a2db06c5ca09be4be45885306b71733540ace3980ff3794bfaa22c6b38a15446e46ca8880478c018d8ecd0f14e14cb203a6c3ba9b901853db0a9890ba
languageName: node
linkType: hard
"@vitejs/plugin-react@npm:4.3.4": "@vitejs/plugin-react@npm:4.3.4":
version: 4.3.4 version: 4.3.4
resolution: "@vitejs/plugin-react@npm:4.3.4" resolution: "@vitejs/plugin-react@npm:4.3.4"
@ -9609,6 +9618,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tiny-emitter@npm:^2.1.0":
version: 2.1.0
resolution: "tiny-emitter@npm:2.1.0"
checksum: 10c0/459c0bd6e636e80909898220eb390e1cba2b15c430b7b06cec6ac29d87acd29ef618b9b32532283af749f5d37af3534d0e3bde29fdf6bcefbf122784333c953d
languageName: node
linkType: hard
"tinybench@npm:^2.9.0": "tinybench@npm:^2.9.0":
version: 2.9.0 version: 2.9.0
resolution: "tinybench@npm:2.9.0" resolution: "tinybench@npm:2.9.0"
@ -10123,6 +10139,7 @@ __metadata:
"@types/uuid": "npm:^9.0.0" "@types/uuid": "npm:^9.0.0"
"@uiw/codemirror-theme-duotone": "npm:4.23.10" "@uiw/codemirror-theme-duotone": "npm:4.23.10"
"@uiw/react-codemirror": "npm:4.23.10" "@uiw/react-codemirror": "npm:4.23.10"
"@unleash/proxy-client-react": "npm:^5.0.0"
"@vitejs/plugin-react": "npm:4.3.4" "@vitejs/plugin-react": "npm:4.3.4"
cartesian: "npm:^1.0.1" cartesian: "npm:^1.0.1"
chart.js: "npm:3.9.1" chart.js: "npm:3.9.1"
@ -10173,6 +10190,7 @@ __metadata:
swr: "npm:2.3.3" swr: "npm:2.3.3"
tss-react: "npm:4.9.15" tss-react: "npm:4.9.15"
typescript: "npm:5.4.5" typescript: "npm:5.4.5"
unleash-proxy-client: "npm:^3.7.3"
use-query-params: "npm:^2.2.1" use-query-params: "npm:^2.2.1"
vanilla-jsoneditor: "npm:^0.23.0" vanilla-jsoneditor: "npm:^0.23.0"
vite: "npm:5.4.16" vite: "npm:5.4.16"
@ -10184,6 +10202,16 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"unleash-proxy-client@npm:^3.7.3":
version: 3.7.3
resolution: "unleash-proxy-client@npm:3.7.3"
dependencies:
tiny-emitter: "npm:^2.1.0"
uuid: "npm:^9.0.1"
checksum: 10c0/3a061d4e3587325046fea0133fe405fef143dbcfdd6ed20c54200b46a22bf49acdccb6dcc0b250400a9ace2350b0065f856731a5712598d27c1e9266a141f559
languageName: node
linkType: hard
"untildify@npm:^4.0.0": "untildify@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "untildify@npm:4.0.0" resolution: "untildify@npm:4.0.0"
@ -10298,6 +10326,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"uuid@npm:^9.0.1":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
bin:
uuid: dist/bin/uuid
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
languageName: node
linkType: hard
"uvu@npm:^0.5.0": "uvu@npm:^0.5.0":
version: 0.5.6 version: 0.5.6
resolution: "uvu@npm:0.5.6" resolution: "uvu@npm:0.5.6"

View File

@ -158,6 +158,7 @@ exports[`should create default config 1`] = `
"ui": { "ui": {
"environment": "Open Source", "environment": "Open Source",
}, },
"unleashFrontendToken": undefined,
"userInactivityThresholdInDays": 180, "userInactivityThresholdInDays": 180,
"versionCheck": { "versionCheck": {
"enable": true, "enable": true,

View File

@ -751,6 +751,9 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
const openAIAPIKey = process.env.OPENAI_API_KEY; const openAIAPIKey = process.env.OPENAI_API_KEY;
const unleashFrontendToken =
options.unleashFrontendToken || process.env.UNLEASH_FRONTEND_TOKEN;
const defaultDaysToBeConsideredInactive = 180; const defaultDaysToBeConsideredInactive = 180;
const userInactivityThresholdInDays = const userInactivityThresholdInDays =
options.userInactivityThresholdInDays ?? options.userInactivityThresholdInDays ??
@ -801,6 +804,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
openAIAPIKey, openAIAPIKey,
userInactivityThresholdInDays, userInactivityThresholdInDays,
buildDate: process.env.BUILD_DATE, buildDate: process.env.BUILD_DATE,
unleashFrontendToken,
}; };
} }

View File

@ -31,6 +31,7 @@ const flagResolver = {
isEnabled: jest.fn(), isEnabled: jest.fn(),
getAll: jest.fn(), getAll: jest.fn(),
getVariant: jest.fn(), getVariant: jest.fn(),
getStaticContext: jest.fn(),
}; };
// Make sure it's always cleaned up // Make sure it's always cleaned up

View File

@ -191,6 +191,11 @@ export const uiConfigSchema = {
description: 'The maximum number of sessions that a user has.', description: 'The maximum number of sessions that a user has.',
example: 10, example: 10,
}, },
unleashContext: {
type: 'object',
description:
'The context object used to configure the Unleash instance.',
},
}, },
components: { components: {
schemas: { schemas: {

View File

@ -174,6 +174,12 @@ class ConfigController extends Controller {
...expFlags, ...expFlags,
}; };
const unleashContext = {
...this.flagResolver.getStaticContext(), //clientId etc.
email: req.user.email,
userId: req.user.id,
};
const response: UiConfigSchema = { const response: UiConfigSchema = {
...this.config.ui, ...this.config.ui,
flags, flags,
@ -192,6 +198,7 @@ class ConfigController extends Controller {
maintenanceMode, maintenanceMode,
feedbackUriPath: this.config.feedbackUriPath, feedbackUriPath: this.config.feedbackUriPath,
maxSessionsCount, maxSessionsCount,
unleashContext: unleashContext,
}; };
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(

View File

@ -352,6 +352,7 @@ export interface IFlagResolver {
getAll: (context?: IFlagContext) => IFlags; getAll: (context?: IFlagContext) => IFlags;
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean; isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant; getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant;
getStaticContext: () => IFlagContext;
} }
export interface IExternalFlagResolver { export interface IExternalFlagResolver {

View File

@ -4,6 +4,7 @@ import type { LogLevel, LogProvider } from '../logger';
import type { ILegacyApiTokenCreate } from './models/api-token'; import type { ILegacyApiTokenCreate } from './models/api-token';
import type { import type {
IExperimentalOptions, IExperimentalOptions,
IFlagContext,
IFlagResolver, IFlagResolver,
IFlags, IFlags,
} from './experimental'; } from './experimental';
@ -165,6 +166,7 @@ export interface IUnleashOptions {
> >
>; >;
userInactivityThresholdInDays?: number; userInactivityThresholdInDays?: number;
unleashFrontendToken?: string;
} }
export interface IEmailOption { export interface IEmailOption {
@ -198,6 +200,8 @@ export interface IUIConfig {
title: string; title: string;
}[]; }[];
flags?: IFlags; flags?: IFlags;
unleashToken?: string;
unleashContext?: IFlagContext;
} }
export interface ICspDomainOptions { export interface ICspDomainOptions {
@ -287,4 +291,5 @@ export interface IUnleashConfig {
openAIAPIKey?: string; openAIAPIKey?: string;
userInactivityThresholdInDays: number; userInactivityThresholdInDays: number;
buildDate?: string; buildDate?: string;
unleashFrontendToken?: string;
} }

View File

@ -61,6 +61,10 @@ export default class FlagResolver implements IFlagResolver {
} }
return this.externalResolver.getVariant(expName, context); return this.externalResolver.getVariant(expName, context);
} }
getStaticContext(): IFlagContext {
return {};
}
} }
export const getVariantValue = <T = string>( export const getVariantValue = <T = string>(

View File

@ -10,6 +10,7 @@ export async function loadIndexHTML(
): Promise<string> { ): Promise<string> {
const { cdnPrefix, baseUriPath = '' } = config.server; const { cdnPrefix, baseUriPath = '' } = config.server;
const uiFlags = encodeURI(JSON.stringify(config.ui.flags || '{}')); const uiFlags = encodeURI(JSON.stringify(config.ui.flags || '{}'));
const unleashToken = config.unleashFrontendToken;
let indexHTML: string; let indexHTML: string;
if (cdnPrefix) { if (cdnPrefix) {
@ -23,5 +24,11 @@ export async function loadIndexHTML(
.toString(); .toString();
} }
return rewriteHTML(indexHTML, baseUriPath, cdnPrefix, uiFlags); return rewriteHTML(
indexHTML,
baseUriPath,
cdnPrefix,
uiFlags,
unleashToken,
);
} }

View File

@ -3,6 +3,7 @@ export const rewriteHTML = (
rewriteValue: string, rewriteValue: string,
cdnPrefix?: string, cdnPrefix?: string,
uiFlags?: string, uiFlags?: string,
unleashToken?: string,
): string => { ): string => {
let result = input; let result = input;
result = result.replace(/::baseUriPath::/gi, rewriteValue); result = result.replace(/::baseUriPath::/gi, rewriteValue);
@ -13,6 +14,8 @@ export const rewriteHTML = (
result = result.replace(/::uiFlags::/gi, uiFlags || '{}'); result = result.replace(/::uiFlags::/gi, uiFlags || '{}');
result = result.replace(/::unleashToken::/gi, unleashToken || '');
result = result.replace( result = result.replace(
/\/static/gi, /\/static/gi,
`${cdnPrefix || rewriteValue}/static`, `${cdnPrefix || rewriteValue}/static`,