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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CloudStatus> = {}, 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<string, unknown>): 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})
|
||||
})
|
||||
})
|
||||
@@ -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<string, CloudServiceType[]> = {
|
||||
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<string, unknown>; 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<CloudProxyService, 'status' | 'services'>,
|
||||
dependencies: DiagnosticsDependencies = {},
|
||||
): Promise<RuntimeDiagnostics> {
|
||||
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<FlociHealth>(fetcher, `${awsEndpoint()}/_floci/health`) : null,
|
||||
flociCore ? fetchJson<FlociDiagnose>(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<string, unknown> | 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<T>(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
|
||||
}
|
||||
Reference in New Issue
Block a user