From 8980604b728b25ed3cbc825f2fbb526cfadfd11f Mon Sep 17 00:00:00 2001 From: Matthias Hempel <7202441+fotopixel@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:24:32 +0200 Subject: [PATCH] feat(dashboard): add fwiz dashboard command with live MF runtime graph Introduce a react-flow federation dashboard on port 5000, MF 2.0 runtime inspector plugin, and CLI command to launch the dev server from fwiz.config.yaml. Co-authored-by: Cursor --- apps/cli/src/commands/dashboard.spec.ts | 84 +++++ apps/cli/src/commands/dashboard.ts | 135 ++++++++ apps/cli/src/program.ts | 2 + apps/dashboard/package.json | 10 +- apps/dashboard/src/app/app.module.css | 68 +++- apps/dashboard/src/app/app.spec.tsx | 87 ++++- apps/dashboard/src/app/app.tsx | 94 +++++- .../src/components/ExportControls.tsx | 73 +++++ .../src/components/FederationGraph.tsx | 78 +++++ .../src/components/FederationNode.module.css | 40 +++ .../src/components/FederationNode.tsx | 28 ++ .../src/components/NodeDetailPanel.tsx | 87 +++++ .../dashboard/src/components/RuntimePanel.tsx | 56 ++++ apps/dashboard/src/components/ThemeToggle.tsx | 12 + apps/dashboard/src/hooks/useFwizConfig.ts | 50 +++ apps/dashboard/src/hooks/useRuntimeData.ts | 58 ++++ apps/dashboard/src/lib/graph.spec.ts | 38 +++ apps/dashboard/src/lib/graph.ts | 111 +++++++ apps/dashboard/src/styles.css | 131 +++++++- apps/dashboard/tsconfig.app.json | 10 +- apps/dashboard/vite.config.mts | 109 ++++++- libs/mf-plugins/src/index.ts | 1 + .../src/lib/runtime-inspector.spec.ts | 121 +++++++ libs/mf-plugins/src/lib/runtime-inspector.ts | 300 ++++++++++++++++++ pnpm-lock.yaml | 218 ++++++++++++- 25 files changed, 1972 insertions(+), 29 deletions(-) create mode 100644 apps/cli/src/commands/dashboard.spec.ts create mode 100644 apps/cli/src/commands/dashboard.ts create mode 100644 apps/dashboard/src/components/ExportControls.tsx create mode 100644 apps/dashboard/src/components/FederationGraph.tsx create mode 100644 apps/dashboard/src/components/FederationNode.module.css create mode 100644 apps/dashboard/src/components/FederationNode.tsx create mode 100644 apps/dashboard/src/components/NodeDetailPanel.tsx create mode 100644 apps/dashboard/src/components/RuntimePanel.tsx create mode 100644 apps/dashboard/src/components/ThemeToggle.tsx create mode 100644 apps/dashboard/src/hooks/useFwizConfig.ts create mode 100644 apps/dashboard/src/hooks/useRuntimeData.ts create mode 100644 apps/dashboard/src/lib/graph.spec.ts create mode 100644 apps/dashboard/src/lib/graph.ts create mode 100644 libs/mf-plugins/src/lib/runtime-inspector.spec.ts create mode 100644 libs/mf-plugins/src/lib/runtime-inspector.ts diff --git a/apps/cli/src/commands/dashboard.spec.ts b/apps/cli/src/commands/dashboard.spec.ts new file mode 100644 index 0000000..dfe630e --- /dev/null +++ b/apps/cli/src/commands/dashboard.spec.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { stringify as stringifyYaml } from 'yaml'; + +import { + registerDashboardCommand, + resolveDashboardRoot, + startDashboardServer, +} from './dashboard.js'; + +jest.mock('node:child_process', () => ({ + spawn: jest.fn(() => ({ + on: jest.fn(), + })), +})); + +describe('fwiz dashboard command', () => { + it('resolveDashboardRoot finds the dashboard app in the monorepo', () => { + expect(resolveDashboardRoot()).toContain('apps/dashboard'); + }); + + it('startDashboardServer spawns vite with config env', () => { + const dir = mkdtempSync(join(tmpdir(), 'fwiz-dashboard-cli-')); + writeFileSync( + join(dir, 'fwiz.config.yaml'), + `${stringifyYaml({ + version: '1', + workspace: { type: 'plain' }, + hosts: [{ name: 'shell', port: 4200 }], + remotes: [{ name: 'checkout', port: 4201 }], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, + })}\n`, + ); + + startDashboardServer({ + cwd: dir, + port: 5000, + dashboardRoot: resolveDashboardRoot(), + }); + + expect(spawn).toHaveBeenCalledWith( + 'pnpm', + expect.arrayContaining(['exec', 'vite', '--port', '5000']), + expect.objectContaining({ + env: expect.objectContaining({ + FWIZ_CONFIG_JSON: expect.stringContaining('"shell"'), + FWIZ_DASHBOARD_URL: 'http://localhost:5000', + }), + }), + ); + }); + + it('registerDashboardCommand adds dashboard subcommand', () => { + const commands: Array<{ name: () => string }> = []; + const program = { + command: jest.fn(() => { + const command = { + description: jest.fn().mockReturnThis(), + summary: jest.fn().mockReturnThis(), + addHelpText: jest.fn().mockReturnThis(), + option: jest.fn().mockReturnThis(), + action: jest.fn().mockReturnThis(), + name: () => 'dashboard', + }; + commands.push(command); + return command; + }), + }; + + registerDashboardCommand(program as never); + + expect(program.command).toHaveBeenCalledWith('dashboard'); + expect(commands[0]?.name()).toBe('dashboard'); + }); +}); diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts new file mode 100644 index 0000000..8ec831b --- /dev/null +++ b/apps/cli/src/commands/dashboard.ts @@ -0,0 +1,135 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { FwizConfigError, loadFwizConfig } from '@federation-wizards/core'; +import type { Command } from 'commander'; + +export function resolveDashboardRoot(): string { + const cliDir = __dirname; + + for (const candidate of [ + join(cliDir, '../../../dashboard'), + join(cliDir, '../../dashboard'), + join(process.cwd(), 'apps/dashboard'), + ]) { + if (existsSync(join(candidate, 'vite.config.mts'))) { + return candidate; + } + } + + throw new Error( + 'Dashboard app not found. Reinstall @federation-wizards/fwiz or run from the fwiz monorepo.', + ); +} + +export function startDashboardServer(options: { + cwd: string; + port?: number; + open?: boolean; + dashboardRoot?: string; +}): ChildProcess { + const port = options.port ?? 5000; + const config = loadFwizConfig(options.cwd); + const dashboardRoot = options.dashboardRoot ?? resolveDashboardRoot(); + const dashboardUrl = `http://localhost:${port}`; + + const child = spawn( + 'pnpm', + [ + 'exec', + 'vite', + '--config', + join(dashboardRoot, 'vite.config.mts'), + '--port', + String(port), + '--host', + 'localhost', + ...(options.open ? ['--open'] : []), + ], + { + cwd: join(dashboardRoot, '../..'), + env: { + ...process.env, + FWIZ_CONFIG_JSON: JSON.stringify(config), + FWIZ_CWD: options.cwd, + FWIZ_DASHBOARD_PORT: String(port), + FWIZ_DASHBOARD_URL: dashboardUrl, + }, + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); + + child.on('error', (error) => { + console.error(`Failed to start dashboard: ${error.message}`); + process.exitCode = 1; + }); + + console.log(`Starting fwiz dashboard at ${dashboardUrl}`); + console.log( + `Loaded ${config.hosts.length} host(s), ${config.remotes.length} remote(s), and ${Object.keys(config.shared).length} shared dependency group(s).`, + ); + console.log( + 'Add createRuntimeInspectorPlugin() from @federation-wizards/mf-plugins to your host federation runtime plugins for live data.', + ); + + return child; +} + +export function registerDashboardCommand(program: Command): void { + program + .command('dashboard') + .description( + 'Launch a local federation graph dashboard with live runtime insights', + ) + .summary('Start the Vite + React dashboard on port 5000') + .addHelpText( + 'after', + ` +Examples: + $ fwiz dashboard + $ fwiz dashboard --cwd ./my-monorepo + $ fwiz dashboard --port 5000 --open + +Reads fwiz.config.yaml from the workspace and serves a react-flow graph of +hosts, remotes, and shared dependencies. Live runtime data is collected when +your host app registers the fwiz runtime inspector plugin. +`, + ) + .option( + '--cwd ', + 'Workspace directory (defaults to current working directory)', + process.cwd(), + ) + .option('--port ', 'Dashboard port', '5000') + .option('--open', 'Open the dashboard in the default browser', false) + .action((options: { cwd: string; port: string; open?: boolean }) => { + const port = Number.parseInt(options.port, 10); + + if (Number.isNaN(port)) { + console.error('Invalid port value.'); + process.exitCode = 1; + return; + } + + try { + const child = startDashboardServer({ + cwd: options.cwd, + port, + open: options.open, + }); + + child.on('exit', (code) => { + process.exitCode = code ?? 0; + }); + } catch (error) { + const message = + error instanceof FwizConfigError || error instanceof Error + ? error.message + : String(error); + console.error(message); + process.exitCode = 1; + } + }); +} diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 1537071..6a03625 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; +import { registerDashboardCommand } from './commands/dashboard.js'; import { registerInitCommand } from './commands/init.js'; export function createProgram(): Command { @@ -11,6 +12,7 @@ export function createProgram(): Command { .version('0.0.1'); registerInitCommand(program); + registerDashboardCommand(program); return program; } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index d68cc9b..b8c3bd5 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,5 +1,13 @@ { "name": "@federation-wizards/dashboard", "version": "0.0.1", - "private": true + "private": true, + "dependencies": { + "@federation-wizards/core": "workspace:*", + "@federation-wizards/mf-plugins": "workspace:*", + "@xyflow/react": "^12.8.5", + "html-to-image": "^1.11.13", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } } diff --git a/apps/dashboard/src/app/app.module.css b/apps/dashboard/src/app/app.module.css index 7b88fba..858d099 100644 --- a/apps/dashboard/src/app/app.module.css +++ b/apps/dashboard/src/app/app.module.css @@ -1 +1,67 @@ -/* Your styles goes here. */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--app-bg); + color: var(--text-color); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.header h1 { + margin: 0; + font-size: 1.5rem; +} + +.subtitle { + margin: 0.25rem 0 0; + color: var(--muted-text); +} + +.toolbar { + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.layout { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + min-height: 0; +} + +.sidebar { + border-left: 1px solid var(--border-color); + overflow: auto; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; +} + +.status, +.error { + padding: 1rem 1.5rem; +} + +.error { + color: #f87171; +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } + + .sidebar { + border-left: none; + border-top: 1px solid var(--border-color); + } +} diff --git a/apps/dashboard/src/app/app.spec.tsx b/apps/dashboard/src/app/app.spec.tsx index 48a4f0b..d50f516 100644 --- a/apps/dashboard/src/app/app.spec.tsx +++ b/apps/dashboard/src/app/app.spec.tsx @@ -1,19 +1,88 @@ -import { render } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from './app'; +const sampleConfig = { + version: '1', + workspace: { type: 'nx' }, + hosts: [{ name: 'shell', port: 4200, project: 'shell' }], + remotes: [{ name: 'checkout', port: 4201, project: 'checkout' }], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, +}; + describe('App', () => { - it('should render successfully', () => { + beforeEach(() => { + class ResizeObserverMock { + observe(): void { + // jsdom polyfill for React Flow + } + unobserve(): void { + // jsdom polyfill for React Flow + } + disconnect(): void { + // jsdom polyfill for React Flow + } + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.endsWith('/api/config')) { + return Promise.resolve({ + ok: true, + json: async () => sampleConfig, + }); + } + + if (url.endsWith('/api/runtime')) { + return Promise.resolve({ + ok: true, + json: async () => [], + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete document.documentElement.dataset.theme; + }); + + it('renders successfully', async () => { const { baseElement } = render(); expect(baseElement).toBeTruthy(); + await waitFor(() => + expect(screen.getByText(/fwiz dashboard/i)).toBeTruthy(), + ); }); - it('should have a greeting as the title', () => { - const { getAllByText } = render(); - expect( - getAllByText( - new RegExp('Welcome @federation-wizards/dashboard', 'i'), - ).length > 0, - ).toBeTruthy(); + it('loads federation graph nodes from config API', async () => { + render(); + + await waitFor(() => expect(screen.getByText('shell')).toBeTruthy()); + expect(screen.getByText('checkout')).toBeTruthy(); + expect(screen.getByText('react')).toBeTruthy(); + }); + + it('defaults to dark mode theme', async () => { + render(); + + await waitFor(() => + expect(document.documentElement.dataset.theme).toBe('dark'), + ); }); }); diff --git a/apps/dashboard/src/app/app.tsx b/apps/dashboard/src/app/app.tsx index c723e83..ecfe207 100644 --- a/apps/dashboard/src/app/app.tsx +++ b/apps/dashboard/src/app/app.tsx @@ -1,11 +1,95 @@ -// Uncomment this line to use CSS modules -// import styles from './app.module.css'; -import NxWelcome from './nx-welcome'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { Node } from '@xyflow/react'; + +import { ExportControls } from '../components/ExportControls.js'; +import { FederationGraph } from '../components/FederationGraph.js'; +import { NodeDetailPanel } from '../components/NodeDetailPanel.js'; +import { RuntimePanel } from '../components/RuntimePanel.js'; +import { ThemeToggle } from '../components/ThemeToggle.js'; +import { useFwizConfig } from '../hooks/useFwizConfig.js'; +import { useRuntimeData } from '../hooks/useRuntimeData.js'; +import { + buildGraphFromConfig, + type FederationNodeData, +} from '../lib/graph.js'; + +import styles from './app.module.css'; export function App() { + const [darkMode, setDarkMode] = useState(true); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const graphRef = useRef(null); + const { config, loading, error } = useFwizConfig(); + const { + snapshots, + loading: runtimeLoading, + error: runtimeError, + } = useRuntimeData(); + + useEffect(() => { + document.documentElement.dataset.theme = darkMode ? 'dark' : 'light'; + }, [darkMode]); + + const graph = useMemo( + () => (config ? buildGraphFromConfig(config) : { nodes: [], edges: [] }), + [config], + ); + + const selectedNode = useMemo( + () => + graph.nodes.find((node) => node.id === selectedNodeId) ?? + null, + [graph.nodes, selectedNodeId], + ) as Node | null; + return ( -
- +
+
+
+

fwiz dashboard

+

+ Module Federation graph and live runtime inspector +

+
+ setDarkMode((value) => !value)} + /> +
+ + {loading ?

Loading fwiz config…

: null} + {error ?

{error}

: null} + + {config ? ( + <> +
+ +
+
+ + +
+ + ) : null}
); } diff --git a/apps/dashboard/src/components/ExportControls.tsx b/apps/dashboard/src/components/ExportControls.tsx new file mode 100644 index 0000000..ecc11bc --- /dev/null +++ b/apps/dashboard/src/components/ExportControls.tsx @@ -0,0 +1,73 @@ +import { useCallback, type RefObject } from 'react'; + +import type { Edge, Node } from '@xyflow/react'; +import { toPng } from 'html-to-image'; + +import type { FwizConfig } from '@federation-wizards/core'; +import type { RuntimeSnapshot } from '@federation-wizards/mf-plugins'; + +import { + buildExportPayload, + type FederationNodeData, +} from '../lib/graph.js'; + +interface ExportControlsProps { + config: FwizConfig; + nodes: Node[]; + edges: Edge[]; + runtime: RuntimeSnapshot[]; + graphRef: RefObject; +} + +export function ExportControls({ + config, + nodes, + edges, + runtime, + graphRef, +}: ExportControlsProps) { + const exportJson = useCallback(() => { + const payload = buildExportPayload(config, nodes, edges, runtime); + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'fwiz-federation-graph.json'; + anchor.click(); + URL.revokeObjectURL(url); + }, [config, nodes, edges, runtime]); + + const exportPng = useCallback(async () => { + const element = graphRef.current; + + if (!element) { + return; + } + + const dataUrl = await toPng(element, { + cacheBust: true, + pixelRatio: 2, + backgroundColor: getComputedStyle(document.documentElement) + .getPropertyValue('--app-bg') + .trim(), + }); + + const anchor = document.createElement('a'); + anchor.href = dataUrl; + anchor.download = 'fwiz-federation-graph.png'; + anchor.click(); + }, [graphRef]); + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/FederationGraph.tsx b/apps/dashboard/src/components/FederationGraph.tsx new file mode 100644 index 0000000..e903d1e --- /dev/null +++ b/apps/dashboard/src/components/FederationGraph.tsx @@ -0,0 +1,78 @@ +import { useMemo, type RefObject } from 'react'; + +import { + Background, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + type Edge, + type Node, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import type { FwizConfig } from '@federation-wizards/core'; + +import { + buildGraphFromConfig, + type FederationNodeData, +} from '../lib/graph.js'; + +import { FederationNode } from './FederationNode.js'; + +const nodeTypes = { + federation: FederationNode, +}; + +interface FederationGraphProps { + config: FwizConfig; + selectedNodeId: string | null; + onSelectNode: (nodeId: string | null) => void; + graphRef?: RefObject; +} + +function FederationGraphInner({ + config, + selectedNodeId, + onSelectNode, + graphRef, +}: FederationGraphProps) { + const { nodes, edges } = useMemo( + () => buildGraphFromConfig(config), + [config], + ); + + const styledNodes = useMemo( + () => + nodes.map((node) => ({ + ...node, + selected: node.id === selectedNodeId, + })), + [nodes, selectedNodeId], + ); + + return ( +
+ []} + edges={edges as Edge[]} + nodeTypes={nodeTypes} + fitView + onNodeClick={(_, node) => onSelectNode(node.id)} + onPaneClick={() => onSelectNode(null)} + > + + + + +
+ ); +} + +export function FederationGraph(props: FederationGraphProps) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/FederationNode.module.css b/apps/dashboard/src/components/FederationNode.module.css new file mode 100644 index 0000000..eb402a4 --- /dev/null +++ b/apps/dashboard/src/components/FederationNode.module.css @@ -0,0 +1,40 @@ +.node { + min-width: 160px; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + border: 1px solid var(--border-color); + background: var(--panel-bg); + box-shadow: 0 8px 24px rgb(0 0 0 / 12%); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.selected { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgb(96 165 250 / 35%); +} + +.badge { + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-text); +} + +.meta { + font-size: 0.75rem; + color: var(--muted-text); +} + +.host { + border-left: 4px solid #38bdf8; +} + +.remote { + border-left: 4px solid #a78bfa; +} + +.shared { + border-left: 4px solid #34d399; +} diff --git a/apps/dashboard/src/components/FederationNode.tsx b/apps/dashboard/src/components/FederationNode.tsx new file mode 100644 index 0000000..661cd15 --- /dev/null +++ b/apps/dashboard/src/components/FederationNode.tsx @@ -0,0 +1,28 @@ +import { Handle, Position, type NodeProps } from '@xyflow/react'; + +import type { FederationNodeData } from '../lib/graph.js'; + +import styles from './FederationNode.module.css'; + +export function FederationNode({ data, selected }: NodeProps) { + const nodeData = data as FederationNodeData; + + return ( +
+ +
{nodeData.kind}
+ {nodeData.label} + {nodeData.port !== undefined ? ( + port {nodeData.port} + ) : null} + {nodeData.requiredVersion ? ( + {nodeData.requiredVersion} + ) : null} + +
+ ); +} diff --git a/apps/dashboard/src/components/NodeDetailPanel.tsx b/apps/dashboard/src/components/NodeDetailPanel.tsx new file mode 100644 index 0000000..3c1b096 --- /dev/null +++ b/apps/dashboard/src/components/NodeDetailPanel.tsx @@ -0,0 +1,87 @@ +import type { Node } from '@xyflow/react'; + +import type { RuntimeSnapshot } from '@federation-wizards/mf-plugins'; + +import type { FederationNodeData } from '../lib/graph.js'; + +interface NodeDetailPanelProps { + node: Node | null; + snapshots: RuntimeSnapshot[]; +} + +function findRuntimeForNode( + node: Node, + snapshots: RuntimeSnapshot[], +): RuntimeSnapshot | undefined { + const label = node.data.label; + + return snapshots.find( + (snapshot) => + snapshot.hostName === label || + snapshot.remotes.some((remote) => remote.name === label), + ); +} + +export function NodeDetailPanel({ node, snapshots }: NodeDetailPanelProps) { + if (!node) { + return ( +
+

Node details

+

Select a node to inspect federation metadata.

+
+ ); + } + + const runtime = findRuntimeForNode(node, snapshots); + const remoteRuntime = runtime?.remotes.find( + (remote) => remote.name === node.data.label, + ); + + return ( +
+

{node.data.label}

+
+
+
Kind
+
{node.data.kind}
+
+ {node.data.project ? ( +
+
Project
+
{node.data.project}
+
+ ) : null} + {node.data.port !== undefined ? ( +
+
Port
+
{node.data.port}
+
+ ) : null} + {node.data.requiredVersion ? ( +
+
Required version
+
{node.data.requiredVersion}
+
+ ) : null} + {node.data.singleton !== undefined ? ( +
+
Singleton
+
{node.data.singleton ? 'yes' : 'no'}
+
+ ) : null} + {remoteRuntime?.entry ? ( +
+
Remote entry
+
{remoteRuntime.entry}
+
+ ) : null} + {remoteRuntime?.loadedAt ? ( +
+
Loaded at
+
{new Date(remoteRuntime.loadedAt).toLocaleString()}
+
+ ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/components/RuntimePanel.tsx b/apps/dashboard/src/components/RuntimePanel.tsx new file mode 100644 index 0000000..553c27f --- /dev/null +++ b/apps/dashboard/src/components/RuntimePanel.tsx @@ -0,0 +1,56 @@ +import type { RuntimeSnapshot } from '@federation-wizards/mf-plugins'; + +interface RuntimePanelProps { + snapshots: RuntimeSnapshot[]; + loading: boolean; + error: string | null; +} + +export function RuntimePanel({ snapshots, loading, error }: RuntimePanelProps) { + return ( +
+

Live runtime

+ {loading ?

Loading runtime snapshots…

: null} + {error ?

{error}

: null} + {!loading && snapshots.length === 0 ? ( +

+ No runtime snapshots yet. Start a federated host with the fwiz runtime + inspector plugin to stream live data. +

+ ) : null} + {snapshots.map((snapshot) => ( +
+
+ {snapshot.hostName} + + {new Date(snapshot.updatedAt).toLocaleTimeString()} + +
+

{snapshot.remotes.length} remote(s)

+
    + {Object.values(snapshot.shareScope).map((entry) => ( +
  • + {entry.name} + {entry.version} + {entry.loaded ? 'loaded' : 'pending'} +
  • + ))} +
+ {snapshot.events.length > 0 ? ( +
+ {snapshot.events.length} recent event(s) +
    + {snapshot.events.slice(-5).map((event, index) => ( +
  • + {event.type} at{' '} + {new Date(event.timestamp).toLocaleTimeString()} +
  • + ))} +
+
+ ) : null} +
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/ThemeToggle.tsx b/apps/dashboard/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..c6148b9 --- /dev/null +++ b/apps/dashboard/src/components/ThemeToggle.tsx @@ -0,0 +1,12 @@ +interface ThemeToggleProps { + darkMode: boolean; + onToggle: () => void; +} + +export function ThemeToggle({ darkMode, onToggle }: ThemeToggleProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/hooks/useFwizConfig.ts b/apps/dashboard/src/hooks/useFwizConfig.ts new file mode 100644 index 0000000..beb49cb --- /dev/null +++ b/apps/dashboard/src/hooks/useFwizConfig.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; + +import type { FwizConfig } from '@federation-wizards/core'; + +interface UseFwizConfigResult { + config: FwizConfig | null; + loading: boolean; + error: string | null; +} + +export function useFwizConfig(): UseFwizConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + fetch('/api/config') + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load config (${response.status})`); + } + return response.json() as Promise; + }) + .then((data) => { + if (!cancelled) { + setConfig(data); + } + }) + .catch((fetchError: unknown) => { + if (!cancelled) { + const message = + fetchError instanceof Error ? fetchError.message : 'Unknown error'; + setError(message); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return { config, loading, error }; +} diff --git a/apps/dashboard/src/hooks/useRuntimeData.ts b/apps/dashboard/src/hooks/useRuntimeData.ts new file mode 100644 index 0000000..fd933c5 --- /dev/null +++ b/apps/dashboard/src/hooks/useRuntimeData.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; + +import type { RuntimeSnapshot } from '@federation-wizards/mf-plugins'; + +interface UseRuntimeDataResult { + snapshots: RuntimeSnapshot[]; + loading: boolean; + error: string | null; +} + +export function useRuntimeData(pollIntervalMs = 3000): UseRuntimeDataResult { + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const load = async (): Promise => { + try { + const response = await fetch('/api/runtime'); + + if (!response.ok) { + throw new Error(`Failed to load runtime (${response.status})`); + } + + const data = (await response.json()) as RuntimeSnapshot[]; + + if (!cancelled) { + setSnapshots(data); + setError(null); + } + } catch (fetchError: unknown) { + if (!cancelled) { + const message = + fetchError instanceof Error ? fetchError.message : 'Unknown error'; + setError(message); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); + const intervalId = window.setInterval(() => { + void load(); + }, pollIntervalMs); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [pollIntervalMs]); + + return { snapshots, loading, error }; +} diff --git a/apps/dashboard/src/lib/graph.spec.ts b/apps/dashboard/src/lib/graph.spec.ts new file mode 100644 index 0000000..7c05a71 --- /dev/null +++ b/apps/dashboard/src/lib/graph.spec.ts @@ -0,0 +1,38 @@ +import type { FwizConfig } from '@federation-wizards/core'; +import { describe, expect, it } from 'vitest'; + +import { buildExportPayload, buildGraphFromConfig } from './graph.js'; + +const sampleConfig: FwizConfig = { + version: '1', + workspace: { type: 'nx' }, + hosts: [{ name: 'shell', port: 4200, project: 'shell' }], + remotes: [{ name: 'checkout', port: 4201, project: 'checkout' }], + shared: { + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + }, +}; + +describe('graph', () => { + it('buildGraphFromConfig creates host, remote, and shared nodes', () => { + const { nodes, edges } = buildGraphFromConfig(sampleConfig); + + expect(nodes.some((node) => node.id === 'host:shell')).toBe(true); + expect(nodes.some((node) => node.id === 'remote:checkout')).toBe(true); + expect(nodes.some((node) => node.id === 'shared:react')).toBe(true); + expect(edges.some((edge) => edge.source === 'host:shell')).toBe(true); + }); + + it('buildExportPayload includes config, graph, and runtime data', () => { + const { nodes, edges } = buildGraphFromConfig(sampleConfig); + const payload = buildExportPayload(sampleConfig, nodes, edges, []); + + expect(payload.config.hosts).toHaveLength(1); + expect(payload.graph.nodes).toHaveLength(nodes.length); + expect(payload.exportedAt).toBeTruthy(); + }); +}); diff --git a/apps/dashboard/src/lib/graph.ts b/apps/dashboard/src/lib/graph.ts new file mode 100644 index 0000000..f34728e --- /dev/null +++ b/apps/dashboard/src/lib/graph.ts @@ -0,0 +1,111 @@ +import type { FwizConfig } from '@federation-wizards/core'; +import type { Edge, Node } from '@xyflow/react'; + +import type { RuntimeSnapshot } from '@federation-wizards/mf-plugins'; + +export type FederationNodeKind = 'host' | 'remote' | 'shared'; + +export interface FederationNodeData extends Record { + kind: FederationNodeKind; + label: string; + port?: number; + project?: string; + singleton?: boolean; + requiredVersion?: string; + eager?: boolean; +} + +export interface GraphExportPayload { + exportedAt: string; + config: FwizConfig; + graph: { + nodes: Node[]; + edges: Edge[]; + }; + runtime: RuntimeSnapshot[]; +} + +export function buildGraphFromConfig(config: FwizConfig): { + nodes: Node[]; + edges: Edge[]; +} { + const nodes: Node[] = []; + const edges: Edge[] = []; + + config.hosts.forEach((host, index) => { + nodes.push({ + id: `host:${host.name}`, + type: 'federation', + position: { x: 80 + index * 280, y: 80 }, + data: { + kind: 'host', + label: host.name, + port: host.port, + project: host.project, + }, + }); + }); + + config.remotes.forEach((remote, index) => { + nodes.push({ + id: `remote:${remote.name}`, + type: 'federation', + position: { x: 80 + index * 220, y: 280 }, + data: { + kind: 'remote', + label: remote.name, + port: remote.port, + project: remote.project, + }, + }); + + for (const host of config.hosts) { + edges.push({ + id: `edge:${host.name}->${remote.name}`, + source: `host:${host.name}`, + target: `remote:${remote.name}`, + label: 'consumes', + }); + } + }); + + Object.entries(config.shared).forEach(([name, shared], index) => { + nodes.push({ + id: `shared:${name}`, + type: 'federation', + position: { x: 720, y: 80 + index * 90 }, + data: { + kind: 'shared', + label: name, + singleton: shared.singleton, + requiredVersion: shared.requiredVersion, + eager: shared.eager, + }, + }); + + for (const host of config.hosts) { + edges.push({ + id: `edge:${host.name}->shared:${name}`, + source: `host:${host.name}`, + target: `shared:${name}`, + label: 'shared', + }); + } + }); + + return { nodes, edges }; +} + +export function buildExportPayload( + config: FwizConfig, + nodes: Node[], + edges: Edge[], + runtime: RuntimeSnapshot[], +): GraphExportPayload { + return { + exportedAt: new Date().toISOString(), + config, + graph: { nodes, edges }, + runtime, + }; +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 90d4ee0..07a7821 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1 +1,130 @@ -/* You can add global styles to this file, and also import other style files */ +:root, +:root[data-theme='light'] { + --app-bg: #f8fafc; + --panel-bg: #ffffff; + --text-color: #0f172a; + --muted-text: #64748b; + --border-color: #e2e8f0; + --accent-color: #2563eb; +} + +:root[data-theme='dark'] { + --app-bg: #0b1220; + --panel-bg: #111827; + --text-color: #e5e7eb; + --muted-text: #94a3b8; + --border-color: #1f2937; + --accent-color: #60a5fa; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + background: var(--app-bg); + color: var(--text-color); +} + +button { + font: inherit; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--panel-bg); + color: var(--text-color); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; +} + +button:hover { + border-color: var(--accent-color); +} + +.theme-toggle, +.export-controls button { + white-space: nowrap; +} + +.export-controls { + display: flex; + gap: 0.75rem; +} + +.graph-canvas { + height: calc(100vh - 160px); + min-height: 420px; +} + +.panel { + background: var(--panel-bg); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 1rem; +} + +.panel h2 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.muted { + color: var(--muted-text); +} + +.detail-list { + margin: 0; + display: grid; + gap: 0.75rem; +} + +.detail-list dt { + font-size: 0.75rem; + color: var(--muted-text); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.detail-list dd { + margin: 0.15rem 0 0; +} + +.runtime-card { + border-top: 1px solid var(--border-color); + padding-top: 0.75rem; + margin-top: 0.75rem; +} + +.runtime-card header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.runtime-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: grid; + gap: 0.35rem; +} + +.runtime-list li { + display: flex; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.85rem; +} + +.error { + color: #f87171; +} diff --git a/apps/dashboard/tsconfig.app.json b/apps/dashboard/tsconfig.app.json index c571334..b3d9340 100644 --- a/apps/dashboard/tsconfig.app.json +++ b/apps/dashboard/tsconfig.app.json @@ -34,5 +34,13 @@ "eslint.config.cjs", "eslint.config.mjs" ], - "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"], + "references": [ + { + "path": "../../libs/mf-plugins/tsconfig.lib.json" + }, + { + "path": "../../libs/core/tsconfig.lib.json" + } + ] } diff --git a/apps/dashboard/vite.config.mts b/apps/dashboard/vite.config.mts index 266e28b..8bb47e3 100644 --- a/apps/dashboard/vite.config.mts +++ b/apps/dashboard/vite.config.mts @@ -1,23 +1,116 @@ /// -import { defineConfig } from 'vite'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import react from '@vitejs/plugin-react'; +import type { Plugin } from 'vite'; +import { defineConfig } from 'vite'; + +import type { RuntimeSnapshot } from '../../../libs/mf-plugins/src/lib/runtime-inspector.js'; + +const workspaceRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +function readConfigFromEnv(): unknown { + const raw = process.env['FWIZ_CONFIG_JSON']; + + if (!raw) { + return { + version: '1', + workspace: { type: 'plain' }, + hosts: [], + remotes: [], + shared: {}, + }; + } + + try { + return JSON.parse(raw); + } catch { + return { + version: '1', + workspace: { type: 'plain' }, + hosts: [], + remotes: [], + shared: {}, + }; + } +} + +function fwizApiPlugin(): Plugin { + const runtimeStore = new Map(); + + return { + name: 'fwiz-api', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url ?? ''; + + if (url === '/api/config' && req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(readConfigFromEnv())); + return; + } + + if (url === '/api/runtime' && req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(Array.from(runtimeStore.values()))); + return; + } + + const runtimeMatch = url.match(/^\/api\/runtime\/([^/?]+)/); + + if (runtimeMatch && req.method === 'POST') { + const hostName = decodeURIComponent(runtimeMatch[1] ?? ''); + const chunks: Buffer[] = []; + + req.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + req.on('end', () => { + try { + const body = JSON.parse( + Buffer.concat(chunks).toString('utf8'), + ) as RuntimeSnapshot; + runtimeStore.set(hostName, { ...body, hostName }); + res.statusCode = 204; + res.end(); + } catch { + res.statusCode = 400; + res.end('Invalid runtime snapshot payload'); + } + }); + + return; + } + + next(); + }); + }, + }; +} export default defineConfig(() => ({ root: import.meta.dirname, cacheDir: '../../node_modules/.vite/apps/dashboard', + resolve: { + alias: { + '@federation-wizards/core': join(workspaceRoot, 'libs/core/src/index.ts'), + '@federation-wizards/mf-plugins': join( + workspaceRoot, + 'libs/mf-plugins/src/index.ts', + ), + }, + }, server: { - port: 4200, + port: 5000, host: 'localhost', }, preview: { - port: 4200, + port: 5000, host: 'localhost', }, - plugins: [react()], - // Uncomment this if you are using workers. - // worker: { - // plugins: [], - // }, + plugins: [react(), fwizApiPlugin()], build: { outDir: './dist', emptyOutDir: true, diff --git a/libs/mf-plugins/src/index.ts b/libs/mf-plugins/src/index.ts index 3635578..b7c8a7d 100644 --- a/libs/mf-plugins/src/index.ts +++ b/libs/mf-plugins/src/index.ts @@ -1 +1,2 @@ export * from './lib/mf-plugins.js'; +export * from './lib/runtime-inspector.js'; diff --git a/libs/mf-plugins/src/lib/runtime-inspector.spec.ts b/libs/mf-plugins/src/lib/runtime-inspector.spec.ts new file mode 100644 index 0000000..dd57659 --- /dev/null +++ b/libs/mf-plugins/src/lib/runtime-inspector.spec.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createRuntimeInspectorPlugin, + getRuntimeSnapshot, + inspectFederationGlobal, + mergeRuntimeSnapshots, + reportSnapshotToDashboard, + updateRuntimeSnapshot, + type RuntimeSnapshot, +} from './runtime-inspector.js'; + +describe('runtime-inspector', () => { + beforeEach(() => { + globalThis.__FEDERATION__ = { + __INSTANCES__: [ + { + options: { + name: 'shell', + remotes: [{ name: 'checkout', entry: 'http://localhost:4201/remoteEntry.js' }], + }, + instance: { + options: { name: 'shell' }, + shareScopeMap: new Map([ + [ + 'default', + new Map([ + [ + 'react', + { + version: '19.0.0', + from: 'shell', + loaded: true, + singleton: true, + }, + ], + ]), + ], + ]), + }, + }, + ], + }; + globalThis.__FWIZ_RUNTIME__ = { snapshots: {} }; + }); + + afterEach(() => { + delete globalThis.__FEDERATION__; + delete globalThis.__FWIZ_RUNTIME__; + vi.restoreAllMocks(); + }); + + it('inspectFederationGlobal reads __FEDERATION__.__INSTANCES__', () => { + const snapshot = inspectFederationGlobal('shell'); + + expect(snapshot.hostName).toBe('shell'); + expect(snapshot.containers).toHaveLength(1); + expect(snapshot.remotes[0]?.name).toBe('checkout'); + expect(snapshot.shareScope['default:react']?.version).toBe('19.0.0'); + }); + + it('updateRuntimeSnapshot and getRuntimeSnapshot store in __FWIZ_RUNTIME__', () => { + const snapshot = inspectFederationGlobal('shell'); + updateRuntimeSnapshot(snapshot); + + const stored = getRuntimeSnapshot('shell') as RuntimeSnapshot; + expect(stored.hostName).toBe('shell'); + expect(stored.remotes).toHaveLength(1); + }); + + it('mergeRuntimeSnapshots combines multiple host snapshots', () => { + const shell = inspectFederationGlobal('shell'); + const checkout = inspectFederationGlobal('checkout'); + checkout.remotes.push({ + name: 'payment', + entry: 'http://localhost:4202/remoteEntry.js', + }); + + const merged = mergeRuntimeSnapshots([shell, checkout]); + + expect(merged.containers.length).toBeGreaterThan(1); + expect(merged.remotes.some((remote) => remote.name === 'payment')).toBe( + true, + ); + }); + + it('createRuntimeInspectorPlugin hooks update runtime snapshot', async () => { + const plugin = createRuntimeInspectorPlugin({ + hostName: 'shell', + report: false, + }); + + await plugin.afterInit?.({ name: 'shell' }); + + const snapshot = getRuntimeSnapshot('shell') as RuntimeSnapshot; + expect(snapshot.events.some((event) => event.type === 'init')).toBe(true); + + plugin.resolveShare?.({ pkgName: 'react', shareScope: 'default' }); + plugin.beforeLoadShare?.({ pkgName: 'react' }); + await plugin.loadRemoteEntry?.({ remoteName: 'checkout' }); + + const updated = getRuntimeSnapshot('shell') as RuntimeSnapshot; + expect(updated.events.length).toBeGreaterThanOrEqual(3); + }); + + it('reportSnapshotToDashboard POSTs to dashboard API', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + const snapshot = inspectFederationGlobal('shell'); + await reportSnapshotToDashboard(snapshot, 'http://localhost:5000'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:5000/api/runtime/shell', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); +}); diff --git a/libs/mf-plugins/src/lib/runtime-inspector.ts b/libs/mf-plugins/src/lib/runtime-inspector.ts new file mode 100644 index 0000000..819392d --- /dev/null +++ b/libs/mf-plugins/src/lib/runtime-inspector.ts @@ -0,0 +1,300 @@ +export interface ShareScopeEntry { + name: string; + version: string; + from: string; + loaded?: boolean; + eager?: boolean; + singleton?: boolean; + requiredVersion?: string; +} + +export interface RemoteRuntimeInfo { + name: string; + entry: string; + loadedAt?: number; + exposedModules?: string[]; + shareScope?: Record; +} + +export interface ContainerRuntimeInfo { + name: string; + hostName: string; + remotes: RemoteRuntimeInfo[]; + shareScope: Record; + loadedAt?: number; +} + +export interface RuntimeEvent { + type: 'init' | 'resolveShare' | 'loadRemoteEntry' | 'beforeLoadShare'; + timestamp: number; + detail: Record; +} + +export interface RuntimeSnapshot { + hostName: string; + updatedAt: number; + containers: ContainerRuntimeInfo[]; + shareScope: Record; + remotes: RemoteRuntimeInfo[]; + events: RuntimeEvent[]; +} + +export interface FederationRuntimePlugin { + name: string; + afterInit?: (args: Record) => void | Promise; + resolveShare?: (args: Record) => Record; + loadRemoteEntry?: ( + args: Record, + ) => Record | Promise>; + beforeLoadShare?: (args: Record) => Record; +} + +interface FederationInstanceRecord { + options?: { + name?: string; + remotes?: Array<{ name?: string; entry?: string; alias?: string }>; + shared?: Record; + }; + instance?: { + options?: { name?: string }; + moduleCache?: Map; + shareScopeMap?: Map>; + }; +} + +interface FederationGlobal { + __INSTANCES__?: FederationInstanceRecord[]; +} + +interface FwizRuntimeGlobal { + snapshots: Record; +} + +declare global { + // eslint-disable-next-line no-var + var __FEDERATION__: FederationGlobal | undefined; + // eslint-disable-next-line no-var + var __FWIZ_RUNTIME__: FwizRuntimeGlobal | undefined; +} + +function ensureFwizRuntime(): FwizRuntimeGlobal { + if (!globalThis.__FWIZ_RUNTIME__) { + globalThis.__FWIZ_RUNTIME__ = { snapshots: {} }; + } + return globalThis.__FWIZ_RUNTIME__; +} + +function parseShareScope( + shareScopeMap?: Map>, +): Record { + const result: Record = {}; + + if (!shareScopeMap) { + return result; + } + + for (const [scopeName, scopeEntries] of shareScopeMap.entries()) { + for (const [pkgName, entry] of scopeEntries.entries()) { + const record = entry as Record; + const key = `${scopeName}:${pkgName}`; + result[key] = { + name: pkgName, + version: String(record['version'] ?? record['loaded'] ?? 'unknown'), + from: String(record['from'] ?? scopeName), + loaded: Boolean(record['loaded']), + eager: Boolean(record['eager']), + singleton: Boolean(record['singleton']), + requiredVersion: record['requiredVersion'] + ? String(record['requiredVersion']) + : undefined, + }; + } + } + + return result; +} + +export function inspectFederationGlobal( + hostName = 'unknown', +): RuntimeSnapshot { + const federation = globalThis.__FEDERATION__; + const instances = federation?.__INSTANCES__ ?? []; + const containers: ContainerRuntimeInfo[] = []; + const remotes: RemoteRuntimeInfo[] = []; + const shareScope: Record = {}; + + for (const record of instances) { + const name = + record.options?.name ?? record.instance?.options?.name ?? hostName; + const instanceShareScope = parseShareScope(record.instance?.shareScopeMap); + Object.assign(shareScope, instanceShareScope); + + const remoteList: RemoteRuntimeInfo[] = (record.options?.remotes ?? []).map( + (remote) => ({ + name: remote.name ?? remote.alias ?? 'unknown', + entry: remote.entry ?? '', + }), + ); + + remotes.push(...remoteList); + containers.push({ + name, + hostName, + remotes: remoteList, + shareScope: instanceShareScope, + loadedAt: Date.now(), + }); + } + + return { + hostName, + updatedAt: Date.now(), + containers, + shareScope, + remotes, + events: [], + }; +} + +export function updateRuntimeSnapshot(snapshot: RuntimeSnapshot): void { + const runtime = ensureFwizRuntime(); + runtime.snapshots[snapshot.hostName] = snapshot; +} + +export function getRuntimeSnapshot( + hostName?: string, +): RuntimeSnapshot | Record { + const runtime = ensureFwizRuntime(); + + if (hostName) { + return ( + runtime.snapshots[hostName] ?? { + hostName, + updatedAt: Date.now(), + containers: [], + shareScope: {}, + remotes: [], + events: [], + } + ); + } + + return { ...runtime.snapshots }; +} + +export function mergeRuntimeSnapshots( + snapshots: RuntimeSnapshot[], +): RuntimeSnapshot { + const merged: RuntimeSnapshot = { + hostName: 'merged', + updatedAt: Date.now(), + containers: [], + shareScope: {}, + remotes: [], + events: [], + }; + + for (const snapshot of snapshots) { + merged.containers.push(...snapshot.containers); + merged.remotes.push(...snapshot.remotes); + merged.events.push(...snapshot.events); + Object.assign(merged.shareScope, snapshot.shareScope); + } + + merged.updatedAt = Date.now(); + return merged; +} + +export async function reportSnapshotToDashboard( + snapshot: RuntimeSnapshot, + dashboardUrl = process.env['FWIZ_DASHBOARD_URL'] ?? 'http://localhost:5000', +): Promise { + const url = `${dashboardUrl.replace(/\/$/, '')}/api/runtime/${encodeURIComponent(snapshot.hostName)}`; + + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot), + }); +} + +function appendEvent( + snapshot: RuntimeSnapshot, + type: RuntimeEvent['type'], + detail: Record, +): RuntimeSnapshot { + return { + ...snapshot, + updatedAt: Date.now(), + events: [ + ...snapshot.events, + { + type, + timestamp: Date.now(), + detail, + }, + ], + }; +} + +export function createRuntimeInspectorPlugin(options?: { + hostName?: string; + dashboardUrl?: string; + report?: boolean; +}): FederationRuntimePlugin { + const hostName = options?.hostName ?? 'host'; + const dashboardUrl = options?.dashboardUrl ?? process.env['FWIZ_DASHBOARD_URL']; + const shouldReport = options?.report ?? true; + + const refresh = async ( + type: RuntimeEvent['type'], + detail: Record, + ): Promise => { + const existing = getRuntimeSnapshot(hostName) as RuntimeSnapshot; + const base = + existing.hostName === hostName + ? existing + : inspectFederationGlobal(hostName); + const inspected = inspectFederationGlobal(hostName); + const snapshot = appendEvent( + { + ...base, + containers: inspected.containers, + shareScope: inspected.shareScope, + remotes: inspected.remotes, + hostName, + }, + type, + detail, + ); + + updateRuntimeSnapshot(snapshot); + + if (shouldReport && dashboardUrl) { + try { + await reportSnapshotToDashboard(snapshot, dashboardUrl); + } catch { + // Dashboard may not be running during local dev. + } + } + }; + + return { + name: 'fwiz-runtime-inspector', + afterInit: async (args) => { + await refresh('init', args); + }, + resolveShare: (args) => { + void refresh('resolveShare', args); + return args; + }, + loadRemoteEntry: async (args) => { + await refresh('loadRemoteEntry', args); + return args; + }, + beforeLoadShare: (args) => { + void refresh('beforeLoadShare', args); + return args; + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2639d3c..453b392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,7 +173,26 @@ importers: version: 15.0.0 publishDirectory: dist - apps/dashboard: {} + apps/dashboard: + dependencies: + '@federation-wizards/core': + specifier: workspace:* + version: link:../../libs/core + '@federation-wizards/mf-plugins': + specifier: workspace:* + version: link:../../libs/mf-plugins + '@xyflow/react': + specifier: ^12.8.5 + version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) libs/core: dependencies: @@ -2638,6 +2657,24 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3011,6 +3048,22 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.11.0': + resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.77': + resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==} + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -3443,6 +3496,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -3641,6 +3697,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4483,6 +4577,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + http-assert@1.5.0: resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} engines: {node: '>= 0.8'} @@ -6642,6 +6739,11 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6892,6 +6994,21 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@babel/code-frame@7.27.1': @@ -9796,6 +9913,27 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -10269,6 +10407,31 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@xyflow/system': 0.0.77 + classcat: 5.0.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + zustand: 4.5.7(@types/react@19.2.7)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.77': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@yarnpkg/lockfile@1.1.0': {} '@yarnpkg/parsers@3.0.2': @@ -10753,6 +10916,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + classcat@5.0.5: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -10961,6 +11126,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-urls@4.0.0: @@ -11373,7 +11574,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.1 '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - optional: true escalade@3.2.0: {} @@ -12028,6 +12228,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + http-assert@1.5.0: dependencies: deep-equal: 1.0.1 @@ -14380,7 +14582,6 @@ snapshots: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - optional: true type-check@0.4.0: dependencies: @@ -14534,6 +14735,10 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -14785,3 +14990,10 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zustand@4.5.7(@types/react@19.2.7)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.3