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:
@@ -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