diff --git a/CLAUDE.md b/CLAUDE.md index 81d3af6..a69ffaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,13 @@ OtelContext is a self-hosted OTLP observability platform. Single Go binary with embedded React frontend. - **Backend:** Go 1.25, native `net/http` (no frameworks), GORM ORM, gRPC + HTTP for OTLP ingestion -- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules; the flow map is own deterministic SVG layout code. No UI framework: `@ossrandom/design-system`, cytoscape, uplot all removed (rewrite completed 2026-06-12, phases C1–C7) +- **Frontend:** React 19 + TypeScript + TanStack Query/Virtual + wouter + Radix primitives + cmdk palette + hand-rolled token CSS (`ui/src/styles/tokens.css`) with CSS Modules. The **service map renders via React Flow (`@xyflow/react`, MIT)** fed by our own deterministic radial layout (`ui/src/lib/radialLayout.ts` — phyllotaxis, no physics); React Flow owns pan/zoom/fit/minimap/grid/a11y, nodes are token-themed React components, edges are straight centre-to-centre. No UI framework: `@ossrandom/design-system`, cytoscape, uplot remain removed (cytoscape's physics hairball → deterministic layout + React Flow; the prior hand-rolled-SVG map was replaced by React Flow on 2026-06-18) - **Ports:** gRPC `:4317` (OTLP), HTTP `:8080` (API + HTTP OTLP + WebSocket + UI) ## Strict Rules - NO Express.js/Gin/Echo — use native Go `net/http` -- NO Tailwind CSS, NO Mantine, NO component frameworks — UI styling is the hand-rolled token sheet (`ui/src/styles/tokens.css`) + per-component CSS Modules; Radix primitives (unstyled) only for the a11y-hard parts (dialog/tabs/tooltip/dropdown). Token values only — no raw hex outside tokens.css. +- NO Tailwind CSS, NO Mantine, NO general-purpose component frameworks — UI styling is the hand-rolled token sheet (`ui/src/styles/tokens.css`) + per-component CSS Modules. Sanctioned third-party UI: Radix primitives (unstyled) for the a11y-hard parts (dialog/tabs/tooltip/dropdown), and **React Flow (`@xyflow/react`) for the service map only** — themed to the token sheet, nodes/edges are our own components. Token values only — no raw hex outside tokens.css. - Single-service architecture (no microservices split) - All internal DBs must be **embedded** (no external processes) - Relational DB (SQLite/MySQL/PostgreSQL/MSSQL) is the **single source of truth** diff --git a/ui/budgets.json b/ui/budgets.json index 4e69e0b..8e2e45d 100644 --- a/ui/budgets.json +++ b/ui/budgets.json @@ -1,10 +1,12 @@ { - "$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). C7 measured actuals after the design-system exit: initial JS 107.6 KB, initial CSS 2.7 KB, fonts 67.8 KB, largest lazy chunk 7.9 KB - budgets are actual + ~10% headroom. lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap; currently empty because every lazy chunk fits the default.", + "$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk-name prefix (before the content hash) to a per-chunk cap. The ConstellationHome lazy chunk carries the service map, which now renders via React Flow (@xyflow/react, MIT) instead of hand-rolled SVG — React Flow + zustand + d3-zoom add ~52 KB gz, all lazy (initial JS is unaffected, still ~106 KB). Measured ConstellationHome chunk: 61.3 KB gz; cap set to actual + headroom.", "gzipKb": { "initialJs": 118, "initialCss": 6, "fonts": 75, "lazyChunkDefault": 10, - "lazyChunkExceptions": {} + "lazyChunkExceptions": { + "ConstellationHome": 66 + } } } diff --git a/ui/index.html b/ui/index.html index afef7b2..a1dcadc 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,18 +4,18 @@ - + =17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2662,6 +2754,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -2728,6 +2826,111 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5022,6 +5225,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/ui/package.json b/ui/package.json index a6270b8..576bae6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-tooltip": "^1.2.9", "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "^3.14.2", + "@xyflow/react": "^12.11.0", "cmdk": "^1.1.1", "lucide-react": "^0.469.0", "react": "^19.2.5", diff --git a/ui/public/fonts/LICENSE-jetbrains-mono b/ui/public/fonts/LICENSE-martian-grotesk similarity index 95% rename from ui/public/fonts/LICENSE-jetbrains-mono rename to ui/public/fonts/LICENSE-martian-grotesk index 8f7ed67..68afb73 100644 --- a/ui/public/fonts/LICENSE-jetbrains-mono +++ b/ui/public/fonts/LICENSE-martian-grotesk @@ -1,4 +1,4 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) +Copyright 2021 The Martian Grotesk Project Authors (https://github.com/evilmartians/grotesk) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: @@ -18,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The diff --git a/ui/public/fonts/LICENSE-inter b/ui/public/fonts/LICENSE-martian-mono similarity index 96% rename from ui/public/fonts/LICENSE-inter rename to ui/public/fonts/LICENSE-martian-mono index 40589da..efbd896 100644 --- a/ui/public/fonts/LICENSE-inter +++ b/ui/public/fonts/LICENSE-martian-mono @@ -1,4 +1,4 @@ -Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2020 The Martian Mono Project Authors (https://github.com/evilmartians/mono) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: diff --git a/ui/public/fonts/inter-latin-wght-normal.woff2 b/ui/public/fonts/inter-latin-wght-normal.woff2 deleted file mode 100644 index d15208d..0000000 Binary files a/ui/public/fonts/inter-latin-wght-normal.woff2 and /dev/null differ diff --git a/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 b/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 deleted file mode 100644 index 5858873..0000000 Binary files a/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 and /dev/null differ diff --git a/ui/public/fonts/martian-grotesk-latin-wght-normal.woff2 b/ui/public/fonts/martian-grotesk-latin-wght-normal.woff2 new file mode 100644 index 0000000..1d7df50 Binary files /dev/null and b/ui/public/fonts/martian-grotesk-latin-wght-normal.woff2 differ diff --git a/ui/public/fonts/martian-mono-latin-wght-normal.woff2 b/ui/public/fonts/martian-mono-latin-wght-normal.woff2 new file mode 100644 index 0000000..6b943d2 Binary files /dev/null and b/ui/public/fonts/martian-mono-latin-wght-normal.woff2 differ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a85d52b..d84a230 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,7 +2,6 @@ import { lazy, Suspense, useCallback, useState } from 'react' import { Redirect, Route, Switch, useSearch } from 'wouter' import RouteFallback from './components/common/RouteFallback' import Shell from './components/shell/Shell' -import TrailBar from './components/trail/TrailBar' import { useGlobalKeys } from './hooks/useGlobalKeys' import { useInvestigation } from './hooks/useInvestigation' import type { Theme } from './hooks/useTheme' @@ -32,7 +31,7 @@ function MapRedirect() { } export default function App({ theme, onToggleTheme }: Readonly) { - const { service, trail, popToFrame, popOne } = useInvestigation() + const { service } = useInvestigation() const [paletteOpen, setPaletteOpen] = useState(false) const [shortcutsOpen, setShortcutsOpen] = useState(false) @@ -83,7 +82,6 @@ export default function App({ theme, onToggleTheme }: Readonly) { )} - {overlaysWanted && ( { it('mounts the Constellation home at /', async () => { renderApp('/') - // Empty graph → the home's anomaly tape + connect empty state prove the + // Empty graph → the home's OTLP connect empty state proves the // ConstellationHome lazy chunk mounted. - expect( - await screen.findByRole('region', { name: /recent anomalies/i }), - ).toBeInTheDocument() + expect(await screen.findByText(/no telemetry yet/i)).toBeInTheDocument() }) it('redirects unknown paths to /', async () => { @@ -134,17 +132,10 @@ describe('App routing', () => { ).toBeInTheDocument() }) - it('renders the trail bar when ?trail= is present', async () => { - renderApp('/?trail=svc:checkout') - expect( - await screen.findByRole('navigation', { name: /investigation trail/i }), - ).toBeInTheDocument() - }) - it('opens the command palette on the global ⌘K shortcut', async () => { const user = userEvent.setup() renderApp('/') - await screen.findByRole('region', { name: /recent anomalies/i }) + await screen.findByText(/no telemetry yet/i) await user.keyboard('{Meta>}k{/Meta}') expect( await screen.findByRole('dialog', { name: /command palette/i }), @@ -154,7 +145,7 @@ describe('App routing', () => { it('opens the shortcut sheet on "?"', async () => { const user = userEvent.setup() renderApp('/') - await screen.findByRole('region', { name: /recent anomalies/i }) + await screen.findByText(/no telemetry yet/i) await user.keyboard('?') expect( await screen.findByRole('dialog', { name: /keyboard shortcuts/i }), diff --git a/ui/src/components/ErrorBoundary.tsx b/ui/src/components/ErrorBoundary.tsx index a4b22f8..79e2f15 100644 --- a/ui/src/components/ErrorBoundary.tsx +++ b/ui/src/components/ErrorBoundary.tsx @@ -69,8 +69,8 @@ export class ErrorBoundary extends Component { alignItems: 'center', justifyContent: 'center', padding: '24px', - background: 'var(--bg-base, #0b0d10)', - color: 'var(--text-1, #e8ecf2)', + background: 'var(--bg-base, #0a0c0f)', + color: 'var(--text-1, #eceef3)', fontFamily: 'var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)', zIndex: 9999, diff --git a/ui/src/components/common/ServiceGroups.module.css b/ui/src/components/common/ServiceGroups.module.css index 960f962..27a67d1 100644 --- a/ui/src/components/common/ServiceGroups.module.css +++ b/ui/src/components/common/ServiceGroups.module.css @@ -24,7 +24,8 @@ .disclosure { display: flex; align-items: center; - gap: var(--space-1); + gap: var(--space-2); + width: 100%; min-height: 44px; padding: 0 var(--space-2); border: none; @@ -34,6 +35,13 @@ cursor: pointer; } +/* Group size — always visible in the header, open OR collapsed, pushed to the + * trailing edge so a folded group still reads "CRITICAL 3". */ +.count { + margin-left: auto; + color: var(--text-3); +} + @media (hover: hover) { .disclosure:hover { color: var(--text-1); diff --git a/ui/src/components/common/ServiceGroups.tsx b/ui/src/components/common/ServiceGroups.tsx index c5f67c3..44e4dfa 100644 --- a/ui/src/components/common/ServiceGroups.tsx +++ b/ui/src/components/common/ServiceGroups.tsx @@ -11,75 +11,93 @@ interface ServiceGroupsProps { /** Id of the inspected service — drives row focus-distortion. Optional so * the xs flow-map fallback (no inspector) renders a plain list. */ selectedId?: string | null + /** Ids of services with an active anomaly. When present, a collapsible + * "Anomalies" group lists them worst-first — a cross-cut of "what's spiking + * now", distinct from the status groups (current health). */ + anomalyServiceIds?: ReadonlySet } -function Section({ +/** + * A collapsible status group: a disclosure header (chevron + caps title + + * count) over its worst-first rows. Renders nothing when empty. Each group + * owns its open state so an operator can fold away groups they don't care + * about — at 120 services the panel stays navigable. + */ +function CollapsibleSection({ title, nodes, onOpen, selectedId, + defaultOpen = true, }: Readonly<{ title: string nodes: SystemNode[] onOpen: (id: string) => void selectedId?: string | null + defaultOpen?: boolean }>) { + const [open, setOpen] = useState(defaultOpen) if (nodes.length === 0) return null return (
-

{title}

-
    - {nodes.map((n) => ( -
  • - -
  • - ))} -
+ + {open && ( +
    + {nodes.map((n) => ( +
  • + +
  • + ))} +
+ )}
) } /** - * Status-grouped service list, worst first; quiet healthy services collapsed - * behind a disclosure. Hairline-separated rows (not cards). Doubles as the xs - * default for /map. + * Status-grouped service list, worst first. Every group is collapsible behind a + * disclosure header; the quiet healthy group starts collapsed. Hairline- + * separated rows (not cards). Doubles as the xs default for /map. */ -export default function ServiceGroups({ nodes, onOpen, selectedId }: Readonly) { +export default function ServiceGroups({ + nodes, + onOpen, + selectedId, + anomalyServiceIds, +}: Readonly) { const ranked = rankServices(nodes) - const [healthyOpen, setHealthyOpen] = useState(false) - + // Worst-first list of services carrying an active anomaly. + const anomalous = anomalyServiceIds + ? [...ranked.critical, ...ranked.degraded, ...ranked.alerted, ...ranked.healthy].filter((n) => + anomalyServiceIds.has(n.id), + ) + : [] return (
-
-
-
- {ranked.healthy.length > 0 && ( -
- - {healthyOpen && ( -
    - {ranked.healthy.map((n) => ( -
  • - -
  • - ))} -
- )} -
- )} + + + + +
) } diff --git a/ui/src/components/common/ServiceRow.module.css b/ui/src/components/common/ServiceRow.module.css index b67d6b2..7f66e4c 100644 --- a/ui/src/components/common/ServiceRow.module.css +++ b/ui/src/components/common/ServiceRow.module.css @@ -94,18 +94,3 @@ height: 100%; border-radius: 2px; } - -.metrics { - flex: none; - display: flex; - gap: var(--space-3); - justify-content: flex-end; - font-size: var(--text-xs); - color: var(--text-2); -} - -.metric { - display: inline-block; - min-width: 44px; - text-align: right; -} diff --git a/ui/src/components/common/ServiceRow.tsx b/ui/src/components/common/ServiceRow.tsx index bb321bd..aec8eda 100644 --- a/ui/src/components/common/ServiceRow.tsx +++ b/ui/src/components/common/ServiceRow.tsx @@ -1,4 +1,3 @@ -import { formatCount, formatMs, formatPercent } from '@/lib/format' import { nodeStatus, statusToken } from '@/lib/triage' import type { SystemNode } from '@/types/api' import styles from './ServiceRow.module.css' @@ -17,10 +16,11 @@ interface ServiceRowProps { } /** - * One service row: status dot, name, inline health bar, then the mono - * indicator columns (rps / err% / p99) — every measured quantity in the - * indicator voice. Shared by the Triage feed and the flow map's xs card list - * so "how a service looks" has one source of truth. + * One service row: status dot, name, alert badge, and an inline health bar. + * The numeric stats (rps / err% / p99) are intentionally NOT shown here — the + * list stays name-first and legible on the narrow rail; the full stats live in + * the Inspector popup, one click away. Shared by the Triage feed and the flow + * map's xs card list so "how a service looks" has one source of truth. * * Focus-distortion: when a service is inspected, the selected row lifts * (scale 1.015) with an --accent-edge ring and the rest dim. This is a pure @@ -65,17 +65,6 @@ export default function ServiceRow({ node, onOpen, selectedId }: Readonly - - - {formatCount(node.metrics.request_rate_rps)} - - - {formatPercent(node.metrics.error_rate)} - - - {formatMs(node.metrics.p99_latency_ms)} - - ) } diff --git a/ui/src/components/inspector/ServiceInspector.module.css b/ui/src/components/inspector/ServiceInspector.module.css index 56e7c2f..fad9c63 100644 --- a/ui/src/components/inspector/ServiceInspector.module.css +++ b/ui/src/components/inspector/ServiceInspector.module.css @@ -1,52 +1,26 @@ -/* Service Inspector — md: fixed overlay sheet; lg: docked 380px column; - * xl: 440px. The xs bottom sheet is a separate Radix dialog (below). */ +/* Service Inspector — the popup IS the inspector: a single floating Radix + * Dialog that carries the category tabs + content on every breakpoint. There + * is no docked side rail (dropped once the popup became self-sufficient). */ -.panel { +/* ---- details popup (Radix Dialog, non-modal, top-anchored) ---- */ +.popup { position: fixed; - inset: 0 0 0 auto; - z-index: 45; + z-index: 61; + /* Anchor the top edge (not vertically centered) so switching categories — + which changes the body height — never re-centers and shifts the box. The + header stays put; taller content grows downward and .tabContent scrolls. */ + top: 12dvh; + left: 50%; + transform: translateX(-50%); display: flex; flex-direction: column; - width: min(420px, calc(100vw - 48px)); - border-left: 1px solid var(--stroke-1); + width: min(480px, calc(100vw - 32px)); + max-height: min(76dvh, 720px); + border: 1px solid var(--stroke-1); + border-radius: var(--radius-3); background: var(--bg-raised); box-shadow: var(--shadow-sheet); - overflow-y: auto; -} - -@media (min-width: 1024px) { - .panel { - position: static; - z-index: auto; - flex: none; - width: 380px; - background: var(--bg-raised); - /* Docked = the single inspected entity anchored to the canvas selection. - * Hairline left border + a raised 2px inset --accent rail reads as - * "this column belongs to the lit node" at the same intensity as the - * canvas node ring and the rail row; separation by hairline + edge, - * never a drop shadow. */ - border-left: 1px solid var(--stroke-1); - box-shadow: inset 2px 0 0 var(--accent); - } - - /* Header wash — a faint --accent-muted gradient under the title band echoes - * the canvas focus-distortion (the lit node's accent ring) so the docked - * column reads as the selection's surface, not a generic panel. Fades to - * transparent before the dense content, keeping name/numeral AA intact. */ - .header { - background: linear-gradient( - to bottom, - var(--accent-muted), - transparent - ); - } -} - -@media (min-width: 1440px) { - .panel { - width: 440px; - } + overflow: hidden; } /* ---- header ---- */ @@ -68,13 +42,17 @@ .name { flex: 1; min-width: 0; - overflow: hidden; margin: 0; font-family: var(--font-sans); - font-size: var(--text-base); + font-size: var(--text-lg); font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; + /* Explicit high-contrast colour — the panel title must read clearly in both + themes (don't rely on inherited body colour). */ + color: var(--text-1); + /* Show the FULL service name — wrap long ids instead of truncating with an + ellipsis (names like "notification-service" were getting cut off). */ + overflow-wrap: anywhere; + word-break: break-word; } /* Header health reading — mono numeral, status-tinted, the authoritative @@ -111,13 +89,6 @@ } /* ---- tabs ---- */ -.tabs { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; -} - .tabList { display: flex; flex: none; @@ -525,64 +496,3 @@ .skeletonRow { min-height: 16px; } - -/* ---- xs bottom sheet ---- */ -.sheetOverlay { - position: fixed; - inset: 0; - z-index: 48; - background: var(--scrim); -} - -.sheet { - position: fixed; - inset: auto 0 0 0; - z-index: 49; - display: flex; - flex-direction: column; - border-top: 1px solid var(--stroke-2); - border-radius: var(--radius-3) var(--radius-3) 0 0; - /* Floating bottom sheet → overlay surface + drop shadow (the one place a - * shadow is for elevation, not separation). */ - background: var(--bg-overlay); - box-shadow: var(--shadow-sheet); - transition: height var(--dur-3) var(--ease); -} - -.sheetHandle { - display: flex; - flex: none; - align-items: center; - justify-content: center; - height: 24px; - cursor: grab; - touch-action: none; -} - -.sheetGrip { - width: 36px; - height: 4px; - border-radius: 2px; - background: var(--stroke-2); -} - -.sheetScroll { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - overflow-y: auto; - padding-bottom: env(safe-area-inset-bottom); -} - -.srOnly { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - border: 0; - clip-path: inset(50%); - overflow: hidden; - white-space: nowrap; -} diff --git a/ui/src/components/inspector/ServiceInspector.tsx b/ui/src/components/inspector/ServiceInspector.tsx index d09cd8d..6ad6f1b 100644 --- a/ui/src/components/inspector/ServiceInspector.tsx +++ b/ui/src/components/inspector/ServiceInspector.tsx @@ -1,53 +1,27 @@ -import { useCallback, useRef, useState, type ReactNode } from 'react' +import { useCallback, useState } from 'react' import * as Dialog from '@radix-ui/react-dialog' -import * as Tabs from '@radix-ui/react-tabs' import { X } from 'lucide-react' import { useLocation, useSearch } from 'wouter' import { useInvestigation } from '@/hooks/useInvestigation' import { useSystemGraph } from '@/hooks/useSystemGraph' -import { useMediaQuery } from '@/hooks/useMediaQuery' import { formatPercent } from '@/lib/format' import { Metric } from '@/components/common/Metric' import { nodeStatus, statusToken } from '@/lib/triage' -import { nextSheetState, type SheetSnap } from '@/lib/sheet' import { buildHref, readParam } from '@/lib/urlState' -import type { SystemNode } from '@/types/api' import { INSPECTOR_TABS, isInspectorTabId } from './registry' import styles from './ServiceInspector.module.css' -// Service Inspector — the omnipresent ?service=X panel: -// xs (<768) bottom sheet (Radix dialog), snap 50/92dvh, swipe-down dismiss -// md (<1024) fixed overlay sheet on the right (CSS-only difference) -// lg+ docked grid column (380px / 440px at xl), content reflows +// Service Inspector — two surfaces driven by ?service= (set on a node click): +// • CATEGORY PANEL (md+ docked): the service name + the category list +// (Overview / Why / Impact / Dependencies). NO stats — picking a category +// just drives the popup. A slim, read-light rail. +// • DETAILS POPUP (Radix Dialog, every breakpoint): the active category's +// full content (stats / why / impact / deps). On xs the categories ride +// inside the popup itself (a phone has no room for a side rail). -function Header({ - node, - service, - onClose, -}: Readonly<{ node: SystemNode | null; service: string; onClose: () => void }>) { - const status = nodeStatus(node?.status) +function StatusDot({ status }: Readonly<{ status: ReturnType }>) { return ( -
-
+