Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:38 +02:00
commit 105251b9c8
96 files changed
+23587

No files matched your search

+13
View File
@@ -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
+170
View File
@@ -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);
});
+38
View File
@@ -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,
},
},
]
+19
View File
@@ -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>
+23
View File
@@ -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;
}
}
+3929
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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"
}
}
+27
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -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"}]}
+3739
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -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);
+132
View File
@@ -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',
])
})
})
+940
View File
@@ -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();
}
+37
View File
@@ -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"]);
});
});
+31
View File
@@ -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))),
);
}
+15
View File
@@ -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",
]);
});
});
+77
View File
@@ -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>
);
}
+10
View File
@@ -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>,
)
+118
View File
@@ -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]);
}
+376
View File
@@ -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; }
}
+223
View File
@@ -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);
});
});
+117
View File
@@ -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>
);
}
+285
View File
@@ -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 },
},
];
}),
);
}
+256
View File
@@ -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}`,
})),
};
}
+88
View File
@@ -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();
}
+20
View File
@@ -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());
});
});
+37
View File
@@ -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";
+20
View File
@@ -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"]
}
+16
View File
@@ -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/**'],
},
})