953ba951db
CI / checks (pull_request) Failing after 13m18s
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.
153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
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
|
|
}
|