Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
No files matched your search
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json ./package.json
|
||||
COPY frontend/package-lock.json ./package-lock.json
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
type ServiceSnapshot = {
|
||||
service: {
|
||||
id: string;
|
||||
name: string;
|
||||
depends_on: string[];
|
||||
};
|
||||
latest_probe: { status: string } | null;
|
||||
effective_status: string;
|
||||
};
|
||||
|
||||
const topologyOf = (snapshots: ServiceSnapshot[]) =>
|
||||
snapshots
|
||||
.map(({ service }) => ({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
depends_on: [...service.depends_on].sort(),
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
const isDemoMutation = (path: string) =>
|
||||
(response: {
|
||||
url: () => string;
|
||||
request: () => { method: () => string };
|
||||
}) =>
|
||||
response.url().endsWith(path) && response.request().method() === "POST";
|
||||
|
||||
test("Explore demo exposes evidence-backed root cause and reset-safe exit", async ({ page, request }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// The Playwright server uses a persistent temporary database, so restore any
|
||||
// session left by an interrupted run before taking this test's baseline.
|
||||
const cleanup = await request.post("/api/v1/demo/exit");
|
||||
expect(cleanup.ok()).toBeTruthy();
|
||||
|
||||
const baselineResponse = await request.get("/api/v1/services");
|
||||
expect(baselineResponse.ok()).toBeTruthy();
|
||||
const baselineTopology = topologyOf(await baselineResponse.json());
|
||||
|
||||
await page.goto("/");
|
||||
const explore = page.getByRole("button", { name: "Explore demo", exact: true });
|
||||
await expect(explore).toBeEnabled({ timeout: 30_000 });
|
||||
|
||||
const loadResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo"));
|
||||
await explore.click();
|
||||
if (baselineTopology.length > 0) {
|
||||
const confirmation = page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "temporarily replace this topology" });
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Explore demo", exact: true }).click();
|
||||
}
|
||||
const loadResponse = await loadResponsePromise;
|
||||
expect(loadResponse.ok()).toBeTruthy();
|
||||
expect(await loadResponse.json()).toMatchObject({
|
||||
active: true,
|
||||
synthetic: true,
|
||||
scenario_name: "Northstar Platform",
|
||||
service_count: 9,
|
||||
total: 9,
|
||||
root_cause_service_name: "PostgreSQL",
|
||||
affected_count: 5,
|
||||
});
|
||||
|
||||
const demoServicesResponse = await request.get("/api/v1/services");
|
||||
expect(demoServicesResponse.ok()).toBeTruthy();
|
||||
const demoServices: ServiceSnapshot[] = await demoServicesResponse.json();
|
||||
expect(demoServices).toHaveLength(9);
|
||||
const postgres = demoServices.find(({ service }) => service.name === "PostgreSQL");
|
||||
expect(postgres).toBeDefined();
|
||||
expect(postgres!.latest_probe?.status).toBe("down");
|
||||
expect(postgres!.effective_status).toBe("down");
|
||||
|
||||
const [incidentsResponse, historyResponse, availabilityResponse] =
|
||||
await Promise.all([
|
||||
request.get("/api/v1/incidents"),
|
||||
request.get(`/api/v1/services/${postgres!.service.id}/history?limit=50`),
|
||||
request.get(`/api/v1/services/${postgres!.service.id}/availability?hours=24`),
|
||||
]);
|
||||
expect(incidentsResponse.ok()).toBeTruthy();
|
||||
const incident = (await incidentsResponse.json()) as {
|
||||
active: boolean;
|
||||
candidates: Array<{
|
||||
service_name: string;
|
||||
affected_service_ids: string[];
|
||||
}>;
|
||||
};
|
||||
expect(incident.active).toBe(true);
|
||||
expect(incident.candidates[0]?.service_name).toBe("PostgreSQL");
|
||||
expect(incident.candidates[0]?.affected_service_ids).toHaveLength(5);
|
||||
|
||||
expect(historyResponse.ok()).toBeTruthy();
|
||||
expect(await historyResponse.json()).toHaveLength(24);
|
||||
expect(availabilityResponse.ok()).toBeTruthy();
|
||||
expect(await availabilityResponse.json()).toMatchObject({
|
||||
total_checks: 24,
|
||||
down_checks: 2,
|
||||
uptime_percent: 91.67,
|
||||
});
|
||||
|
||||
const demoBanner = page.getByRole("status", { name: "Explore demo scenario" });
|
||||
await expect(demoBanner).toBeVisible();
|
||||
await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByText("Demo scenario", { exact: false })).toBeVisible();
|
||||
await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Likely root cause", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Health evidence", { exact: true })).toBeVisible();
|
||||
|
||||
const workspaces = page.getByRole("navigation", { name: "ServiceMap workspaces" });
|
||||
await workspaces.getByRole("button", { name: "Overview" }).click();
|
||||
await expect(page.getByRole("heading", { name: "9/9 with current health evidence" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Review unresolved" })).toBeDisabled();
|
||||
await page.locator(".overview-lead").getByRole("button", { name: /1 down/i }).click();
|
||||
await expect(page.getByRole("combobox", { name: "Status", exact: true })).toHaveValue("down");
|
||||
await workspaces.getByRole("button", { name: "Incidents" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Active and historical incidents" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Search incidents").fill("does not exist");
|
||||
await expect(page.getByText("No incidents match these filters.")).toBeVisible();
|
||||
await page.getByLabel("Search incidents").fill("");
|
||||
await workspaces.getByRole("button", { name: "Topology", exact: true }).click();
|
||||
await expect(page.getByRole("region", { name: "Topology commands" })).toBeVisible();
|
||||
await page.getByRole("region", { name: "Topology commands" }).locator("summary").filter({ hasText: "Filters" }).click();
|
||||
await expect(page.getByRole("combobox", { name: "Filter topology by status" })).toBeVisible();
|
||||
const inspector = page.getByRole("complementary");
|
||||
await expect(inspector.getByRole("heading", { name: "PostgreSQL" })).toBeVisible();
|
||||
await expect(inspector.getByText("Likely root cause", { exact: true })).toBeVisible();
|
||||
await expect(inspector.getByText("Health evidence", { exact: true })).toBeVisible();
|
||||
|
||||
const reset = page.locator("header").getByRole("button", { name: "Reset demo" });
|
||||
const resetResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/reset"));
|
||||
await reset.click();
|
||||
const resetResponse = await resetResponsePromise;
|
||||
expect(resetResponse.ok()).toBeTruthy();
|
||||
expect(await resetResponse.json()).toMatchObject({
|
||||
active: true,
|
||||
root_cause_service_name: "PostgreSQL",
|
||||
});
|
||||
|
||||
// A rendered PostgreSQL node can be left over from before Reset completed.
|
||||
// Wait for the demo region to leave its busy state before attempting Exit.
|
||||
await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible();
|
||||
const exit = page.getByRole("button", { name: "Exit demo", exact: true });
|
||||
await expect(exit).toBeEnabled();
|
||||
|
||||
const exitResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/exit"));
|
||||
await exit.click();
|
||||
const exitResponse = await exitResponsePromise;
|
||||
expect(exitResponse.ok()).toBeTruthy();
|
||||
expect(await exitResponse.json()).toMatchObject({ active: false, restored: true });
|
||||
|
||||
await expect(demoBanner).toBeHidden({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByText("Demo closed. Your previous topology was restored.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Explore demo", exact: true }),
|
||||
).toBeEnabled({ timeout: 30_000 });
|
||||
|
||||
const restoredDemoResponse = await request.get("/api/v1/demo");
|
||||
expect(restoredDemoResponse.ok()).toBeTruthy();
|
||||
expect(await restoredDemoResponse.json()).toMatchObject({ active: false });
|
||||
|
||||
const restoredServicesResponse = await request.get("/api/v1/services");
|
||||
expect(restoredServicesResponse.ok()).toBeTruthy();
|
||||
expect(topologyOf(await restoredServicesResponse.json())).toEqual(baselineTopology);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'coverage/**', 'playwright-report/**', 'test-results/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['vite.config.ts', 'playwright.config.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#071722" />
|
||||
<meta name="description" content="ServiceMap — production infrastructure intelligence" />
|
||||
<link rel="icon" href="/brand/servicemap-mark.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="icon" href="/icons/favicon-16.png" sizes="16x16" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>ServiceMap — Infrastructure Intelligence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "servicemap-frontend",
|
||||
"private": true,
|
||||
"version": "1.6.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"elkjs": "^0.12.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: "python -m uvicorn app.main:app --app-dir ../backend --host 127.0.0.1 --port 4173",
|
||||
url: "http://127.0.0.1:4173/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
...process.env,
|
||||
SERVICEMAP_DB: join(tmpdir(), "servicemap-playwright-v160.db"),
|
||||
SERVICEMAP_STATIC_DIR: "dist",
|
||||
SERVICEMAP_AUTH_MODE: "disabled",
|
||||
SERVICEMAP_PROBE_INTERVAL: "0",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ServiceMap"><rect width="512" height="512" rx="112" fill="#eef8ff"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z M128 148l128 84 128-84m-128 84v168" fill="none" stroke="#123a57" stroke-width="22"/><circle cx="128" cy="148" r="31" fill="#18755e"/><circle cx="384" cy="148" r="31" fill="#18755e"/><circle cx="256" cy="92" r="31" fill="#18755e"/><circle cx="256" cy="400" r="35" fill="#9a6516"/><circle cx="256" cy="232" r="42" fill="#d9eefb" stroke="#123a57" stroke-width="13"/></svg>
|
||||
|
After Width: | Height: | Size: 575 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title"><title id="title">ServiceMap</title><rect width="512" height="512" rx="112" fill="#071722"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z" fill="none" stroke="#72b8e7" stroke-width="22"/><path d="M128 148 256 232l128-84M256 232v168" fill="none" stroke="#72b8e7" stroke-width="18"/><circle cx="128" cy="148" r="31" fill="#7ee2ac"/><circle cx="384" cy="148" r="31" fill="#7ee2ac"/><circle cx="256" cy="92" r="31" fill="#7ee2ac"/><circle cx="256" cy="400" r="35" fill="#ffd27c"/><circle cx="256" cy="232" r="42" fill="#0d2940" stroke="#eef8ff" stroke-width="13"/></svg>
|
||||
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ServiceMap"><rect width="512" height="512" rx="112" fill="currentColor"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z M128 148l128 84 128-84m-128 84v168" fill="none" stroke="#fff" stroke-width="22"/><circle cx="128" cy="148" r="31" fill="#fff"/><circle cx="384" cy="148" r="31" fill="#fff"/><circle cx="256" cy="92" r="31" fill="#fff"/><circle cx="256" cy="400" r="35" fill="#fff"/><circle cx="256" cy="232" r="42" fill="currentColor" stroke="#fff" stroke-width="13"/></svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1420 512" role="img" aria-labelledby="title"><title id="title">ServiceMap</title><g transform="translate(0 0)"><rect width="512" height="512" rx="112" fill="#071722"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z" fill="none" stroke="#72b8e7" stroke-width="22"/><path d="M128 148 256 232l128-84M256 232v168" fill="none" stroke="#72b8e7" stroke-width="18"/><circle cx="128" cy="148" r="31" fill="#7ee2ac"/><circle cx="384" cy="148" r="31" fill="#7ee2ac"/><circle cx="256" cy="92" r="31" fill="#7ee2ac"/><circle cx="256" cy="400" r="35" fill="#ffd27c"/><circle cx="256" cy="232" r="42" fill="#0d2940" stroke="#eef8ff" stroke-width="13"/></g><text x="570" y="317" fill="#eef8ff" font-family="Arial,Helvetica,sans-serif" font-size="176" font-weight="700" letter-spacing="-8">ServiceMap</text></svg>
|
||||
|
After Width: | Height: | Size: 854 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 803 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"ServiceMap","short_name":"ServiceMap","description":"Production infrastructure intelligence","start_url":"/","display":"standalone","background_color":"#07111d","theme_color":"#071722","icons":[{"src":"/icons/icon-192.png","sizes":"192x192","type":"image/png"},{"src":"/icons/icon-512.png","sizes":"512x512","type":"image/png"}]}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BaseEdge, type EdgeProps } from "@xyflow/react";
|
||||
import { memo } from "react";
|
||||
|
||||
export type LivingEdgeData = {
|
||||
relation?: "depends_on" | "routes_to" | "review";
|
||||
signal?: boolean;
|
||||
selected?: boolean;
|
||||
route?: { x: number; y: number }[];
|
||||
};
|
||||
|
||||
function LivingEdgeView({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const edgeData = (data ?? {}) as LivingEdgeData;
|
||||
const route = edgeData.route?.filter(
|
||||
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
|
||||
);
|
||||
const path =
|
||||
route && route.length >= 2
|
||||
? route
|
||||
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x} ${point.y}`)
|
||||
.join(" ")
|
||||
: `M${sourceX} ${sourceY} L${targetX} ${targetY}`;
|
||||
const relation = edgeData.relation ?? "depends_on";
|
||||
const classes = [
|
||||
"living-edge-path",
|
||||
`living-edge-${relation}`,
|
||||
edgeData.selected ? "is-selected" : "",
|
||||
edgeData.signal ? "has-signal" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<g className="living-edge" aria-hidden="true">
|
||||
<BaseEdge path={path} markerEnd={markerEnd} className={classes} />
|
||||
{edgeData.signal && (
|
||||
<path d={path} className="living-edge-signal" markerEnd={markerEnd} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const LivingEdge = memo(LivingEdgeView);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from './api'
|
||||
|
||||
const service = {
|
||||
id: 'svc-1',
|
||||
name: 'API',
|
||||
kind: 'http' as const,
|
||||
url: 'https://api.local',
|
||||
host: null,
|
||||
port: null,
|
||||
depends_on: [],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ServiceMap API client', () => {
|
||||
it('creates services with the v1 JSON contract', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(service), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await api.createService({
|
||||
name: 'API',
|
||||
kind: 'http',
|
||||
url: 'https://api.local',
|
||||
depends_on: [],
|
||||
})
|
||||
|
||||
expect(result.name).toBe('API')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/services',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'API',
|
||||
kind: 'http',
|
||||
url: 'https://api.local',
|
||||
depends_on: [],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses dedicated bulk probing and incident endpoints', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ requested: 0, completed: 0, results: [] }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ generated_at: '2026-01-01T00:00:00Z', active: false, candidates: [], degraded_service_ids: [], down_service_ids: [] }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.probeAll()
|
||||
await api.incidents()
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/probe-all')
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/incidents')
|
||||
})
|
||||
|
||||
it('uses the safe synthetic demo session endpoints', async () => {
|
||||
const demoState = {
|
||||
active: true,
|
||||
synthetic: true,
|
||||
scenario_name: 'Northstar Platform',
|
||||
service_count: 9,
|
||||
root_cause_service_id: 'postgresql',
|
||||
root_cause_service_name: 'PostgreSQL',
|
||||
affected_service_ids: ['api', 'web'],
|
||||
affected_service_names: ['API Gateway', 'Web Frontend'],
|
||||
affected_count: 2,
|
||||
}
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(demoState), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ...demoState, created: 9, updated: 0, total: 9, restored: false }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ...demoState, created: 9, updated: 0, total: 9, restored: false }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ active: false, synthetic: false, scenario_name: null, service_count: 0, root_cause_service_id: null, root_cause_service_name: null, affected_service_ids: [], affected_service_names: [], affected_count: 0, created: 0, updated: 0, total: 0, restored: true }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.demoState()
|
||||
await api.loadDemo()
|
||||
await api.resetDemo()
|
||||
await api.exitDemo()
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/v1/demo',
|
||||
'/api/v1/demo',
|
||||
'/api/v1/demo/reset',
|
||||
'/api/v1/demo/exit',
|
||||
])
|
||||
expect(fetchMock.mock.calls[1][1]).toEqual(expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
|
||||
it('uses the production discovery and canonical edge contracts', async () => {
|
||||
const run = { run: { id: 'run-1', status: 'completed', started_at: '2026-01-01T00:00:00Z', completed_at: '2026-01-01T00:01:00Z', source_counts: {}, diff: { new: 1, changed: 0, missing: 0, unchanged: 0, stale: 0, new_suggestions: 1 }, error: null }, candidates: [], suggestions: [], observations: [], appops_available: true, appops_mappings: 1, proxy_routes: 2 }
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(run), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: 'edge-1' }), { status: 201 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.runDiscovery('unix:///var/run/docker.sock', 'http://appops.local:1216')
|
||||
await api.discoverySuggestions('open')
|
||||
await api.createEdge({ source_service_id: 'dependent', target_service_id: 'dependency', lock: true })
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/discovery/run')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ docker_host: 'unix:///var/run/docker.sock', appops_url: 'http://appops.local:1216', include_stopped: false })
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/discovery/suggestions?status=open')
|
||||
expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/topology/edges')
|
||||
})
|
||||
|
||||
it('previews and applies reachability repair through the explicit safety endpoint', async () => {
|
||||
const report = { run_id: 'run-1', dry_run: true, inspected: 2, changed: 1, disabled_unsafe: 1, enabled_published: 0, docker_health_observed: 0, unmonitored: 1, resolved_incidents: 0, changes: [] }
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(report), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ...report, dry_run: false }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.repairDiscoveredProbes(true, 'run-1')
|
||||
await api.repairDiscoveredProbes(false, 'run-1')
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/v1/discovery/probes/repair?dry_run=true&run_id=run-1',
|
||||
'/api/v1/discovery/probes/repair?dry_run=false&run_id=run-1',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,940 @@
|
||||
export type HealthStatus =
|
||||
| "healthy"
|
||||
| "degraded"
|
||||
| "down"
|
||||
| "unknown"
|
||||
| "unmonitored"
|
||||
| "recovering"
|
||||
| "maintenance";
|
||||
export type ServiceKind = "http" | "tcp" | "dns" | "tls";
|
||||
export type Criticality = "low" | "medium" | "high" | "critical";
|
||||
export type DiscoveryCategory =
|
||||
| "application"
|
||||
| "database"
|
||||
| "cache"
|
||||
| "proxy"
|
||||
| "monitoring"
|
||||
| "development"
|
||||
| "storage"
|
||||
| "infrastructure"
|
||||
| "background_worker"
|
||||
| "system"
|
||||
| "unknown";
|
||||
export type ComponentRole =
|
||||
| "frontend"
|
||||
| "api"
|
||||
| "database"
|
||||
| "cache"
|
||||
| "proxy"
|
||||
| "worker"
|
||||
| "storage"
|
||||
| "monitoring"
|
||||
| "agent"
|
||||
| "scheduler"
|
||||
| "auxiliary"
|
||||
| "unknown";
|
||||
export type DiscoveryVisibility = "visible" | "hidden" | "ignored";
|
||||
export type DiscoveryLifecycle =
|
||||
"active" | "missing" | "stale" | "ignored" | "retired";
|
||||
export type DiscoveryConfidence = "confirmed" | "high" | "medium" | "low";
|
||||
export type RelationType =
|
||||
"depends_on" | "routes_to" | "uses_storage" | "communicates_with";
|
||||
export type MonitoringState =
|
||||
| "active_probe"
|
||||
| "docker_health"
|
||||
| "appops_observation"
|
||||
| "observed"
|
||||
| "unmonitored"
|
||||
| "disabled";
|
||||
export type ProbeEndpointSource =
|
||||
| "manual"
|
||||
| "explicit_label"
|
||||
| "known_url"
|
||||
| "appops"
|
||||
| "reverse_proxy"
|
||||
| "published_port"
|
||||
| "docker_healthcheck"
|
||||
| "container_internal"
|
||||
| "discovered_environment";
|
||||
export type ProbeReachability =
|
||||
"verified" | "reachable" | "unverified" | "internal_only" | "not_configured";
|
||||
|
||||
export type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ServiceKind;
|
||||
url: string | null;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
depends_on: string[];
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
group: string | null;
|
||||
environment: string | null;
|
||||
owner: string | null;
|
||||
enabled: boolean;
|
||||
monitoring_enabled: boolean;
|
||||
monitoring_state: MonitoringState;
|
||||
probe_source: ProbeEndpointSource;
|
||||
probe_reachability: ProbeReachability;
|
||||
probe_confidence: DiscoveryConfidence;
|
||||
runtime_state: string | null;
|
||||
runtime_health: string | null;
|
||||
application: string | null;
|
||||
domain: string | null;
|
||||
component_role: ComponentRole;
|
||||
application_locked: boolean;
|
||||
domain_locked: boolean;
|
||||
component_role_locked: boolean;
|
||||
criticality: Criticality;
|
||||
category: DiscoveryCategory;
|
||||
visibility: DiscoveryVisibility;
|
||||
lifecycle: DiscoveryLifecycle;
|
||||
source_type: string | null;
|
||||
source_ref: string | null;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
last_discovery_status: string | null;
|
||||
container_image: string | null;
|
||||
compose_project: string | null;
|
||||
layout_x: number | null;
|
||||
layout_y: number | null;
|
||||
layout_pinned: boolean;
|
||||
interval_seconds: number | null;
|
||||
timeout_seconds: number | null;
|
||||
retries: number | null;
|
||||
failure_threshold: number;
|
||||
recovery_threshold: number;
|
||||
expected_status_min: number | null;
|
||||
expected_status_max: number | null;
|
||||
availability_target: number | null;
|
||||
maintenance_enabled: boolean;
|
||||
maintenance_reason: string | null;
|
||||
state: HealthStatus;
|
||||
failure_streak: number;
|
||||
recovery_streak: number;
|
||||
flapping: boolean;
|
||||
flap_count: number;
|
||||
last_state_change_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ProbeResult = {
|
||||
id: string;
|
||||
service_id: string;
|
||||
status: HealthStatus;
|
||||
latency_ms: number | null;
|
||||
detail: string;
|
||||
checked_at: string;
|
||||
trigger: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EvidenceProvider = "active_probe" | "docker" | "appops" | "runtime" | "manual";
|
||||
export type EvidenceScope = "component" | "application";
|
||||
export type EvidenceFreshness = "fresh" | "stale" | "expired";
|
||||
export type HealthResolutionReason =
|
||||
| "selected_component_evidence"
|
||||
| "application_scoped_only"
|
||||
| "no_current_component_evidence"
|
||||
| "stale_evidence"
|
||||
| "expired_evidence"
|
||||
| "selected_application_evidence"
|
||||
| "component_failure"
|
||||
| "component_dependency_impact"
|
||||
| "component_coverage"
|
||||
| "no_application_evidence";
|
||||
export type HealthEvidence = {
|
||||
id: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
service_id: string | null;
|
||||
provider: EvidenceProvider;
|
||||
provider_ref: string | null;
|
||||
status: HealthStatus;
|
||||
scope: EvidenceScope;
|
||||
confidence: number;
|
||||
observed_at: string;
|
||||
valid_until: string;
|
||||
received_at: string;
|
||||
summary: string;
|
||||
metadata: Record<string, unknown>;
|
||||
active: boolean;
|
||||
superseded_by: string | null;
|
||||
freshness: EvidenceFreshness;
|
||||
};
|
||||
export type HealthResolution = {
|
||||
status: HealthStatus;
|
||||
selected_evidence_id: string | null;
|
||||
selected_provider: EvidenceProvider | null;
|
||||
confidence: number | null;
|
||||
reason_code: HealthResolutionReason;
|
||||
explanation: string;
|
||||
conflict: boolean;
|
||||
evidence: HealthEvidence[];
|
||||
};
|
||||
export type ProviderStatus = {
|
||||
provider: EvidenceProvider;
|
||||
status: "healthy" | "degraded" | "unavailable" | "unknown";
|
||||
observed_at: string;
|
||||
valid_until: string;
|
||||
detail: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ServiceSnapshot = {
|
||||
service: Service;
|
||||
latest_probe: ProbeResult | null;
|
||||
availability_24h: AvailabilitySummary | null;
|
||||
dependency_status: HealthStatus;
|
||||
effective_status: HealthStatus;
|
||||
direct_status: HealthStatus;
|
||||
flapping: boolean;
|
||||
flap_count: number;
|
||||
next_probe_at: string | null;
|
||||
dependents: string[];
|
||||
sources: ServiceSource[];
|
||||
evidence: RelationEvidence[];
|
||||
health_evidence: HealthEvidence[];
|
||||
health_resolution: HealthResolution;
|
||||
};
|
||||
|
||||
export type ServiceSource = {
|
||||
id: string;
|
||||
source_type: string;
|
||||
source_id: string;
|
||||
source_name: string | null;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
lifecycle: DiscoveryLifecycle;
|
||||
managed: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RelationEvidence = {
|
||||
id: string;
|
||||
source_service_id: string;
|
||||
target_service_id: string | null;
|
||||
target_ref: string | null;
|
||||
relation_type: RelationType;
|
||||
evidence_type: string;
|
||||
confidence: DiscoveryConfidence;
|
||||
explanation: string;
|
||||
source_ref: string | null;
|
||||
locked: boolean;
|
||||
active: boolean;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
export type LogicalRelation = {
|
||||
id: string;
|
||||
source_service_id: string;
|
||||
target_service_id: string | null;
|
||||
target_ref: string | null;
|
||||
relation_type: RelationType;
|
||||
confidence: DiscoveryConfidence;
|
||||
active: boolean;
|
||||
locked: boolean;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
evidence: RelationEvidence[];
|
||||
};
|
||||
|
||||
export type TopologySnapshot = {
|
||||
id: string;
|
||||
run_id: string | null;
|
||||
created_at: string;
|
||||
revision: string;
|
||||
service_count: number;
|
||||
relationship_count: number;
|
||||
summary: Record<string, number>;
|
||||
};
|
||||
export type TopologyChange = {
|
||||
change_type: "added" | "removed" | "changed";
|
||||
entity_type: "service" | "relationship";
|
||||
entity_id: string;
|
||||
name: string;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
};
|
||||
export type TopologyDiff = {
|
||||
before_snapshot_id: string | null;
|
||||
after_snapshot_id: string;
|
||||
added: TopologyChange[];
|
||||
removed: TopologyChange[];
|
||||
changed: TopologyChange[];
|
||||
summary: Record<string, number>;
|
||||
};
|
||||
|
||||
export type SemanticComponent = {
|
||||
service_id: string;
|
||||
name: string;
|
||||
kind: ServiceKind;
|
||||
application_id: string;
|
||||
domain_id: string;
|
||||
role: ComponentRole;
|
||||
direct_status: HealthStatus;
|
||||
effective_status: HealthStatus;
|
||||
unknown_reason: HealthResolutionReason | null;
|
||||
runtime_state: string | null;
|
||||
runtime_health: string | null;
|
||||
monitoring_state: MonitoringState;
|
||||
latest_latency_ms: number | null;
|
||||
dependency_ids: string[];
|
||||
dependent_ids: string[];
|
||||
root_cause: boolean;
|
||||
affected: boolean;
|
||||
};
|
||||
|
||||
export type SemanticApplication = {
|
||||
id: string;
|
||||
name: string;
|
||||
domain_id: string;
|
||||
component_ids: string[];
|
||||
component_count: number;
|
||||
roles: ComponentRole[];
|
||||
status: HealthStatus;
|
||||
status_explanation: string;
|
||||
health_resolution: {
|
||||
status: HealthStatus;
|
||||
confidence: number | null;
|
||||
explanation: string;
|
||||
reason_code: HealthResolutionReason;
|
||||
selected_evidence_id: string | null;
|
||||
selected_provider: EvidenceProvider | null;
|
||||
evidence_freshness: EvidenceFreshness | null;
|
||||
conflict: boolean;
|
||||
resolved_components: number;
|
||||
unresolved_components: number;
|
||||
component_coverage_percent: number;
|
||||
};
|
||||
dependency_application_ids: string[];
|
||||
dependent_application_ids: string[];
|
||||
monitored_components: number;
|
||||
unmonitored_components: number;
|
||||
resolved_components: number;
|
||||
unresolved_components: number;
|
||||
component_coverage_percent: number;
|
||||
root_cause: boolean;
|
||||
affected: boolean;
|
||||
};
|
||||
|
||||
export type SemanticDomain = {
|
||||
id: string;
|
||||
name: string;
|
||||
application_ids: string[];
|
||||
application_count: number;
|
||||
status: HealthStatus;
|
||||
resolved_application_count: number;
|
||||
unresolved_application_count: number;
|
||||
application_coverage_percent: number;
|
||||
incident_count: number;
|
||||
root_cause: boolean;
|
||||
affected: boolean;
|
||||
};
|
||||
|
||||
export type SemanticTopology = {
|
||||
generated_at: string;
|
||||
domains: SemanticDomain[];
|
||||
applications: SemanticApplication[];
|
||||
components: SemanticComponent[];
|
||||
relations: LogicalRelation[];
|
||||
monitoring_coverage: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AvailabilitySummary = {
|
||||
hours: number;
|
||||
total_checks: number;
|
||||
healthy_checks: number;
|
||||
degraded_checks: number;
|
||||
down_checks: number;
|
||||
uptime_percent: number | null;
|
||||
average_latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
target_percent: number | null;
|
||||
meets_target: boolean | null;
|
||||
};
|
||||
|
||||
export type ServiceInput = {
|
||||
name: string;
|
||||
kind: ServiceKind;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
depends_on?: string[];
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
group?: string;
|
||||
environment?: string;
|
||||
owner?: string;
|
||||
enabled?: boolean;
|
||||
monitoring_enabled?: boolean;
|
||||
monitoring_state?: MonitoringState;
|
||||
probe_source?: ProbeEndpointSource;
|
||||
probe_reachability?: ProbeReachability;
|
||||
probe_confidence?: DiscoveryConfidence;
|
||||
runtime_state?: string;
|
||||
runtime_health?: string;
|
||||
application?: string;
|
||||
domain?: string;
|
||||
component_role?: ComponentRole;
|
||||
application_locked?: boolean;
|
||||
domain_locked?: boolean;
|
||||
component_role_locked?: boolean;
|
||||
criticality?: Criticality;
|
||||
category?: DiscoveryCategory;
|
||||
visibility?: DiscoveryVisibility;
|
||||
lifecycle?: DiscoveryLifecycle;
|
||||
layout_x?: number;
|
||||
layout_y?: number;
|
||||
layout_pinned?: boolean;
|
||||
interval_seconds?: number;
|
||||
timeout_seconds?: number;
|
||||
retries?: number;
|
||||
failure_threshold?: number;
|
||||
recovery_threshold?: number;
|
||||
expected_status_min?: number;
|
||||
expected_status_max?: number;
|
||||
availability_target?: number;
|
||||
source_type?: string;
|
||||
source_ref?: string;
|
||||
first_seen_at?: string;
|
||||
last_seen_at?: string;
|
||||
last_discovery_status?: string;
|
||||
container_image?: string;
|
||||
compose_project?: string;
|
||||
maintenance_enabled?: boolean;
|
||||
maintenance_reason?: string;
|
||||
};
|
||||
|
||||
export type IncidentCandidate = {
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
status: HealthStatus;
|
||||
reason: string;
|
||||
affected_service_ids: string[];
|
||||
affected_service_names: string[];
|
||||
};
|
||||
|
||||
export type IncidentStatus = "open" | "acknowledged" | "resolved";
|
||||
|
||||
export type IncidentRecord = {
|
||||
id: string;
|
||||
status: IncidentStatus;
|
||||
root_service_id: string;
|
||||
root_service_name: string;
|
||||
started_at: string;
|
||||
detected_at: string;
|
||||
acknowledged_at: string | null;
|
||||
resolved_at: string | null;
|
||||
title: string;
|
||||
root_cause_reason: string;
|
||||
affected_service_ids: string[];
|
||||
affected_service_names: string[];
|
||||
severity: Criticality;
|
||||
latest_update_at: string;
|
||||
};
|
||||
|
||||
export type IncidentTimelineEvent = {
|
||||
id: string;
|
||||
incident_id: string;
|
||||
event_type: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
service_id: string | null;
|
||||
};
|
||||
|
||||
export type IncidentDetail = {
|
||||
incident: IncidentRecord;
|
||||
timeline: IncidentTimelineEvent[];
|
||||
};
|
||||
|
||||
export type IncidentOverview = {
|
||||
generated_at: string;
|
||||
active: boolean;
|
||||
candidates: IncidentCandidate[];
|
||||
degraded_service_ids: string[];
|
||||
down_service_ids: string[];
|
||||
incidents: IncidentRecord[];
|
||||
};
|
||||
|
||||
export type EventRecord = {
|
||||
id: string;
|
||||
event_type: string;
|
||||
service_id: string | null;
|
||||
incident_id: string | null;
|
||||
from_status: HealthStatus | null;
|
||||
to_status: HealthStatus | null;
|
||||
message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OverviewSummary = {
|
||||
generated_at: string;
|
||||
total_services: number;
|
||||
health_counts: Record<string, number>;
|
||||
active_incidents: number;
|
||||
likely_root_causes: string[];
|
||||
flapping_services: string[];
|
||||
recent_events: EventRecord[];
|
||||
monitoring_coverage: Record<string, number>;
|
||||
};
|
||||
|
||||
export type DemoState = {
|
||||
active: boolean;
|
||||
synthetic: boolean;
|
||||
scenario_name: string | null;
|
||||
service_count: number;
|
||||
root_cause_service_id: string | null;
|
||||
root_cause_service_name: string | null;
|
||||
affected_service_ids: string[];
|
||||
affected_service_names: string[];
|
||||
affected_count: number;
|
||||
};
|
||||
|
||||
export type DemoMutationResult = DemoState & {
|
||||
created: number;
|
||||
updated: number;
|
||||
total: number;
|
||||
restored: boolean;
|
||||
};
|
||||
|
||||
export type DockerPort = {
|
||||
container_port: string;
|
||||
host_ip: string | null;
|
||||
host_port: number | null;
|
||||
};
|
||||
|
||||
export type DockerSuggestion = {
|
||||
name: string;
|
||||
kind: ServiceKind;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
url: string | null;
|
||||
group: string | null;
|
||||
evidence: string[];
|
||||
confidence: string;
|
||||
depends_on_names: string[];
|
||||
source_type: string | null;
|
||||
source_ref: string | null;
|
||||
stable_source_ref: string | null;
|
||||
category: DiscoveryCategory;
|
||||
visibility: DiscoveryVisibility;
|
||||
relation_type: RelationType;
|
||||
};
|
||||
|
||||
export type DockerCandidate = {
|
||||
container_id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
health: string | null;
|
||||
networks: string[];
|
||||
ports: DockerPort[];
|
||||
labels: Record<string, string>;
|
||||
import_state: string;
|
||||
suggestions: DockerSuggestion[];
|
||||
dependency_suggestions: string[];
|
||||
stable_source_ref: string | null;
|
||||
compose_project: string | null;
|
||||
compose_service: string | null;
|
||||
category: DiscoveryCategory;
|
||||
visibility: DiscoveryVisibility;
|
||||
lifecycle: DiscoveryLifecycle;
|
||||
healthcheck_present: boolean;
|
||||
environment_evidence: string[];
|
||||
proxy_route_evidence: string[];
|
||||
appops_app_id: string | null;
|
||||
appops_category: string | null;
|
||||
appops_criticality: string | null;
|
||||
appops_health: string | null;
|
||||
probe_candidates: ProbeEndpointCandidate[];
|
||||
};
|
||||
|
||||
export type ProbeEndpointCandidate = {
|
||||
source: ProbeEndpointSource;
|
||||
kind: ServiceKind;
|
||||
url: string | null;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
reachability: ProbeReachability;
|
||||
confidence: DiscoveryConfidence;
|
||||
primary: boolean;
|
||||
evidence: string;
|
||||
preflight_detail: string | null;
|
||||
preflight_latency_ms: number | null;
|
||||
};
|
||||
|
||||
export type ProbeRepairChange = {
|
||||
service_id: string;
|
||||
service_name: string;
|
||||
previous_monitoring_state: MonitoringState;
|
||||
next_monitoring_state: MonitoringState;
|
||||
previous_endpoint: string | null;
|
||||
next_endpoint: string | null;
|
||||
next_probe_source: ProbeEndpointSource | null;
|
||||
next_probe_confidence: DiscoveryConfidence | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ProbeRepairReport = {
|
||||
run_id: string | null;
|
||||
dry_run: boolean;
|
||||
inspected: number;
|
||||
changed: number;
|
||||
disabled_unsafe: number;
|
||||
enabled_published: number;
|
||||
docker_health_observed: number;
|
||||
appops_observed: number;
|
||||
runtime_observed: number;
|
||||
unmonitored: number;
|
||||
resolved_incidents: number;
|
||||
changes: ProbeRepairChange[];
|
||||
};
|
||||
|
||||
export type DockerDiscoveryResult = {
|
||||
docker_host: string;
|
||||
candidates: DockerCandidate[];
|
||||
proxy_routes?: number;
|
||||
};
|
||||
|
||||
export type DiscoveryObservation = {
|
||||
id: string;
|
||||
run_id: string;
|
||||
source_id: string;
|
||||
source_type: string;
|
||||
source_name: string;
|
||||
lifecycle: DiscoveryLifecycle;
|
||||
change_type: string;
|
||||
observed_at: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TopologySuggestion = {
|
||||
id: string;
|
||||
run_id: string;
|
||||
suggestion_type: string;
|
||||
status: "open" | "accepted" | "rejected" | "superseded";
|
||||
source_ref: string | null;
|
||||
source_service_id: string | null;
|
||||
target_ref: string | null;
|
||||
target_service_id: string | null;
|
||||
proposed_name: string | null;
|
||||
proposed_kind: ServiceKind | null;
|
||||
proposed_url: string | null;
|
||||
proposed_host: string | null;
|
||||
proposed_port: number | null;
|
||||
proposed_category: DiscoveryCategory;
|
||||
proposed_group: string | null;
|
||||
relation_type: RelationType | null;
|
||||
confidence: DiscoveryConfidence;
|
||||
evidence: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type DiscoveryDiff = {
|
||||
new: number;
|
||||
changed: number;
|
||||
missing: number;
|
||||
unchanged: number;
|
||||
stale: number;
|
||||
new_suggestions: number;
|
||||
};
|
||||
|
||||
export type DiscoveryRun = {
|
||||
id: string;
|
||||
status: "idle" | "discovering" | "reconciling" | "completed" | "failed";
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
source_counts: Record<string, number>;
|
||||
diff: DiscoveryDiff;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type DiscoveryResult = DockerDiscoveryResult & {
|
||||
run: DiscoveryRun;
|
||||
suggestions: TopologySuggestion[];
|
||||
observations: DiscoveryObservation[];
|
||||
appops_available: boolean;
|
||||
appops_mappings: number;
|
||||
proxy_routes: number;
|
||||
};
|
||||
|
||||
export type DiscoveryStatus = {
|
||||
current: DiscoveryRun | null;
|
||||
last_completed: DiscoveryRun | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type ReconcileResult = {
|
||||
run: DiscoveryRun;
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
missing: number;
|
||||
retired: number;
|
||||
accepted_relations: number;
|
||||
open_suggestions: number;
|
||||
service_ids: string[];
|
||||
};
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
/** An optional in-memory OIDC bearer token; never persisted in topology config. */
|
||||
export function setApiAccessToken(token: string | null): void {
|
||||
accessToken = token?.trim() || null;
|
||||
}
|
||||
|
||||
function requestHeaders(init?: RequestInit): HeadersInit {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...init?.headers,
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: requestHeaders(init),
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.detail || `Request failed (${response.status})`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listServices: (includeHidden = false) =>
|
||||
request<ServiceSnapshot[]>(
|
||||
`/api/v1/services?include_hidden=${includeHidden}`,
|
||||
),
|
||||
createService: (payload: ServiceInput) =>
|
||||
request<Service>("/api/v1/services", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateService: (serviceId: string, payload: ServiceInput) =>
|
||||
request<Service>(`/api/v1/services/${serviceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
probeService: (serviceId: string) =>
|
||||
request<ProbeResult>(`/api/v1/services/${serviceId}/probe`, {
|
||||
method: "POST",
|
||||
}),
|
||||
probeAll: () =>
|
||||
request<{ requested: number; completed: number; results: ProbeResult[] }>(
|
||||
"/api/v1/probe-all",
|
||||
{ method: "POST" },
|
||||
),
|
||||
deleteService: (serviceId: string) =>
|
||||
request<void>(`/api/v1/services/${serviceId}`, { method: "DELETE" }),
|
||||
history: (serviceId: string, limit = 24) =>
|
||||
request<ProbeResult[]>(
|
||||
`/api/v1/services/${serviceId}/history?limit=${limit}`,
|
||||
),
|
||||
availability: (serviceId: string, hours = 24) =>
|
||||
request<AvailabilitySummary>(
|
||||
`/api/v1/services/${serviceId}/availability?hours=${hours}`,
|
||||
),
|
||||
serviceEvidence: (serviceId: string) =>
|
||||
request<HealthResolution>(`/api/v1/services/${serviceId}/evidence`),
|
||||
providers: () => request<ProviderStatus[]>("/api/v1/providers"),
|
||||
incidents: () => request<IncidentOverview>("/api/v1/incidents"),
|
||||
incident: (incidentId: string) =>
|
||||
request<IncidentDetail>(`/api/v1/incidents/${incidentId}`),
|
||||
acknowledgeIncident: (incidentId: string) =>
|
||||
request<IncidentDetail>(`/api/v1/incidents/${incidentId}/acknowledge`, {
|
||||
method: "POST",
|
||||
}),
|
||||
events: (limit = 50) =>
|
||||
request<EventRecord[]>(`/api/v1/events?limit=${limit}`),
|
||||
overview: () => request<OverviewSummary>("/api/v1/overview"),
|
||||
maintenance: (serviceId: string, enabled: boolean, reason?: string) =>
|
||||
request<Service>(`/api/v1/services/${serviceId}/maintenance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled, reason }),
|
||||
}),
|
||||
demoState: () => request<DemoState>("/api/v1/demo"),
|
||||
loadDemo: () =>
|
||||
request<DemoMutationResult>("/api/v1/demo", { method: "POST" }),
|
||||
resetDemo: () =>
|
||||
request<DemoMutationResult>("/api/v1/demo/reset", { method: "POST" }),
|
||||
exitDemo: () =>
|
||||
request<DemoMutationResult>("/api/v1/demo/exit", { method: "POST" }),
|
||||
importConfig: (
|
||||
content: string,
|
||||
format: "yaml" | "json",
|
||||
mode: "merge" | "replace",
|
||||
) =>
|
||||
request<{ created: number; updated: number; total: number }>(
|
||||
"/api/v1/config/import",
|
||||
{ method: "POST", body: JSON.stringify({ content, format, mode }) },
|
||||
),
|
||||
exportConfig: async (format: "yaml" | "json") => {
|
||||
const response = await fetch(`/api/v1/config?format=${format}`, { headers: requestHeaders() });
|
||||
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
||||
return response.text();
|
||||
},
|
||||
discoverDocker: (dockerHost: string) =>
|
||||
request<DockerDiscoveryResult>("/api/v1/docker/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ docker_host: dockerHost }),
|
||||
}),
|
||||
runDiscovery: (
|
||||
dockerHost?: string,
|
||||
appopsUrl?: string,
|
||||
includeStopped = false,
|
||||
) =>
|
||||
request<DiscoveryResult>("/api/v1/discovery/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
docker_host: dockerHost || undefined,
|
||||
appops_url: appopsUrl || undefined,
|
||||
include_stopped: includeStopped,
|
||||
}),
|
||||
}),
|
||||
discoveryStatus: () => request<DiscoveryStatus>("/api/v1/discovery/status"),
|
||||
discoveryItems: (runId?: string) =>
|
||||
request<DiscoveryObservation[]>(
|
||||
`/api/v1/discovery/items${runId ? `?run_id=${encodeURIComponent(runId)}` : ""}`,
|
||||
),
|
||||
discoverySuggestions: (status = "open") =>
|
||||
request<TopologySuggestion[]>(
|
||||
`/api/v1/discovery/suggestions?status=${status}`,
|
||||
),
|
||||
acceptSuggestion: (suggestionId: string) =>
|
||||
request<TopologySuggestion>(
|
||||
`/api/v1/discovery/suggestions/${suggestionId}/accept`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
rejectSuggestion: (suggestionId: string) =>
|
||||
request<TopologySuggestion>(
|
||||
`/api/v1/discovery/suggestions/${suggestionId}/reject`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
reconcileDiscovery: (runId?: string, applyHighConfidence = false) =>
|
||||
request<ReconcileResult>("/api/v1/discovery/reconcile", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
run_id: runId,
|
||||
apply_high_confidence: applyHighConfidence,
|
||||
}),
|
||||
}),
|
||||
repairDiscoveredProbes: (dryRun = true, runId?: string) =>
|
||||
request<ProbeRepairReport>(
|
||||
`/api/v1/discovery/probes/repair?dry_run=${dryRun}${runId ? `&run_id=${encodeURIComponent(runId)}` : ""}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
topologyEdges: () => request<RelationEvidence[]>("/api/v1/topology/edges"),
|
||||
topologyRelations: () =>
|
||||
request<LogicalRelation[]>("/api/v1/topology/relations"),
|
||||
topologySnapshots: (limit = 30) =>
|
||||
request<TopologySnapshot[]>(`/api/v1/topology/snapshots?limit=${limit}`),
|
||||
topologyDiff: (afterSnapshotId: string, beforeSnapshotId?: string) =>
|
||||
request<TopologyDiff>(
|
||||
`/api/v1/topology/diff?after_snapshot_id=${encodeURIComponent(afterSnapshotId)}${beforeSnapshotId ? `&before_snapshot_id=${encodeURIComponent(beforeSnapshotId)}` : ""}`,
|
||||
),
|
||||
semanticTopology: () =>
|
||||
request<SemanticTopology>("/api/v1/topology/semantic"),
|
||||
createEdge: (payload: {
|
||||
source_service_id: string;
|
||||
target_service_id: string;
|
||||
relation_type?: RelationType;
|
||||
lock?: boolean;
|
||||
explanation?: string;
|
||||
}) =>
|
||||
request<RelationEvidence>("/api/v1/topology/edges", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
edgeAction: (
|
||||
evidenceId: string,
|
||||
action: "confirm" | "reject" | "lock" | "remove",
|
||||
) =>
|
||||
request<RelationEvidence>(`/api/v1/topology/edges/${evidenceId}/action`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action }),
|
||||
}),
|
||||
};
|
||||
|
||||
export function subscribeToEvents(
|
||||
onEvent: (event: EventRecord | Record<string, unknown>) => void,
|
||||
onError?: () => void,
|
||||
onOpen?: () => void,
|
||||
): () => void {
|
||||
if (accessToken) {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/events/stream", {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: "text/event-stream" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error(`SSE failed (${response.status})`);
|
||||
onOpen?.();
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (!controller.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const frames = buffer.split("\n\n");
|
||||
buffer = frames.pop() ?? "";
|
||||
for (const frame of frames) {
|
||||
const data = frame
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trim())
|
||||
.join("\n");
|
||||
if (!data) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(data) as EventRecord);
|
||||
} catch {
|
||||
// Ignore a malformed event and retain the durable polling fallback.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) onError?.();
|
||||
} catch {
|
||||
if (!controller.signal.aborted) onError?.();
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}
|
||||
const source = new EventSource("/api/v1/events/stream");
|
||||
const emit = (message: MessageEvent) => {
|
||||
try {
|
||||
onEvent(JSON.parse(message.data) as EventRecord);
|
||||
} catch {
|
||||
/* reconnect/poll remains the fallback */
|
||||
}
|
||||
};
|
||||
source.onmessage = emit;
|
||||
for (const eventType of [
|
||||
"probe.completed",
|
||||
"service.state_changed",
|
||||
"service.maintenance_changed",
|
||||
"flapping.detected",
|
||||
"incident.opened",
|
||||
"incident.updated",
|
||||
"incident.acknowledged",
|
||||
"incident.resolved",
|
||||
"topology.changed",
|
||||
]) {
|
||||
source.addEventListener(eventType, emit);
|
||||
}
|
||||
source.onerror = () => onError?.();
|
||||
source.onopen = () => onOpen?.();
|
||||
return () => source.close();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { changedFields, filterSuggestions } from "./auditViewModel";
|
||||
import type { TopologySuggestion } from "./api";
|
||||
|
||||
describe("change explanation", () => {
|
||||
it("shows only changed fields with their before and after values", () => {
|
||||
expect(changedFields(
|
||||
{ name: "API", lifecycle: "active", depends_on: ["db"] },
|
||||
{ name: "API", lifecycle: "stale", depends_on: ["db", "cache"] },
|
||||
)).toEqual([
|
||||
{ field: "lifecycle", before: "active", after: "stale" },
|
||||
{ field: "depends_on", before: "db", after: "db, cache" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery review", () => {
|
||||
const suggestion = (id: string, confidence: "low" | "high" | "confirmed", evidence: string[]): TopologySuggestion => ({
|
||||
id, confidence, evidence, suggestion_type: "relation", status: "open",
|
||||
run_id: "run", source_ref: "source", source_service_id: null,
|
||||
target_ref: "target", target_service_id: null, proposed_name: null,
|
||||
proposed_kind: null, proposed_url: null, proposed_host: null,
|
||||
proposed_port: null, proposed_category: "unknown", proposed_group: null,
|
||||
relation_type: null, created_at: "", updated_at: "",
|
||||
});
|
||||
|
||||
it("filters by confidence and evidence text", () => {
|
||||
const items = [suggestion("low", "low", ["Shared Docker network hint: bridge"]), suggestion("high", "high", ["Explicit link"] )];
|
||||
expect(filterSuggestions(items, { query: "bridge", confidence: "all", type: "all" }).map((item) => item.id)).toEqual(["low"]);
|
||||
expect(filterSuggestions(items, { query: "", confidence: "high", type: "all" }).map((item) => item.id)).toEqual(["high"]);
|
||||
});
|
||||
|
||||
it("keeps the actionable queue focused on high-confidence findings", () => {
|
||||
const items = [suggestion("low", "low", []), suggestion("high", "high", []), suggestion("confirmed", "confirmed", [])];
|
||||
expect(filterSuggestions(items, { query: "", confidence: "actionable", type: "all" }).map((item) => item.id)).toEqual(["high", "confirmed"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TopologySuggestion } from "./api";
|
||||
|
||||
const displayValue = (value: unknown): string => {
|
||||
if (value == null || value === "") return "—";
|
||||
if (Array.isArray(value)) return value.map(displayValue).join(", ") || "—";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export function changedFields(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
|
||||
if (!before || !after) return [];
|
||||
return Array.from(new Set([...Object.keys(before), ...Object.keys(after)]))
|
||||
.filter((field) => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
|
||||
.map((field) => ({ field, before: displayValue(before[field]), after: displayValue(after[field]) }));
|
||||
}
|
||||
|
||||
export function filterSuggestions(
|
||||
suggestions: TopologySuggestion[],
|
||||
filters: { query: string; confidence: string; type: string },
|
||||
) {
|
||||
const query = filters.query.trim().toLowerCase();
|
||||
return suggestions.filter((item) =>
|
||||
(filters.confidence === "all" ||
|
||||
(filters.confidence === "actionable"
|
||||
? ["high", "confirmed"].includes(item.confidence)
|
||||
: item.confidence === filters.confidence)) &&
|
||||
(filters.type === "all" || item.suggestion_type === filters.type) &&
|
||||
(!query || [item.proposed_name, item.source_ref, item.target_ref, ...item.evidence]
|
||||
.some((value) => value?.toLowerCase().includes(query))),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { demoWalkthroughSteps } from "./DemoWalkthrough";
|
||||
|
||||
describe("demo walkthrough", () => {
|
||||
it("keeps the semantic incident story ordered and compact", () => {
|
||||
expect(demoWalkthroughSteps).toHaveLength(4);
|
||||
expect(demoWalkthroughSteps.map((step) => step.title)).toEqual([
|
||||
"See the infrastructure",
|
||||
"Follow the data layer",
|
||||
"Trace the impact",
|
||||
"Inspect the root cause",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
export const demoWalkthroughSteps = [
|
||||
{
|
||||
title: "See the infrastructure",
|
||||
description:
|
||||
"Start with the semantic domain landscape: Edge, Application and Data.",
|
||||
},
|
||||
{
|
||||
title: "Follow the data layer",
|
||||
description:
|
||||
"Open Data Platform to compare PostgreSQL with its healthy Redis sibling.",
|
||||
},
|
||||
{
|
||||
title: "Trace the impact",
|
||||
description:
|
||||
"Follow the dependency path from the unavailable database to affected applications.",
|
||||
},
|
||||
{
|
||||
title: "Inspect the root cause",
|
||||
description:
|
||||
"Review PostgreSQL direct health, effective health, probe history and downstream impact.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type DemoWalkthroughProps = {
|
||||
step: number;
|
||||
affectedCount: number;
|
||||
busy: boolean;
|
||||
onStepChange: (step: number) => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
export function DemoWalkthrough({
|
||||
step,
|
||||
affectedCount,
|
||||
busy,
|
||||
onStepChange,
|
||||
onExit,
|
||||
}: DemoWalkthroughProps) {
|
||||
const current = demoWalkthroughSteps[step] ?? demoWalkthroughSteps[0];
|
||||
return (
|
||||
<section className="demo-walkthrough" aria-label="Demo walkthrough">
|
||||
<div className="demo-walkthrough-copy">
|
||||
<span className="eyebrow">
|
||||
Demo walkthrough · {step + 1} / {demoWalkthroughSteps.length}
|
||||
</span>
|
||||
<strong>{current.title}</strong>
|
||||
<p>
|
||||
{current.description} PostgreSQL is the likely root cause affecting{" "}
|
||||
{affectedCount} downstream services.
|
||||
</p>
|
||||
</div>
|
||||
<div className="demo-walkthrough-actions">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => onStepChange(Math.max(0, step - 1))}
|
||||
disabled={step === 0 || busy}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() =>
|
||||
onStepChange(
|
||||
step === demoWalkthroughSteps.length - 1 ? 0 : step + 1,
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
{step === demoWalkthroughSteps.length - 1 ? "Restart" : "Next"}
|
||||
</button>
|
||||
<button onClick={onExit} disabled={busy}>
|
||||
Exit demo
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { subscribeToEvents } from "../api";
|
||||
|
||||
type Options = {
|
||||
onEvent: () => void;
|
||||
onFallback: () => void;
|
||||
fallbackIntervalMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event-driven refresh with bounded recovery when SSE is unavailable.
|
||||
*
|
||||
* The fallback is intentionally active only while disconnected. That avoids
|
||||
* continuously reloading a healthy dashboard every few seconds just because
|
||||
* it happens to be open in a browser tab.
|
||||
*/
|
||||
export function useRealtimeEvents({
|
||||
onEvent,
|
||||
onFallback,
|
||||
fallbackIntervalMs = 60_000,
|
||||
}: Options): void {
|
||||
const eventRef = useRef(onEvent);
|
||||
const fallbackRef = useRef(onFallback);
|
||||
|
||||
useEffect(() => {
|
||||
eventRef.current = onEvent;
|
||||
fallbackRef.current = onFallback;
|
||||
}, [onEvent, onFallback]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let connected = false;
|
||||
let retryMs = 1_000;
|
||||
let close: (() => void) | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
let fallbackTimer: number | undefined;
|
||||
|
||||
const stop = () => {
|
||||
close?.();
|
||||
close = null;
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
if (fallbackTimer !== undefined) window.clearInterval(fallbackTimer);
|
||||
reconnectTimer = undefined;
|
||||
fallbackTimer = undefined;
|
||||
};
|
||||
const enableFallback = () => {
|
||||
if (fallbackTimer !== undefined) return;
|
||||
fallbackTimer = window.setInterval(() => {
|
||||
if (!connected && !document.hidden) fallbackRef.current();
|
||||
}, fallbackIntervalMs);
|
||||
};
|
||||
const connect = () => {
|
||||
if (disposed || document.hidden || typeof EventSource === "undefined") {
|
||||
connected = false;
|
||||
enableFallback();
|
||||
return;
|
||||
}
|
||||
close?.();
|
||||
close = subscribeToEvents(
|
||||
() => {
|
||||
connected = true;
|
||||
retryMs = 1_000;
|
||||
if (fallbackTimer !== undefined) {
|
||||
window.clearInterval(fallbackTimer);
|
||||
fallbackTimer = undefined;
|
||||
}
|
||||
eventRef.current();
|
||||
},
|
||||
() => {
|
||||
if (disposed) return;
|
||||
connected = false;
|
||||
close?.();
|
||||
close = null;
|
||||
enableFallback();
|
||||
if (reconnectTimer === undefined) {
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = undefined;
|
||||
retryMs = Math.min(retryMs * 2, 30_000);
|
||||
connect();
|
||||
}, retryMs);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
connected = true;
|
||||
retryMs = 1_000;
|
||||
if (fallbackTimer !== undefined) {
|
||||
window.clearInterval(fallbackTimer);
|
||||
fallbackTimer = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
connected = false;
|
||||
close?.();
|
||||
close = null;
|
||||
enableFallback();
|
||||
} else {
|
||||
if (fallbackTimer !== undefined) {
|
||||
window.clearInterval(fallbackTimer);
|
||||
fallbackTimer = undefined;
|
||||
}
|
||||
connect();
|
||||
eventRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
disposed = true;
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
stop();
|
||||
};
|
||||
}, [fallbackIntervalMs]);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
:root {
|
||||
--surface: #091725;
|
||||
--surface-raised: #0d2033;
|
||||
--line: #203d59;
|
||||
--ink-muted: #91a6bf;
|
||||
--accent: #78a8d5;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #eef4ff;
|
||||
background: #07111d;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #162843 0, #08111f 42%, #050a12 100%); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { border: 1px solid #355779; border-radius: 11px; padding: 10px 14px; background: #17324f; color: #f4f8ff; cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
|
||||
button:hover:not(:disabled) { border-color: #5d84ad; background: #1b3a5c; }
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled { cursor: wait; opacity: .6; }
|
||||
button.ghost { background: #0b1a2b; }
|
||||
button.danger { color: #ffb1ba; border-color: #6d3944; background: #29151c; }
|
||||
button.text-button { padding: 0; border: 0; background: transparent; color: #8da9c9; font-size: 12px; }
|
||||
input, select, textarea { width: 100%; margin-top: 6px; border: 1px solid #29435f; border-radius: 10px; padding: 10px 11px; color: #eef4ff; background: #0a1625; outline: none; }
|
||||
textarea { min-height: 270px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; }
|
||||
input:focus, select:focus, textarea:focus { border-color: #638ab4; box-shadow: 0 0 0 3px rgba(99, 138, 180, .12); }
|
||||
label { color: #98abc3; font-size: 13px; }
|
||||
fieldset { min-width: 0; }
|
||||
|
||||
.shell { width: min(1540px, calc(100% - 40px)); margin: 0 auto; padding: 26px 0 42px; }
|
||||
.topbar, .hero, .panel-heading, .inspector-title { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.topbar { min-height: 58px; padding: 6px 0 12px; }
|
||||
.brand-lockup { display: flex; align-items: center; gap: 10px; }
|
||||
.brand-lockup img { width: 38px; height: 38px; }
|
||||
.toolbar, .button-row { display: flex; align-items: center; gap: 9px; }
|
||||
.button-row.wrap { flex-wrap: wrap; }
|
||||
.eyebrow, .kicker { text-transform: uppercase; letter-spacing: .14em; font-size: 11px; color: #87a1c2; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: 27px; letter-spacing: -.02em; }
|
||||
h1 small { color: #7e97b7; font-size: 12px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h2 { max-width: 760px; margin-bottom: 9px; font-size: clamp(29px, 3.6vw, 46px); line-height: 1.04; letter-spacing: -.035em; }
|
||||
h3 { margin: 6px 0 12px; font-size: 22px; letter-spacing: -.02em; }
|
||||
.hero { margin-bottom: 12px; padding: 14px 18px; border: 1px solid #1f3550; border-radius: 14px; background: linear-gradient(135deg, rgba(14, 30, 51, .92), rgba(9, 21, 36, .86)); }
|
||||
.operations-strip { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin-bottom: 12px; padding: 12px 16px; border: 1px solid #1f3550; border-radius: 14px; background: #0b1b2e; }
|
||||
.operations-strip > div:first-child { display: grid; gap: 3px; }
|
||||
.operations-strip strong { font-size: 16px; }
|
||||
.operations-strip small { color: #8fa6c2; font-size: 12px; }
|
||||
.compact-hero { align-items: center; }
|
||||
.compact-hero h2 { margin: 3px 0; font-size: clamp(20px, 2vw, 28px); }
|
||||
.compact-hero .kicker { margin-bottom: 3px; }
|
||||
.compact-hero .muted { font-size: 12px; line-height: 1.4; }
|
||||
.demo-mode .hero { margin-bottom: 10px; padding: 18px 22px; }
|
||||
.demo-mode .hero h2 { max-width: 650px; font-size: clamp(28px, 3vw, 39px); }
|
||||
.muted { max-width: 760px; margin-bottom: 0; color: #91a4bd; line-height: 1.55; }
|
||||
.muted.compact { margin-top: 10px; font-size: 13px; }
|
||||
.error-banner, .notice-banner { margin-bottom: 14px; padding: 11px 13px; border-radius: 11px; }
|
||||
.error-banner { border: 1px solid #7b3842; background: #35151c; color: #ffd7dc; }
|
||||
.notice-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #315d56; background: #102b28; color: #c8eee5; }
|
||||
.demo-banner, .demo-confirm { align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 14px; padding: 14px 16px; border-radius: 14px; }
|
||||
.demo-banner { display: grid; grid-template-columns: minmax(0, 1fr) auto; border: 1px solid #755d2b; background: linear-gradient(110deg, #2a2515, #16251f); }
|
||||
.demo-confirm { display: flex; }
|
||||
.demo-confirm { border: 1px solid #496d91; background: #0e2338; }
|
||||
.demo-banner strong, .demo-confirm strong { display: block; margin-top: 4px; color: #fff2c9; font-size: 16px; }
|
||||
.demo-confirm strong { color: #dcecff; }
|
||||
.demo-banner p, .demo-confirm p { margin: 4px 0 0; color: #b6c4ba; font-size: 13px; line-height: 1.45; }
|
||||
.demo-confirm p { color: #a9bdd2; }
|
||||
.demo-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.demo-walkthrough { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 12px; border-top: 1px solid #66562d; }
|
||||
.demo-walkthrough-copy { min-width: 0; }
|
||||
.demo-walkthrough-copy strong { font-size: 14px; }
|
||||
.demo-walkthrough-copy p { max-width: 700px; }
|
||||
.demo-walkthrough-actions { display: flex; flex: 0 0 auto; gap: 8px; flex-wrap: wrap; }
|
||||
.health-summary { display: grid; grid-template-columns: repeat(4, minmax(64px, 1fr)); gap: 6px; min-width: 300px; }
|
||||
.health-summary div, .incident-metrics div { padding: 9px; border: 1px solid #233d5a; border-radius: 10px; background: #0a1828; }
|
||||
.health-summary strong, .incident-metrics strong { display: block; font-size: 20px; }
|
||||
.health-summary span, .incident-metrics span { color: #8198b3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
.panel-tabs { display: flex; gap: 7px; margin-bottom: 12px; overflow-x: auto; }
|
||||
.panel-tabs button { flex: 0 0 auto; padding: 8px 12px; border-color: #203a56; background: #091726; color: #90a5bf; }
|
||||
.panel-tabs button.active { border-color: #557da8; background: #142a43; color: #eef6ff; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
.topology-workspace:has(.topology-drawer) { grid-template-columns: minmax(0, 1fr) minmax(330px, 380px); }
|
||||
.demo-mode .canvas-card { min-height: 480px; height: min(620px, calc(100vh - 360px)); }
|
||||
.canvas-card { min-height: 500px; height: min(700px, calc(100vh - 370px)); overflow: hidden; border: 1px solid #203754; border-radius: 16px; background: #08131f; box-shadow: 0 26px 90px rgba(0,0,0,.28); }
|
||||
.empty-state { height: 100%; min-height: 620px; display: grid; place-content: center; gap: 8px; padding: 30px; text-align: center; color: #8fa4be; }
|
||||
.empty-state strong { color: #eaf3ff; font-size: 22px; }
|
||||
.side-panel { display: grid; min-width: 0; align-content: start; gap: 12px; height: min(700px, calc(100vh - 370px)); min-height: 500px; overflow-x: hidden; overflow-y: auto; padding-right: 2px; }
|
||||
.topology-drawer { position: sticky; top: 14px; border-left: 1px solid #203754; padding-left: 14px; }
|
||||
.panel-section { min-width: 0; padding: 19px; border: 1px solid #203754; border-radius: 18px; background: rgba(8, 20, 34, .95); box-shadow: 0 18px 50px rgba(0,0,0,.18); }
|
||||
.form-section { padding: 0; }
|
||||
.form-section summary { padding: 16px 19px; cursor: pointer; list-style: none; }
|
||||
.form-section summary::-webkit-details-marker { display: none; }
|
||||
.form-section summary::after { content: '+'; color: #7f99b7; font-size: 18px; }
|
||||
.form-section[open] summary::after { content: '−'; }
|
||||
.form-section .service-form { padding: 0 19px 19px; }
|
||||
.tall-panel { min-height: 520px; }
|
||||
.service-form { display: grid; gap: 13px; margin-top: 13px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 105px; gap: 10px; }
|
||||
.semantic-fields { display: grid; gap: 9px; margin: 4px 0; padding: 10px; border: 1px solid #284563; border-radius: 10px; }
|
||||
.semantic-fields legend { padding: 0 5px; color: #89a8c9; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.semantic-fields .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.semantic-fields .muted { align-self: end; font-size: 11px; }
|
||||
.dependency-fieldset { display: grid; gap: 7px; margin: 0; padding: 11px; border: 1px solid #263d57; border-radius: 11px; max-height: 150px; overflow-y: auto; }
|
||||
.dependency-fieldset legend { padding: 0 5px; color: #8197b2; font-size: 12px; }
|
||||
.check-row { display: flex; align-items: center; gap: 8px; }
|
||||
.check-row input { width: auto; margin: 0; }
|
||||
.inspector-section { min-height: 280px; }
|
||||
.observation-callout { border: 1px solid #31576c; background: #102433; color: #b7d9e8; }
|
||||
.root-cause-label { display: inline-block; margin-top: 2px; color: #ffd27c; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.inspector-callout { display: grid; gap: 5px; margin: 13px 0; padding: 11px 12px; border-radius: 10px; font-size: 12px; line-height: 1.4; }
|
||||
.inspector-callout strong { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.inspector-callout span, .inspector-callout small { color: #b7c8d9; }
|
||||
.root-cause-callout { border: 1px solid #80622f; background: #2a2517; color: #ffe7a2; }
|
||||
.impacted-callout { border: 1px solid #6a4f2a; background: #241f16; color: #f2c977; }
|
||||
.inspector dl { display: grid; gap: 8px; margin: 15px 0; }
|
||||
.inspector dl div { display: grid; grid-template-columns: 125px minmax(0, 1fr); gap: 9px; }
|
||||
dt { color: #7f94ae; font-size: 12px; }
|
||||
dd { margin: 0; overflow-wrap: anywhere; color: #dce8f7; font-size: 12px; }
|
||||
.probe-detail { padding: 10px; border-radius: 9px; background: #0a1625; color: #9db0c8; font-size: 12px; line-height: 1.45; }
|
||||
.health-pill { display: inline-flex; padding: 5px 9px; border-radius: 999px; border: 1px solid currentColor; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; }
|
||||
.status-healthy { color: #7ee2ac !important; }
|
||||
.status-degraded { color: #ffd27c !important; }
|
||||
.status-down { color: #ff929e !important; }
|
||||
.status-unknown { color: #9aabc0 !important; }
|
||||
.status-unmonitored { color: #87a9c4 !important; }
|
||||
.history-strip { display: flex; align-items: flex-end; gap: 4px; min-height: 24px; margin: 13px 0; padding: 7px 8px; border-radius: 9px; background: #08131f; overflow-x: auto; }
|
||||
.history-dot { flex: 0 0 8px; width: 8px; height: 18px; border-radius: 4px; background: currentColor; opacity: .85; }
|
||||
.incident-metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; margin: 15px 0; }
|
||||
.candidate-list, .docker-list { display: grid; gap: 9px; margin-top: 14px; }
|
||||
.candidate { width: 100%; display: grid; gap: 5px; text-align: left; background: #0a1727; }
|
||||
.candidate strong { font-size: 15px; }
|
||||
.candidate small, .docker-card small { color: #8196af; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.config-label { display: grid; gap: 4px; margin: 14px 0; }
|
||||
.full-button { width: 100%; margin-top: 11px; }
|
||||
.docker-card { display: grid; gap: 7px; padding: 12px; border: 1px solid #243c57; border-radius: 12px; background: #0a1727; }
|
||||
.docker-card > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.docker-card > div span { color: #85a0be; font-size: 11px; }
|
||||
.empty-inline { padding: 18px 0 8px; color: #8094ad; font-size: 13px; }
|
||||
|
||||
.react-flow__node { min-width: 165px; border: 1px solid #36587e !important; background: #10233a !important; color: #edf6ff !important; border-radius: 13px !important; padding: 0 !important; box-shadow: 0 10px 30px rgba(0,0,0,.25); font-size: 12px; }
|
||||
.react-flow__node.service-node { min-width: 198px; overflow: visible; }
|
||||
.react-flow__node.application-node { min-width: 226px; border-radius: 15px !important; }
|
||||
.application-node .service-node-body { min-height: 122px; background: linear-gradient(145deg, rgba(21, 51, 76, .88), rgba(12, 29, 47, .96)); }
|
||||
.scope-label { display: block; margin: 8px 0 4px; color: #91a8c2; font-size: 11px; }
|
||||
.node-kicker { color: #7fb6dd; font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.react-flow__node.service-node .react-flow__handle { width: 8px; height: 8px; border: 2px solid #9bb5d1; background: #173552; }
|
||||
.service-node-body { display: grid; gap: 4px; min-height: 108px; padding: 12px 14px; border-radius: 12px; }
|
||||
.service-node-body strong { overflow-wrap: anywhere; font-size: 14px; line-height: 1.15; }
|
||||
.service-node-body small { color: #91a8c2; font-size: 10px; }
|
||||
.node-badges { min-height: 15px; }
|
||||
.node-badge { display: inline-flex; padding: 2px 5px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: .08em; }
|
||||
.app-micro-topology { display: flex; gap: 4px; margin: 6px 0 2px; }
|
||||
.app-micro-topology i { display: grid; width: 16px; height: 16px; place-items: center; border: 1px solid #416181; border-radius: 50%; color: #b7d9f4; background: #102842; font-size: 8px; font-style: normal; font-weight: 800; }
|
||||
.app-micro-topology i.role-database, .app-micro-topology i.role-cache, .app-micro-topology i.role-storage { border-radius: 4px; background: #19353c; }
|
||||
.app-micro-topology i.role-proxy, .app-micro-topology i.role-api { border-radius: 3px; background: #29395c; }
|
||||
.root-cause-badge { background: #825f25; color: #ffe8a3; }
|
||||
.impacted-badge { background: #68452a; color: #ffd69b; }
|
||||
.node-status { width: fit-content; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.react-flow__node.service-node:has(.root-cause-badge) { border-color: #d39b38 !important; box-shadow: 0 0 0 2px rgba(211,155,56,.28), 0 12px 36px rgba(211,155,56,.2); }
|
||||
.react-flow__node.selected { outline: 2px solid #dcecff; outline-offset: 3px; }
|
||||
.react-flow__node.status-healthy { border-color: #3b8c67 !important; box-shadow: 0 10px 34px rgba(47,128,90,.12); }
|
||||
.react-flow__node.status-degraded { border-color: #9a7330 !important; box-shadow: 0 10px 34px rgba(154,115,48,.12); }
|
||||
.react-flow__node.status-down { border-color: #9b414d !important; box-shadow: 0 10px 34px rgba(155,65,77,.14); }
|
||||
.react-flow__node.status-unmonitored { border-color: #466a86 !important; box-shadow: none; }
|
||||
.react-flow__edge-path { stroke: #668ab3; stroke-width: 2; }
|
||||
.living-edge-path { fill: none; stroke: #668ab3; stroke-width: 2; transition: stroke .18s ease, stroke-width .18s ease, opacity .18s ease; }
|
||||
.living-edge-routes_to { stroke: #82a9cf; stroke-dasharray: 8 4; }
|
||||
.living-edge-review { stroke: #58718c; stroke-dasharray: 3 6; opacity: .68; }
|
||||
.living-edge-path.is-selected { stroke: #d5ebff; stroke-width: 3.2; }
|
||||
.living-edge-signal { fill: none; stroke: #bde6ff; stroke-width: 3.2; stroke-linecap: round; stroke-dasharray: 2 20; animation: servicemap-edge-signal 1.7s linear infinite; filter: drop-shadow(0 0 3px rgba(143, 211, 255, .8)); }
|
||||
.edge-down .living-edge-path { stroke: #b55967; }
|
||||
.edge-degraded .living-edge-path { stroke: #b08a45; }
|
||||
.edge-down .living-edge-signal, .edge-degraded .living-edge-signal { stroke: #ffd17b; }
|
||||
@keyframes servicemap-edge-signal { to { stroke-dashoffset: -44; } }
|
||||
@media (prefers-reduced-motion: reduce) { .living-edge-signal { animation: none; stroke-dasharray: none; opacity: .85; } }
|
||||
.react-flow__edge-marker { fill: #668ab3; }
|
||||
.react-flow__edge.edge-down .react-flow__edge-path { stroke: #b55967; }
|
||||
.react-flow__edge.edge-degraded .react-flow__edge-path { stroke: #b08a45; }
|
||||
.react-flow__edge.edge-down .react-flow__edge-marker { fill: #b55967; }
|
||||
.react-flow__edge.edge-degraded .react-flow__edge-marker { fill: #b08a45; }
|
||||
.react-flow__controls { border: 1px solid #274563; overflow: hidden; border-radius: 12px; }
|
||||
.react-flow__controls button { background: #10233a; color: #fff; border-bottom-color: #274563; }
|
||||
.react-flow__minimap { border: 1px solid #274563; border-radius: 12px; overflow: hidden; background: #08131f !important; }
|
||||
|
||||
/* v1.1 information architecture */
|
||||
.overview-grid, .incident-workspace, .services-workspace { display: grid; gap: 16px; }
|
||||
.overview-grid { grid-template-columns: minmax(0, 1.35fr) minmax(300px, .9fr); }
|
||||
.overview-lead { min-height: 270px; }
|
||||
.overview-lead h2 { margin: 9px 0; font-size: clamp(28px, 3vw, 42px); }
|
||||
.overview-incident { min-height: 270px; }
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 28px; }
|
||||
.summary-cards button { display: block; min-width: 0; padding: 14px; border: 1px solid #233d5a; border-radius: 14px; background: #0a1828; text-align: left; }
|
||||
.summary-cards strong { display: block; font-size: 26px; }
|
||||
.summary-cards span { color: #a9bdd4; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.overview-root-list { max-height: 220px; overflow-y: auto; padding-right: 4px; }
|
||||
.coverage-overview { grid-column: span 2; }
|
||||
.coverage-overview h3 { margin-bottom: 0; }
|
||||
.coverage-breakdown { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin: 14px 0; }
|
||||
.coverage-breakdown div { padding: 10px; border: 1px solid #233d5a; border-radius: 11px; background: #0a1828; }
|
||||
.coverage-breakdown strong, .coverage-breakdown span { display: block; }
|
||||
.coverage-breakdown strong { font-size: 20px; }
|
||||
.coverage-breakdown span { margin-top: 3px; color: #91a6bf; font-size: 10px; line-height: 1.25; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.event-panel { grid-column: 1 / -1; }
|
||||
.event-list { display: grid; gap: 2px; margin-top: 12px; }
|
||||
.event-row { display: flex; align-items: flex-start; gap: 11px; padding: 10px 0; border-bottom: 1px solid #172c43; }
|
||||
.event-dot { flex: 0 0 8px; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: #78a8d5; box-shadow: 0 0 0 4px rgba(120,168,213,.12); }
|
||||
.event-row strong, .event-row small { display: block; }
|
||||
.event-row strong { font-size: 13px; }
|
||||
.event-row small { margin-top: 3px; color: #8095ae; font-size: 11px; }
|
||||
.incident-workspace { grid-template-columns: minmax(0, 1.05fr) minmax(320px, .85fr); }
|
||||
.incident-list-panel, .incident-detail-panel { min-height: 560px; }
|
||||
.incident-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #8398b3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.incident-card.resolved { opacity: .72; }
|
||||
.review-filters { display: grid; grid-template-columns: minmax(170px, 1fr) repeat(2, minmax(130px, auto)); gap: 10px; margin-top: 16px; }
|
||||
.review-filters label { display: grid; gap: 5px; color: #afc3d9; font-size: 12px; }
|
||||
.review-filters input, .review-filters select { min-width: 0; width: 100%; margin: 0; }
|
||||
.incident-list-panel .review-filters { grid-template-columns: minmax(170px, 1fr) minmax(130px, 170px); }
|
||||
.timeline { display: grid; gap: 0; margin-top: 20px; }
|
||||
.timeline-row { display: grid; grid-template-columns: 17px minmax(0, 1fr); gap: 10px; min-height: 58px; }
|
||||
.timeline-line { position: relative; width: 9px; height: 9px; margin-top: 4px; border: 2px solid #78a8d5; border-radius: 50%; background: #0b1b2c; }
|
||||
.timeline-line::after { content: ''; position: absolute; top: 7px; left: 2px; width: 1px; height: 50px; background: #315271; }
|
||||
.timeline-row:last-child .timeline-line::after { display: none; }
|
||||
.timeline-row strong, .timeline-row small { display: block; }
|
||||
.timeline-row strong { font-size: 13px; line-height: 1.35; }
|
||||
.timeline-row small { margin-top: 3px; color: #8298b2; font-size: 11px; }
|
||||
.services-workspace { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
.services-workspace:has(.service-drawer) { grid-template-columns: minmax(0, 1fr) minmax(340px, 400px); }
|
||||
.service-drawer { position: sticky; top: 14px; display: grid; align-content: start; gap: 12px; max-height: calc(100vh - 42px); overflow-y: auto; padding: 0 2px 2px 14px; border-left: 1px solid #203754; }
|
||||
.drawer-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 2px 0; }
|
||||
.table-panel { min-height: 630px; }
|
||||
.table-toolbar { display: grid; grid-template-columns: minmax(180px, 1.5fr) auto repeat(5, minmax(105px, 1fr)); gap: 9px; margin-bottom: 15px; }
|
||||
.search-field { min-width: 0; }
|
||||
.service-table { display: grid; gap: 1px; overflow-x: auto; }
|
||||
.service-table-head, .service-table-row { display: grid; grid-template-columns: minmax(180px, 1.25fr) minmax(115px, .8fr) minmax(110px, .65fr) minmax(130px, .9fr) minmax(100px, .7fr) minmax(75px, .45fr) minmax(75px, .45fr); gap: 12px; align-items: center; min-width: 920px; }
|
||||
.service-table-head { padding: 8px 12px; color: #7f96b2; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
|
||||
.service-table-row { width: 100%; padding: 12px; border: 1px solid #1d3853; border-radius: 10px; background: #0a1727; color: #dbe9f8; text-align: left; }
|
||||
.service-table-row:hover { background: #10243a; }
|
||||
.service-table-row[aria-pressed="true"] { border-color: #78a8d5; background: #102943; box-shadow: inset 3px 0 #8dc5ee; }
|
||||
.monitoring-cell { display: grid; gap: 2px; }
|
||||
.service-table-row span { min-width: 0; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.service-table-row strong, .service-table-row small { display: block; }
|
||||
.service-table-row strong { font-size: 13px; }
|
||||
.service-table-row small { margin-top: 3px; color: #8197b1; font-size: 11px; }
|
||||
.graph-tools { display: none; }
|
||||
.topology-command-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 12px; padding: 9px 11px; border: 1px solid #203754; border-radius: 14px; background: rgba(8, 20, 34, .94); box-shadow: 0 16px 40px rgba(0,0,0,.14); }
|
||||
.topology-command-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
|
||||
.filter-chip { justify-self: start; padding: 4px 8px; border-color: #456b8d; border-radius: 999px; background: #102c43; color: #c5e2f7; font-size: 10px; }
|
||||
.topology-command-actions input { width: 175px; margin: 0; padding: 7px 9px; font-size: 12px; }
|
||||
.compact-button { padding: 7px 9px; border-radius: 9px; font-size: 11px; }
|
||||
.filter-popover { position: relative; }
|
||||
.filter-popover summary { cursor: pointer; list-style: none; padding: 7px 9px; border: 1px solid #355779; border-radius: 9px; background: #0b1a2b; color: #eef4ff; font-size: 11px; }
|
||||
.filter-popover summary::-webkit-details-marker { display: none; }
|
||||
.filter-popover > div { position: absolute; z-index: 12; right: 0; top: calc(100% + 7px); display: grid; grid-template-columns: repeat(2, minmax(145px, 1fr)); gap: 9px; width: 330px; padding: 12px; border: 1px solid #355779; border-radius: 12px; background: #0b1b2e; box-shadow: 0 20px 50px rgba(0,0,0,.42); }
|
||||
.filter-popover label { font-size: 11px; }
|
||||
.filter-popover select { padding: 7px 8px; font-size: 12px; }
|
||||
.mode-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.mode-tabs button { padding: 7px 9px; font-size: 11px; }
|
||||
.mode-tabs button.active { border-color: #79a6d1; background: #193956; }
|
||||
.topology-breadcrumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; color: #9fb4ca; font-size: 12px; }
|
||||
.domain-node .service-node-body { min-width: 205px; }
|
||||
.application-node .service-node-body { min-width: 225px; }
|
||||
.component-node .service-node-body { min-width: 198px; }
|
||||
.react-flow__node.domain-node { border-style: double; }
|
||||
.filter-row { display: grid; grid-template-columns: minmax(0, 1fr) 125px; gap: 8px; }
|
||||
.detail-grid { display: grid; gap: 8px; margin: 15px 0; }
|
||||
.detail-grid div { display: grid; grid-template-columns: 125px minmax(0, 1fr); gap: 9px; }
|
||||
.discovery-panel, .config-panel { max-width: 980px; min-height: 620px; }
|
||||
.suggestion { display: grid; gap: 4px; margin-top: 9px; padding: 10px; border: 1px solid #294563; border-radius: 9px; background: #0c1d30; }
|
||||
.suggestion small { color: #92a8c1; }
|
||||
.suggestion button { margin-top: 4px; }
|
||||
.discovery-workspace { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.05fr) minmax(340px, .95fr); align-items: start; }
|
||||
.discovery-workspace > .discovery-observations { grid-column: 1 / -1; }
|
||||
.discovery-panel, .suggestion-panel, .discovery-observations { min-height: 0; }
|
||||
.discovery-summary { display: grid; grid-template-columns: repeat(5, minmax(72px, 1fr)); gap: 8px; margin: 18px 0 12px; }
|
||||
.discovery-summary div { display: grid; gap: 4px; padding: 12px; border: 1px solid #233d5a; border-radius: 12px; background: #0a1828; }
|
||||
.discovery-summary strong { font-size: 21px; }
|
||||
.discovery-summary span { color: #8198b3; font-size: 10px; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.discovery-callout, .notice-inline { display: grid; gap: 4px; margin: 13px 0; padding: 11px 12px; border: 1px solid #315c5e; border-radius: 10px; background: #0c2528; color: #bce8dd; font-size: 12px; line-height: 1.45; }
|
||||
.probe-safety-callout { display: grid; gap: 7px; margin: 13px 0; padding: 12px; border: 1px solid #496d91; border-radius: 10px; background: #0d2237; color: #d0e5fa; font-size: 12px; line-height: 1.45; }
|
||||
.probe-safety-callout span, .probe-safety-callout small { color: #9fb8d2; }
|
||||
.coverage-change-list { display: grid; gap: 6px; max-height: 260px; overflow-y: auto; }
|
||||
.coverage-change-list > div { display: grid; gap: 2px; padding: 8px 9px; border: 1px solid #294863; border-radius: 8px; background: #0a1b2b; }
|
||||
.coverage-change-list strong { font-size: 12px; }
|
||||
.coverage-change-list small { font-size: 11px; line-height: 1.35; }
|
||||
.discovery-callout span { color: #91bdb8; }
|
||||
.notice-inline { display: block; color: #b8d8f5; border-color: #315271; background: #0d2237; }
|
||||
.suggestion-list { display: grid; gap: 10px; margin-top: 14px; max-height: 720px; overflow-y: auto; padding-right: 2px; }
|
||||
.discovery-panel .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.review-confirm { margin-top: 14px; padding: 14px; border: 1px solid #b28a46; border-radius: 12px; background: #322714; }
|
||||
.review-confirm p { color: #e0d2b6; line-height: 1.5; }
|
||||
.show-more { margin-top: 12px; }
|
||||
.suggestion-card { display: grid; gap: 7px; padding: 13px; border: 1px solid #2a4663; border-radius: 13px; background: #0a1727; }
|
||||
.suggestion-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.suggestion-card-heading strong { display: block; margin-top: 4px; font-size: 14px; line-height: 1.3; }
|
||||
.suggestion-card > small { color: #8fa5be; }
|
||||
.evidence-list { display: grid; gap: 4px; margin-top: 6px; }
|
||||
.evidence-list strong { color: #9db4cf; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.evidence-list small { display: block; color: #849bb6; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.provider-statuses { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.provider-statuses small { border: 1px solid #294968; border-radius: 999px; padding: 3px 7px; text-transform: capitalize; }
|
||||
.change-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 9px; margin-top: 14px; align-items: start; }
|
||||
.snapshot-picker { display: grid; gap: 5px; max-width: 440px; margin: 14px 0; color: #afc3d9; font-size: 12px; }
|
||||
.change-card { padding: 12px; border: 1px solid #294563; border-radius: 10px; background: #0a1727; }
|
||||
.change-card summary { display: grid; gap: 4px; cursor: pointer; }
|
||||
.change-card summary strong { color: #e3efff; }
|
||||
.change-card summary small { color: #9cb2cb; }
|
||||
.change-fields { display: grid; gap: 6px; margin: 10px 0; }
|
||||
.change-fields div { display: grid; gap: 2px; padding: 7px; border: 1px solid #203b57; border-radius: 6px; }
|
||||
.change-fields strong { font-size: 11px; text-transform: capitalize; }
|
||||
.change-fields small { overflow-wrap: anywhere; color: #a8bed6; }
|
||||
.current-evidence-callout { border-color: #3b6d7b; background: #102b37; }
|
||||
.observation-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px; margin: 14px 0; }
|
||||
.observation-row { display: grid; grid-template-columns: minmax(0, 1.2fr) auto; gap: 3px 9px; padding: 9px 10px; border: 1px solid #203b57; border-radius: 9px; background: #0a1727; }
|
||||
.observation-row strong { overflow-wrap: anywhere; font-size: 12px; }
|
||||
.observation-row span, .observation-row small { color: #8ca3bd; font-size: 11px; }
|
||||
.observation-row small { grid-column: 1 / -1; }
|
||||
.candidate-details { margin-top: 14px; }
|
||||
.candidate-details summary { cursor: pointer; color: #a9bdd4; font-size: 12px; }
|
||||
.compact-list { max-height: 430px; overflow-y: auto; }
|
||||
.group-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.group-chips button { padding: 6px 8px; font-size: 11px; }
|
||||
.group-chips button.active { border-color: #79a6d1; background: #193956; }
|
||||
.edge-inspector { border-color: #46698b; }
|
||||
.edge-inspector .detail-grid { max-height: 280px; overflow-y: auto; }
|
||||
.flap-badge { margin-left: 4px; background: #3d3f74; color: #d9d8ff; }
|
||||
.status-recovering { color: #a9c5ff !important; }
|
||||
.status-maintenance { color: #b6a9dc !important; }
|
||||
.react-flow__edge.edge-dimmed { opacity: .28; }
|
||||
.react-flow__edge.edge-down .react-flow__edge-path, .react-flow__edge.edge-degraded .react-flow__edge-path { stroke-dasharray: 7 5; }
|
||||
.react-flow__node.status-recovering { border-color: #687ab1 !important; }
|
||||
.react-flow__node.status-maintenance { border-color: #705d99 !important; }
|
||||
.react-flow__node.service-node:has(.flap-badge) { border-style: dashed !important; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.side-panel { max-height: none; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
|
||||
.tall-panel { grid-column: 1 / -1; }
|
||||
.canvas-card { height: 620px; min-height: 620px; }
|
||||
.services-workspace, .incident-workspace { grid-template-columns: 1fr; }
|
||||
.discovery-workspace { grid-template-columns: 1fr; }
|
||||
.discovery-workspace > .discovery-observations { grid-column: auto; }
|
||||
.service-side { max-height: none; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
|
||||
.coverage-overview { grid-column: auto; }
|
||||
.topology-command-bar { grid-template-columns: 1fr; }
|
||||
.topology-command-actions { justify-content: flex-start; }
|
||||
.topology-drawer, .service-drawer { position: static; height: auto; max-height: none; border-left: 0; padding-left: 0; overflow: visible; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell { width: min(100% - 24px, 1540px); padding-top: 18px; }
|
||||
.topbar, .hero, .operations-strip { align-items: flex-start; flex-direction: column; }
|
||||
.toolbar { width: 100%; flex-wrap: wrap; }
|
||||
.toolbar button { flex: 1 1 auto; }
|
||||
.health-summary { min-width: 0; width: 100%; grid-template-columns: repeat(2, 1fr); }
|
||||
.canvas-card { height: 520px; min-height: 520px; }
|
||||
.side-panel { grid-template-columns: 1fr; }
|
||||
.service-side { grid-template-columns: 1fr; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
.discovery-panel .field-row, .review-filters, .incident-list-panel .review-filters { grid-template-columns: 1fr; }
|
||||
.table-toolbar, .overview-grid { grid-template-columns: 1fr; }
|
||||
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.coverage-breakdown { grid-template-columns: repeat(2, 1fr); }
|
||||
.change-list { grid-template-columns: 1fr; }
|
||||
.topology-command-actions input { width: 100%; flex: 1 1 180px; }
|
||||
.filter-popover > div { position: fixed; right: 12px; left: 12px; top: auto; width: auto; }
|
||||
.discovery-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
.inspector-title { align-items: flex-start; }
|
||||
.demo-banner { display: flex; align-items: flex-start; flex-direction: column; }
|
||||
.demo-confirm, .demo-walkthrough { align-items: flex-start; flex-direction: column; }
|
||||
.demo-actions { width: 100%; }
|
||||
.demo-actions button, .demo-walkthrough-actions button { flex: 1 1 auto; }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applicationEdges,
|
||||
applicationTopologyLayout,
|
||||
} from "./App";
|
||||
import type { SemanticTopology, ServiceSnapshot } from "./api";
|
||||
import { semanticTopologyGraph } from "./topology/semanticGraph";
|
||||
|
||||
function component(application: string, number: number): ServiceSnapshot {
|
||||
const id = `${application}-${number}`;
|
||||
return {
|
||||
service: {
|
||||
id,
|
||||
name: `${application} component ${number}`,
|
||||
kind: "http",
|
||||
url: null,
|
||||
host: null,
|
||||
port: null,
|
||||
depends_on:
|
||||
number === 0 && application !== "Application 0"
|
||||
? [`Application ${Number(application.split(" ")[1]) - 1}-0`]
|
||||
: [],
|
||||
description: null,
|
||||
tags: [],
|
||||
group: application,
|
||||
environment: "production",
|
||||
owner: null,
|
||||
enabled: true,
|
||||
monitoring_enabled: false,
|
||||
monitoring_state: "unmonitored",
|
||||
probe_source: "container_internal",
|
||||
probe_reachability: "internal_only",
|
||||
probe_confidence: "low",
|
||||
runtime_state: "running",
|
||||
runtime_health: null,
|
||||
application,
|
||||
domain: null,
|
||||
component_role: "unknown",
|
||||
application_locked: false,
|
||||
domain_locked: false,
|
||||
component_role_locked: false,
|
||||
criticality: "medium",
|
||||
category: "application",
|
||||
visibility: "visible",
|
||||
lifecycle: "active",
|
||||
source_type: "docker",
|
||||
source_ref: `docker:name:${id}`,
|
||||
first_seen_at: null,
|
||||
last_seen_at: null,
|
||||
last_discovery_status: "running",
|
||||
container_image: null,
|
||||
compose_project: null,
|
||||
layout_x: null,
|
||||
layout_y: null,
|
||||
layout_pinned: false,
|
||||
interval_seconds: null,
|
||||
timeout_seconds: null,
|
||||
retries: null,
|
||||
failure_threshold: 1,
|
||||
recovery_threshold: 1,
|
||||
expected_status_min: null,
|
||||
expected_status_max: null,
|
||||
availability_target: null,
|
||||
maintenance_enabled: false,
|
||||
maintenance_reason: null,
|
||||
state: "unmonitored",
|
||||
failure_streak: 0,
|
||||
recovery_streak: 0,
|
||||
flapping: false,
|
||||
flap_count: 0,
|
||||
last_state_change_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
latest_probe: null,
|
||||
availability_24h: null,
|
||||
direct_status: "unmonitored",
|
||||
dependency_status: "healthy",
|
||||
effective_status: "unmonitored",
|
||||
flapping: false,
|
||||
flap_count: 0,
|
||||
next_probe_at: null,
|
||||
dependents: [],
|
||||
sources: [],
|
||||
evidence: [],
|
||||
health_evidence: [],
|
||||
health_resolution: {
|
||||
status: "unknown",
|
||||
selected_evidence_id: null,
|
||||
selected_provider: null,
|
||||
confidence: null,
|
||||
reason_code: "no_current_component_evidence",
|
||||
explanation: "No current component-scoped health evidence is available.",
|
||||
conflict: false,
|
||||
evidence: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("application topology layout", () => {
|
||||
it("collapses 150 components into a deterministic 25-application landscape", () => {
|
||||
const services = Array.from({ length: 25 }, (_, app) =>
|
||||
Array.from({ length: 6 }, (_, componentNumber) =>
|
||||
component(`Application ${app}`, componentNumber),
|
||||
),
|
||||
).flat();
|
||||
|
||||
const nodes = applicationTopologyLayout(services);
|
||||
const edges = applicationEdges(services, "landscape", new Set());
|
||||
|
||||
expect(nodes).toHaveLength(25);
|
||||
expect(edges).toHaveLength(24);
|
||||
expect(
|
||||
new Set(nodes.map((node) => `${node.position.x}:${node.position.y}`))
|
||||
.size,
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("keeps unrelated singleton applications distinct instead of grouping them by category", () => {
|
||||
const services = Array.from({ length: 40 }, (_, app) =>
|
||||
component(`Singleton ${app}`, 0),
|
||||
);
|
||||
|
||||
const nodes = applicationTopologyLayout(services);
|
||||
const edges = applicationEdges(services, "landscape", new Set());
|
||||
|
||||
expect(nodes).toHaveLength(40);
|
||||
expect(new Set(nodes.map((node) => node.data.name)).size).toBe(40);
|
||||
expect(nodes.every((node) => node.data.memberIds?.length === 1)).toBe(true);
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("balances a large independent application landscape into readable columns", () => {
|
||||
const services = Array.from({ length: 40 }, (_, app) => [
|
||||
component(`Stack ${app}`, 0),
|
||||
component(`Stack ${app}`, 1),
|
||||
]).flat();
|
||||
|
||||
const nodes = applicationTopologyLayout(services);
|
||||
|
||||
expect(nodes).toHaveLength(40);
|
||||
expect(new Set(nodes.map((node) => node.position.y)).size).toBe(5);
|
||||
expect(new Set(nodes.map((node) => node.position.x)).size).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps a 500-component inventory bounded to its application-level nodes", () => {
|
||||
const services = Array.from({ length: 100 }, (_, app) =>
|
||||
Array.from({ length: 5 }, (_, componentNumber) =>
|
||||
component(`Application ${app}`, componentNumber),
|
||||
),
|
||||
).flat();
|
||||
|
||||
const nodes = applicationTopologyLayout(services);
|
||||
const edges = applicationEdges(services, "landscape", new Set());
|
||||
|
||||
expect(nodes).toHaveLength(100);
|
||||
expect(new Set(nodes.map((node) => node.id)).size).toBe(100);
|
||||
expect(edges).toHaveLength(99);
|
||||
});
|
||||
|
||||
it("marks only the exact root-cause component, not its healthy siblings", () => {
|
||||
const topology: SemanticTopology = {
|
||||
generated_at: "2026-09-13T12:00:00Z",
|
||||
domains: [],
|
||||
applications: [],
|
||||
components: [
|
||||
{
|
||||
service_id: "postgres",
|
||||
name: "PostgreSQL",
|
||||
kind: "tcp",
|
||||
application_id: "data",
|
||||
domain_id: "data-domain",
|
||||
role: "database",
|
||||
direct_status: "down",
|
||||
effective_status: "down",
|
||||
unknown_reason: null,
|
||||
runtime_state: "running",
|
||||
runtime_health: "down",
|
||||
monitoring_state: "active_probe",
|
||||
latest_latency_ms: null,
|
||||
dependency_ids: [],
|
||||
dependent_ids: ["api"],
|
||||
root_cause: true,
|
||||
affected: false,
|
||||
},
|
||||
{
|
||||
service_id: "redis",
|
||||
name: "Redis",
|
||||
kind: "tcp",
|
||||
application_id: "data",
|
||||
domain_id: "data-domain",
|
||||
role: "cache",
|
||||
direct_status: "healthy",
|
||||
effective_status: "healthy",
|
||||
unknown_reason: null,
|
||||
runtime_state: "running",
|
||||
runtime_health: "healthy",
|
||||
monitoring_state: "active_probe",
|
||||
latest_latency_ms: 11,
|
||||
dependency_ids: [],
|
||||
dependent_ids: ["api"],
|
||||
root_cause: false,
|
||||
affected: false,
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
monitoring_coverage: { active_probe: 2 },
|
||||
};
|
||||
|
||||
const graph = semanticTopologyGraph(
|
||||
topology,
|
||||
"components",
|
||||
"data",
|
||||
false,
|
||||
"",
|
||||
"all",
|
||||
);
|
||||
|
||||
expect(graph.nodes.find((node) => node.id === "postgres")?.data.rootCause).toBe(true);
|
||||
expect(graph.nodes.find((node) => node.id === "redis")?.data.rootCause).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type {
|
||||
ComponentRole,
|
||||
Criticality,
|
||||
HealthStatus,
|
||||
ServiceKind,
|
||||
} from "../api";
|
||||
|
||||
export type ServiceNodeData = {
|
||||
name: string;
|
||||
status: HealthStatus;
|
||||
kind: ServiceKind;
|
||||
latency: number | null;
|
||||
dependencyCount: number;
|
||||
rootCause: boolean;
|
||||
impacted: boolean;
|
||||
group: string | null;
|
||||
criticality: Criticality;
|
||||
flapping: boolean;
|
||||
category: string;
|
||||
application?: string;
|
||||
applicationNode?: boolean;
|
||||
memberIds?: string[];
|
||||
nodeKind?: "domain" | "application" | "component";
|
||||
role?: ComponentRole;
|
||||
roles?: ComponentRole[];
|
||||
explanation?: string;
|
||||
monitoredComponents?: number;
|
||||
unmonitoredComponents?: number;
|
||||
domainId?: string;
|
||||
};
|
||||
|
||||
export type ServiceNode = Node<ServiceNodeData, "service">;
|
||||
|
||||
const statusLabel = (status: HealthStatus) =>
|
||||
status === "unmonitored"
|
||||
? "Not monitored"
|
||||
: status[0].toUpperCase() + status.slice(1);
|
||||
const statusIcon = (status: HealthStatus) =>
|
||||
({
|
||||
healthy: "✓",
|
||||
degraded: "△",
|
||||
down: "!",
|
||||
recovering: "↻",
|
||||
maintenance: "Ⅱ",
|
||||
unknown: "?",
|
||||
unmonitored: "○",
|
||||
})[status];
|
||||
|
||||
export function ServiceNodeView({ data }: NodeProps<ServiceNode>) {
|
||||
const targetLabel =
|
||||
data.nodeKind === "domain"
|
||||
? `${data.memberIds?.length ?? 0} components`
|
||||
: data.applicationNode
|
||||
? `${data.monitoredComponents ?? 0} resolved · ${data.unmonitoredComponents ?? 0} unresolved`
|
||||
: data.latency != null
|
||||
? `${data.latency} ms · ${data.kind.toUpperCase()}`
|
||||
: data.kind.toUpperCase();
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div
|
||||
className={`service-node-body status-${data.status}`}
|
||||
aria-label={`${data.name}, ${statusLabel(data.status)}${data.rootCause ? ", likely root cause" : ""}`}
|
||||
>
|
||||
<div className="node-badges">
|
||||
{data.rootCause && (
|
||||
<span className="node-badge root-cause-badge">ROOT CAUSE</span>
|
||||
)}
|
||||
{!data.rootCause && data.impacted && (
|
||||
<span className="node-badge impacted-badge">IMPACTED</span>
|
||||
)}
|
||||
{data.flapping && (
|
||||
<span className="node-badge flap-badge">FLAPPING</span>
|
||||
)}
|
||||
</div>
|
||||
{data.nodeKind === "domain" && (
|
||||
<span className="node-kicker">
|
||||
DOMAIN · {data.memberIds?.length ?? 0} components
|
||||
</span>
|
||||
)}
|
||||
{data.applicationNode && (
|
||||
<span className="node-kicker">
|
||||
APPLICATION · {data.memberIds?.length ?? 0} components
|
||||
</span>
|
||||
)}
|
||||
{data.applicationNode && data.roles?.length ? (
|
||||
<span
|
||||
className="app-micro-topology"
|
||||
aria-label={`Component roles: ${data.roles.join(", ")}`}
|
||||
>
|
||||
{data.roles.slice(0, 5).map((role) => (
|
||||
<i key={role} className={`role-${role}`}>
|
||||
{role.slice(0, 1).toUpperCase()}
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{data.nodeKind === "component" && (
|
||||
<span className="node-kicker">{data.role ?? data.category}</span>
|
||||
)}
|
||||
<strong>{data.name}</strong>
|
||||
<span className={`node-status status-${data.status}`}>
|
||||
<i aria-hidden="true">{statusIcon(data.status)}</i>{" "}
|
||||
{statusLabel(data.status)}
|
||||
</span>
|
||||
<small>{targetLabel} · {data.criticality}</small>
|
||||
<small>
|
||||
{data.explanation ??
|
||||
`${data.category.replace("_", " ")} · ${data.dependencyCount} ${data.dependencyCount === 1 ? "dependency" : "dependencies"}`}
|
||||
</small>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import type { Criticality, HealthStatus, MonitoringState } from "../api";
|
||||
import {
|
||||
domainLocation,
|
||||
isTopologyRoot,
|
||||
topologyRoot,
|
||||
topologyUp,
|
||||
type TopologyLocation,
|
||||
} from "../topologyNavigation";
|
||||
|
||||
export type TopologyMode = "landscape" | "dependencies" | "impact";
|
||||
export type FilterStatus = "all" | "unresolved" | HealthStatus;
|
||||
export type MonitoringFilter = "all" | MonitoringState;
|
||||
|
||||
type TopologyCommandBarProps = {
|
||||
location: TopologyLocation;
|
||||
setLocation: Dispatch<SetStateAction<TopologyLocation>>;
|
||||
selectedDomain: { id: string; name: string } | null;
|
||||
selectedApplication: { name: string } | null;
|
||||
mode: TopologyMode;
|
||||
setMode: (mode: TopologyMode) => void;
|
||||
setImpact: (enabled: boolean) => void;
|
||||
showImpact: boolean;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
statusFilter: FilterStatus;
|
||||
setStatusFilter: (value: FilterStatus) => void;
|
||||
monitoringFilter: MonitoringFilter;
|
||||
setMonitoringFilter: (value: MonitoringFilter) => void;
|
||||
groupFilter: string;
|
||||
environmentFilter: string;
|
||||
setEnvironmentFilter: (value: string) => void;
|
||||
criticalityFilter: string;
|
||||
setCriticalityFilter: (value: string) => void;
|
||||
domainFilter: string;
|
||||
setDomainFilter: (value: string) => void;
|
||||
applicationFilter: string;
|
||||
setApplicationFilter: (value: string) => void;
|
||||
lifecycleFilter: string;
|
||||
setLifecycleFilter: (value: string) => void;
|
||||
filtersOpen: boolean;
|
||||
setFiltersOpen: (value: boolean) => void;
|
||||
domains: string[];
|
||||
applications: string[];
|
||||
environments: string[];
|
||||
activeFilters: Array<{ label: string; value: string; clear: () => void }>;
|
||||
onFit: () => void;
|
||||
onArrange: () => void;
|
||||
inspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
};
|
||||
|
||||
export function TopologyCommandBar({
|
||||
location,
|
||||
setLocation,
|
||||
selectedDomain,
|
||||
selectedApplication,
|
||||
mode,
|
||||
setMode,
|
||||
setImpact,
|
||||
showImpact,
|
||||
search,
|
||||
setSearch,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
monitoringFilter,
|
||||
setMonitoringFilter,
|
||||
groupFilter,
|
||||
environmentFilter,
|
||||
setEnvironmentFilter,
|
||||
criticalityFilter,
|
||||
setCriticalityFilter,
|
||||
domainFilter,
|
||||
setDomainFilter,
|
||||
applicationFilter,
|
||||
setApplicationFilter,
|
||||
lifecycleFilter,
|
||||
setLifecycleFilter,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
domains,
|
||||
applications,
|
||||
environments,
|
||||
activeFilters,
|
||||
onFit,
|
||||
onArrange,
|
||||
inspectorOpen,
|
||||
onToggleInspector,
|
||||
}: TopologyCommandBarProps) {
|
||||
const activeFilterCount = [
|
||||
statusFilter,
|
||||
monitoringFilter,
|
||||
groupFilter,
|
||||
environmentFilter,
|
||||
criticalityFilter,
|
||||
domainFilter,
|
||||
applicationFilter,
|
||||
lifecycleFilter,
|
||||
].filter((value) => value !== "all").length;
|
||||
return (
|
||||
<section className="topology-command-bar" aria-label="Topology commands">
|
||||
<div className="topology-breadcrumbs" aria-label="Topology hierarchy">
|
||||
{!isTopologyRoot(location) && (
|
||||
<button
|
||||
className="ghost compact-button"
|
||||
onClick={() => setLocation((current) => topologyUp(current))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
<button className="text-button" onClick={() => setLocation(topologyRoot())}>
|
||||
Infrastructure
|
||||
</button>
|
||||
{selectedDomain && (
|
||||
<>
|
||||
<span>›</span>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => setLocation(domainLocation(selectedDomain.id))}
|
||||
>
|
||||
{selectedDomain.name}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selectedApplication && (
|
||||
<>
|
||||
<span>›</span>
|
||||
<span>{selectedApplication.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="topology-command-actions">
|
||||
{(["landscape", "dependencies", "impact"] as TopologyMode[])
|
||||
.filter((candidate) => candidate !== "impact" || showImpact)
|
||||
.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
className={mode === candidate ? "active compact-button" : "ghost compact-button"}
|
||||
onClick={() => {
|
||||
setMode(candidate);
|
||||
setImpact(candidate === "impact");
|
||||
}}
|
||||
>
|
||||
{candidate === "impact"
|
||||
? "Impact"
|
||||
: candidate[0].toUpperCase() + candidate.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
aria-label="Search topology"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search topology"
|
||||
/>
|
||||
<button
|
||||
className={statusFilter === "down" || statusFilter === "degraded" ? "compact-button" : "ghost compact-button"}
|
||||
onClick={() => setStatusFilter(statusFilter === "all" ? "down" : "all")}
|
||||
>
|
||||
Problems only
|
||||
</button>
|
||||
<details
|
||||
className="filter-popover"
|
||||
open={filtersOpen}
|
||||
onToggle={(event) => setFiltersOpen((event.target as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary>Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}</summary>
|
||||
<div>
|
||||
<label>Status<select aria-label="Filter topology by status" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as FilterStatus)}><option value="all">All status</option><option value="unresolved">Unresolved health</option>{(["healthy", "degraded", "down", "recovering", "unmonitored", "unknown", "maintenance"] as HealthStatus[]).map((item) => <option key={item} value={item}>{item === "unmonitored" ? "Not monitored" : item}</option>)}</select></label>
|
||||
<label>Monitoring<select aria-label="Filter topology by monitoring source" value={monitoringFilter} onChange={(event) => setMonitoringFilter(event.target.value as MonitoringFilter)}><option value="all">Any source</option>{(["active_probe", "docker_health", "appops_observation", "observed", "unmonitored"] as MonitoringState[]).map((item) => <option key={item} value={item}>{item.replaceAll("_", " ")}</option>)}</select></label>
|
||||
<label>Domain<select aria-label="Filter topology by domain" value={domainFilter} onChange={(event) => setDomainFilter(event.target.value)}><option value="all">All domains</option>{domains.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label>Application<select aria-label="Filter topology by application" value={applicationFilter} onChange={(event) => setApplicationFilter(event.target.value)}><option value="all">All applications</option>{applications.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label>Environment<select aria-label="Filter topology by environment" value={environmentFilter} onChange={(event) => setEnvironmentFilter(event.target.value)}><option value="all">All environments</option>{environments.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label>Criticality<select aria-label="Filter topology by criticality" value={criticalityFilter} onChange={(event) => setCriticalityFilter(event.target.value)}><option value="all">All criticality</option>{(["low", "medium", "high", "critical"] as Criticality[]).map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label>Lifecycle<select aria-label="Filter topology by lifecycle" value={lifecycleFilter} onChange={(event) => setLifecycleFilter(event.target.value)}><option value="all">All lifecycle</option>{["active", "missing", "stale", "retired", "ignored"].map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
</div>
|
||||
</details>
|
||||
<button className="ghost compact-button" onClick={onFit}>Fit</button>
|
||||
<button className="ghost compact-button" onClick={onArrange}>Arrange</button>
|
||||
<button className="ghost compact-button" onClick={onToggleInspector}>{inspectorOpen ? "Hide details" : "Details"}</button>
|
||||
</div>
|
||||
{activeFilters
|
||||
.filter((item) => item.value !== "all")
|
||||
.map(({ label, value, clear }) => (
|
||||
<button key={label} className="filter-chip" onClick={clear}>
|
||||
{label}: {value} ×
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { MarkerType, type Edge } from "@xyflow/react";
|
||||
|
||||
import type { HealthStatus, ServiceSnapshot } from "../api";
|
||||
import type { ServiceNode } from "./ServiceNode";
|
||||
|
||||
export type LegacyTopologyMode = "landscape" | "dependencies" | "impact";
|
||||
|
||||
const effectiveStatus = (snapshot: ServiceSnapshot): HealthStatus =>
|
||||
snapshot.effective_status ?? snapshot.latest_probe?.status ?? "unknown";
|
||||
|
||||
export function topologyLayout(
|
||||
services: ServiceSnapshot[],
|
||||
rootCauseId: string | null = null,
|
||||
impactedIds: Set<string> = new Set(),
|
||||
): ServiceNode[] {
|
||||
const byId = new Map(services.map((item) => [item.service.id, item]));
|
||||
const memo = new Map<string, number>();
|
||||
const levelOf = (id: string, visiting = new Set<string>()): number => {
|
||||
if (memo.has(id)) return memo.get(id) ?? 0;
|
||||
if (visiting.has(id)) return 0;
|
||||
const snapshot = byId.get(id);
|
||||
if (!snapshot || snapshot.service.depends_on.length === 0) {
|
||||
memo.set(id, 0);
|
||||
return 0;
|
||||
}
|
||||
const next = new Set(visiting);
|
||||
next.add(id);
|
||||
const level =
|
||||
1 +
|
||||
Math.max(
|
||||
0,
|
||||
...snapshot.service.depends_on.map((dependency) =>
|
||||
levelOf(dependency, next),
|
||||
),
|
||||
);
|
||||
memo.set(id, level);
|
||||
return level;
|
||||
};
|
||||
const grouped = new Map<number, ServiceSnapshot[]>();
|
||||
const dependentCount = new Map<string, number>();
|
||||
for (const snapshot of services) {
|
||||
const level = levelOf(snapshot.service.id);
|
||||
grouped.set(level, [...(grouped.get(level) ?? []), snapshot]);
|
||||
for (const dependencyId of snapshot.service.depends_on)
|
||||
dependentCount.set(
|
||||
dependencyId,
|
||||
(dependentCount.get(dependencyId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
const nodes: ServiceNode[] = [];
|
||||
for (const [level, items] of [...grouped.entries()].sort(
|
||||
([a], [b]) => a - b,
|
||||
)) {
|
||||
items.sort((a, b) => {
|
||||
if (level === 0) {
|
||||
const fanout =
|
||||
(dependentCount.get(b.service.id) ?? 0) -
|
||||
(dependentCount.get(a.service.id) ?? 0);
|
||||
if (fanout) return fanout;
|
||||
}
|
||||
return `${a.service.group ?? ""}-${a.service.name}`.localeCompare(
|
||||
`${b.service.group ?? ""}-${b.service.name}`,
|
||||
);
|
||||
});
|
||||
const rowSpacing = items.length > 4 ? 92 : 118;
|
||||
items.forEach((snapshot, index) => {
|
||||
const status = effectiveStatus(snapshot);
|
||||
nodes.push({
|
||||
id: snapshot.service.id,
|
||||
type: "service",
|
||||
position:
|
||||
snapshot.service.layout_pinned &&
|
||||
snapshot.service.layout_x != null &&
|
||||
snapshot.service.layout_y != null
|
||||
? { x: snapshot.service.layout_x, y: snapshot.service.layout_y }
|
||||
: {
|
||||
x: level * 224,
|
||||
y: (index - (items.length - 1) / 2) * rowSpacing,
|
||||
},
|
||||
selected: snapshot.service.id === rootCauseId,
|
||||
data: {
|
||||
name: snapshot.service.name,
|
||||
status,
|
||||
kind: snapshot.service.kind,
|
||||
latency: snapshot.latest_probe?.latency_ms ?? null,
|
||||
dependencyCount: snapshot.service.depends_on.length,
|
||||
rootCause: snapshot.service.id === rootCauseId,
|
||||
impacted: impactedIds.has(snapshot.service.id),
|
||||
group: snapshot.service.group,
|
||||
criticality: snapshot.service.criticality,
|
||||
flapping: snapshot.flapping,
|
||||
category: snapshot.service.category,
|
||||
},
|
||||
className: `service-node status-${status}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const statusRank: Record<HealthStatus, number> = {
|
||||
down: 6,
|
||||
degraded: 5,
|
||||
recovering: 4,
|
||||
healthy: 3,
|
||||
unknown: 2,
|
||||
unmonitored: 1,
|
||||
maintenance: 0,
|
||||
};
|
||||
|
||||
export const applicationName = (snapshot: ServiceSnapshot) =>
|
||||
snapshot.service.application ||
|
||||
snapshot.service.compose_project ||
|
||||
snapshot.service.name;
|
||||
|
||||
const LANDSCAPE_ROWS_PER_COLUMN = 5;
|
||||
|
||||
/**
|
||||
* Keep the landscape legible on hosts where discovery finds many one-container
|
||||
* applications. Application identity must never fall back to a shared
|
||||
* category bucket: two independent containers are still two applications.
|
||||
*/
|
||||
export function landscapeGroups(
|
||||
services: ServiceSnapshot[],
|
||||
_rootCauseId: string | null = null,
|
||||
_impactedIds: Set<string> = new Set(),
|
||||
): Map<string, ServiceSnapshot[]> {
|
||||
const applications = new Map<string, ServiceSnapshot[]>();
|
||||
for (const snapshot of services) {
|
||||
const name = applicationName(snapshot);
|
||||
applications.set(name, [...(applications.get(name) ?? []), snapshot]);
|
||||
}
|
||||
return applications;
|
||||
}
|
||||
|
||||
function landscapeGroupForService(
|
||||
services: ServiceSnapshot[],
|
||||
rootCauseId: string | null,
|
||||
impactedIds: Set<string>,
|
||||
): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const [name, members] of landscapeGroups(
|
||||
services,
|
||||
rootCauseId,
|
||||
impactedIds,
|
||||
)) {
|
||||
for (const member of members) result.set(member.service.id, name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applicationTopologyLayout(
|
||||
services: ServiceSnapshot[],
|
||||
rootCauseId: string | null = null,
|
||||
impactedIds: Set<string> = new Set(),
|
||||
): ServiceNode[] {
|
||||
const groups = landscapeGroups(services, rootCauseId, impactedIds);
|
||||
const appForService = landscapeGroupForService(
|
||||
services,
|
||||
rootCauseId,
|
||||
impactedIds,
|
||||
);
|
||||
const dependencies = new Map<string, Set<string>>();
|
||||
for (const [application, members] of groups) {
|
||||
const linked = new Set<string>();
|
||||
for (const member of members)
|
||||
for (const dependency of member.service.depends_on) {
|
||||
const target = appForService.get(dependency);
|
||||
if (target && target !== application) linked.add(target);
|
||||
}
|
||||
dependencies.set(application, linked);
|
||||
}
|
||||
const levels = new Map<string, number>();
|
||||
const levelOf = (
|
||||
application: string,
|
||||
visiting = new Set<string>(),
|
||||
): number => {
|
||||
if (levels.has(application)) return levels.get(application) ?? 0;
|
||||
if (visiting.has(application)) return 0;
|
||||
const next = new Set(visiting);
|
||||
next.add(application);
|
||||
const level =
|
||||
1 +
|
||||
Math.max(
|
||||
-1,
|
||||
...[...(dependencies.get(application) ?? [])].map((item) =>
|
||||
levelOf(item, next),
|
||||
),
|
||||
);
|
||||
levels.set(application, Math.max(0, level));
|
||||
return Math.max(0, level);
|
||||
};
|
||||
const byLevel = new Map<number, string[]>();
|
||||
for (const name of groups.keys()) {
|
||||
const level = levelOf(name);
|
||||
byLevel.set(level, [...(byLevel.get(level) ?? []), name]);
|
||||
}
|
||||
const nodes: ServiceNode[] = [];
|
||||
let levelOffset = 0;
|
||||
for (const [, names] of [...byLevel.entries()].sort(
|
||||
([a], [b]) => a - b,
|
||||
)) {
|
||||
names.sort((a, b) => a.localeCompare(b));
|
||||
const columns = Math.ceil(names.length / LANDSCAPE_ROWS_PER_COLUMN);
|
||||
names.forEach((name, index) => {
|
||||
const members = groups.get(name) ?? [];
|
||||
const primary =
|
||||
members.find((item) => item.service.id === rootCauseId) ??
|
||||
members.sort(
|
||||
(a, b) =>
|
||||
statusRank[effectiveStatus(b)] - statusRank[effectiveStatus(a)],
|
||||
)[0];
|
||||
const aggregate =
|
||||
members
|
||||
.map(effectiveStatus)
|
||||
.sort((a, b) => statusRank[b] - statusRank[a])[0] ?? "unmonitored";
|
||||
const rootCause = members.some((item) => item.service.id === rootCauseId);
|
||||
const impacted = members.some((item) => impactedIds.has(item.service.id));
|
||||
const column = Math.floor(index / LANDSCAPE_ROWS_PER_COLUMN);
|
||||
const rowsInColumn = Math.min(
|
||||
LANDSCAPE_ROWS_PER_COLUMN,
|
||||
names.length - column * LANDSCAPE_ROWS_PER_COLUMN,
|
||||
);
|
||||
const row = index % LANDSCAPE_ROWS_PER_COLUMN;
|
||||
nodes.push({
|
||||
id: `app:${name}`,
|
||||
type: "service",
|
||||
position: {
|
||||
x: levelOffset + column * 270,
|
||||
y: (row - (rowsInColumn - 1) / 2) * 138,
|
||||
},
|
||||
selected: rootCause,
|
||||
data: {
|
||||
name,
|
||||
status: aggregate,
|
||||
kind: primary?.service.kind ?? "tcp",
|
||||
latency: primary?.latest_probe?.latency_ms ?? null,
|
||||
dependencyCount: dependencies.get(name)?.size ?? 0,
|
||||
rootCause,
|
||||
impacted,
|
||||
group: name,
|
||||
criticality: primary?.service.criticality ?? "medium",
|
||||
flapping: members.some((item) => item.flapping),
|
||||
category: primary?.service.category ?? "unknown",
|
||||
application: name,
|
||||
applicationNode: true,
|
||||
memberIds: members.map((item) => item.service.id),
|
||||
},
|
||||
className: `service-node application-node status-${aggregate}`,
|
||||
});
|
||||
});
|
||||
levelOffset += columns * 270 + 105;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function applicationEdges(
|
||||
services: ServiceSnapshot[],
|
||||
mode: LegacyTopologyMode,
|
||||
impactedIds: Set<string>,
|
||||
rootCauseId: string | null = null,
|
||||
): Edge[] {
|
||||
const apps = landscapeGroupForService(services, rootCauseId, impactedIds);
|
||||
const dedupe = new Set<string>();
|
||||
return services.flatMap((snapshot) =>
|
||||
snapshot.service.depends_on.flatMap((dependencyId) => {
|
||||
const sourceApp = apps.get(dependencyId);
|
||||
const targetApp = apps.get(snapshot.service.id);
|
||||
if (!sourceApp || !targetApp || sourceApp === targetApp) return [];
|
||||
const id = `app:${sourceApp}->app:${targetApp}`;
|
||||
if (dedupe.has(id)) return [];
|
||||
dedupe.add(id);
|
||||
const impacted = impactedIds.has(snapshot.service.id);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
source: `app:${sourceApp}`,
|
||||
target: `app:${targetApp}`,
|
||||
className: `edge-${effectiveStatus(snapshot)}${mode === "impact" && !impacted ? " edge-dimmed" : ""}`,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { MarkerType, type Edge } from "@xyflow/react";
|
||||
|
||||
import type { HealthStatus, SemanticTopology } from "../api";
|
||||
import type { ServiceNode } from "./ServiceNode";
|
||||
|
||||
export type SemanticView = "domains" | "applications" | "components";
|
||||
export type SemanticFilterStatus = "all" | "unresolved" | HealthStatus;
|
||||
|
||||
/** Projects canonical services and relationships into the active semantic level. */
|
||||
export function semanticTopologyGraph(
|
||||
topology: SemanticTopology,
|
||||
view: SemanticView,
|
||||
scopeId: string | null,
|
||||
incidentOnly: boolean,
|
||||
search: string,
|
||||
statusFilter: SemanticFilterStatus,
|
||||
): { nodes: ServiceNode[]; edges: Edge[] } {
|
||||
const applicationById = new Map(
|
||||
topology.applications.map((item) => [item.id, item]),
|
||||
);
|
||||
const componentById = new Map(
|
||||
topology.components.map((item) => [item.service_id, item]),
|
||||
);
|
||||
const domainById = new Map(topology.domains.map((item) => [item.id, item]));
|
||||
const scopedApplications =
|
||||
view === "applications" && scopeId
|
||||
? topology.applications.filter((item) => item.domain_id === scopeId)
|
||||
: topology.applications;
|
||||
const scopedComponents =
|
||||
view === "components" && scopeId
|
||||
? topology.components.filter((item) => item.application_id === scopeId)
|
||||
: topology.components;
|
||||
const isIncidentVisible = <
|
||||
T extends { root_cause: boolean; affected: boolean },
|
||||
>(
|
||||
item: T,
|
||||
) => !incidentOnly || item.root_cause || item.affected;
|
||||
const nodes: ServiceNode[] =
|
||||
view === "domains"
|
||||
? topology.domains.filter(isIncidentVisible).map((domain) => ({
|
||||
id: domain.id,
|
||||
type: "service",
|
||||
position: { x: 0, y: 0 },
|
||||
selected: domain.root_cause,
|
||||
data: {
|
||||
name: domain.name,
|
||||
status: domain.status,
|
||||
kind: "http",
|
||||
latency: null,
|
||||
dependencyCount: 0,
|
||||
rootCause: domain.root_cause,
|
||||
impacted: domain.affected,
|
||||
group: domain.name,
|
||||
criticality: "medium",
|
||||
flapping: false,
|
||||
category: "domain",
|
||||
memberIds: domain.application_ids.flatMap(
|
||||
(id) => applicationById.get(id)?.component_ids ?? [],
|
||||
),
|
||||
nodeKind: "domain",
|
||||
explanation: `${domain.resolved_application_count}/${domain.application_count} resolved · ${domain.unresolved_application_count} unresolved${domain.incident_count ? ` · ${domain.incident_count} incident` : ""}`,
|
||||
},
|
||||
className: `service-node domain-node status-${domain.status}`,
|
||||
}))
|
||||
: view === "applications"
|
||||
? scopedApplications.filter(isIncidentVisible).map((app) => ({
|
||||
id: app.id,
|
||||
type: "service",
|
||||
position: { x: 0, y: 0 },
|
||||
selected: app.root_cause,
|
||||
data: {
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
kind: "http",
|
||||
latency: null,
|
||||
dependencyCount: app.dependency_application_ids.length,
|
||||
rootCause: app.root_cause,
|
||||
impacted: app.affected,
|
||||
group: domainById.get(app.domain_id)?.name ?? null,
|
||||
criticality: "medium",
|
||||
flapping: false,
|
||||
category: "application",
|
||||
application: app.name,
|
||||
applicationNode: true,
|
||||
memberIds: app.component_ids,
|
||||
nodeKind: "application",
|
||||
roles: app.roles,
|
||||
explanation: app.status_explanation,
|
||||
monitoredComponents: app.resolved_components,
|
||||
unmonitoredComponents: app.unresolved_components,
|
||||
domainId: app.domain_id,
|
||||
},
|
||||
className: `service-node application-node status-${app.status}`,
|
||||
}))
|
||||
: scopedComponents.filter(isIncidentVisible).map((component) => ({
|
||||
id: component.service_id,
|
||||
type: "service",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
name: component.name,
|
||||
status: component.effective_status,
|
||||
kind: component.kind,
|
||||
latency: component.latest_latency_ms,
|
||||
dependencyCount: component.dependency_ids.length,
|
||||
rootCause: component.root_cause,
|
||||
impacted: component.affected,
|
||||
group:
|
||||
applicationById.get(component.application_id)?.name ?? null,
|
||||
criticality: "medium",
|
||||
flapping: false,
|
||||
category: component.role,
|
||||
nodeKind: "component",
|
||||
role: component.role,
|
||||
explanation: component.unknown_reason
|
||||
? `${component.unknown_reason.replaceAll("_", " ")} · ${component.dependent_ids.length} dependents`
|
||||
: `${component.monitoring_state.replace("_", " ")} · ${component.dependent_ids.length} dependents`,
|
||||
},
|
||||
className: `service-node component-node status-${component.effective_status}`,
|
||||
}));
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
const visibleNodes = nodes.filter((node) => {
|
||||
const descendants = node.data.memberIds ?? [node.id];
|
||||
const descendantMatches = descendants.some((serviceId) =>
|
||||
componentById
|
||||
.get(serviceId)
|
||||
?.name.toLocaleLowerCase()
|
||||
.includes(normalizedSearch),
|
||||
);
|
||||
return (
|
||||
(!normalizedSearch ||
|
||||
`${node.data.name} ${node.data.explanation ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearch) ||
|
||||
descendantMatches) &&
|
||||
(statusFilter === "all" ||
|
||||
(statusFilter === "unresolved"
|
||||
? ["unknown", "unmonitored"].includes(node.data.status)
|
||||
: node.data.status === statusFilter))
|
||||
);
|
||||
});
|
||||
const nodeIds = new Set(visibleNodes.map((node) => node.id));
|
||||
const nodeIdFor = (serviceId: string): string | null => {
|
||||
const component = componentById.get(serviceId);
|
||||
if (!component) return null;
|
||||
if (view === "components") return component.service_id;
|
||||
if (view === "applications") return component.application_id;
|
||||
return component.domain_id;
|
||||
};
|
||||
const canonicalPairs = topology.components.flatMap((component) =>
|
||||
component.dependency_ids.map((target_service_id) => ({
|
||||
source_service_id: component.service_id,
|
||||
target_service_id,
|
||||
active: true,
|
||||
relation_type: "depends_on",
|
||||
})),
|
||||
);
|
||||
const relationshipPairs = topology.relations
|
||||
.filter((relation) => relation.active && relation.target_service_id)
|
||||
.map((relation) => ({
|
||||
source_service_id: relation.source_service_id,
|
||||
target_service_id: relation.target_service_id as string,
|
||||
active: relation.active,
|
||||
relation_type: relation.relation_type,
|
||||
}));
|
||||
const uniquePairs = new Map<
|
||||
string,
|
||||
{
|
||||
source_service_id: string;
|
||||
target_service_id: string;
|
||||
active: boolean;
|
||||
relation_type: string;
|
||||
}
|
||||
>();
|
||||
for (const relation of [...canonicalPairs, ...relationshipPairs]) {
|
||||
const key = `${relation.source_service_id}->${relation.target_service_id}`;
|
||||
if (!uniquePairs.has(key)) uniquePairs.set(key, relation);
|
||||
}
|
||||
const statusRank: Record<HealthStatus, number> = {
|
||||
down: 5,
|
||||
degraded: 4,
|
||||
recovering: 3,
|
||||
maintenance: 2,
|
||||
unknown: 1,
|
||||
unmonitored: 0,
|
||||
healthy: 0,
|
||||
};
|
||||
const projected = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
status: HealthStatus;
|
||||
weight: number;
|
||||
relationType: string;
|
||||
}
|
||||
>();
|
||||
for (const relation of uniquePairs.values()) {
|
||||
if (!relation.active) continue;
|
||||
// A relation is stored dependent -> dependency; render dependency -> dependent.
|
||||
const source = nodeIdFor(relation.target_service_id);
|
||||
const target = nodeIdFor(relation.source_service_id);
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
source === target ||
|
||||
!nodeIds.has(source) ||
|
||||
!nodeIds.has(target)
|
||||
)
|
||||
continue;
|
||||
const id = `${source}->${target}`;
|
||||
const status =
|
||||
componentById.get(relation.source_service_id)?.effective_status ??
|
||||
"unknown";
|
||||
const existing = projected.get(id);
|
||||
if (existing) {
|
||||
existing.weight += 1;
|
||||
if (statusRank[status] > statusRank[existing.status])
|
||||
existing.status = status;
|
||||
} else {
|
||||
projected.set(id, {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
status,
|
||||
weight: 1,
|
||||
relationType: relation.relation_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
const projectedEdges = [...projected.values()].sort(
|
||||
(left, right) =>
|
||||
right.weight - left.weight || left.id.localeCompare(right.id),
|
||||
);
|
||||
const edgeLimit =
|
||||
view === "domains" ? 12 : view === "applications" ? 28 : Infinity;
|
||||
const highSignal =
|
||||
view === "components"
|
||||
? projectedEdges
|
||||
: projectedEdges.filter((edge) => edge.weight >= 2);
|
||||
const visibleEdges = (highSignal.length ? highSignal : projectedEdges).slice(
|
||||
0,
|
||||
edgeLimit,
|
||||
);
|
||||
return {
|
||||
nodes: visibleNodes,
|
||||
edges: visibleEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
animated: edge.status === "down" || edge.status === "degraded",
|
||||
className: `edge-${edge.status} relation-${edge.relationType}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type ElkConstructor from "elkjs/lib/elk.bundled.js";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type ElkInstance = InstanceType<typeof ElkConstructor>;
|
||||
|
||||
export type TopologyRoute = { x: number; y: number }[];
|
||||
export type TopologyLayout = {
|
||||
positions: Record<string, { x: number; y: number }>;
|
||||
routes: Record<string, TopologyRoute>;
|
||||
};
|
||||
type ElkPoint = { x: number; y: number };
|
||||
type ElkRoutedEdge = {
|
||||
id: string;
|
||||
sections?: { startPoint?: ElkPoint; bendPoints?: ElkPoint[]; endPoint?: ElkPoint }[];
|
||||
};
|
||||
|
||||
let elk: ElkInstance | null = null;
|
||||
const cache = new Map<string, TopologyLayout>();
|
||||
|
||||
async function layoutEngine(): Promise<ElkInstance> {
|
||||
if (elk) return elk;
|
||||
const { default: ElkConstructor } = await import("elkjs/lib/elk.bundled.js");
|
||||
elk = new ElkConstructor();
|
||||
return elk;
|
||||
}
|
||||
|
||||
/** The key intentionally excludes health, latency and selected state. */
|
||||
export function topologyStructureKey(nodes: Node[], edges: Edge[]): string {
|
||||
return `${nodes
|
||||
.map((node) => node.id)
|
||||
.sort()
|
||||
.join(",")}|${edges
|
||||
.map((edge) => `${edge.source}>${edge.target}`)
|
||||
.sort()
|
||||
.join(",")}`;
|
||||
}
|
||||
|
||||
export async function arrangeTopology(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
): Promise<TopologyLayout> {
|
||||
const key = topologyStructureKey(nodes, edges);
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
const layout = await (await layoutEngine()).layout({
|
||||
id: "servicemap-topology",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "RIGHT",
|
||||
"elk.edgeRouting": "ORTHOGONAL",
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
"elk.spacing.nodeNode": "44",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "92",
|
||||
"elk.padding": "[top=42,left=42,bottom=42,right=42]",
|
||||
},
|
||||
children: nodes.map((node) => ({ id: node.id, width: 230, height: 132 })),
|
||||
edges: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
sources: [edge.source],
|
||||
targets: [edge.target],
|
||||
})),
|
||||
});
|
||||
const positions = Object.fromEntries(
|
||||
(layout.children ?? []).map((node) => [
|
||||
node.id,
|
||||
{ x: node.x ?? 0, y: node.y ?? 0 },
|
||||
]),
|
||||
);
|
||||
const routedEdges = (layout as unknown as { edges?: ElkRoutedEdge[] }).edges ?? [];
|
||||
const routes = Object.fromEntries(
|
||||
routedEdges.map((edge) => [
|
||||
edge.id,
|
||||
(edge.sections ?? []).flatMap((section) => [
|
||||
section.startPoint,
|
||||
...(section.bendPoints ?? []),
|
||||
section.endPoint,
|
||||
]).filter((point): point is ElkPoint => Boolean(point)),
|
||||
]),
|
||||
);
|
||||
const result = { positions, routes };
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearTopologyLayoutCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applicationLocation,
|
||||
domainLocation,
|
||||
topologyRoot,
|
||||
topologyUp,
|
||||
} from "./topologyNavigation";
|
||||
|
||||
describe("semantic topology navigation", () => {
|
||||
it("returns components to their parent domain applications", () => {
|
||||
expect(topologyUp(applicationLocation("domain-monitoring", "app-zabbix"))).toEqual(
|
||||
domainLocation("domain-monitoring"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a domain to the infrastructure root", () => {
|
||||
expect(topologyUp(domainLocation("domain-monitoring"))).toEqual(topologyRoot());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export type TopologyLevel = "domains" | "applications" | "components";
|
||||
|
||||
export type TopologyLocation = {
|
||||
level: TopologyLevel;
|
||||
domainId: string | null;
|
||||
applicationId: string | null;
|
||||
};
|
||||
|
||||
export const topologyRoot = (): TopologyLocation => ({
|
||||
level: "domains",
|
||||
domainId: null,
|
||||
applicationId: null,
|
||||
});
|
||||
|
||||
export const domainLocation = (domainId: string): TopologyLocation => ({
|
||||
level: "applications",
|
||||
domainId,
|
||||
applicationId: null,
|
||||
});
|
||||
|
||||
export const applicationLocation = (
|
||||
domainId: string | null,
|
||||
applicationId: string,
|
||||
): TopologyLocation => ({
|
||||
level: "components",
|
||||
domainId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
export const topologyUp = (location: TopologyLocation): TopologyLocation => {
|
||||
if (location.level === "components" && location.domainId)
|
||||
return domainLocation(location.domainId);
|
||||
return topologyRoot();
|
||||
};
|
||||
|
||||
export const isTopologyRoot = (location: TopologyLocation): boolean =>
|
||||
location.level === "domains";
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'],
|
||||
},
|
||||
})
|
||||