From 953ba951db1c6fa68569cc5cb630eb7002864945 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 23 Oct 2026 11:29:48 -0400 Subject: [PATCH] feat: add a runtime diagnostics page Adds GET /api/clouds/:cloud/diagnostics and a Diagnostics page in the General nav. The report combines, in one call: - runtime reachability, latency, version and edition from /_floci/health - every service the runtime emulates, with the console pages backed by it - per-console-service probes (the existing status check) - EC2 instance containers from /_floci/diagnose - console uptime, sign-in mode and active session count Each source is optional, so a down runtime still yields a partial report. --- packages/api/src/cloud-spi/types.ts | 42 ++++ packages/api/src/routes/clouds.ts | 7 + .../src/service/runtimeDiagnostics.test.ts | 102 ++++++++ .../api/src/service/runtimeDiagnostics.ts | 152 ++++++++++++ packages/frontend/src/App.tsx | 5 + packages/frontend/src/api/api.ts | 8 + packages/frontend/src/api/cloudProxyClient.ts | 13 ++ .../frontend/src/api/queries/cloudQueries.ts | 11 + packages/frontend/src/components/Layout.tsx | 5 +- packages/frontend/src/index.css | 98 ++++++++ .../src/pages/DiagnosticsPage.test.tsx | 95 ++++++++ .../frontend/src/pages/DiagnosticsPage.tsx | 221 ++++++++++++++++++ packages/frontend/src/types/cloud.ts | 36 +++ 13 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/service/runtimeDiagnostics.test.ts create mode 100644 packages/api/src/service/runtimeDiagnostics.ts create mode 100644 packages/frontend/src/pages/DiagnosticsPage.test.tsx create mode 100644 packages/frontend/src/pages/DiagnosticsPage.tsx diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 4e0dcf2..fd68405 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -77,6 +77,48 @@ export interface CloudServiceStatus { errorCode: string | null } +/** One service as the runtime itself reports it, which can be far more than the console covers. */ +export interface EmulatedServiceStatus { + /** The runtime's own service id, e.g. `states` or `elasticloadbalancing`. */ + name: string + state: string + /** Console pages backed by this service; empty when the console has none yet. */ + consolePages: Array<{service: CloudServiceType; displayName: string; route: string}> +} + +/** A container the runtime started to back a resource, as reported by its diagnostics. */ +export interface RuntimeContainer { + kind: 'ec2-instance' + resourceId: string + status: string | null + sshHost: string | null + sshPort: number | null +} + +export interface RuntimeDiagnostics { + cloud: CloudProvider + checkedAt: string + runtime: { + status: RuntimeReachability + endpoint: string | null + latencyMs: number | null + error: string | null + version: string | null + edition: string | null + } + /** Null when the runtime does not publish a per-service list. */ + emulatedServices: EmulatedServiceStatus[] | null + consoleServices: CloudServiceStatus[] + /** Null when the runtime does not publish container diagnostics. */ + containers: RuntimeContainer[] | null + console: { + startedAt: string + uptimeSeconds: number + authMode: 'local' | 'oidc' + activeSessions: number + } +} + export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value' export interface FieldSchema { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 4e34a75..06f39de 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog' import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {serviceForAccount} from '../cloudProxy' import {CloudProxyService} from '../service/CloudProxyService' +import {runtimeDiagnostics} from '../service/runtimeDiagnostics' import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment' // Header (and query-param fallback for direct links such as object downloads) @@ -64,6 +65,12 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { return c.json(await svc(c).status(cloud, {includeServices})) }) + app.get('/:cloud/diagnostics', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + return c.json(await runtimeDiagnostics(cloud, svc(c))) + }) + app.get('/:cloud/services/:service/status', async (c) => { const cloud = c.req.param('cloud') as CloudProvider const serviceType = c.req.param('service') as CloudServiceType diff --git a/packages/api/src/service/runtimeDiagnostics.test.ts b/packages/api/src/service/runtimeDiagnostics.test.ts new file mode 100644 index 0000000..732b67e --- /dev/null +++ b/packages/api/src/service/runtimeDiagnostics.test.ts @@ -0,0 +1,102 @@ +import {describe, expect, test} from 'bun:test' +import type {CloudProvider, CloudServiceDescriptor, CloudStatus} from '../cloud-spi/types' +import {runtimeDiagnostics} from './runtimeDiagnostics' + +const STARTED_AT = new Date('2026-10-23T08:00:00.000Z') +const NOW = new Date('2026-10-23T10:30:15.000Z') + +function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor { + return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1} +} + +function proxy(status: Partial = {}, descriptors: CloudServiceDescriptor[] = []) { + return { + status: async (cloud: CloudProvider) => ({ + cloud, adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), error: null, + services: [{cloud, service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), latencyMs: 4, error: null, errorCode: null}], + ...status, + }) as CloudStatus, + services: () => descriptors, + } +} + +function runtime(responses: Record): typeof fetch { + return (async (input: string | URL | Request) => { + const path = new URL(String(input instanceof Request ? input.url : input)).pathname + return path in responses ? Response.json(responses[path]) : new Response('not found', {status: 404}) + }) as unknown as typeof fetch +} + +const fixed = {now: () => NOW, startedAt: STARTED_AT, authMode: 'oidc' as const, activeSessions: () => 2} + +describe('runtimeDiagnostics', () => { + test('reports the runtime version, every emulated service and its console pages', async () => { + const report = await runtimeDiagnostics('aws', proxy({}, [ + descriptor('storage', 'S3'), + descriptor('loadbalancing', 'Load Balancers'), + descriptor('targetgroups', 'Target Groups'), + descriptor('scheduler', 'Scheduler', 'coming_soon'), + ]), { + ...fixed, + fetch: runtime({'/_floci/health': {version: '1.9.0', edition: 'compat', services: {s3: 'running', elasticloadbalancing: 'running', route53: 'running', scheduler: 'stopped'}}}), + }) + + expect(report.runtime).toMatchObject({status: 'reachable', endpoint: 'http://floci:4566', version: '1.9.0', edition: 'compat', error: null}) + expect(report.runtime.latencyMs).toBeNumber() + expect(report.emulatedServices).toEqual([ + {name: 'elasticloadbalancing', state: 'running', consolePages: [ + {service: 'loadbalancing', displayName: 'Load Balancers', route: '/cloud-explorer/aws/loadbalancing'}, + {service: 'targetgroups', displayName: 'Target Groups', route: '/cloud-explorer/aws/targetgroups'}, + ]}, + {name: 'route53', state: 'running', consolePages: []}, + {name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]}, + {name: 'scheduler', state: 'stopped', consolePages: []}, + ]) + expect(report.consoleServices).toHaveLength(1) + }) + + test('lists the containers the runtime started for EC2 instances', async () => { + const report = await runtimeDiagnostics('aws', proxy(), { + ...fixed, + fetch: runtime({'/_floci/diagnose': {ec2: {instances: [ + {instanceId: 'i-0abc', containerStatus: 'running', sshHost: 'localhost', sshPort: 2201}, + {instanceId: 'i-0def', containerStatus: 'stopped'}, + {containerStatus: 'running'}, + ]}}}), + }) + + expect(report.containers).toEqual([ + {kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}, + {kind: 'ec2-instance', resourceId: 'i-0def', status: 'stopped', sshHost: null, sshPort: null}, + ]) + }) + + test('still returns a partial report when the runtime is down', async () => { + const report = await runtimeDiagnostics('aws', proxy({runtime: 'unavailable', error: 'Cannot reach Floci core at http://floci:4566', services: []}), { + ...fixed, + fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch, + }) + + expect(report.runtime).toEqual({status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null}) + expect(report.emulatedServices).toBeNull() + expect(report.containers).toBeNull() + }) + + test('does not query Floci core endpoints for other clouds', async () => { + const requested: string[] = [] + const report = await runtimeDiagnostics('azure', proxy(), { + ...fixed, + fetch: (async (input: string | URL | Request) => { requested.push(String(input)); return Response.json({}) }) as unknown as typeof fetch, + }) + + expect(requested).toEqual([]) + expect(report.emulatedServices).toBeNull() + }) + + test('describes the console process itself', async () => { + const report = await runtimeDiagnostics('aws', proxy(), {...fixed, fetch: runtime({})}) + + expect(report.checkedAt).toBe('2026-10-23T10:30:15.000Z') + expect(report.console).toEqual({startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9015, authMode: 'oidc', activeSessions: 2}) + }) +}) diff --git a/packages/api/src/service/runtimeDiagnostics.ts b/packages/api/src/service/runtimeDiagnostics.ts new file mode 100644 index 0000000..c8076c4 --- /dev/null +++ b/packages/api/src/service/runtimeDiagnostics.ts @@ -0,0 +1,152 @@ +import {authStateSizes} from '../auth' +import type { + CloudProvider, + CloudServiceDescriptor, + CloudServiceType, + EmulatedServiceStatus, + RuntimeContainer, + RuntimeDiagnostics, +} from '../cloud-spi/types' +import type {CloudProxyService} from './CloudProxyService' +import {awsEndpoint} from './runtimeProbe' + +const DIAGNOSTICS_TIMEOUT_MS = 3_000 +const processStartedAt = new Date() + +/** + * Floci core service ids mapped to the console services they back. Only the + * runtime's own ids are listed; whether a console page is actually available is + * still decided by the adapter registry through `services()`. + */ +const AWS_CONSOLE_SERVICES: Record = { + apigateway: ['apigateway'], + appconfig: ['configuration'], + cloudformation: ['iac'], + codebuild: ['codebuild'], + codedeploy: ['codedeploy'], + codepipeline: ['codepipeline'], + dynamodb: ['nosql'], + ec2: ['compute', 'networking'], + ecr: ['ecr'], + ecs: ['ecs', 'fargate'], + eks: ['k8s'], + elasticache: ['elasticache'], + elasticloadbalancing: ['loadbalancing', 'targetgroups'], + email: ['email'], + events: ['events'], + iam: ['identity'], + kafka: ['msk'], + kafkaconnect: ['msk-connect'], + kinesis: ['kinesis'], + kms: ['kms'], + lambda: ['serverless'], + lightsail: ['lightsail'], + logs: ['logs'], + rds: ['database'], + 'rds-data': ['database'], + s3: ['storage'], + secretsmanager: ['secrets'], + sns: ['notifications'], + sqs: ['messaging'], + ssm: ['parameters'], + states: ['workflows'], +} + +type FlociHealth = {services?: Record; version?: unknown; edition?: unknown} +type FlociDiagnose = {ec2?: {instances?: Array<{instanceId?: unknown; containerStatus?: unknown; sshHost?: unknown; sshPort?: unknown}>}} + +export type DiagnosticsDependencies = { + fetch?: typeof fetch + now?: () => Date + startedAt?: Date + authMode?: 'local' | 'oidc' + activeSessions?: () => number +} + +/** + * Everything the console can learn about a runtime in one call. Each source is + * optional: a runtime that is down, or one that does not publish a service list + * or container diagnostics, still yields a partial report rather than an error. + */ +export async function runtimeDiagnostics( + cloud: CloudProvider, + proxy: Pick, + dependencies: DiagnosticsDependencies = {}, +): Promise { + const fetcher = dependencies.fetch ?? globalThis.fetch + const now = dependencies.now?.() ?? new Date() + const startedAt = dependencies.startedAt ?? processStartedAt + const flociCore = cloud === 'aws' + + const [status, health, diagnose] = await Promise.all([ + proxy.status(cloud, {includeServices: true}), + flociCore ? fetchJson(fetcher, `${awsEndpoint()}/_floci/health`) : null, + flociCore ? fetchJson(fetcher, `${awsEndpoint()}/_floci/diagnose`) : null, + ]) + + return { + cloud, + checkedAt: now.toISOString(), + runtime: { + status: status.runtime, + endpoint: status.endpoint, + latencyMs: health?.latencyMs ?? null, + error: status.error, + version: stringOrNull(health?.body.version), + edition: stringOrNull(health?.body.edition), + }, + emulatedServices: emulatedServices(health?.body.services, proxy.services(cloud)), + consoleServices: status.services ?? [], + containers: containers(diagnose?.body), + console: { + startedAt: startedAt.toISOString(), + uptimeSeconds: Math.max(0, Math.floor((now.getTime() - startedAt.getTime()) / 1000)), + authMode: dependencies.authMode ?? (process.env.FLOCI_UI_AUTH_MODE === 'oidc' ? 'oidc' : 'local'), + activeSessions: (dependencies.activeSessions ?? (() => authStateSizes().sessions))(), + }, + } +} + +function emulatedServices(reported: Record | undefined, descriptors: CloudServiceDescriptor[]): EmulatedServiceStatus[] | null { + if (!reported || typeof reported !== 'object') return null + const available = new Map(descriptors.filter((descriptor) => descriptor.availability === 'available').map((descriptor) => [descriptor.service, descriptor])) + return Object.entries(reported) + .map(([name, state]) => ({ + name, + state: typeof state === 'string' ? state : 'unknown', + consolePages: (AWS_CONSOLE_SERVICES[name] ?? []) + .map((service) => available.get(service)) + .filter((descriptor): descriptor is CloudServiceDescriptor => Boolean(descriptor)) + .map(({service, displayName, route}) => ({service, displayName, route})), + })) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function containers(diagnose: FlociDiagnose | undefined): RuntimeContainer[] | null { + const instances = diagnose?.ec2?.instances + if (!Array.isArray(instances)) return null + return instances + .filter((instance) => typeof instance.instanceId === 'string') + .map((instance) => ({ + kind: 'ec2-instance' as const, + resourceId: instance.instanceId as string, + status: stringOrNull(instance.containerStatus), + sshHost: stringOrNull(instance.sshHost), + sshPort: typeof instance.sshPort === 'number' ? instance.sshPort : null, + })) +} + +async function fetchJson(fetcher: typeof fetch, url: string): Promise<{body: T; latencyMs: number} | null> { + const startedAt = performance.now() + try { + const response = await fetcher(url, {signal: AbortSignal.timeout(DIAGNOSTICS_TIMEOUT_MS)}) + if (!response.ok) return null + return {body: await response.json() as T, latencyMs: Math.round(performance.now() - startedAt)} + } catch { + return null + } +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value ? value : null +} diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 35f7f2c..39c4c3c 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage' import {CloudExplorerPage} from '@/pages/CloudExplorerPage' import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage' import {DatabaseDataPage} from '@/pages/DatabaseDataPage' +import {DiagnosticsPage} from '@/pages/DiagnosticsPage' import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage' import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage' @@ -18,6 +19,10 @@ export default function App() { }/> }/> }/> + }/> + }/> + }/> + }/> }/> }/> }/> diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 9593132..0a4f2f1 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -13,6 +13,7 @@ export const apiEndpointKeys = { list: "clouds.list", services: "clouds.services.list", status: "clouds.status.get", + diagnostics: "clouds.diagnostics.get", serviceStatus: "clouds.services.status.get", schema: "clouds.services.schema.get", dependencies: "clouds.services.dependencies.get", @@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "GET", }, ], + [ + apiEndpointKeys.clouds.diagnostics, + { + path: "/clouds/:cloud/diagnostics", + method: "GET", + }, + ], [ apiEndpointKeys.clouds.serviceStatus, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index a9bef9c..7a387e3 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -6,6 +6,7 @@ import type { CloudServiceStatus, CloudServiceType, CloudStatus, + RuntimeDiagnostics, } from "@/types/cloud"; import type { AppConfigConfigurationProfile, @@ -86,6 +87,18 @@ export async function getCloudStatus( return res.data; } +export async function getCloudDiagnostics( + cloud: CloudProvider, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.diagnostics, + requestOptions(cloud, "cloud-proxy", { signal }), + { cloud }, + ); + return res.data; +} + export async function getCloudServiceStatus( cloud: CloudProvider, service: CloudServiceType, diff --git a/packages/frontend/src/api/queries/cloudQueries.ts b/packages/frontend/src/api/queries/cloudQueries.ts index 3e0715f..f7e4770 100644 --- a/packages/frontend/src/api/queries/cloudQueries.ts +++ b/packages/frontend/src/api/queries/cloudQueries.ts @@ -1,5 +1,6 @@ import {useQuery} from '@tanstack/react-query' import { + getCloudDiagnostics, getCloudResource, getCloudServiceStatus, getCloudStatus, @@ -20,6 +21,7 @@ export const cloudQueryKeys = { clouds: ['clouds'] as const, services: (cloud: CloudProvider) => ['cloud-services', cloud] as const, status: (cloud: CloudProvider) => ['cloud-status', cloud] as const, + diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const, serviceStatus: (cloud: CloudProvider, service: CloudServiceType) => ['cloud-service-status', cloud, service] as const, resources: (cloud: CloudProvider, service: CloudServiceType) => @@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter }) } +/** Full runtime report; polled slowly because it probes every console service. */ +export function useCloudDiagnosticsQuery(cloud: CloudProvider) { + return useQuery({ + queryKey: cloudQueryKeys.diagnostics(cloud), + queryFn: ({signal}) => getCloudDiagnostics(cloud, signal), + refetchInterval: 30_000, + }) +} + /** Health of the single service being viewed, rather than of the whole cloud. */ export function useCloudServiceStatusQuery( cloud: CloudProvider, diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 222e576..daa57e8 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom' -import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react' +import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react' import {useCallback, useEffect, useRef, useState} from 'react' import theCloud from '../../../../TheCloud.png' import {useTheme} from '@/lib/useTheme' @@ -184,6 +184,7 @@ export function Layout() {
General
@@ -356,6 +357,6 @@ function TopbarSearch() { } function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' { - const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/) + const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/) return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp' } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index a066f85..db17acb 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -4261,6 +4261,104 @@ pre, gap: 14px; } +.diagnostics { + display: grid; + gap: 14px; +} + +.diagnostics .table-panel { + overflow-x: auto; +} + +.diagnostics-section-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.diagnostics-section-header h2 { + margin: 0; + color: var(--text-bright); + font-size: 14px; +} + +.diagnostics-filters, +.diagnostics-filter-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.diagnostics-filters .input { + width: 180px; +} + +.diagnostics-service-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + margin: 0; + padding: 0; + list-style: none; +} + +.diagnostics-service { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + padding: 9px 16px; + border-bottom: 1px solid var(--border-faint); + font-size: 12px; +} + +.diagnostics-page-link { + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--link); + font-size: 11px; + text-decoration: none; +} + +.diagnostics-page-link:hover { + border-color: var(--link); +} + +.diagnostics-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin: 0; +} + +.diagnostics-facts div { + padding: 12px 16px; +} + +.diagnostics-facts dt { + color: var(--text-2); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.diagnostics-facts dd { + margin: 4px 0 0; + color: var(--text-bright); +} + +@media (max-width: 520px) { + .diagnostics-filters .input { + width: 100%; + } +} + .console-provider-banner { display: flex; align-items: center; diff --git a/packages/frontend/src/pages/DiagnosticsPage.test.tsx b/packages/frontend/src/pages/DiagnosticsPage.test.tsx new file mode 100644 index 0000000..2f74ea2 --- /dev/null +++ b/packages/frontend/src/pages/DiagnosticsPage.test.tsx @@ -0,0 +1,95 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {render, screen, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {MemoryRouter, Route, Routes} from 'react-router-dom' +import {expect, test, vi} from 'vitest' +import {DiagnosticsPage} from './DiagnosticsPage' +import type {RuntimeDiagnostics} from '@/types/cloud' + +const api = vi.hoisted(() => ({getCloudDiagnostics: vi.fn()})) + +vi.mock('@/api/cloudProxyClient', () => api) + +const healthy: RuntimeDiagnostics = { + cloud: 'aws', + checkedAt: new Date().toISOString(), + runtime: {status: 'reachable', endpoint: 'http://floci:4566', latencyMs: 12, error: null, version: '1.9.0', edition: 'community'}, + emulatedServices: [ + {name: 'route53', state: 'running', consolePages: []}, + {name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]}, + {name: 'sqs', state: 'stopped', consolePages: [{service: 'messaging', displayName: 'SQS', route: '/cloud-explorer/aws/messaging'}]}, + ], + consoleServices: [ + {cloud: 'aws', service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 4, error: null, errorCode: null}, + {cloud: 'aws', service: 'messaging', adapterRegistered: true, runtime: 'unavailable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 9, error: 'Service is not running', errorCode: 'RUNTIME_UNAVAILABLE'}, + ], + containers: [{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}], + console: {startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9_015, authMode: 'oidc', activeSessions: 2}, +} + +function renderPage(path = '/diagnostics/aws') { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render( + + + }/> + + , + ) +} + +test('summarises the runtime and lists failing console services first', async () => { + api.getCloudDiagnostics.mockResolvedValue(healthy) + renderPage() + + const summary = await screen.findByRole('region', {name: 'Runtime summary'}) + expect(within(summary).getByText('Reachable')).toBeInTheDocument() + expect(screen.getByText('http://floci:4566 · 12 ms')).toBeInTheDocument() + expect(screen.getByText('1.9.0')).toBeInTheDocument() + expect(screen.getByText('1 not running')).toBeInTheDocument() + expect(screen.getByText('2 / 3')).toBeInTheDocument() + + const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row') + expect(rows[1]).toHaveTextContent('messaging') + expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)') + + expect(screen.getByRole('link', {name: 'i-0abc'})).toHaveAttribute('href', '/cloud-explorer/aws/compute?resource=i-0abc') + expect(screen.getByText('localhost:2201')).toBeInTheDocument() + expect(screen.getByText('2 h 30 min')).toBeInTheDocument() + expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument() +}) + +test('filters emulated services by console coverage and state', async () => { + const user = userEvent.setup() + api.getCloudDiagnostics.mockResolvedValue(healthy) + renderPage() + const section = await screen.findByRole('region', {name: 'Emulated services'}) + + await user.click(within(section).getByRole('button', {name: 'No console page'})) + expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['route53']) + + await user.click(within(section).getByRole('button', {name: 'Not running'})) + expect(within(section).getByRole('link', {name: 'SQS'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging') + expect(within(section).getAllByRole('listitem')).toHaveLength(1) + + await user.click(within(section).getByRole('button', {name: 'All'})) + await user.type(within(section).getByRole('searchbox'), 's3') + expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['s3S3']) +}) + +test('shows what is known when the runtime is down', async () => { + api.getCloudDiagnostics.mockResolvedValue({ + ...healthy, + runtime: {status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null}, + emulatedServices: null, + consoleServices: [], + containers: null, + } satisfies RuntimeDiagnostics) + renderPage() + + expect(await screen.findByText('Unavailable')).toBeInTheDocument() + expect(screen.getByText('Cannot reach Floci core at http://floci:4566')).toBeInTheDocument() + expect(screen.getAllByText('Not reported by the runtime')).toHaveLength(2) + expect(screen.queryByRole('region', {name: 'Emulated services'})).not.toBeInTheDocument() + expect(screen.getByText('This runtime does not publish container diagnostics.')).toBeInTheDocument() +}) diff --git a/packages/frontend/src/pages/DiagnosticsPage.tsx b/packages/frontend/src/pages/DiagnosticsPage.tsx new file mode 100644 index 0000000..4fddce8 --- /dev/null +++ b/packages/frontend/src/pages/DiagnosticsPage.tsx @@ -0,0 +1,221 @@ +import {useMemo, useState} from 'react' +import {Activity, RefreshCw} from 'lucide-react' +import {Link, Navigate, useParams} from 'react-router-dom' +import {useCloudDiagnosticsQuery} from '@/api/queries/cloudQueries' +import {formatDateTime, formatRelativeTime} from '@/lib/format' +import type {CloudProvider, CloudServiceStatus, EmulatedServiceStatus, RuntimeDiagnostics} from '@/types/cloud' + +type ServiceFilter = 'all' | 'not-running' | 'with-page' | 'without-page' + +const SERVICE_FILTERS: Array<{id: ServiceFilter; label: string}> = [ + {id: 'all', label: 'All'}, + {id: 'not-running', label: 'Not running'}, + {id: 'with-page', label: 'Has console page'}, + {id: 'without-page', label: 'No console page'}, +] + +export function DiagnosticsPage() { + const params = useParams() + const cloud = normalizeCloud(params.cloud) + const query = useCloudDiagnosticsQuery(cloud ?? 'aws') + + if (!cloud) return + + const report = query.data + return ( + <> +
+
+ +
+

Diagnostics

+

{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}

+
+
+ +
+ +
+ {query.isLoading &&

Running diagnostics

} + {query.error &&

Could not load diagnostics: {query.error.message}

} + {report && } +
+ + ) +} + +function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) { + const emulated = report.emulatedServices + const notRunning = emulated?.filter((service) => service.state !== 'running').length ?? 0 + const covered = emulated?.filter((service) => service.consolePages.length > 0).length ?? 0 + const reachable = report.runtime.status === 'reachable' + + return ( + <> +
+
+ Runtime + {reachable ? 'Reachable' : 'Unavailable'} + {report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''} +
+
+ Version + {report.runtime.version ?? 'Unknown'} + {report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'} +
+
0 ? 'pending' : ''}`}> + Emulated services + {emulated ? emulated.length : '—'} + {emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'} +
+
+ Console coverage + {emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`} + {emulated ? 'Emulated services with a console page' : 'Console services'} +
+
+ + {report.runtime.error &&

{report.runtime.error}

} + + + {emulated && } + + + + ) +} + +function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) { + const failing = services.filter((service) => service.runtime !== 'reachable') + const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')] + return ( +
+
+

Console services

+ {failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`} +
+ {services.length === 0 + ?

No console services are registered for this cloud.

+ : + + {ordered.map((service) => ( + + + + + + + ))} +
ServiceStatusLatencyDetail
{service.service}{statusLabel(service.runtime)}{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}{service.error ? {service.error}{service.errorCode ? ` (${service.errorCode})` : ''} : —}
} +
+ ) +} + +function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) { + const [filter, setFilter] = useState('all') + const [search, setSearch] = useState('') + const visible = useMemo(() => { + const term = search.trim().toLowerCase() + return services.filter((service) => { + if (term && !service.name.includes(term) && !service.consolePages.some((page) => page.displayName.toLowerCase().includes(term))) return false + if (filter === 'not-running') return service.state !== 'running' + if (filter === 'with-page') return service.consolePages.length > 0 + if (filter === 'without-page') return service.consolePages.length === 0 + return true + }) + }, [services, filter, search]) + + return ( +
+
+

Emulated services

+
+
+ {SERVICE_FILTERS.map((option) => ( + + ))} +
+ setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/> +
+
+ {visible.length === 0 + ?

No emulated services match this filter.

+ :
    {visible.map((service) => ( +
  • +
  • + ))}
} +
+ ) +} + +function ContainersTable({report}: {report: RuntimeDiagnostics}) { + const containers = report.containers + return ( +
+
+

Runtime containers

+ Containers started for EC2 instances +
+ {containers === null &&

This runtime does not publish container diagnostics.

} + {containers?.length === 0 &&

No EC2 instance containers are running.

} + {containers && containers.length > 0 && + + {containers.map((container) => ( + + + + + + ))} +
InstanceContainerSSH
{container.resourceId}{container.status ?? '—'}{container.sshPort ? {container.sshHost ?? 'localhost'}:{container.sshPort} : '—'}
} +
+ ) +} + +function ConsoleProcess({report}: {report: RuntimeDiagnostics}) { + return ( +
+
+

Console

+
+
+
Uptime
{formatUptime(report.console.uptimeSeconds)}
+
Started
{formatDateTime(report.console.startedAt) ?? report.console.startedAt}
+
Sign-in
{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}
+
Active sessions
{report.console.activeSessions}
+
+
+ ) +} + +function formatUptime(totalSeconds: number): string { + const days = Math.floor(totalSeconds / 86_400) + const hours = Math.floor((totalSeconds % 86_400) / 3_600) + const minutes = Math.floor((totalSeconds % 3_600) / 60) + if (days > 0) return `${days} d ${hours} h` + if (hours > 0) return `${hours} h ${minutes} min` + if (minutes > 0) return `${minutes} min` + return `${totalSeconds} s` +} + +function statusClass(runtime: CloudServiceStatus['runtime']): string { + if (runtime === 'reachable') return 'healthy' + if (runtime === 'coming_soon') return 'degraded' + return 'unavailable' +} + +function statusLabel(runtime: CloudServiceStatus['runtime']): string { + if (runtime === 'reachable') return 'Reachable' + if (runtime === 'coming_soon') return 'Not registered' + return 'Unavailable' +} + +function normalizeCloud(value?: string): CloudProvider | null { + return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null +} diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index e7df236..3f9c5e1 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -83,3 +83,39 @@ export interface CloudServiceStatus { /** Mapped error code, so "not implemented" reads differently from "down". */ errorCode: string | null } + +export interface EmulatedServiceStatus { + name: string + state: string + consolePages: Array<{service: CloudServiceType; displayName: string; route: string}> +} + +export interface RuntimeContainer { + kind: 'ec2-instance' + resourceId: string + status: string | null + sshHost: string | null + sshPort: number | null +} + +export interface RuntimeDiagnostics { + cloud: CloudProvider + checkedAt: string + runtime: { + status: RuntimeReachability + endpoint: string | null + latencyMs: number | null + error: string | null + version: string | null + edition: string | null + } + emulatedServices: EmulatedServiceStatus[] | null + consoleServices: CloudServiceStatus[] + containers: RuntimeContainer[] | null + console: { + startedAt: string + uptimeSeconds: number + authMode: 'local' | 'oidc' + activeSessions: number + } +}