Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b166d86d4 | |||
| f956d977ca | |||
| 9f38313e59 | |||
| ece36e4a34 | |||
| 953ba951db | |||
| dd16bcf4cd | |||
| 432d9d4ce1 | |||
| b171f745db | |||
| f5e70a78d8 | |||
| c5a2fc9105 | |||
| c9b0677fed |
@@ -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
|
||||||
+6
-1
@@ -23,7 +23,9 @@ RUN bun build --compile --minify src/index.ts --outfile server
|
|||||||
|
|
||||||
# Stage 3 — minimal runtime (no bun, no node_modules)
|
# Stage 3 — minimal runtime (no bun, no node_modules)
|
||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
RUN apk add --no-cache ca-certificates libstdc++
|
RUN apk add --no-cache ca-certificates libstdc++ \
|
||||||
|
&& addgroup -S -g 10001 thecloud \
|
||||||
|
&& adduser -S -D -H -u 10001 -G thecloud thecloud
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-build /app/server ./server
|
COPY --from=api-build /app/server ./server
|
||||||
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||||
@@ -33,4 +35,7 @@ COPY svgAsset/authentik.svg ./public/authentik.svg
|
|||||||
COPY aws-icons-svg ./public/aws-icons
|
COPY aws-icons-svg ./public/aws-icons
|
||||||
ENV PORT=4500
|
ENV PORT=4500
|
||||||
EXPOSE 4500
|
EXPOSE 4500
|
||||||
|
USER thecloud
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
|
|||||||
@@ -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 root = document.documentElement;
|
||||||
const buttons = document.querySelectorAll('.tg button');
|
const buttons = document.querySelectorAll('.tg button');
|
||||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('thecloud-theme', theme);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
} catch (ignored) {
|
} catch (ignored) {
|
||||||
// Theme persistence is optional when browser storage is unavailable.
|
// Theme persistence is optional when browser storage is unavailable.
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
|
|
||||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||||
initialTheme = savedTheme;
|
initialTheme = savedTheme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ describe('AwsLightsailAdapter', () => {
|
|||||||
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
test('merges diagnostic host-port mappings into UI metadata', async () => {
|
||||||
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
|
||||||
const fetchMock = globalThis.fetch
|
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'},
|
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
|
||||||
]}))
|
]}))) as unknown as typeof fetch
|
||||||
try {
|
try {
|
||||||
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
|
||||||
expect(resource?.metadata.hostPortMappings).toEqual([
|
expect(resource?.metadata.hostPortMappings).toEqual([
|
||||||
|
|||||||
@@ -106,6 +106,27 @@ describe('login throttling', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('post-login redirect', () => {
|
||||||
|
async function redirectAfterLogin(returnTo: string) {
|
||||||
|
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
|
||||||
|
})
|
||||||
|
return (await response.json() as {redirectTo: string}).redirectTo
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps same-origin paths with their query and fragment', async () => {
|
||||||
|
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses destinations that a browser would resolve to another origin', async () => {
|
||||||
|
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
|
||||||
|
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('OIDC authorization', () => {
|
describe('OIDC authorization', () => {
|
||||||
let privateKey: CryptoKey
|
let privateKey: CryptoKey
|
||||||
let jwk: JWK
|
let jwk: JWK
|
||||||
|
|||||||
@@ -290,7 +290,19 @@ function loginErrorMessage(code: string | undefined): string | null {
|
|||||||
|
|
||||||
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
|
||||||
|
// otherwise pass a plain prefix check and land on another origin.
|
||||||
|
function safeReturnTo(value?: string): string {
|
||||||
|
const fallback = '/console/aws'
|
||||||
|
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
|
||||||
|
const base = 'http://return-to.invalid'
|
||||||
|
try {
|
||||||
|
const url = new URL(value, base)
|
||||||
|
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||||
|
|||||||
@@ -77,6 +77,48 @@ export interface CloudServiceStatus {
|
|||||||
errorCode: string | null
|
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 type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import server from './index'
|
||||||
|
|
||||||
|
describe('API surface hardening', () => {
|
||||||
|
test('serves an unauthenticated JSON health check', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/health'))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({status: 'ok'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
|
||||||
|
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an oversized sign-in body before parsing it', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
expect(await res.json()).toEqual({error: 'Request body is too large'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the upload ceiling for object uploads', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'content-type': 'application/octet-stream'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies the default limit to other API routes', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import {authConfig, authMiddleware, authRoutes} from './auth';
|
import {authConfig, authMiddleware, authRoutes} from './auth';
|
||||||
@@ -44,9 +45,19 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
|
// Sign-in endpoints read the body before a session exists, so they get a small
|
||||||
|
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
|
||||||
|
// Bun's default 128 MB ceiling.
|
||||||
|
app.use("/api/*", (c, next) =>
|
||||||
|
bodyLimit({
|
||||||
|
maxSize: requestBodyLimit(c.req.path),
|
||||||
|
onError: (c) => c.json({ error: "Request body is too large" }, 413),
|
||||||
|
})(c, next),
|
||||||
|
);
|
||||||
app.use("*", authMiddleware(authentication));
|
app.use("*", authMiddleware(authentication));
|
||||||
app.use("*", authRoutes(authentication));
|
app.use("*", authRoutes(authentication));
|
||||||
|
|
||||||
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
app.route("/api/eks", eks);
|
app.route("/api/eks", eks);
|
||||||
app.route("/api/ec2", ec2);
|
app.route("/api/ec2", ec2);
|
||||||
app.route("/api/secretsmanager", secretsmanager);
|
app.route("/api/secretsmanager", secretsmanager);
|
||||||
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
|
|||||||
app.route("/api/msk-connect", mskConnect);
|
app.route("/api/msk-connect", mskConnect);
|
||||||
app.route("/api/ses", ses);
|
app.route("/api/ses", ses);
|
||||||
app.route("/api/elasticache", elasticache);
|
app.route("/api/elasticache", elasticache);
|
||||||
|
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
||||||
// Serve static frontend files when public/ directory is present (production)
|
// Serve static frontend files when public/ directory is present (production)
|
||||||
app.use("*", serveStatic({ root: "./public" }));
|
app.use("*", serveStatic({ root: "./public" }));
|
||||||
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
|
|||||||
const port = Number(process.env.PORT ?? 4501);
|
const port = Number(process.env.PORT ?? 4501);
|
||||||
export default { port, fetch: app.fetch };
|
export default { port, fetch: app.fetch };
|
||||||
|
|
||||||
|
function requestBodyLimit(path: string): number {
|
||||||
|
if (path.startsWith("/api/auth/")) return 16 * 1024;
|
||||||
|
if (
|
||||||
|
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
|
||||||
|
) {
|
||||||
|
return 128 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
return 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveCredentialRoute(path: string): boolean {
|
function isSensitiveCredentialRoute(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith("/api/secretsmanager") ||
|
path.startsWith("/api/secretsmanager") ||
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
|||||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||||
import {serviceForAccount} from '../cloudProxy'
|
import {serviceForAccount} from '../cloudProxy'
|
||||||
import {CloudProxyService} from '../service/CloudProxyService'
|
import {CloudProxyService} from '../service/CloudProxyService'
|
||||||
|
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
|
||||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||||
|
|
||||||
// Header (and query-param fallback for direct links such as object downloads)
|
// 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}))
|
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) => {
|
app.get('/:cloud/services/:service/status', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
const serviceType = c.req.param('service') as CloudServiceType
|
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 {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||||
|
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
|
||||||
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
||||||
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
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/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
<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" 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/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/gcp/*" 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",
|
list: "clouds.list",
|
||||||
services: "clouds.services.list",
|
services: "clouds.services.list",
|
||||||
status: "clouds.status.get",
|
status: "clouds.status.get",
|
||||||
|
diagnostics: "clouds.diagnostics.get",
|
||||||
serviceStatus: "clouds.services.status.get",
|
serviceStatus: "clouds.services.status.get",
|
||||||
schema: "clouds.services.schema.get",
|
schema: "clouds.services.schema.get",
|
||||||
dependencies: "clouds.services.dependencies.get",
|
dependencies: "clouds.services.dependencies.get",
|
||||||
@@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.diagnostics,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/diagnostics",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.serviceStatus,
|
apiEndpointKeys.clouds.serviceStatus,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CloudServiceStatus,
|
CloudServiceStatus,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
CloudStatus,
|
CloudStatus,
|
||||||
|
RuntimeDiagnostics,
|
||||||
} from "@/types/cloud";
|
} from "@/types/cloud";
|
||||||
import type {
|
import type {
|
||||||
AppConfigConfigurationProfile,
|
AppConfigConfigurationProfile,
|
||||||
@@ -86,6 +87,18 @@ export async function getCloudStatus(
|
|||||||
return res.data;
|
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(
|
export async function getCloudServiceStatus(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
service: CloudServiceType,
|
service: CloudServiceType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
getCloudDiagnostics,
|
||||||
getCloudResource,
|
getCloudResource,
|
||||||
getCloudServiceStatus,
|
getCloudServiceStatus,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
|
|||||||
clouds: ['clouds'] as const,
|
clouds: ['clouds'] as const,
|
||||||
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
||||||
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
||||||
|
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
|
||||||
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||||
['cloud-service-status', cloud, service] as const,
|
['cloud-service-status', cloud, service] as const,
|
||||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
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. */
|
/** Health of the single service being viewed, rather than of the whole cloud. */
|
||||||
export function useCloudServiceStatusQuery(
|
export function useCloudServiceStatusQuery(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
|
|||||||
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
|
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.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
|
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()
|
const onSubmit = vi.fn()
|
||||||
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
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(/^Service role ARN/), 'role-arn')
|
||||||
await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
|
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})
|
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}/>)
|
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
|
||||||
|
|
||||||
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
|
||||||
await user.type(screen.getByLabelText('Application name'), 'orders')
|
await user.type(screen.getByLabelText(/^Application name/), 'orders')
|
||||||
await user.type(screen.getByLabelText('Deployment group name'), 'production')
|
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(/^Service role ARN/), 'role-arn')
|
||||||
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster')
|
await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
|
||||||
await user.type(screen.getByLabelText('ECS service name'), 'orders-service')
|
await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
|
||||||
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue')
|
await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
|
||||||
await user.type(screen.getByLabelText('Green target group name'), 'orders-green')
|
await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
|
||||||
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn')
|
await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
|
||||||
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
|
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'}))
|
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})
|
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 {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 {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import theCloud from '../../../../TheCloud.png'
|
import theCloud from '../../../../TheCloud.png'
|
||||||
import {useTheme} from '@/lib/useTheme'
|
import {useTheme} from '@/lib/useTheme'
|
||||||
@@ -184,6 +184,7 @@ export function Layout() {
|
|||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
<span className="nav-label">General</span>
|
<span className="nav-label">General</span>
|
||||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
<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>
|
</div>
|
||||||
<CloudServiceNav collapsed={collapsed}/>
|
<CloudServiceNav collapsed={collapsed}/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -356,6 +357,6 @@ function TopbarSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
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'
|
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 userEvent from '@testing-library/user-event'
|
||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
import {expect, test, vi} from 'vitest'
|
import {afterEach, expect, test, vi} from 'vitest'
|
||||||
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
|
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.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.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
|
||||||
api.putFlatItem.mockResolvedValue({})
|
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}))
|
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'})
|
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}))
|
await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
|
||||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
|
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),
|
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
|
||||||
enabled: Boolean(resource) && runtimeReachable,
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
})
|
})
|
||||||
|
const refetchHealth = healthQuery.refetch
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInstanceId('')
|
setInstanceId('')
|
||||||
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void healthQuery.refetch()
|
void refetchHealth()
|
||||||
}, 1_000)
|
}, 1_000)
|
||||||
return () => window.clearInterval(interval)
|
return () => window.clearInterval(interval)
|
||||||
}, [pendingTargets.length, healthQuery.refetch])
|
}, [pendingTargets.length, refetchHealth])
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||||
|
|||||||
@@ -779,6 +779,47 @@ pre,
|
|||||||
position: relative;
|
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 {
|
.topbar-status:empty + .account-switcher {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -4261,6 +4302,104 @@ pre,
|
|||||||
gap: 14px;
|
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 {
|
.console-provider-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
|
|||||||
|
|
||||||
export type Theme = 'dark' | 'light' | 'cyber'
|
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 {
|
function getStored(): Theme {
|
||||||
const v = localStorage.getItem('floci-theme')
|
try {
|
||||||
|
const v = localStorage.getItem(THEME_KEY)
|
||||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode) — fall back to the default theme.
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
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])
|
}, [theme])
|
||||||
|
|
||||||
return {theme, setTheme}
|
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". */
|
/** Mapped error code, so "not implemented" reads differently from "down". */
|
||||||
errorCode: string | null
|
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