@@ -133,7 +133,7 @@ export function SidebarNav() {
{/* Spacer for mobile top bar */}
- {/* Overlay */}
+ {/* Mobile overlay */}
{mobileOpen && (
)}
- {/* Sidebar — always visible on md+, slide-in on mobile */}
+ {/* Sidebar — static on desktop, fixed on mobile */}
diff --git a/dashboard/web/src/lib/api.test.ts b/dashboard/web/src/lib/api.test.ts
new file mode 100644
index 0000000..1bb8350
--- /dev/null
+++ b/dashboard/web/src/lib/api.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { api } from './api.js';
+
+// Mock fetch
+global.fetch = vi.fn();
+
+describe('API Client', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getServices', () => {
+ it('should fetch services successfully', async () => {
+ const mockServices = [
+ {
+ id: 'test-service',
+ name: 'Test Service',
+ scriptPath: '../deploy-test.sh',
+ healthUrl: 'https://test.example.com/health',
+ repoPath: '../test-repo',
+ status: 'up' as const,
+ version: '1.0.0',
+ productId: 'devops-internal',
+ },
+ ];
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockServices,
+ });
+
+ const services = await api.getServices();
+
+ expect(services).toEqual(mockServices);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:4004/api/services',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ }),
+ })
+ );
+ });
+
+ it('should throw error on fetch failure', async () => {
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ });
+
+ await expect(api.getServices()).rejects.toThrow('API error: 500 Internal Server Error');
+ });
+
+ it('should include auth token when available', async () => {
+ // Mock localStorage
+ const localStorageMock = {
+ getItem: vi.fn(() => 'test-token'),
+ };
+ Object.defineProperty(global, 'localStorage', {
+ value: localStorageMock,
+ });
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [],
+ });
+
+ await api.getServices();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:4004/api/services',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'Authorization': 'Bearer test-token',
+ }),
+ })
+ );
+ });
+ });
+
+ describe('triggerDeployment', () => {
+ it('should trigger deployment successfully', async () => {
+ const mockResponse = {
+ deploymentId: 'deployment-123',
+ status: 'running',
+ };
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ });
+
+ const result = await api.triggerDeployment('test-service');
+
+ expect(result).toEqual(mockResponse);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:4004/api/deployments/trigger/test-service',
+ expect.objectContaining({
+ method: 'POST',
+ })
+ );
+ });
+ });
+
+ describe('seedServices', () => {
+ it('should seed services successfully', async () => {
+ const mockResponse = {
+ message: 'Seeded default services',
+ };
+
+ (global.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ });
+
+ const result = await api.seedServices();
+
+ expect(result).toEqual(mockResponse);
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:4004/api/seed',
+ expect.objectContaining({
+ method: 'POST',
+ })
+ );
+ });
+ });
+});
diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts
new file mode 100644
index 0000000..dffbcfe
--- /dev/null
+++ b/dashboard/web/src/lib/api.ts
@@ -0,0 +1,530 @@
+import { devopsApiUrl, platformUrl } from './product-config';
+
+// Platform service URL for auth
+const PLATFORM_SERVICE_URL = platformUrl;
+
+export interface Service {
+ id: string;
+ name: string;
+ scriptPath: string;
+ healthUrl: string;
+ repoPath: string;
+ status: 'up' | 'down' | 'degraded';
+ version: string;
+ lastDeployedAt?: string;
+ lastHealthCheckAt?: string;
+ productId: string;
+}
+
+export interface Deployment {
+ id: string;
+ serviceId: string;
+ version: string;
+ status: 'running' | 'success' | 'failed';
+ logs: string;
+ triggeredBy: string;
+ triggeredAt: string;
+ completedAt?: string;
+ productId: string;
+}
+
+export interface ServiceHealth {
+ serviceId: string;
+ status: 'up' | 'down' | 'degraded';
+ responseTime?: number;
+ lastCheck: string;
+}
+
+export interface ApiError {
+ error: string;
+ status?: number;
+}
+
+export interface EnvVar {
+ id: string;
+ name: string;
+ value: string;
+ isSecret: boolean;
+ source: 'local' | 'azure-key-vault';
+ azureKeyVaultName?: string;
+ azureSecretName?: string;
+ updatedAt: string;
+}
+
+let csrfToken: string | null = null;
+let csrfTokenExpiresAt: number = 0;
+
+async function getAccessToken(): Promise
{
+ if (typeof window === 'undefined') return null;
+ let token = getAccessTokenFromStorage();
+
+ // If no token, try to refresh
+ if (!token) {
+ token = await refreshAccessToken();
+ }
+
+ return token;
+}
+
+async function getCsrfToken(): Promise {
+ if (csrfToken && Date.now() < csrfTokenExpiresAt) {
+ return csrfToken;
+ }
+
+ try {
+ const token = await getAccessToken();
+ const response = await fetch(`${devopsApiUrl}/api/csrf-token`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token && { Authorization: `Bearer ${token}` }),
+ },
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ csrfToken = data.csrfToken;
+ csrfTokenExpiresAt = Date.now() + 3000000; // 50 minutes (before 1 hour expiry)
+ return csrfToken;
+ }
+ } catch (error) {
+ console.error('Failed to fetch CSRF token:', error);
+ }
+
+ return null;
+}
+
+export async function apiRequest(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise {
+ let token = await getAccessToken();
+ const headers: HeadersInit = {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ };
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const method = options.method?.toUpperCase();
+ const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
+
+ if (method && stateChangingMethods.includes(method)) {
+ const csrf = await getCsrfToken();
+ if (csrf) {
+ headers['X-CSRF-Token'] = csrf;
+ }
+ }
+
+ let response = await fetch(`${devopsApiUrl}${endpoint}`, {
+ ...options,
+ headers,
+ });
+
+ // Handle 401 - try to refresh token and retry
+ if (response.status === 401 && token) {
+ const newToken = await refreshAccessToken();
+ if (newToken) {
+ headers['Authorization'] = `Bearer ${newToken}`;
+ response = await fetch(`${devopsApiUrl}${endpoint}`, {
+ ...options,
+ headers,
+ });
+ }
+ }
+
+ // Handle 403 - CSRF token retry
+ if (response.status === 403) {
+ const errorData = await response.json().catch(() => ({}));
+ if (errorData.error === 'Invalid CSRF token') {
+ csrfToken = null;
+ csrfTokenExpiresAt = 0;
+ const newCsrf = await getCsrfToken();
+ if (newCsrf) {
+ headers['X-CSRF-Token'] = newCsrf;
+ response = await fetch(`${devopsApiUrl}${endpoint}`, {
+ ...options,
+ headers,
+ });
+ }
+ }
+ }
+
+ if (!response.ok) {
+ const error: ApiError = {
+ error: `API error: ${response.status} ${response.statusText}`,
+ status: response.status
+ };
+ throw error;
+ }
+
+ return response.json();
+}
+
+export interface SseEvent {
+ event: string;
+ data: string;
+}
+
+export function streamDeploymentLogs(
+ deploymentId: string,
+ onEvent: (event: SseEvent) => void,
+ onError: (error: Error) => void,
+ onComplete: () => void
+): () => void {
+ const token = getAccessToken();
+ const headers: HeadersInit = {
+ 'Accept': 'text/event-stream',
+ };
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const eventSource = new EventSource(
+ `${devopsApiUrl}/api/deployments/${deploymentId}/logs`
+ );
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ onEvent({ event: event.type || 'message', data: event.data });
+
+ if (event.type === 'complete' || event.type === 'error') {
+ onComplete();
+ eventSource.close();
+ }
+ } catch (error) {
+ onError(error as Error);
+ }
+ };
+
+ eventSource.onerror = (error) => {
+ onError(new Error('SSE connection error'));
+ eventSource.close();
+ onComplete();
+ };
+
+ // Return cleanup function
+ return () => {
+ eventSource.close();
+ };
+}
+
+export const api = {
+ // Services
+ getServices: () => apiRequest('/api/services'),
+ getService: (id: string) => apiRequest(`/api/services/${id}`),
+ createService: (data: Partial) =>
+ apiRequest('/api/services', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateService: (id: string, data: Partial) =>
+ apiRequest(`/api/services/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ deleteService: (id: string) =>
+ apiRequest(`/api/services/${id}`, {
+ method: 'DELETE',
+ }),
+
+ // Deployments
+ getDeployments: (limit = 20) => apiRequest(`/api/deployments?limit=${limit}`),
+ getServiceDeployments: (serviceId: string, limit = 50) =>
+ apiRequest(`/api/deployments/service/${serviceId}?limit=${limit}`),
+ getDeployment: (id: string) => apiRequest(`/api/deployments/${id}`),
+ triggerDeployment: (serviceId: string) =>
+ apiRequest<{ deploymentId: string; status: string }>(`/api/deployments/trigger/${serviceId}`, {
+ method: 'POST',
+ }),
+
+ // Health
+ getHealth: () => apiRequest('/api/health'),
+ getServiceHealth: (serviceId: string) =>
+ apiRequest(`/api/health/${serviceId}`),
+ clearHealthCache: () => apiRequest<{ message: string }>('/api/health/cache', { method: 'DELETE' }),
+
+ // Seed
+ seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }),
+
+ // Environment Variables
+ getEnvVars: () => apiRequest('/api/env'),
+ getEnvVar: (id: string) => apiRequest(`/api/env/${id}`),
+ createEnvVar: (data: Partial) =>
+ apiRequest('/api/env', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateEnvVar: (id: string, data: Partial) =>
+ apiRequest(`/api/env/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ deleteEnvVar: (id: string) =>
+ apiRequest(`/api/env/${id}`, {
+ method: 'DELETE',
+ }),
+ syncAzureKeyVault: () =>
+ apiRequest<{ synced: number; errors: string[] }>('/api/env/sync-azure', {
+ method: 'POST',
+ }),
+};
+
+// Standalone functions for environment variables (used by env page)
+export const getEnvVars = () => api.getEnvVars();
+export const createEnvVar = (data: Partial) => api.createEnvVar(data);
+export const updateEnvVar = (id: string, data: Partial) => api.updateEnvVar(id, data);
+export const deleteEnvVar = (id: string) => api.deleteEnvVar(id);
+
+// Azure Config
+export interface AzureConfig {
+ id: string;
+ tenantId: string;
+ clientId: string;
+ keyVaultUrl: string;
+ isActive: boolean;
+ updatedAt: string;
+ hasClientSecret?: boolean;
+}
+
+export const azureApi = {
+ getAzureConfig: () => apiRequest('/api/azure-config'),
+ createAzureConfig: (data: Partial) =>
+ apiRequest('/api/azure-config', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateAzureConfig: (id: string, data: Partial) =>
+ apiRequest(`/api/azure-config/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+ deleteAzureConfig: (id: string) =>
+ apiRequest(`/api/azure-config/${id}`, {
+ method: 'DELETE',
+ }),
+ testAzureConnection: () =>
+ apiRequest<{ success: boolean; error?: string }>('/api/azure-config/test', {
+ method: 'POST',
+ }),
+};
+
+export const getAzureConfig = () => azureApi.getAzureConfig();
+export const createAzureConfig = (data: Partial) => azureApi.createAzureConfig(data);
+export const updateAzureConfig = (id: string, data: Partial) => azureApi.updateAzureConfig(id, data);
+export const deleteAzureConfig = (id: string) => azureApi.deleteAzureConfig(id);
+export const testAzureConnection = () => azureApi.testAzureConnection();
+
+// Code Quality
+export interface CodeQualityIssue {
+ id: string;
+ type: 'error' | 'warning' | 'info';
+ category: 'typescript' | 'eslint' | 'build' | 'test' | 'format';
+ file: string;
+ line?: number;
+ column?: number;
+ message: string;
+ rule?: string;
+}
+
+export interface CodeQualityReport {
+ id: string;
+ projectId: string;
+ projectName: string;
+ projectPath: string;
+ timestamp: string;
+ summary: {
+ totalIssues: number;
+ errors: number;
+ warnings: number;
+ infos: number;
+ };
+ categories: {
+ typescript: {
+ errors: number;
+ warnings: number;
+ duration: number;
+ };
+ eslint: {
+ errors: number;
+ warnings: number;
+ duration: number;
+ };
+ build: {
+ success: boolean;
+ duration: number;
+ errors: number;
+ };
+ test: {
+ success: boolean;
+ passed: number;
+ failed: number;
+ duration: number;
+ };
+ };
+ issues: CodeQualityIssue[];
+}
+
+export interface CodeQualityCheckParams {
+ projectId: string;
+ projectPath: string;
+ checks: Array<'typescript' | 'eslint' | 'build' | 'test'>;
+}
+
+export const codeQualityApi = {
+ runCheck: (params: CodeQualityCheckParams) =>
+ apiRequest('/api/code-quality/check', {
+ method: 'POST',
+ body: JSON.stringify(params),
+ }),
+};
+
+export const runCodeQualityCheck = (params: CodeQualityCheckParams) => codeQualityApi.runCheck(params);
+
+// Auth API - calls platform-service for authentication
+export interface LoginRequest {
+ email: string;
+ password: string;
+ productId: string;
+}
+
+export interface LoginResponse {
+ accessToken: string;
+ refreshToken: string;
+ user: {
+ id: string;
+ email: string;
+ role: string;
+ plan: string;
+ displayName: string;
+ products?: Array<{
+ productId: string;
+ plan: string;
+ role: string;
+ }>;
+ };
+}
+
+export interface RefreshRequest {
+ refreshToken: string;
+}
+
+export interface RefreshResponse {
+ accessToken: string;
+ refreshToken: string;
+}
+
+export interface MeResponse {
+ id: string;
+ email: string;
+ role: string;
+ plan: string;
+ displayName: string;
+ emailVerified: boolean;
+ currentProduct: string;
+ products: Array<{
+ productId: string;
+ plan: string;
+ role: string;
+ }>;
+ mfaEnabled: boolean;
+ mfaMethods: string[];
+}
+
+export const authApi = {
+ login: async (data: LoginRequest): Promise => {
+ const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ error: 'Login failed' }));
+ throw new Error(error.error || 'Login failed');
+ }
+
+ return response.json();
+ },
+
+ refresh: async (data: RefreshRequest): Promise => {
+ const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/refresh`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) {
+ throw new Error('Token refresh failed');
+ }
+
+ return response.json();
+ },
+
+ me: async (token: string): Promise => {
+ const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/me`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to get user info');
+ }
+
+ return response.json();
+ },
+};
+
+// Helper functions for auth state management
+export function setAccessToken(token: string): void {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('access_token', token);
+ }
+}
+
+export function setRefreshToken(token: string): void {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('refresh_token', token);
+ }
+}
+
+export function getAccessTokenFromStorage(): string | null {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem('access_token');
+}
+
+export function getRefreshTokenFromStorage(): string | null {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem('refresh_token');
+}
+
+export function clearAuthTokens(): void {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ }
+}
+
+export async function refreshAccessToken(): Promise {
+ const refreshToken = getRefreshTokenFromStorage();
+ if (!refreshToken) return null;
+
+ try {
+ const response = await authApi.refresh({ refreshToken });
+ setAccessToken(response.accessToken);
+ setRefreshToken(response.refreshToken);
+ return response.accessToken;
+ } catch {
+ clearAuthTokens();
+ return null;
+ }
+}
diff --git a/dashboard/web/src/lib/product-config.ts b/dashboard/web/src/lib/product-config.ts
new file mode 100644
index 0000000..0add3ba
--- /dev/null
+++ b/dashboard/web/src/lib/product-config.ts
@@ -0,0 +1,11 @@
+// Local product identity (replaces @bytelyst/config)
+const productIdentity = {
+ productId: process.env.NEXT_PUBLIC_PRODUCT_ID || 'bytelyst-devops',
+ name: process.env.NEXT_PUBLIC_PRODUCT_NAME || 'ByteLyst DevOps Dashboard',
+};
+
+export const devopsApiUrl = process.env.NEXT_PUBLIC_DEVOPS_API_URL || 'http://localhost:4004';
+export const platformUrl = process.env.NEXT_PUBLIC_PLATFORM_URL || 'https://api.bytelyst.com/platform/api';
+
+export const productId = productIdentity.productId;
+export const productName = productIdentity.name;
diff --git a/dashboard/web/src/lib/telemetry.ts b/dashboard/web/src/lib/telemetry.ts
new file mode 100644
index 0000000..0100f83
--- /dev/null
+++ b/dashboard/web/src/lib/telemetry.ts
@@ -0,0 +1,39 @@
+/**
+ * Client-side self-telemetry for the DevOps dashboard.
+ * Delegates to @bytelyst/telemetry-client shared package.
+ * Sends to platform-service via /api/telemetry/admin-ingest proxy.
+ * Privacy: No PII. Only page paths, action names, and timing metrics.
+ *
+ * NOTE: Telemetry is disabled for now until @bytelyst/telemetry-client is available
+ */
+
+export interface TelemetryEvent {
+ action: string;
+ category?: string;
+ properties?: Record;
+ metrics?: Record;
+}
+
+export function trackEvent(event: TelemetryEvent): void {
+ // No-op - telemetry disabled
+}
+
+export function trackPageView(path: string): void {
+ // No-op - telemetry disabled
+}
+
+export function trackDeployment(serviceId: string, action: 'trigger' | 'success' | 'failed'): void {
+ // No-op - telemetry disabled
+}
+
+export function trackHealthCheck(serviceId: string, status: 'up' | 'down' | 'degraded'): void {
+ // No-op - telemetry disabled
+}
+
+export function trackUserAction(action: string, properties?: Record): void {
+ // No-op - telemetry disabled
+}
+
+export function initTelemetry(): void {
+ // No-op - telemetry disabled
+}
diff --git a/dashboard/web/src/lib/types.ts b/dashboard/web/src/lib/types.ts
new file mode 100644
index 0000000..6925596
--- /dev/null
+++ b/dashboard/web/src/lib/types.ts
@@ -0,0 +1 @@
+export type { Service, Deployment, ServiceHealth } from './api.js';
diff --git a/dashboard/web/src/test/setup.ts b/dashboard/web/src/test/setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/dashboard/web/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/dashboard/web/tailwind.config.ts b/dashboard/web/tailwind.config.ts
new file mode 100644
index 0000000..386bc84
--- /dev/null
+++ b/dashboard/web/tailwind.config.ts
@@ -0,0 +1,15 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/dashboard/web/tsconfig.json b/dashboard/web/tsconfig.json
new file mode 100644
index 0000000..f3c9779
--- /dev/null
+++ b/dashboard/web/tsconfig.json
@@ -0,0 +1,41 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/dashboard/web/vitest.config.ts b/dashboard/web/vitest.config.ts
new file mode 100644
index 0000000..366bed9
--- /dev/null
+++ b/dashboard/web/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ passWithNoTests: true,
+ },
+});
diff --git a/docs/repo-map.md b/docs/repo-map.md
index cfac83f..1221639 100644
--- a/docs/repo-map.md
+++ b/docs/repo-map.md
@@ -149,6 +149,25 @@ Key files:
- `utils/`
- `output/`
+### `dashboard/`
+
+ByteLyst DevOps dashboard — internal product for deployment orchestration and service monitoring.
+
+This is a full ByteLyst product (backend + web) integrated with the common platform:
+
+- Backend: Fastify 5 (port 4004) with platform-service auth, Cosmos DB, deployment orchestration
+- Web: Next.js 16 (port 3000) with react-auth, service status cards, deploy buttons
+- Integration: Links to/from admin-web, uses @bytelyst/* packages
+
+Key files:
+
+- `dashboard/backend/src/` — Fastify server, services/deployments/health modules
+- `dashboard/web/src/` — Next.js app, API client, auth provider
+- `dashboard/shared/product.json` — Product identity (devops-internal)
+- `dashboard/README.md` — Setup and usage documentation
+
+See `dashboard/README.md` for architecture and setup instructions.
+
### `_AZURE/`
Account-specific notes and operational docs.