Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/cli/src/commands/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
135 changes: 135 additions & 0 deletions apps/cli/src/commands/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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 <path>',
'Workspace directory (defaults to current working directory)',
process.cwd(),
)
.option('--port <number>', '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;
}
});
}
2 changes: 2 additions & 0 deletions apps/cli/src/program.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from 'commander';

import { registerDashboardCommand } from './commands/dashboard.js';
import { registerInitCommand } from './commands/init.js';

export function createProgram(): Command {
Expand All @@ -11,6 +12,7 @@ export function createProgram(): Command {
.version('0.0.1');

registerInitCommand(program);
registerDashboardCommand(program);

return program;
}
10 changes: 9 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
68 changes: 67 additions & 1 deletion apps/dashboard/src/app/app.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading