Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b166d86d4 | |||
| f956d977ca | |||
| 9f38313e59 | |||
| ece36e4a34 | |||
| 953ba951db | |||
| dd16bcf4cd | |||
| 432d9d4ce1 | |||
| b171f745db | |||
| f5e70a78d8 |
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type-check
|
||||
run: pnpm type-check
|
||||
|
||||
- name: API tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Frontend tests
|
||||
run: pnpm --filter @floci/frontend test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -1,4 +1,6 @@
|
||||
(() => {
|
||||
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
|
||||
const THEME_KEY = 'floci-theme';
|
||||
const root = document.documentElement;
|
||||
const buttons = document.querySelectorAll('.tg button');
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
@@ -6,7 +8,7 @@
|
||||
function setTheme(theme) {
|
||||
root.setAttribute('data-theme', theme);
|
||||
try {
|
||||
localStorage.setItem('thecloud-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
} catch (ignored) {
|
||||
// Theme persistence is optional when browser storage is unavailable.
|
||||
}
|
||||
@@ -15,7 +17,7 @@
|
||||
|
||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
try {
|
||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
||||
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||
initialTheme = savedTheme;
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ describe('AwsLightsailAdapter', () => {
|
||||
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||
const fetchMock = globalThis.fetch
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({mappings: [
|
||||
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
||||
]}))
|
||||
]}))) as unknown as typeof fetch
|
||||
try {
|
||||
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
||||
expect(resource?.metadata.hostPortMappings).toEqual([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
|
||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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<RuntimeDiagnostics> {
|
||||
const res = await apiClient.call<RuntimeDiagnostics>(
|
||||
apiEndpointKeys.clouds.diagnostics,
|
||||
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudServiceStatus(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
||||
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'})
|
||||
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml', parameters: '{}', capabilities: ''})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('CodeDeployCreatePanel', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
|
||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
||||
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||
|
||||
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/^Cluster name/)).not.toBeInTheDocument()
|
||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
|
||||
})
|
||||
|
||||
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
|
||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
||||
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn')
|
||||
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
|
||||
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
|
||||
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
|
||||
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
|
||||
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
|
||||
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||
await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
|
||||
await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
|
||||
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
|
||||
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
|
||||
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
|
||||
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
|
||||
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
|
||||
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
||||
await user.click(screen.getByLabelText('Roll back automatically on deployment failure'))
|
||||
await user.click(screen.getByLabelText(/Automatic rollback/))
|
||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
|
||||
|
||||
@@ -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() {
|
||||
<div className="nav-section">
|
||||
<span className="nav-label">General</span>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
|
||||
</div>
|
||||
<CloudServiceNav collapsed={collapsed}/>
|
||||
</nav>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {afterEach, expect, test, vi} from 'vitest'
|
||||
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.useFakeTimers({shouldAdvanceTime: true})
|
||||
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})
|
||||
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
|
||||
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
||||
api.putFlatItem.mockResolvedValue({})
|
||||
@@ -32,6 +37,10 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
|
||||
await user.click(screen.getByRole('button', {name: /create listener/i}))
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
|
||||
|
||||
// Creation holds its pending state for a minimum of five seconds, which disables the row actions.
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: /delete listener 80/i})).toBeEnabled())
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const refetchHealth = healthQuery.refetch
|
||||
|
||||
useEffect(() => {
|
||||
setInstanceId('')
|
||||
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
return
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void healthQuery.refetch()
|
||||
void refetchHealth()
|
||||
}, 1_000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [pendingTargets.length, healthQuery.refetch])
|
||||
}, [pendingTargets.length, refetchHealth])
|
||||
|
||||
const refresh = () => {
|
||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||
|
||||
@@ -779,6 +779,47 @@ pre,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.topbar .search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar .search input {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.topbar .kbd,
|
||||
.topbar .account-meta,
|
||||
.topbar .connection-target {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar .connection-state {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar .connection {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topbar .account-trigger {
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-status:empty + .account-switcher {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -4261,6 +4302,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;
|
||||
|
||||
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'cyber'
|
||||
|
||||
/** Shared with the login page (public/login.js) and index.html. */
|
||||
const THEME_KEY = 'floci-theme'
|
||||
|
||||
function getStored(): Theme {
|
||||
const v = localStorage.getItem('floci-theme')
|
||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — fall back to the default theme.
|
||||
return 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('floci-theme', theme)
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
} catch {
|
||||
// Persisting the choice is optional when storage is unavailable.
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return {theme, setTheme}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes><Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/></Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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 consoleServices = screen.getByRole('region', {name: 'Console services'})
|
||||
const rows = within(consoleServices).getAllByRole('row')
|
||||
expect(rows).toHaveLength(2)
|
||||
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('keeps responding console services behind a toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue({
|
||||
...healthy,
|
||||
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
|
||||
} satisfies RuntimeDiagnostics)
|
||||
renderPage()
|
||||
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
|
||||
|
||||
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
|
||||
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
|
||||
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
|
||||
|
||||
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
|
||||
expect(within(consoleServices).queryByRole('table')).not.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()
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
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 <Navigate to="/diagnostics/aws" replace/>
|
||||
|
||||
const report = query.data
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Activity size={20}/>
|
||||
<div>
|
||||
<h1>Diagnostics</h1>
|
||||
<p className="muted">{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>
|
||||
<RefreshCw size={14}/>{query.isFetching ? 'Checking' : 'Re-check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="content diagnostics">
|
||||
{query.isLoading && <p className="muted padded">Running diagnostics</p>}
|
||||
{query.error && <p className="error-text">Could not load diagnostics: {query.error.message}</p>}
|
||||
{report && <DiagnosticsReport report={report}/>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<section className="console-summary" aria-label="Runtime summary">
|
||||
<div className={`summary-tile ${reachable ? 'ready' : 'unavailable'}`}>
|
||||
<span>Runtime</span>
|
||||
<strong>{reachable ? 'Reachable' : 'Unavailable'}</strong>
|
||||
<small>{report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''}</small>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<span>Version</span>
|
||||
<strong>{report.runtime.version ?? 'Unknown'}</strong>
|
||||
<small>{report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'}</small>
|
||||
</div>
|
||||
<div className={`summary-tile ${notRunning > 0 ? 'pending' : ''}`}>
|
||||
<span>Emulated services</span>
|
||||
<strong>{emulated ? emulated.length : '—'}</strong>
|
||||
<small>{emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'}</small>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<span>Console coverage</span>
|
||||
<strong>{emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`}</strong>
|
||||
<small>{emulated ? 'Emulated services with a console page' : 'Console services'}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{report.runtime.error && <p className="error-text">{report.runtime.error}</p>}
|
||||
|
||||
<ConsoleServicesTable services={report.consoleServices}/>
|
||||
{emulated && <EmulatedServices services={emulated}/>}
|
||||
<ContainersTable report={report}/>
|
||||
<ConsoleProcess report={report}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||
const [showResponding, setShowResponding] = useState(false)
|
||||
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||
const responding = services.filter((service) => service.runtime === 'reachable')
|
||||
const ordered = showResponding ? [...failing, ...responding] : failing
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console-services">Console services</h2>
|
||||
<div className="diagnostics-filters">
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
|
||||
{responding.length > 0 && (
|
||||
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
|
||||
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
|
||||
{ordered.length > 0 && <table className="table">
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||
<tbody>{ordered.map((service) => (
|
||||
<tr key={service.service}>
|
||||
<td><code>{service.service}</code></td>
|
||||
<td><span className={`status ${statusClass(service.runtime)}`}>{statusLabel(service.runtime)}</span></td>
|
||||
<td>{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}</td>
|
||||
<td>{service.error ? <span className="error-text compact-text">{service.error}{service.errorCode ? ` (${service.errorCode})` : ''}</span> : <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) {
|
||||
const [filter, setFilter] = useState<ServiceFilter>('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 (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-emulated-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-emulated-services">Emulated services</h2>
|
||||
<div className="diagnostics-filters">
|
||||
<div className="diagnostics-filter-group" role="group" aria-label="Filter emulated services">
|
||||
{SERVICE_FILTERS.map((option) => (
|
||||
<button key={option.id} className={`button compact ${filter === option.id ? 'primary' : ''}`} type="button" aria-pressed={filter === option.id} onClick={() => setFilter(option.id)}>{option.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input" type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/>
|
||||
</div>
|
||||
</header>
|
||||
{visible.length === 0
|
||||
? <p className="muted padded">No emulated services match this filter.</p>
|
||||
: <ul className="diagnostics-service-grid">{visible.map((service) => (
|
||||
<li key={service.name} className="diagnostics-service">
|
||||
<span className={`dot ${service.state === 'running' ? 'healthy' : 'unavailable'}`} aria-hidden="true"/>
|
||||
<code>{service.name}</code>
|
||||
{service.state !== 'running' && <span className="muted">{service.state}</span>}
|
||||
{service.consolePages.map((page) => <Link key={page.service} className="diagnostics-page-link" to={page.route}>{page.displayName}</Link>)}
|
||||
</li>
|
||||
))}</ul>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ContainersTable({report}: {report: RuntimeDiagnostics}) {
|
||||
const containers = report.containers
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-containers">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-containers">Runtime containers</h2>
|
||||
<span className="muted">Containers started for EC2 instances</span>
|
||||
</header>
|
||||
{containers === null && <p className="muted padded">This runtime does not publish container diagnostics.</p>}
|
||||
{containers?.length === 0 && <p className="muted padded">No EC2 instance containers are running.</p>}
|
||||
{containers && containers.length > 0 && <table className="table">
|
||||
<thead><tr><th>Instance</th><th>Container</th><th>SSH</th></tr></thead>
|
||||
<tbody>{containers.map((container) => (
|
||||
<tr key={container.resourceId}>
|
||||
<td><Link to={`/cloud-explorer/${report.cloud}/compute?resource=${encodeURIComponent(container.resourceId)}`}><code>{container.resourceId}</code></Link></td>
|
||||
<td>{container.status ?? '—'}</td>
|
||||
<td>{container.sshPort ? <code>{container.sshHost ?? 'localhost'}:{container.sshPort}</code> : '—'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleProcess({report}: {report: RuntimeDiagnostics}) {
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console">Console</h2>
|
||||
</header>
|
||||
<dl className="diagnostics-facts">
|
||||
<div><dt>Uptime</dt><dd>{formatUptime(report.console.uptimeSeconds)}</dd></div>
|
||||
<div><dt>Started</dt><dd>{formatDateTime(report.console.startedAt) ?? report.console.startedAt}</dd></div>
|
||||
<div><dt>Sign-in</dt><dd>{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}</dd></div>
|
||||
<div><dt>Active sessions</dt><dd>{report.console.activeSessions}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user