Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,8 @@ data/

/bin/
/loadsim
/otelcontext
/otelcontext
# 1b agent plan (scratch)
ui/PLAN-1b.md
# preview-only Vite config (not a shipped artifact)
ui/vite.preview.config.ts
15 changes: 15 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Gitleaks configuration.
#
# Extends gitleaks' built-in ruleset (so real secrets are still caught) but
# excludes the embedded UI build artifacts under internal/ui/dist/. Those are
# minified, gitignored bundle files — committed only in pre-"source-only-main"
# history (#99) — whose hashed/minified contents trip the generic-api-key
# heuristic. They are build output, never source, and never carry real secrets.
[extend]
useDefault = true

[allowlist]
description = "Embedded UI build artifacts are build output, not secret-bearing source."
paths = [
'''internal/ui/dist/.*''',
]
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@ last published pre-release tag (`v0.3.0-beta.1`).

## [Unreleased]

_Nothing yet._
### Changed — map-centric "Service Map" UI redesign

## [v0.3.0-beta.1] — 2026-06-12
- The home is now a single live **Service Map**: a sunflower/phyllotaxis radial
layout (most-critical at center) that scales 7→120+ services, with system
vitals in the header, a recent-anomaly strip (15m/30m/1h counts + service
chips), and the Inspector docked on selection.
- Logs and Traces are no longer human screens — they are served by the MCP
`search_logs`/`trace_graph` tools; the nav collapses to Service Map + ⌘K.

## [v0.3.0-beta.1] — 2026-06-12
### Fixed — production OOM restarts (memory-survival series)

- **AnomalyStore memory blowup** (merged from `fix/sqlite-survival-hardening`): stable
Expand Down
387 changes: 204 additions & 183 deletions ui/package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"wouter": "^3.10.0"
},
"overrides": {
"brace-expansion": "5.0.6"
"brace-expansion": "5.0.6",
"@babel/core": "^7.29.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand All @@ -45,7 +46,7 @@
"jsdom": "^29.1.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10",
"vite": "^8.0.16",
"vitest": "^4.1.5"
}
}
31 changes: 20 additions & 11 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useState } from 'react'
import { Redirect, Route, Switch } from 'wouter'
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'
Expand All @@ -11,10 +11,7 @@ import styles from './App.module.css'
// All routes are code-split; the Inspector is split too and only fetched
// once a ?service= drill-down actually happens. The ⌘K palette + shortcut
// sheet chunk loads on the first open request — zero cost until then.
const TriageView = lazy(() => import('./components/triage/TriageView'))
const FlowMapView = lazy(() => import('./components/map/FlowMapView'))
const TracesView = lazy(() => import('./components/traces/TracesView'))
const LogsView = lazy(() => import('./components/logs/LogsView'))
const ConstellationHome = lazy(() => import('./components/triage/ConstellationHome'))
const ServiceInspector = lazy(() => import('./components/inspector/ServiceInspector'))
const PaletteHost = lazy(() => import('./components/palette/PaletteHost'))

Expand All @@ -23,6 +20,17 @@ interface AppProps {
onToggleTheme: () => void
}

/**
* /map folds into the home canvas (the map IS home). wouter's <Redirect to>
* drops the query string, so rebuild the target from useSearch() — this keeps
* deep links like /map?service=x and /map?impact=x alive across the fold.
*/
function MapRedirect() {
const search = useSearch()
const to = search ? `/?${search}` : '/'
return <Redirect to={to} replace />
}

export default function App({ theme, onToggleTheme }: Readonly<AppProps>) {
const { service, trail, popToFrame, popOne } = useInvestigation()

Expand Down Expand Up @@ -56,12 +64,13 @@ export default function App({ theme, onToggleTheme }: Readonly<AppProps>) {
<div className={styles.routes}>
<Suspense fallback={<RouteFallback />}>
<Switch>
<Route path="/" component={TriageView} />
<Route path="/map" component={FlowMapView} />
<Route path="/traces" component={TracesView} />
<Route path="/logs" component={LogsView} />
{/* Unknown paths (incl. the retired /dashboard and /mcp)
land on the Triage home. */}
<Route path="/" component={ConstellationHome} />
{/* The map is the home canvas now — fold /map into / while
preserving ?service= / ?impact= deep links. */}
<Route path="/map" component={MapRedirect} />
{/* Unknown paths (incl. the retired /dashboard, /mcp,
/traces and /logs) land on the Constellation home —
logs and traces are MCP-tool surfaces for agents now. */}
<Route>
<Redirect to="/" replace />
</Route>
Expand Down
66 changes: 55 additions & 11 deletions ui/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Router } from 'wouter'
import { memoryLocation } from 'wouter/memory-location'
Expand Down Expand Up @@ -72,11 +73,12 @@ function renderApp(path: string) {
}

describe('App routing', () => {
it('mounts the Triage home at /', async () => {
it('mounts the Constellation home at /', async () => {
renderApp('/')
// Empty graph → the feed's connect empty state proves the mount.
// Empty graph → the home's anomaly tape + connect empty state prove the
// ConstellationHome lazy chunk mounted.
expect(
await screen.findByRole('region', { name: /service triage feed/i }),
await screen.findByRole('region', { name: /recent anomalies/i }),
).toBeInTheDocument()
})

Expand All @@ -85,35 +87,77 @@ describe('App routing', () => {
await waitFor(() => expect(memory.history).toContain('/'))
})

it('redirects the retired /dashboard route to the Triage home', async () => {
it('redirects the retired /dashboard route to the home', async () => {
const memory = renderApp('/dashboard')
await waitFor(() => expect(memory.history).toContain('/'))
})

it('redirects the retired /mcp route to the Triage home', async () => {
it('redirects the retired /mcp route to the home', async () => {
const memory = renderApp('/mcp')
await waitFor(() => expect(memory.history).toContain('/'))
})

it('mounts the FlowMapView at /map', async () => {
renderApp('/map')
// Lazy chunk + empty graph → the view's empty state proves the mount.
it('redirects the removed /traces screen to the home', async () => {
const memory = renderApp('/traces')
await waitFor(() => expect(memory.history).toContain('/'))
})

it('redirects the removed /logs screen to the home', async () => {
const memory = renderApp('/logs')
await waitFor(() => expect(memory.history).toContain('/'))
})

it('folds /map into the home canvas', async () => {
const memory = renderApp('/map')
await waitFor(() => expect(memory.history.at(-1)).toBe('/'))
})

it('preserves ?service= when folding /map into the home', async () => {
const memory = renderApp('/map?service=ghost')
// The redirect rebuilds the query so the deep link survives the fold.
await waitFor(() => expect(memory.history.at(-1)).toContain('service=ghost'))
// …and the Inspector docks for that service.
expect(
await screen.findByText(/no services discovered yet/i),
await screen.findByRole('button', { name: /close inspector/i }),
).toBeInTheDocument()
})

it('preserves ?impact= when folding /map into the home', async () => {
const memory = renderApp('/map?impact=ghost')
await waitFor(() => expect(memory.history.at(-1)).toContain('impact=ghost'))
})

it('mounts the Service Inspector when ?service= is present', async () => {
renderApp('/map?service=ghost')
renderApp('/?service=ghost')
expect(
await screen.findByRole('button', { name: /close inspector/i }),
).toBeInTheDocument()
})

it('renders the trail bar when ?trail= is present', async () => {
renderApp('/map?trail=svc:checkout')
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 user.keyboard('{Meta>}k{/Meta}')
expect(
await screen.findByRole('dialog', { name: /command palette/i }),
).toBeInTheDocument()
})

it('opens the shortcut sheet on "?"', async () => {
const user = userEvent.setup()
renderApp('/')
await screen.findByRole('region', { name: /recent anomalies/i })
await user.keyboard('?')
expect(
await screen.findByRole('dialog', { name: /keyboard shortcuts/i }),
).toBeInTheDocument()
})
})
59 changes: 59 additions & 0 deletions ui/src/components/common/Gauge.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
.gauge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
}

.dial {
display: block;
width: 100%;
height: 100%;
}

.track,
.fill {
fill: none;
stroke-width: 6;
stroke-linecap: round;
}

.track {
stroke: var(--gauge-track);
}

.fill {
/* Animate the reveal with the shared duration/easing; under
prefers-reduced-motion --dur-2 is zeroed at tokens.css:123, so the arc
snaps to its value with no transition. transform/opacity-free property,
but this is a one-shot value change (no looping), so it is safe to ease. */
transition: stroke-dashoffset var(--dur-2) var(--ease);
}

.ok {
stroke: var(--ok);
}

.warn {
stroke: var(--warn);
}

.crit {
stroke: var(--crit);
}

.accent {
stroke: var(--accent);
}

.readout {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
101 changes: 101 additions & 0 deletions ui/src/components/common/Gauge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Metric } from './Metric'
import styles from './Gauge.module.css'

/** Status palette for the arc fill. Saturation is rationed to status. */
type GaugeTone = 'ok' | 'warn' | 'crit' | 'accent'

interface GaugeProps {
/** Raw measured value (drives the arc fill and aria-valuenow). */
value: number
/** Upper bound of the scale. Defaults to 1 (a 0–1 ratio). */
max?: number
/** Accessible name fragment, e.g. "HEALTH". Also the visible legend. */
label: string
/** Pre-formatted numeral — the source of truth, always rendered. */
valueText: string
/** Optional unit suffix rendered tight against the numeral. */
unit?: string
/** Arc fill color token. Defaults to the single accent. */
tone?: GaugeTone
}

// 270° sweep with a 90° gap centered at the bottom of the dial. The arc starts
// at the lower-left (135° from +x, measuring clockwise in SVG's y-down space)
// and sweeps clockwise through the top to the lower-right.
const SWEEP_DEG = 270
const RADIUS = 42
const CENTER = 50
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
const ARC_LENGTH = CIRCUMFERENCE * (SWEEP_DEG / 360)
// Rotate so the 90° gap sits at the bottom: the dasharray begins at +x (3
// o'clock); rotating the whole ring by 135° puts the visible arc's start at the
// lower-left and its end at the lower-right.
const ROTATION = 135

function polar(angleDeg: number): { x: number; y: number } {
const rad = (angleDeg * Math.PI) / 180
return { x: CENTER + RADIUS * Math.cos(rad), y: CENTER + RADIUS * Math.sin(rad) }
}

// Endpoints of the 270° track, anchored to the same geometry as the dasharray.
const start = polar(ROTATION)
const end = polar(ROTATION + SWEEP_DEG)
// large-arc-flag is 1 because the swept angle (270°) exceeds 180°.
const ARC_PATH = `M ${start.x} ${start.y} A ${RADIUS} ${RADIUS} 0 1 1 ${end.x} ${end.y}`

const TONE_CLASS: Record<GaugeTone, string> = {
ok: styles.ok,
warn: styles.warn,
crit: styles.crit,
accent: styles.accent,
}

/**
* Reusable SVG arc gauge (Instrument Deck). The numeral (`valueText`) is the
* source of truth and is rendered centered in the mono voice via {@link Metric};
* the 270° arc is decoration that fills proportionally to `value / max` and may
* saturate without ever altering the numeral. Pure SVG + CSS, no chart library.
*/
export function Gauge({
value,
max = 1,
label,
valueText,
unit,
tone = 'accent',
}: Readonly<GaugeProps>) {
const safeMax = max > 0 ? max : 1
const fraction = Math.min(Math.max(value / safeMax, 0), 1)
// Reveal `fraction` of the arc by offsetting the remainder out of view.
const fillOffset = ARC_LENGTH * (1 - fraction)
const aria = `${label} ${valueText}${unit ?? ''}`

return (
<div
className={styles.gauge}
role="meter"
aria-label={aria}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={safeMax}
>

Check warning on line 81 in ui/src/components/common/Gauge.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <meter> instead of the "meter" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_otelcontext&issues=AZ7Uaw1dxvlBgGWllVB1&open=AZ7Uaw1dxvlBgGWllVB1&pullRequest=110
<svg
className={styles.dial}
viewBox="0 0 100 100"
aria-hidden="true"
focusable="false"
>
<path className={styles.track} d={ARC_PATH} strokeDasharray={ARC_LENGTH} />
<path
className={`${styles.fill} ${TONE_CLASS[tone]}`}
d={ARC_PATH}
strokeDasharray={ARC_LENGTH}
strokeDashoffset={fillOffset}
/>
</svg>
<div className={styles.readout}>
<Metric label={label} value={valueText} unit={unit} />
</div>
</div>
)
}
Loading