diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b5d2417 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +# Exclude demo build files from linting +demo/ +dist-demo/ +vite.config.demo.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 0000000..789f3f1 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,204 @@ +name: Deploy Demo + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, closed] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + pages: write + id-token: write + +concurrency: + group: 'pages-${{ github.event.pull_request.number || github.ref }}' + cancel-in-progress: true + +jobs: + build: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + outputs: + preview_url: ${{ steps.set-url.outputs.url }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Determine base path + id: base-path + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "path=/flagsmith-backstage-plugin/pr-${{ github.event.pull_request.number }}/" >> $GITHUB_OUTPUT + else + echo "path=/flagsmith-backstage-plugin/" >> $GITHUB_OUTPUT + fi + + - name: Build demo + run: yarn build:demo + env: + VITE_BASE_PATH: ${{ steps.base-path.outputs.path }} + + - name: Set preview URL + id: set-url + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "url=https://${{ github.repository_owner }}.github.io/flagsmith-backstage-plugin/pr-${{ github.event.pull_request.number }}/" >> $GITHUB_OUTPUT + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + deploy-main: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload to Pages + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist-demo + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + deploy-pr-preview: + if: github.event_name == 'pull_request' && github.event.action != 'closed' + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + - name: Deploy PR preview + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Move artifact to temp location before switching branches + mv dist-demo /tmp/dist-demo + + # Check if gh-pages branch exists + if git ls-remote --heads origin gh-pages | grep -q gh-pages; then + git fetch origin gh-pages + git checkout gh-pages + else + # Create orphan gh-pages branch if it doesn't exist + git checkout --orphan gh-pages + git rm -rf . + echo "# GitHub Pages" > README.md + git add README.md + git commit -m "Initialize gh-pages branch" + fi + + # Deploy PR preview + PR_DIR="pr-${{ github.event.pull_request.number }}" + rm -rf "$PR_DIR" + mkdir -p "$PR_DIR" + cp -r /tmp/dist-demo/* "$PR_DIR/" + + git add . + git diff --staged --quiet || git commit -m "Deploy PR #${{ github.event.pull_request.number }} preview" + git push origin gh-pages + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const url = '${{ needs.build.outputs.preview_url }}'; + const body = `## Demo Preview + + Preview URL: ${url} + + This preview will be automatically cleaned up when the PR is closed.`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Demo Preview') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + cleanup-pr-preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Remove PR preview + run: | + # Check if gh-pages branch exists + if ! git ls-remote --heads origin gh-pages | grep -q gh-pages; then + echo "gh-pages branch does not exist, nothing to clean up" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin gh-pages + git checkout gh-pages + + PR_DIR="pr-${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + git add . + git diff --staged --quiet || git commit -m "Remove PR #${{ github.event.pull_request.number }} preview" + git push origin gh-pages + fi diff --git a/demo/App.tsx b/demo/App.tsx new file mode 100644 index 0000000..2ab6d30 --- /dev/null +++ b/demo/App.tsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Tabs, + Tab, + ThemeProvider, + CssBaseline, + createTheme, + AppBar, + Toolbar, + Typography, + Container, + CircularProgress, + IconButton, + Tooltip, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Brightness4Icon from '@material-ui/icons/Brightness4'; +import Brightness7Icon from '@material-ui/icons/Brightness7'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { TestApiProvider } from '@backstage/test-utils'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { DemoBanner } from './DemoBanner'; +import { FlagsTab } from '../src/components/FlagsTab'; +import { FlagsmithOverviewCard } from '../src/components/FlagsmithOverviewCard'; +import { FlagsmithUsageCard } from '../src/components/FlagsmithUsageCard'; +import { useConfig, DemoConfig } from './config'; +import { ConfigScreen } from './components'; +import { startMsw, stopMsw } from './utils/mswLoader'; + +// Fetch first available org and project if not provided +const resolveOrgAndProject = async ( + config: DemoConfig, +): Promise<{ projectId: string; orgId: string }> => { + const headers = { Authorization: `Api-Key ${config.apiKey}` }; + + // Get first organisation + let orgId = config.orgId; + if (!orgId) { + const orgsResponse = await fetch( + 'https://api.flagsmith.com/api/v1/organisations/', + { headers }, + ); + if (!orgsResponse.ok) throw new Error('Failed to fetch organisations'); + const orgs = await orgsResponse.json(); + if (!orgs.results?.length) throw new Error('No organisations found'); + orgId = String(orgs.results[0].id); + console.log('[Demo] Using first organisation:', orgId, orgs.results[0].name); + } + + // Get first project + let projectId = config.projectId; + if (!projectId) { + const projectsResponse = await fetch( + `https://api.flagsmith.com/api/v1/organisations/${orgId}/projects/`, + { headers }, + ); + if (!projectsResponse.ok) throw new Error('Failed to fetch projects'); + const projects = await projectsResponse.json(); + if (!projects.length) throw new Error('No projects found'); + projectId = String(projects[0].id); + console.log('[Demo] Using first project:', projectId, projects[0].name); + } + + return { projectId, orgId }; +}; + +const createMockEntity = (config: DemoConfig): Entity => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'demo-service', + description: 'A demo service with Flagsmith feature flags integration', + annotations: { + 'flagsmith.com/project-id': config.projectId || '31465', + 'flagsmith.com/org-id': config.orgId || '24242', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'guests', + }, +}); + +const createDiscoveryApi = (config: DemoConfig) => ({ + getBaseUrl: async (_pluginId: string) => { + if (config.mode === 'mock') { + // Return /api/proxy so FlagsmithClient builds URLs like /api/proxy/flagsmith/... + // which matches the MSW handlers pattern */proxy/flagsmith/... + return `${window.location.origin}/api/proxy`; + } + return 'https://api.flagsmith.com/api/v1'; + }, +}); + +const createFetchApi = (config: DemoConfig) => ({ + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + let finalUrl = url; + + if (config.mode === 'live') { + // FlagsmithClient appends /flagsmith to all URLs (for Backstage proxy routing) + // but in live mode we're hitting the Flagsmith API directly, so strip it + finalUrl = url.replace('/flagsmith/', '/'); + + const headers = new Headers(init?.headers); + if (config.apiKey) { + // Flagsmith API expects Authorization header with Api-Key prefix + headers.set('Authorization', `Api-Key ${config.apiKey}`); + } + console.log('[Demo] Live mode fetch:', finalUrl, 'Headers:', Object.fromEntries(headers.entries())); + return fetch(finalUrl, { ...init, headers }); + } + return fetch(input, init); + }, +}); + +const createAppTheme = (mode: 'light' | 'dark') => + createTheme({ + palette: { + type: mode, + primary: { + main: '#0AC2A3', + }, + secondary: { + main: '#7B51FB', + }, + background: + mode === 'light' + ? { default: '#f5f5f5', paper: '#ffffff' } + : { default: '#121212', paper: '#1e1e1e' }, + }, + typography: { + fontFamily: 'Roboto, sans-serif', + }, + }); + +const lightTheme = createAppTheme('light'); +const darkTheme = createAppTheme('dark'); + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel = ({ children, value, index }: TabPanelProps) => ( + +); + +const useLoadingStyles = makeStyles(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + flexDirection: 'column', + gap: theme.spacing(2), + }, +})); + +interface LoadingScreenProps { + theme: typeof lightTheme; +} + +const LoadingScreen = ({ theme }: LoadingScreenProps) => { + const classes = useLoadingStyles(); + return ( + + + + + + Loading demo... + + + + ); +}; + +interface DemoContentProps { + config: DemoConfig; + onReconfigure: () => void; + isDarkMode: boolean; + onToggleTheme: () => void; +} + +const DemoContent: React.FC = ({ + config, + onReconfigure, + isDarkMode, + onToggleTheme, +}) => { + const [tabValue, setTabValue] = useState(0); + + const mockEntity = createMockEntity(config); + const mockDiscoveryApi = createDiscoveryApi(config); + const mockFetchApi = createFetchApi(config); + + return ( + + + + + + + + + Flagsmith Backstage Plugin Demo + + + + {isDarkMode ? : } + + + + setTabValue(newValue)} + indicatorColor="primary" + textColor="inherit" + style={{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const App = () => { + const { config, isConfigured, setConfig, clearConfig } = useConfig(); + const [mswStarted, setMswStarted] = useState(false); + const [loading, setLoading] = useState(true); + const [resolvedConfig, setResolvedConfig] = useState(null); + const [error, setError] = useState(null); + const [isDarkMode, setIsDarkMode] = useState(false); + + const theme = isDarkMode ? darkTheme : lightTheme; + const toggleTheme = () => setIsDarkMode(prev => !prev); + + useEffect(() => { + let isMounted = true; + + const initializeDemo = async () => { + if (!config) { + setLoading(false); + return; + } + + if (config.mode === 'mock') { + try { + await startMsw(); + if (isMounted) { + setMswStarted(true); + setResolvedConfig(config); + } + } catch (err) { + console.error('Failed to start MSW:', err); + } + } else if (config.mode === 'live') { + // Resolve org and project if not provided + try { + const { projectId, orgId } = await resolveOrgAndProject(config); + if (isMounted) { + setResolvedConfig({ ...config, projectId, orgId }); + } + } catch (err) { + console.error('Failed to resolve org/project:', err); + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to connect to Flagsmith'); + } + } + } + if (isMounted) { + setLoading(false); + } + }; + + initializeDemo(); + + return () => { + isMounted = false; + }; + }, [config]); + + const handleReconfigure = () => { + if (mswStarted) { + stopMsw(); + setMswStarted(false); + } + setResolvedConfig(null); + setError(null); + clearConfig(); + }; + + const handleConfigure = async (newConfig: DemoConfig) => { + setLoading(true); + setError(null); + setConfig(newConfig); + }; + + if (loading) { + return ; + } + + if (!isConfigured || !config) { + return ( + + + + + ); + } + + // Show error if failed to resolve org/project + if (error) { + return ( + + + + + {error} + + + Please check your API Key and try again. + + + + + + + ); + } + + // Wait for resolved config in live mode + if (!resolvedConfig) { + return ; + } + + return ( + + + + + ); +}; diff --git a/demo/DemoBanner.tsx b/demo/DemoBanner.tsx new file mode 100644 index 0000000..d0e634e --- /dev/null +++ b/demo/DemoBanner.tsx @@ -0,0 +1,91 @@ +import { Box, Typography, Link, Button } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import InfoIcon from '@material-ui/icons/Info'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { DemoMode } from './config'; + +const useStyles = makeStyles(() => ({ + banner: { + backgroundColor: '#7B51FB', + color: '#fff', + padding: '12px 24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + flexWrap: 'wrap', + }, + link: { + color: '#fff', + display: 'inline-flex', + alignItems: 'center', + gap: 4, + '&:hover': { + opacity: 0.9, + }, + }, + icon: { + fontSize: 20, + }, + reconfigureButton: { + color: '#fff', + borderColor: 'rgba(255, 255, 255, 0.5)', + marginLeft: 8, + '&:hover': { + borderColor: '#fff', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }, + modeIndicator: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, +})); + +interface DemoBannerProps { + mode: DemoMode; + onReconfigure: () => void; +} + +export const DemoBanner = ({ mode, onReconfigure }: DemoBannerProps) => { + const classes = useStyles(); + + return ( + + + {mode === 'mock' ? ( + + ) : ( + + )} + + {mode === 'mock' + ? 'Using mock data for demonstration' + : 'Connected to your Flagsmith instance'} + + + + + + + + View on GitHub + + + ); +}; diff --git a/demo/components/ConfigScreen.tsx b/demo/components/ConfigScreen.tsx new file mode 100644 index 0000000..7f7d8ab --- /dev/null +++ b/demo/components/ConfigScreen.tsx @@ -0,0 +1,222 @@ +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + Typography, + Button, + TextField, + RadioGroup, + Radio, + FormControlLabel, + FormControl, + FormLabel, + Collapse, + Link, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Alert } from '@material-ui/lab'; +import { DemoMode, DemoConfig } from '../config'; + +const useStyles = makeStyles(theme => ({ + root: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f5f5f5', + padding: theme.spacing(2), + }, + card: { + maxWidth: 520, + width: '100%', + }, + header: { + textAlign: 'center', + marginBottom: theme.spacing(3), + }, + logo: { + width: 48, + height: 48, + marginBottom: theme.spacing(1), + }, + formControl: { + width: '100%', + marginTop: theme.spacing(2), + }, + liveFields: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + }, + actions: { + marginTop: theme.spacing(3), + display: 'flex', + justifyContent: 'flex-end', + }, + alert: { + marginTop: theme.spacing(2), + }, + helpText: { + marginTop: theme.spacing(2), + fontSize: '0.875rem', + color: theme.palette.text.secondary, + }, +})); + +interface ConfigScreenProps { + onConfigure: (config: DemoConfig) => void; +} + +export const ConfigScreen: React.FC = ({ onConfigure }) => { + const classes = useStyles(); + const [mode, setMode] = useState('mock'); + const [apiKey, setApiKey] = useState(''); + const [projectId, setProjectId] = useState(''); + const [orgId, setOrgId] = useState(''); + const [errors, setErrors] = useState>({}); + + const validate = (): boolean => { + if (mode === 'mock') return true; + + const newErrors: Record = {}; + if (!apiKey.trim()) newErrors.apiKey = 'API Key is required'; + // Project ID and Org ID are optional - will use first available if not provided + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (!validate()) return; + + if (mode === 'mock') { + onConfigure({ mode: 'mock' }); + } else { + onConfigure({ + mode: 'live', + apiKey: apiKey.trim(), + projectId: projectId.trim() || undefined, + orgId: orgId.trim() || undefined, + }); + } + }; + + return ( + + + + + + Flagsmith Plugin Demo + + + Configure how you want to explore the Backstage plugin + + + + + Data Source + setMode(e.target.value as DemoMode)} + > + } + label={ + + Use Mock Data + + Recommended for quick exploration with sample feature + flags + + + } + /> + } + label={ + + Connect to Flagsmith + + Use your real Flagsmith data + + + } + /> + + + + + + + Your credentials will be stored in your browser's local + storage. Click RECONFIGURE to change them. + + + setApiKey(e.target.value)} + error={!!errors.apiKey} + helperText={ + errors.apiKey || + 'Your Master API Key (found in Organisation Settings → API Keys)' + } + fullWidth + required + variant="outlined" + size="small" + /> + + setProjectId(e.target.value)} + helperText="Leave empty to use first available project" + fullWidth + variant="outlined" + size="small" + /> + + setOrgId(e.target.value)} + helperText="Leave empty to use first available organization" + fullWidth + variant="outlined" + size="small" + /> + + + + + + + + + Learn more about the{' '} + + Flagsmith Backstage Plugin + + + + + + ); +}; diff --git a/demo/components/index.ts b/demo/components/index.ts new file mode 100644 index 0000000..5769adf --- /dev/null +++ b/demo/components/index.ts @@ -0,0 +1 @@ +export * from './ConfigScreen'; diff --git a/demo/config/ConfigContext.tsx b/demo/config/ConfigContext.tsx new file mode 100644 index 0000000..a5377fd --- /dev/null +++ b/demo/config/ConfigContext.tsx @@ -0,0 +1,60 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from 'react'; +import { ConfigContextValue, DemoConfig } from './types'; +import { + loadConfig, + saveConfig, + clearConfig as clearStoredConfig, +} from './storage'; + +const ConfigContext = createContext(null); + +interface ConfigProviderProps { + children: ReactNode; +} + +export const ConfigProvider: React.FC = ({ children }) => { + const [config, setConfigState] = useState(null); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const stored = loadConfig(); + setConfigState(stored); + setInitialized(true); + }, []); + + const setConfig = (newConfig: DemoConfig) => { + saveConfig(newConfig); + setConfigState(newConfig); + }; + + const clearConfig = () => { + clearStoredConfig(); + setConfigState(null); + }; + + if (!initialized) { + return null; + } + + return ( + + {children} + + ); +}; + +export const useConfig = (): ConfigContextValue => { + const context = useContext(ConfigContext); + if (!context) { + throw new Error('useConfig must be used within ConfigProvider'); + } + return context; +}; diff --git a/demo/config/index.ts b/demo/config/index.ts new file mode 100644 index 0000000..bc1bfc3 --- /dev/null +++ b/demo/config/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './storage'; +export * from './ConfigContext'; diff --git a/demo/config/storage.ts b/demo/config/storage.ts new file mode 100644 index 0000000..fb33ca6 --- /dev/null +++ b/demo/config/storage.ts @@ -0,0 +1,20 @@ +import { DemoConfig } from './types'; + +const STORAGE_KEY = 'flagsmith-backstage-demo-config'; + +export const loadConfig = (): DemoConfig | null => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +export const saveConfig = (config: DemoConfig): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +}; + +export const clearConfig = (): void => { + localStorage.removeItem(STORAGE_KEY); +}; diff --git a/demo/config/types.ts b/demo/config/types.ts new file mode 100644 index 0000000..e077fd4 --- /dev/null +++ b/demo/config/types.ts @@ -0,0 +1,16 @@ +export type DemoMode = 'mock' | 'live'; + +export interface DemoConfig { + mode: DemoMode; + // For live mode - API credentials + apiKey?: string; // Master API Key from Organisation Settings → API Keys + projectId?: string; + orgId?: string; +} + +export interface ConfigContextValue { + config: DemoConfig | null; + setConfig: (config: DemoConfig) => void; + clearConfig: () => void; + isConfigured: boolean; +} diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..99578c4 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,27 @@ + + + + + + Flagsmith Backstage Plugin Demo + + + + +
+ + + diff --git a/demo/main.tsx b/demo/main.tsx new file mode 100644 index 0000000..113a761 --- /dev/null +++ b/demo/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import { ConfigProvider } from './config'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/demo/mockServiceWorker.js b/demo/mockServiceWorker.js new file mode 100644 index 0000000..1f45c4c --- /dev/null +++ b/demo/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.3.5). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/demo/utils/mswLoader.ts b/demo/utils/mswLoader.ts new file mode 100644 index 0000000..ea00e98 --- /dev/null +++ b/demo/utils/mswLoader.ts @@ -0,0 +1,33 @@ +import { setupWorker, SetupWorkerApi } from 'msw'; +import { handlers } from '../../dev/mockHandlers'; + +let worker: SetupWorkerApi | null = null; + +export const startMsw = async (): Promise => { + if (worker) { + console.log('[MSW] Worker already started'); + return; + } + + console.log('[MSW] Setting up worker with handlers:', handlers.length); + worker = setupWorker(...handlers); + + const basePath = import.meta.env.BASE_URL || '/flagsmith-backstage-plugin/'; + const swUrl = `${basePath}mockServiceWorker.js`; + console.log('[MSW] Starting service worker from:', swUrl); + + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: swUrl, + }, + }); + console.log('[MSW] Worker started successfully'); +}; + +export const stopMsw = (): void => { + if (worker) { + worker.stop(); + worker = null; + } +}; diff --git a/package.json b/package.json index 5a5a587..9e10849 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "postpack": "backstage-cli package postpack", "tsc": "tsc || test -f dist-types/src/index.d.ts", "build:all": "yarn tsc && backstage-cli package build", + "build:demo": "vite build --config vite.config.demo.ts && cp public/mockServiceWorker.js dist-demo/", + "preview:demo": "vite preview --config vite.config.demo.ts", "prepare": "husky" }, "lint-staged": { @@ -44,6 +46,7 @@ "@backstage/plugin-catalog-react": "^1.13.3", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", + "flagsmith": "^10.0.0", "recharts": "^2.5.0" }, "peerDependencies": { @@ -58,12 +61,14 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", + "@vitejs/plugin-react": "^4.3.4", "husky": "^9.1.7", "lint-staged": "^16.2.7", "msw": "^1.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-router-dom": "^6.0.0" + "react-router-dom": "^6.0.0", + "vite": "^6.0.5" }, "files": [ "dist" diff --git a/src/components/FlagsTab/SegmentOverridesSection.tsx b/src/components/FlagsTab/SegmentOverridesSection.tsx new file mode 100644 index 0000000..cd95262 --- /dev/null +++ b/src/components/FlagsTab/SegmentOverridesSection.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { Box, Chip, Collapse, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import { + FlagsmithFeature, + FlagsmithFeatureDetails, +} from '../../api/FlagsmithClient'; +import { FlagStatusIndicator } from '../shared'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + showMoreButton: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + color: theme.palette.primary.main, + fontSize: '0.875rem', + marginTop: theme.spacing(1), + '&:hover': { + textDecoration: 'underline', + }, + }, + showMoreContent: { + marginTop: theme.spacing(1.5), + padding: theme.spacing(1.5), + backgroundColor: + theme.palette.type === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + }, + featureStateItem: { + padding: theme.spacing(1), + marginBottom: theme.spacing(0.5), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + }, + segmentBadge: { + backgroundColor: flagsmithColors.warning, + color: 'white', + fontSize: '0.7rem', + height: 20, + marginLeft: theme.spacing(1), + }, +})); + +type LiveVersionInfo = FlagsmithFeature['live_version']; + +interface SegmentOverridesSectionProps { + feature: FlagsmithFeature; + details: FlagsmithFeatureDetails | null; + liveVersion: LiveVersionInfo; +} + +export const SegmentOverridesSection = ({ + feature, + details, + liveVersion, +}: SegmentOverridesSectionProps) => { + const classes = useStyles(); + const [showMoreOpen, setShowMoreOpen] = useState(false); + + return ( + <> + setShowMoreOpen(!showMoreOpen)} + > + {showMoreOpen ? ( + + ) : ( + + )} + + {showMoreOpen ? 'Hide additional details' : 'Show additional details'} + + + + + + + + Published: {liveVersion?.published ? 'Yes' : 'No'} + {liveVersion?.published_by && ` (by ${liveVersion.published_by})`} + + + Archived: {feature.is_archived ? 'Yes' : 'No'} + + + + {details?.featureState && details.featureState.length > 0 && ( + + + Segment Overrides + + {details.featureState + .filter(state => state.feature_segment !== null) + .map((state, index) => ( + + + + + {state.enabled ? 'Enabled' : 'Disabled'} + + {state.feature_segment && ( + + )} + + {state.feature_state_value && ( + + {state.feature_state_value.string_value !== null && + state.feature_state_value.string_value !== + undefined && ( + + Value: "{state.feature_state_value.string_value}" + + )} + {state.feature_state_value.integer_value !== null && + state.feature_state_value.integer_value !== + undefined && ( + + Value: {state.feature_state_value.integer_value} + + )} + {state.feature_state_value.boolean_value !== null && + state.feature_state_value.boolean_value !== + undefined && ( + + Value:{' '} + {String(state.feature_state_value.boolean_value)} + + )} + + )} + + ))} + {details.featureState.filter(s => s.feature_segment !== null) + .length === 0 && ( + + No segment overrides configured. + + )} + + )} + + + + ); +}; diff --git a/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx b/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx new file mode 100644 index 0000000..d147d6b --- /dev/null +++ b/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx @@ -0,0 +1,42 @@ +import { Box, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagStatusIndicator } from '../shared'; + +const useStyles = makeStyles(theme => ({ + statsRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + marginBottom: theme.spacing(1), + fontSize: '0.75rem', + }, + statItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, +})); + +interface FlagStatsRowProps { + enabledCount: number; + disabledCount: number; +} + +export const FlagStatsRow = ({ enabledCount, disabledCount }: FlagStatsRowProps) => { + const classes = useStyles(); + + return ( + + + + + {enabledCount} Enabled + + + + {disabledCount} Disabled + + + + ); +}; diff --git a/vite.config.demo.ts b/vite.config.demo.ts new file mode 100644 index 0000000..26a20fa --- /dev/null +++ b/vite.config.demo.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +const basePath = + process.env.VITE_BASE_PATH || '/flagsmith-backstage-plugin/'; + +export default defineConfig({ + plugins: [react()], + root: 'demo', + base: basePath, + build: { + outDir: '../dist-demo', + emptyOutDir: true, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'msw'], + }, +}); diff --git a/yarn.lock b/yarn.lock index a2be43e..cf273b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -201,7 +201,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9", "@babel/core@^7.28.0": version "7.28.5" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== @@ -430,6 +430,20 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.6", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" @@ -3709,6 +3723,11 @@ resolved "https://registry.yarnpkg.com/@remixicon/react/-/react-4.7.0.tgz#1e79467e3c47d5d1f4a304717936adb6211272ac" integrity sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ== +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + "@rollup/plugin-commonjs@^26.0.0": version "26.0.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.3.tgz#085ffb49818e43e4a2a96816a37affcc8a8cbaca" @@ -4219,7 +4238,7 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/babel__core@^7.1.14": +"@types/babel__core@^7.1.14", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -4870,6 +4889,18 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@vitejs/plugin-react@^4.3.4": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + "@xmldom/xmldom@^0.8.3": version "0.8.11" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" @@ -7665,7 +7696,7 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -fdir@^6.5.0: +fdir@^6.4.4, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -7751,6 +7782,11 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" +flagsmith@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/flagsmith/-/flagsmith-10.0.0.tgz#9dc4ea1c791afc340f7089afba534aad0d592dce" + integrity sha512-JEO4V6nO6ic4ahi5uZRVBYKFil9IuRkLWemKZuFv3QVUCxluQfgymwiuPSs4/LqwFqQ+89SuubaawHxUxUPTzg== + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -7880,7 +7916,7 @@ fscreen@^1.0.2: resolved "https://registry.yarnpkg.com/fscreen/-/fscreen-1.2.0.tgz#1a8c88e06bc16a07b473ad96196fb06d6657f59e" integrity sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -11862,7 +11898,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.1.0, postcss@^8.4.33: +postcss@^8.1.0, postcss@^8.4.33, postcss@^8.5.3: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -12832,7 +12868,7 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@^4.27.3: +rollup@^4.27.3, rollup@^4.34.9: version "4.53.5" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.5.tgz#820f46d435c207fd640256f34a0deadf8e95b118" integrity sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ== @@ -13846,7 +13882,7 @@ tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== -tinyglobby@^0.2.11, tinyglobby@^0.2.15, tinyglobby@^0.2.9: +tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.15, tinyglobby@^0.2.9: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -14452,6 +14488,20 @@ victory-vendor@^36.6.8: d3-time "^3.0.0" d3-timer "^3.0.1" +vite@^6.0.5: + version "6.4.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96" + integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.4" + picomatch "^4.0.2" + postcss "^8.5.3" + rollup "^4.34.9" + tinyglobby "^0.2.13" + optionalDependencies: + fsevents "~2.3.3" + vm-browserify@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"