diff --git a/docs/DESIGN-core-and-capture.md b/docs/DESIGN-core-and-capture.md index b9bd0932..358ab55b 100644 --- a/docs/DESIGN-core-and-capture.md +++ b/docs/DESIGN-core-and-capture.md @@ -382,9 +382,13 @@ recordings. to 27 when connection capture added one. Three groups still need a scope ruling rather than a merge: the deconfliction webhook, operator tokens, and the MCP server. -- Timeline view modes — the audit-mode shape does not survive §1, but the - eight flat toggles are still a real problem. -- 18 lanes. +- ~~Timeline view modes / the eight flat toggles.~~ **Done 2026-08-24.** The + three-view-modes attempt was reverted (it invented personas); the toggles are + now grouped by effect, with the low-frequency view/audit controls (session + dividers, timezone, auditor view) behind one "More" control. +- ~~18 lanes.~~ **Done 2026-08-24.** Banded by capture group (commands / + traffic / artifacts / signals), collapsible; the timeline opens as three or + four bands instead of eighteen lanes. - ~~The plugin marketplace, publisher trust and revocation lists.~~ **Removed 2026-08-22.** Browsing a registry, pinning publishers by Ed25519 fingerprint, and reading revocation lists are the machinery of *distributing* diff --git a/e2e/timeline-geometry.spec.ts b/e2e/timeline-geometry.spec.ts index 83ae898e..9b4fcf06 100644 --- a/e2e/timeline-geometry.spec.ts +++ b/e2e/timeline-geometry.spec.ts @@ -119,6 +119,13 @@ test.describe.serial('timeline geometry + startup gates', () => { // ---------------------------------------------------------------- P0-2 test('lane stack scrolls vertically instead of clipping (v0.9.4 P0-2)', async () => { + // Lanes now open banded (§6). Expand every band so all 18 lanes show, which + // is the many-rows overflow scenario this test exists for. + for (const b of ['commands', 'traffic', 'artifacts', 'signals']) { + const chip = page.locator(`[data-testid="timeline-band-${b}"]`) + if (await chip.count()) await chip.click() + } + await page.waitForTimeout(200) const geom = await page.evaluate(() => { const outer = document.querySelector('div.overflow-y-auto.min-h-0') as HTMLElement | null if (!outer) return null diff --git a/e2e/timeline-lane-bands.spec.ts b/e2e/timeline-lane-bands.spec.ts new file mode 100644 index 00000000..998cc755 --- /dev/null +++ b/e2e/timeline-lane-bands.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers' + +// docs/DESIGN-core-and-capture.md §6: the 18 lanes banded by capture group. +// The timeline opens as capture-group bands (collapsed), each expandable to its +// lanes, instead of a wall of eighteen. This is the product's most complex +// screen, so this verifies the real render: bands by default, expand reveals +// lanes, and the events are actually drawn (a band that shows no dots would be +// worse than eighteen lanes). + +let app: ElectronApplication +let page: Page + +test.describe.serial('timeline lane bands', () => { + test.beforeAll(async () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-bands-')) + app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } }) + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')) + await openTestProject(page, 'lane-bands') + await page.waitForTimeout(1200) + const base = `http://127.0.0.1:${readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf-8').trim()}` + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf-8').trim() + const post = (agent_type: string, data: Record): Promise => + fetch(`${base}/api/events/seed`, { method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ agent_type, data }) }) + // one event in each of the four bands + await post('shell', { subtype: 'command_end', command: 'whoami' }) // commands + await post('scanner', { subtype: 'connection', proto: 'tcp', remote_addr: '10.0.0.9', remote_port: 445 }) // traffic + await post('screenshot', { trigger: 'manual' }) // artifacts + await post('marker', { title: 'note', severity: 'info' }) // signals + await page.waitForTimeout(600) + await openView(page, 'timeline') + // Wait for the band row to render rather than a fixed sleep — under full- + // suite load the render can lag past a fixed timeout. + await page.locator('[data-testid="timeline-band-commands"]').waitFor({ state: 'visible', timeout: 10_000 }) + }) + test.afterAll(async () => { await app?.close() }) + + test('opens as capture-group bands, not eighteen lanes', async () => { + await expect(page.locator('[data-testid="timeline-band-commands"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-traffic"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-artifacts"]')).toBeVisible() + await expect(page.locator('[data-testid="timeline-band-signals"]')).toBeVisible() + // The band rows collapse many lanes into four; far fewer than 18 rows. + const bandRows = await page.locator('[data-testid^="timeline-band-"]').count() + expect(bandRows).toBeLessThanOrEqual(4) + }) + + test('the collapsed bands still draw their events', async () => { + // A band that hid its dots would defeat the point. Dots render in the track. + const dots = await page.locator('[data-timeline-event]').count() + expect(dots, 'no events rendered in the collapsed bands').toBeGreaterThan(0) + }) + + test('clicking a band expands it into its lanes', async () => { + await page.locator('[data-testid="timeline-band-commands"]').click() + // The commands band is now gone (expanded); its shell lane label shows. + await expect(page.locator('[data-testid="timeline-band-commands"]')).toHaveCount(0) + const text = await page.evaluate(() => document.querySelector('[data-testid="view-root"]')?.textContent ?? '') + expect(text.toLowerCase()).toContain('shell') + }) +}) diff --git a/e2e/timeline-toolbar-overflow.spec.ts b/e2e/timeline-toolbar-overflow.spec.ts new file mode 100644 index 00000000..361abdf6 --- /dev/null +++ b/e2e/timeline-toolbar-overflow.spec.ts @@ -0,0 +1,49 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers' + +// docs/DESIGN-core-and-capture.md §6: the eight flat toolbar toggles grouped by +// effect. The low-frequency view/audit controls (session dividers, timezone, +// auditor view) moved behind one "More" control so the row is scannable instead +// of a flat wall of chips. This checks they are reachable there, not lost. + +let app: ElectronApplication +let page: Page + +test.describe.serial('timeline toolbar overflow', () => { + test.beforeAll(async () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-toolbar-')) + app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } }) + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + await page.evaluate(() => localStorage.setItem('redlog-locale', 'en')) + await openTestProject(page, 'toolbar-overflow') + await openView(page, 'timeline') + }) + test.afterAll(async () => { await app?.close() }) + + test('the rare view controls are not in the flat row', async () => { + // The timezone select and auditor chip should NOT be visible until the + // overflow is opened — that is the whole point of moving them. + await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0) + await expect(page.locator('[data-testid="timeline-more-menu"]')).toBeVisible() + }) + + test('the overflow reveals them and they still work', async () => { + await page.locator('[data-testid="timeline-more-menu"]').click() + const tz = page.locator('[data-testid="timeline-tz-select"]') + await expect(tz).toBeVisible() + // Toggling the timezone through the menu persists to localStorage. + await tz.selectOption('utc') + const stored = await page.evaluate(() => localStorage.getItem('redlog-timeline-tz')) + expect(stored).toContain('utc') + }) + + test('the menu closes on outside click', async () => { + await page.mouse.click(5, 5) + await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0) + }) +}) diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 1d6c4c3b..e3f64ba3 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -41,6 +41,28 @@ const BODY_GONE = '\u0000__redlog_body_gone__' const LANES = ['shell', 'agent', 'http_navigation', 'scanner', 'browser', 'dns', 'pivot', 'screenshot', 'clipboard', 'file_transfer', 'credential_use', 'c2_checkin', 'marker', 'loot', 'cleanup', 'scope', 'process', 'system'] as const type LaneId = (typeof LANES)[number] +// Capture-group bands (docs/DESIGN-core-and-capture.md §6). Eighteen lanes is +// past reliable scanning; most engagements touch three or four. The bands are +// the same grouping the capture-readiness model uses (commands / traffic / +// artifacts) plus a signals band for the derived/alert lanes — so the timeline +// and the readiness card describe capture the same way. A band collapses to a +// single row whose dots keep their per-lane colour (so what happened is still +// legible), and expands to its member lanes. Every lane belongs to exactly one +// band; the order here is the row order. +type BandId = 'commands' | 'traffic' | 'artifacts' | 'signals' +const BANDS: ReadonlyArray<{ id: BandId; lanes: readonly LaneId[] }> = [ + { id: 'commands', lanes: ['shell', 'agent', 'process'] }, + { id: 'traffic', lanes: ['http_navigation', 'scanner', 'browser', 'dns', 'pivot', 'c2_checkin'] }, + { id: 'artifacts', lanes: ['screenshot', 'clipboard', 'file_transfer', 'loot'] }, + { id: 'signals', lanes: ['marker', 'scope', 'credential_use', 'cleanup', 'system'] } +] +const BAND_OF: Record = Object.fromEntries( + BANDS.flatMap((b) => b.lanes.map((l) => [l, b.id])) +) as Record +// Lanes in band order — replaces the raw LANES order for row layout so a +// band's members are contiguous. +const LANES_BY_BAND: readonly LaneId[] = BANDS.flatMap((b) => b.lanes) + // Lanes with no built-in producer — populated only by external agents // (custom MCP tools, third-party plugins) posting to /api/events. Showing // them as plain "empty" is misleading; the chip tooltip says so explicitly. @@ -765,6 +787,14 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // operator who solo'd "shell" doesn't lose that filter after a reload. // Stored as a JSON array of lane ids; unknown ids (renamed lanes from an // old release) are dropped silently. + // Which capture-group bands are collapsed to a single aggregate row (§6). + // Default: all collapsed, so the timeline opens as three or four bands + // instead of up to eighteen lanes. The operator expands the one they are + // working in. Persisted per project. + // Default all bands collapsed; the per-project stored set (if any) is loaded + // when the project id resolves, in the effect below. + const [collapsedBands, setCollapsedBands] = useState>(() => new Set(BANDS.map((b) => b.id))) + const bandsLoadedFor = useRef(null) const [hiddenLanes, setHiddenLanes] = useState>(() => { try { const raw = localStorage.getItem('redlog-timeline-hidden-lanes') @@ -826,6 +856,21 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // typing, focus toggle — doesn't get clobbered when project.active() // arrives seconds later. Migration only runs once per (project, mount). const [projectIdForKeys, setProjectIdForKeys] = useState(null) + // Project-scoped band-collapse load (placed AFTER projectIdForKeys is + // declared — its dep array evaluates during render, so an earlier placement + // is a temporal-dead-zone crash the e2e caught). + useEffect(() => { + if (!projectIdForKeys) return + if (bandsLoadedFor.current === projectIdForKeys) return + bandsLoadedFor.current = projectIdForKeys + try { + const raw = localStorage.getItem(`redlog-timeline-collapsed-bands:${projectIdForKeys}`) + if (raw === null) { setCollapsedBands(new Set(BANDS.map((b) => b.id))); return } + const arr = JSON.parse(raw) + setCollapsedBands(new Set((Array.isArray(arr) ? arr : []).filter((b): b is BandId => BANDS.some((x) => x.id === b)))) + } catch { setCollapsedBands(new Set(BANDS.map((b) => b.id))) } + }, [projectIdForKeys]) + const migrationAppliedFor = useRef(null) const [anomalyFilter, setAnomalyFilter] = useState(() => { try { return localStorage.getItem('redlog-timeline-anomaly-filter') === '1' } catch { return false } @@ -1049,6 +1094,10 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // doesn't crash the panel — the dropdown just stays disabled. const [savedViews, setSavedViews] = useState(null) const [viewsOpen, setViewsOpen] = useState(false) + // Overflow for the low-frequency view/audit controls (session dividers, + // timezone, auditor view) so the toolbar row groups by effect instead of + // listing eight flat toggles (DESIGN-core-and-capture.md §6). + const [moreOpen, setMoreOpen] = useState(false) const [viewsName, setViewsName] = useState('') // v0.6.96 Clean-3: `views` is now non-optional in env.d.ts (preload always // exports it). The old cast is gone; direct access is type-safe. @@ -1199,17 +1248,40 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // `recentEvents` below. }, [events, pluginTypes]) + // The lane an event's row is keyed on: its band's id when that band is + // collapsed, otherwise the lane itself. + const rowKeyOf = useCallback( + (lane: LaneId): string => (collapsedBands.has(BAND_OF[lane]) ? BAND_OF[lane] : lane), + [collapsedBands] + ) + + // The rows actually rendered, in band order. A band with any populated lane + // contributes either one aggregate row (collapsed) or its populated, + // non-hidden lanes (expanded). Empty bands contribute nothing, exactly as + // empty lanes did. + const visibleRows = useMemo(() => { + const rows: string[] = [] + for (const band of BANDS) { + const popLanes = band.lanes.filter((l) => populatedLanes.has(l)) + if (popLanes.length === 0) continue + if (collapsedBands.has(band.id)) rows.push(band.id) + else for (const l of popLanes) if (!hiddenLanes.has(l)) rows.push(l) + } + return rows + }, [populatedLanes, hiddenLanes, collapsedBands]) + + // Still keyed on individual lanes; retained for the zoom-ceiling scan below. const visibleLanes = useMemo( () => LANES.filter((l) => populatedLanes.has(l) && !hiddenLanes.has(l)), [populatedLanes, hiddenLanes] ) const laneH = useMemo(() => { - if (visibleLanes.length === 0) return MIN_LANE_H + if (visibleRows.length === 0) return MIN_LANE_H const axisH = 28 const available = containerH - axisH - return Math.max(MIN_LANE_H, Math.floor(available / visibleLanes.length)) - }, [containerH, visibleLanes.length]) + return Math.max(MIN_LANE_H, Math.floor(available / visibleRows.length)) + }, [containerH, visibleRows.length]) const loadMore = useCallback(() => { if (loading || allLoaded) return @@ -1479,7 +1551,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr }, [fromX, view.left, view.width, TRACK_W]) useContributeExport({ label: t('export.slice'), run: exportSlice }) - const totalH = visibleLanes.length * laneH + const totalH = visibleRows.length * laneH const laneEvents = useMemo(() => { const map = Object.fromEntries(LANES.map((l) => [l, [] as RedLogEvent[]])) as Record @@ -1487,6 +1559,21 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr return map }, [events, pluginTypes]) + // Events grouped by the row they render in — a collapsed band's row holds + // every event from its lanes. Only rows that are actually visible get a + // bucket, so a hidden lane's events fall out here. + const rowEvents = useMemo(() => { + const map: Record = {} + for (const r of visibleRows) map[r] = [] + for (const e of events) { + const lane = toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes) + const key = collapsedBands.has(BAND_OF[lane]) ? BAND_OF[lane] : lane + const bucket = map[key] + if (bucket) bucket.push(e) + } + return map + }, [events, visibleRows, collapsedBands, pluginTypes]) + // Debounced so a held key or a fast typist does not run the scan per // character. 120 ms sits below the point where the filter feels laggy and // above a burst of keystrokes. @@ -1639,8 +1726,9 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr if (!projectIdForKeys) return try { localStorage.setItem(`redlog-timeline-hidden-lanes:${projectIdForKeys}`, JSON.stringify([...hiddenLanes])) + localStorage.setItem(`redlog-timeline-collapsed-bands:${projectIdForKeys}`, JSON.stringify([...collapsedBands])) } catch { /* ignore */ } - }, [hiddenLanes, projectIdForKeys]) + }, [hiddenLanes, collapsedBands, projectIdForKeys]) useEffect(() => { try { if (selectedEvent?.id) localStorage.setItem('redlog-timeline-focus-event', selectedEvent.id) @@ -1717,15 +1805,16 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr useEffect(() => { maxZoomRef.current = maxZoom }, [maxZoom]) const clusters = useMemo(() => { const out: Array<{ key: string; lane: LaneId; li: number; x: number; y: number; events: RedLogEvent[] }> = [] - visibleLanes.forEach((lane, li) => { - const evs = laneEvents[lane] - if (!evs.length) return + visibleRows.forEach((rowKey, li) => { + const evs = rowEvents[rowKey] + if (!evs || !evs.length) return let bucket: RedLogEvent[] = [] let curBi = NaN const flush = (): void => { if (!bucket.length) return const x = bucket.reduce((a, e) => a + toX(displayTs(e)), 0) / bucket.length - out.push({ key: `${lane}-${bucket[0].id}`, lane, li, x, y: li * laneH + laneH / 2, events: bucket }) + const colorLane = toLane(bucket[0].agentType, bucket[0].data?.subtype as string | undefined, pluginTypes) + out.push({ key: `${rowKey}-${bucket[0].id}`, lane: colorLane, li, x, y: li * laneH + laneH / 2, events: bucket }) bucket = [] } for (const e of evs) { @@ -1737,7 +1826,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr flush() }) return out - }, [visibleLanes, laneEvents, toX, laneH]) + }, [visibleRows, rowEvents, toX, laneH, pluginTypes]) // v0.6.91 S3: derive session-band segments from `shell.session_end` + // `system.recording_paused`/`recording_resumed` pairs. session_start is @@ -2107,6 +2196,14 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr // the operator on empty track. }, [TRACK_W, timeMap, updateView, timeStart, timeEnd, loading]) + const toggleBand = useCallback((band: BandId) => { + setCollapsedBands((prev) => { + const next = new Set(prev) + if (next.has(band)) next.delete(band); else next.add(band) + return next + }) + }, []) + const toggleLane = useCallback((lane: LaneId) => { setHiddenLanes((prev) => { const next = new Set(prev) @@ -2195,8 +2292,8 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr events, hiddenLanes, pluginTypes, - laneOrder: LANES as readonly LaneId[], - laneOf: (ev) => toLane(ev.agentType, ev.data?.subtype as string | undefined, pluginTypes), + laneOrder: visibleRows, + laneOf: (ev) => rowKeyOf(toLane(ev.agentType, ev.data?.subtype as string | undefined, pluginTypes)), tsOf: displayTs }) if (next) setSelectedEvent(next) @@ -2799,31 +2896,6 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr )} - {/* v0.6.91 S3: session boundaries visibility toggle. */} - - - {/* v0.6.91 S7: timezone selector. Wraps a setTz(e.target.value as TzMode)} - title={t('timeline.tz.tooltip')} - className="shrink-0 whitespace-nowrap text-xs px-1 py-0.5 rounded font-mono bg-redlog-elevated/60 text-redlog-text border border-redlog-border hover:bg-redlog-elevated focus:outline-none focus-visible:ring-1 focus-visible:ring-redlog-text-dim" - > - - - - - {/* v0.6.89.5 feature 4: anomaly filter — dims every event without an integrity badge (clock anomaly / recovery / evidence removal / anchor failure / chain break). First chip so it's the fastest @@ -2848,30 +2920,62 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr > {t('timeline.anomalies.chip', { count: anomalyCount })} - {/* v0.14 §9.2 auditor-view chip: hides logged-tier rows so the - chained (audit) chain is what the reviewer sees. Uses the - chain glyph `⛓` to visually tie it to the per-row tier badge - shipped in v0.14.0 (§9.1). Disabled at opacity 0.25 when - there are no logged rows to hide (matches the anomaly chip - pattern). */} - + {/* Overflow: the low-frequency view/audit controls, grouped off the + flat row (§6). Session dividers, timezone and the auditor view are + set once and rarely touched, so they live behind one control + rather than each taking a slot the operator scans past. */} +
+ + {moreOpen && ( + <> +
setMoreOpen(false)} /> +
+
{t('timeline.more.viewGroup')}
+ + +
+ {t('timeline.tz.tooltip')} + +
+
+ + )} +
{/* v0.6.87 C2: export the currently-visible time window as JSON. The window is derived from the minimap view (left..left+width in percent) mapped back to (timeStart..timeEnd). Bug-bounty writeups @@ -2955,16 +3059,49 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr {/* Lane labels */}
- {visibleLanes.map((id) => ( -
- - {laneLabels[id]} -
- ))} + {visibleRows.map((rowKey) => { + const band = BANDS.find((b) => b.id === rowKey) + if (band) { + // A collapsed band row: caret + name + how many of its lanes + // have events, clickable to expand. Dots for its events render + // in the track at this row, each keeping its lane colour. + const popCount = band.lanes.filter((l) => populatedLanes.has(l)).length + return ( + + ) + } + const id = rowKey as LaneId + // An expanded lane row: indented under its band, with a caret on + // the band-owning first lane so the operator can collapse back. + return ( +
+ + {laneLabels[id]} + +
+ ) + })}
{/* Scrollable track */} @@ -3037,7 +3174,7 @@ export default function TimelinePanel({ focusEventId, focusTs, focusTarget, onDr {/* Lanes area */}
- {visibleLanes.map((_, i) => ( + {visibleRows.map((_, i) => (