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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Live SSE stream (`/platform/traces/stream`): a single connection multiplexes
`trace` / `metrics` / `modules` / `summary` events pushed ~1×/s, with manual
**Connect / Disconnect** stream control (`LIVE` / `PAUSED`).
- Connection ack (`ready`): the platform states its identity and version, its
trace-ring `capacity`, whether traces/metrics are recording, and its push
period (`tickMs`) as the first frame of every connection. The header shows the
platform it is talking to and the real ring size, the module table says whether
metrics are off or merely idle instead of inferring it from an empty array, and
the silence watchdog is sized from the stated period rather than an assumed
1Hz. Platforms that send no ack are unchanged — the console reports what it was
not told as unknown.
- Windowed header KPIs: the `summary` event drives the trend sub-lines (requests
/ error-rate / p95 vs the previous window) and the active-module split by
isolation mode. Trend fields are nullable — a muted placeholder is shown
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ in logs.
- **Live SSE stream** — one connection to `/platform/traces/stream` multiplexes
`trace` / `metrics` / `modules` / `summary` events pushed ~1×/s, with manual
**Connect / Disconnect** control. Replaces the previous 5s REST polling.
- **Connection ack** — the platform's `ready` frame names itself and its version,
states its trace-ring capacity, whether traces and per-module metrics are
recording, and its push period. The console reports those instead of assuming
them: the silence watchdog is sized from the stated period, the header prints
the stated ring size, and the module table stops guessing why it is empty. A
platform that sends no ack keeps working — the console then says it does not
know rather than falling back to a number nobody gave it.
- **Live / sample auto-fallback** — when a platform is reachable, data is `live`;
when none is running, the console falls back to grounded mock data so the UI is
always explorable. The top bar shows a **LIVE** vs **SAMPLE DATA** badge.
Expand Down Expand Up @@ -180,15 +187,40 @@ src/
## Platform API surface

The live path is a single SSE connection to `GET /platform/traces/stream`, which
multiplexes four named events:
opens with an acknowledgement and then multiplexes four named events:

| Event | Maps to | Notes |
|---|---|---|
| `ready` | `StreamReady` | Connection ack, first frame, repeated on every reconnect. Optional: platforms that predate it are supported. |
| `trace` | `RequestTrace[]` | Incremental delta since the last seq (initial snapshot on connect). |
| `metrics` | `ModuleMetricsSnapshot[]` | Full per-module snapshot each tick. Opt-in via `protean.trace.metrics.enabled`. |
| `modules` | `ModuleStatus[]` | Joined into the module table for isolation mode / trust tier. |
| `summary` | `TraceSummary` | Windowed KPI aggregate + trend vs the previous window + active-module split by mode. |

The `ready` ack is not a data frame. It carries what a client must learn once, at
connect time, and cannot derive from the stream afterwards — on a quiet platform
nothing else arrives at all, so it is also the only thing separating *the stream
is open* from *the platform is running*:

```
event: ready
data: {"platform":"protean","platformVersion":"0.1.0","tracesEnabled":true,
"metricsEnabled":false,"buffered":5,"tickMs":1000,"capacity":200}
```

| Field | What the console does with it |
|---|---|
| `platform` / `platformVersion` | Shown beside the origin in the header. **Display only** — no behaviour branches on which implementation answered. `null` version is rendered as unknown, never replaced with a guess. |
| `tracesEnabled` / `metricsEnabled` | Separates "recording is off" from "recording is on and idle" — the two an empty panel cannot tell apart. `tracesEnabled` is read first: per-module metrics are aggregated from recorded requests, so with recording off no rows can appear whatever the metrics flag says. |
| `buffered` | Read, but not acted on: it counts the rows of the `trace` frame that follows (not the ring's size), and those rows arrive regardless. |
| `tickMs` | Sizes the silence watchdog (6 periods, clamped to 2–60s) instead of assuming 1Hz. |
| `capacity` | The ring size as of this connection, printed in the `LIVE` badge. |

Fields may be added later; the console ignores the ones it does not recognize.
Every value is connection-scoped — the console drops them when the stream is
rebuilt and re-learns them from the next ack, because they are live settings on
the platform.

The read-only REST surface (`GET /platform/traces`, `/platform/traces/metrics`,
`/platform/modules`, `/platform/modules/{id}/routes`) is still mirrored in
`api.ts` as a fallback and for the module-routes drawer.
Expand Down
11 changes: 9 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function App() {
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null)

const { settings, save: saveSettings } = useSettings()
const { data, conn, channels, streaming, setStreaming } = useConsoleData(settings)
const { data, conn, channels, platform, streaming, setStreaming } = useConsoleData(settings)
const disconnected = conn.status === 'disconnected'

// Retain trace history in IndexedDB for a real platform; pass mock through.
Expand Down Expand Up @@ -68,6 +68,7 @@ export default function App() {
onToggleTheme={toggle}
conn={conn}
channels={channels}
platform={platform}
storage={traceStore.storage}
settings={settings}
onSaveSettings={saveSettings}
Expand Down Expand Up @@ -99,6 +100,8 @@ export default function App() {
<ModuleTable
metrics={data.metrics}
modules={data.modules}
metricsEnabled={platform?.metricsEnabled ?? null}
tracesEnabled={platform?.tracesEnabled ?? null}
sort={sort}
onSort={setSort}
selectedId={selectedModuleId}
Expand All @@ -108,7 +111,11 @@ export default function App() {

{/* The trace table itself is left legible — dimming rows the operator is
reading to find something is a poor trade. The note sits in its header. */}
<TraceTable store={traceStore} staleReason={staleTraceDerived} />
<TraceTable
store={traceStore}
staleReason={staleTraceDerived}
tracesEnabled={platform?.tracesEnabled ?? null}
/>

<footer className="font-mono text-[11px] leading-relaxed text-muted-foreground">
Columns map 1:1 to the platform trace surface — rows are{' '}
Expand Down
75 changes: 74 additions & 1 deletion src/components/latency-chart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { act, render, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { LatencyChart } from '@/components/latency-chart'
import { StatusMix } from '@/components/status-mix'
import type { RequestTrace } from '@/lib/types'
import { useConsoleUnderTest } from '@/test/console-driver'
import { GO_CONNECT, SPRING_CONNECT } from '@/test/stream-fixtures'
import { FakeEventSource, installFakeEventSource, replay } from '@/test/sse-stream'
Expand Down Expand Up @@ -99,3 +101,74 @@ describe.each([
expect(svg.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1)
})
})

describe('latency chart with no samples', () => {
// This file has no automatic cleanup (the scenarios above keep their renders), so
// these unmount themselves — two charts in one DOM make every text query ambiguous.
afterEach(cleanup)

/**
* An idle platform is the normal state of a console someone just opened, and the
* card used to vanish on it — taking half of the row it shares with the status
* mix and leaving a layout that looked broken rather than quiet.
*
* The frame is drawn instead. What must not appear with it is a shape: a line,
* an area or a point would all be data this console never received.
*/
it('draws the frame but nothing to read as data', () => {
const { container } = render(<LatencyChart series={[]} />)
const svg = container.querySelector('svg')
expect(svg).not.toBeNull()
expect(svg!.querySelector('polyline')).toBeNull()
expect(svg!.querySelector('polygon')).toBeNull()
expect(svg!.querySelector('circle'), 'marked a sample that does not exist').toBeNull()
})

it('says the scale is a default rather than a measurement', () => {
// The axis numbers are this console's choice, not the platform's data. Left
// unlabelled they read as a measured range — which is the lie the vanished
// card at least never told.
const { getByText } = render(<LatencyChart series={[]} />)
expect(getByText(/default scale/)).toBeTruthy()
expect(getByText('No samples in this window yet')).toBeTruthy()
})

it('goes back to the measured scale as soon as a sample lands', () => {
const { container, getByText, rerender } = render(<LatencyChart series={[]} />)
rerender(<LatencyChart series={[12, 40]} />)
expect(getByText(/rolling window/)).toBeTruthy()
expect(container.querySelector('polyline')).not.toBeNull()
})
})

describe('status mix', () => {
afterEach(cleanup)

/**
* The header count and the bar geometry were one number, and the `|| 1` that
* keeps a width from dividing by zero was also what the header printed: an idle
* platform read "1 req" beside four zeroes, which is a request that never
* happened.
*/
it('counts no requests as none', () => {
const { getByText } = render(<StatusMix traces={[]} />)
expect(getByText('0 req')).toBeTruthy()
})

it('still counts what did arrive', () => {
const t = (status: number, seq: number): RequestTrace => ({
seq,
epochMillis: 1789971324000 + seq,
method: 'GET',
uri: '/x',
pattern: null,
moduleId: null,
status,
latencyMs: 1,
error: null,
traceId: `dddd${seq}`,
})
const { getByText } = render(<StatusMix traces={[t(200, 1), t(404, 2), t(500, 3)]} />)
expect(getByText('3 req')).toBeTruthy()
})
})
35 changes: 29 additions & 6 deletions src/components/latency-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,28 @@ function niceTicks(max: number, count = 4): number[] {
return ticks
}

/**
* The axis an empty chart is drawn on.
*
* With no samples there is nothing to scale to, and the card used to disappear
* entirely — which left the row it shares with the status mix broken, and told a
* platform with no traffic yet that it had no chart. The frame is drawn instead,
* on a scale this console picked rather than measured, and the header says so:
* a chart whose numbers could be read as a measured range is worse than none.
*/
const EMPTY_SCALE_MAX = 100

/** Single-series p95 latency area with a crosshair+tooltip. One hue (telemetry), no legend needed. */
export function LatencyChart({ series }: { series: number[] }) {
const gradId = useId()
const svgRef = useRef<SVGSVGElement>(null)
const [hover, setHover] = useState<number | null>(null)
const n = series.length
const empty = n === 0

if (n === 0) return null
// Nice round ticks give the scale; the top tick already clears the data max,
// so the axis has headroom without an arbitrary multiplier.
const gridVals = niceTicks(Math.max(...series))
const gridVals = niceTicks(empty ? EMPTY_SCALE_MAX : Math.max(...series))
const max = gridVals[gridVals.length - 1] || 1
// A single bucket has no span to interpolate across — `i / (n - 1)` would be
// 0/0 and poison every coordinate with NaN. Pin the lone sample to the right
Expand All @@ -41,9 +52,10 @@ export function LatencyChart({ series }: { series: number[] }) {
const y = (v: number) => PAD.t + (1 - v / max) * (H - PAD.t - PAD.b)

const pts = series.map((v, i) => `${x(i)},${y(v)}`).join(' ')
const area = `${PAD.l},${y(0)} ${pts} ${x(n - 1)},${y(0)}`
const area = empty ? '' : `${PAD.l},${y(0)} ${pts} ${x(n - 1)},${y(0)}`

function onMove(e: React.PointerEvent<SVGSVGElement>) {
if (empty) return // nothing to point at, and `x`/`y` have no sample to resolve
const r = svgRef.current!.getBoundingClientRect()
const relX = ((e.clientX - r.left) / r.width) * W
const i = Math.max(0, Math.min(n - 1, Math.round((relX - PAD.l) / (W - PAD.l - PAD.r) * (n - 1))))
Expand All @@ -54,7 +66,9 @@ export function LatencyChart({ series }: { series: number[] }) {
<Card className="gap-0 overflow-hidden pb-3">
<CardHeader className="flex items-baseline justify-between pb-2">
<CardTitle className="text-[13.5px] font-semibold">Latency p95 over time</CardTitle>
<span className="font-mono text-[11px] text-muted-foreground">ms · rolling window</span>
<span className="font-mono text-[11px] text-muted-foreground">
{empty ? 'ms · default scale' : 'ms · rolling window'}
</span>
</CardHeader>
<div className="relative px-2.5">
<svg
Expand Down Expand Up @@ -82,19 +96,28 @@ export function LatencyChart({ series }: { series: number[] }) {
</g>
))}
{/* Line and area need two points to mean anything; with one bucket the
area would ramp from a zero baseline it never measured. Show the dot alone. */}
area would ramp from a zero baseline it never measured. Show the dot alone.
With no samples at all, nothing is drawn over the grid — an empty chart
must not be given a shape to read. */}
{n > 1 && <polygon points={area} fill={`url(#${gradId})`} />}
{n > 1 && (
<polyline points={pts} fill="none" stroke="var(--telemetry)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
)}
<circle cx={x(n - 1)} cy={y(series[n - 1])} r={3.6} fill="var(--telemetry)" stroke="var(--card)" strokeWidth={2} />
{!empty && (
<circle cx={x(n - 1)} cy={y(series[n - 1])} r={3.6} fill="var(--telemetry)" stroke="var(--card)" strokeWidth={2} />
)}
{hover != null && (
<>
<line x1={x(hover)} y1={PAD.t} x2={x(hover)} y2={H - PAD.b} stroke="var(--telemetry)" strokeWidth={1} opacity={0.5} />
<circle cx={x(hover)} cy={y(series[hover])} r={4} fill="var(--telemetry)" stroke="var(--card)" strokeWidth={2} />
</>
)}
</svg>
{empty && (
<p className="pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-[11.5px] text-muted-foreground">
No samples in this window yet
</p>
)}
{hover != null && (
<div
className="pointer-events-none absolute -translate-x-1/2 -translate-y-2 rounded-md bg-foreground px-2 py-1 font-mono text-[11px] text-background shadow-lg"
Expand Down
Loading
Loading