diff --git a/console/README.md b/console/README.md index 201ef865..09a459ee 100644 --- a/console/README.md +++ b/console/README.md @@ -31,16 +31,30 @@ them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. -The available pages are Inbox (`1`), History (`5`), Logs (`6`) and Security (`7`). Links +The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), History (`5`), Logs (`6`) and Security (`7`). Links keep the page in `?page=`. History includes every ended Command, including old logs, and filters by outcome and repository. Security shows retained findings and Session logs. Older records without finding metadata still show their logs. +Lanes shows each Command and its child Runs on a shared time axis, over the last +3.5, 12 or 24 hours. The window is kept in `hours=`; bars clip to that window, +while their text retains the whole stage's time. Older Commands are on History. +Stages keeps live Runs first across repositories, then finished Runs newest first. +Tables continue when the repository changes or three columns are filled, so +each stage's Usual range belongs to that table's repository. On phones each +stage stacks its labelled cells. Historical Runs without stage metadata show +an unknown time, distinct from a stage that was not reached. Choose +a column's Run link to open its exact peek. Repeated stages retain every visit; +missing end timestamps say “time unknown” rather than inventing a duration. +Both pages use the observations from `status --all --json`. The Rust parity +test checks their stage times and Usual ranges against the CLI; the Playwright +time tests check scale in all windows, child selection, live ordering and 390 px. + `j` / `k` select headings and visible rows, wrapping at either end. `Space` folds a heading or toggles a row's peek; `Enter` opens that row's full Command log. `z` folds the current section. `Escape` closes help, jumps or the peek, one layer at a time, then clears selection. `?` opens the key list. `g` opens -the jump list: `i` Inbox, `h` History, `o` Logs, `s` Security, or a section's displayed +the jump list: `i` Inbox, `a` Lanes, `t` Stages, `h` History, `o` Logs, `s` Security, or a section's displayed letter. Text fields keep their normal keys. The peek starts closed and sits beside the list on a wide screen or at the bottom of a phone. diff --git a/console/reproductions/time-grid.py b/console/reproductions/time-grid.py new file mode 100644 index 00000000..7ca64e0f --- /dev/null +++ b/console/reproductions/time-grid.py @@ -0,0 +1,64 @@ +import datetime as dt +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import urllib.request +from html.parser import HTMLParser + +class Tables(HTMLParser): + def __init__(self): + super().__init__(); self.tables=[]; self.table=None; self.in_cell=False; self.text='' + def handle_starttag(self, tag, attrs): + if tag=='table': self.table=[] + if tag=='tr' and self.table is not None: self.row=[] + if tag in ('th','td') and self.table is not None: self.in_cell=True; self.text='' + def handle_data(self, data): + if self.in_cell: self.text+=data + def handle_endtag(self, tag): + if tag in ('th','td') and self.in_cell: self.row.append(self.text); self.in_cell=False + if tag=='tr' and self.table is not None: self.table.append(self.row) + if tag=='table': self.tables.append(self.table); self.table=None + +binary=str(Path(sys.argv[1] if len(sys.argv)>1 else 'target/debug/thirdshift').resolve()) +case=sys.argv[2] if len(sys.argv)>2 else 'ordering' +with tempfile.TemporaryDirectory(prefix='issue-826-review-') as home: + root=Path(home)/'.thirdshift'; root.mkdir(); (root/'config.toml').write_text('[console]\nport = 0\n') + now=dt.datetime.now(dt.timezone.utc) + stamp=lambda mins:(now-dt.timedelta(minutes=mins)).isoformat() + fields=Path('/proc/self/stat').read_text().rsplit(') ',1)[1].split() + identity=Path('/proc/sys/kernel/random/boot_id').read_text().strip()+':'+fields[19] + def record(repository, number, live): + folder=root/'logs'/repository/'commands/issue'; folder.mkdir(parents=True,exist_ok=True) + value=dict(version=1,kind='Run',name='run-'+str(number),repository=repository,hostname='fixture', + issue=dict(number=number,url=f'https://github.com/{repository}/issues/{number}',title='fixture'), + process_id=os.getpid() if live else 0,process_start=identity if live else 'gone',harness='codex', + started_at=stamp(30),ended_at=None if live else stamp(10),outcome=None if live else 'merged',session_log=None, + stages=[dict(name='implement',began_at=stamp(30),session_log=None)]) + (folder/f'{number}-fixture.command.json').write_text(json.dumps(value)) + record('acme/a',1,False); record('zz/z',2,True) + old=root/'logs/acme/a/activity.log' + old.write_text('2026-10-01 12:00:00 Run #7 started: commands/issue/7-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #7 ended: merged\n') + env=dict(os.environ,HOME=home) + status=json.loads(subprocess.check_output([binary,'status','--all','--json'],env=env)) + server=subprocess.Popen([binary,'console','--no-sign-in'],env=env,stdout=subprocess.PIPE,text=True) + try: + origin=server.stdout.readline().strip() + html=urllib.request.urlopen(origin+'/stages',timeout=5).read().decode() + parser=Tables(); parser.feed(html) + if case=='ordering': + expected=[f'{c["repository"]}#{c["issue"]["number"]}' for c in status['commands'] if c['state']=='live'] + headers=[cell for table in parser.tables for cell in table[0][1:]] + assert headers[0].startswith(expected[0]),f'Live Runs should be pinned first; got columns {headers}; live={expected}' + elif case=='history': + history=next(c for c in status['commands'] if c['repository']=='acme/a' and c['issue']['number']==7) + assert history['stages']==[] and history['source']=='activity_log' + table=next(t for t in parser.tables if any('acme/a#7' in h for h in t[0])) + col=next(i for i,h in enumerate(table[0]) if 'acme/a#7' in h) + cell=table[1][col] + assert 'Not reached' not in cell,f'No stage metadata was retained for the merged historical Run; grid invents a state: {cell}' + else: raise ValueError(case) + finally: + server.terminate(); server.wait(timeout=5) diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 9d764b48..f4fb6fa2 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -200,7 +200,7 @@ test("every keyboard action preserves list nodes and Escape closes one layer at test("page keys, jump letters and URLs survive reload and the peek is a phone bottom sheet", async t => { const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); await page.goto(origin); - for (const [key, name] of [['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { + for (const [key, name] of [['2', 'lanes'], ['3', 'stages'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { await page.keyboard.press(key); await page.waitForURL(`**/?page=${name}`); await page.reload(); @@ -208,12 +208,12 @@ test("page keys, jump letters and URLs survive reload and the peek is a phone bo assert.equal(await page.locator('#peek').isVisible(), false); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); } - for (const key of ['2', '3', '4']) { + for (const key of ['4']) { await page.keyboard.press(key); assert.equal(new URL(page.url()).searchParams.get('page'), 'inbox'); assert.equal(await page.locator(`[data-key="${key}"]`).count(), 0); } - for (const [letter, name] of [['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { + for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { await page.keyboard.press('g'); await page.keyboard.press(letter); await page.waitForURL(`**/?page=${name}`); diff --git a/console/tests/time.test.cjs b/console/tests/time.test.cjs new file mode 100644 index 00000000..b41ec6a8 --- /dev/null +++ b/console/tests/time.test.cjs @@ -0,0 +1,125 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); + +let home, server, exited, browser, origin; +const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-time-')); + const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + await mkdir(folder, { recursive: true }); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + let processStart = 'gone'; + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/); + processStart = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${fields[19]}`; + } + const now = Date.now(); + const time = minutes => new Date(now - minutes * 60000).toISOString(); + for (let number = 1; number <= 7; number++) { + const live = number === 1; + const record = { + version: 1, kind: 'Run', name: `run-${number}`, repository: 'acme/widgets', hostname: 'fixture', + issue: { number, url: `https://github.com/acme/widgets/issues/${number}`, title: `Run ${number} ` }, + process_id: live ? process.pid : 0, process_start: live ? processStart : 'gone', harness: 'codex', + started_at: time(number === 7 ? 1500 : 180 - number * 10), + ended_at: live ? null : time(number === 7 ? 1480 : 150 - number * 10), + outcome: live ? null : 'merged', session_log: null, + stages: [ + { name: 'implement', began_at: time(number === 7 ? 1500 : 180 - number * 10), session_log: null }, + { name: 'CI watch', began_at: time(number === 7 ? 1490 : 170 - number * 10), session_log: null } + ], + ...(number === 6 ? { parent_record: join(folder, '2-fixture.command.json') } : {}) + }; + await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify(record)); + await writeFile(join(folder, `${number}-fixture.log`), `Command ${number}\n`); + } + // A finished Run in an alphabetically earlier repository must not precede + // this repository's live Run, or reorder the finished columns by repository. + const otherFolder = join(home, '.thirdshift/logs/aaa/first/commands/issue'); + await mkdir(otherFolder, { recursive: true }); + const other = JSON.parse(await readFile(join(folder, '3-fixture.command.json'), 'utf8')); + other.repository = 'aaa/first'; + other.issue.number = 8; + other.issue.url = 'https://github.com/aaa/first/issues/8'; + await writeFile(join(otherFolder, '8-fixture.command.json'), JSON.stringify(other)); + await writeFile(join(home, '.thirdshift/logs/acme/widgets/activity.log'), + '2026-10-01 12:00:00 Run #9 started: commands/issue/9-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #9 ended: merged\n'); + server = spawn(binary, ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test('Lanes scales every window, nests child Runs and fits 390 px without CSP violations', async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const hours of [3.5, 12, 24]) { + await page.goto(`${origin}/?page=lanes&hours=${hours}`); + assert.equal(await page.locator('[name="hours"]').inputValue(), String(hours)); + assert.equal(await page.locator('.lane').count(), 6); + assert.equal(await page.locator('.lane').filter({ hasText: 'acme/widgets#2' }).locator('.time-run').count(), 2); + const complete = page.locator('.time-run').filter({ hasText: 'acme/widgets#3' }); + const track = await complete.locator('.timeline').boundingBox(); + const bars = await complete.locator('.timeline rect').all(); + const first = await bars[0].boundingBox(); + const second = await bars[1].boundingBox(); + // Painted bounds include the one-pixel stroke around each scaled interval. + assert.ok(Math.abs(first.width - track.width * 10 / (hours * 60)) <= 1.01, JSON.stringify({ hours, track, first, second })); + assert.ok(Math.abs(second.width - track.width * 20 / (hours * 60)) <= 1.01); + // #3 starts 150 minutes ago; its first bar is placed on the same time axis. + assert.ok(Math.abs((first.x - track.x) / track.width - (1 - 150 / (hours * 60))) < 0.002); + assert.match(await complete.textContent(), /implement: Complete · 10 min/); + assert.match(await complete.textContent(), /CI watch: Complete · 20 min/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + await page.locator('[name="hours"]').selectOption('12'); + await page.getByRole('button', { name: 'Show', exact: true }).click(); + await page.waitForURL('**hours=12'); + assert.equal(await page.locator('.time-run script').count(), 0); + assert.deepEqual(errors, []); +}); + +test('Stages pins live Runs, matches CLI order and opens the selected child Run at 390 px', { skip: process.platform !== 'linux' }, async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(`${origin}/?page=stages`); + const status = JSON.parse(execFileSync(binary, ['status', '--all', '--json'], { env: { ...process.env, HOME: home } })); + const runs = status.commands.flatMap(command => [command, ...(command.children ?? [])]); + runs.sort((a, b) => (a.state !== 'live') - (b.state !== 'live') || Date.parse(b.ended_at ?? b.started_at) - Date.parse(a.ended_at ?? a.started_at)); + assert.deepEqual(await page.locator('thead .peek-link').allTextContents(), runs.map(run => `${run.repository}#${run.issue.number}`)); + assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::run-1`); + assert.match(await page.locator('.stage-grid').first().textContent(), /Running ·/); + assert.match(await page.locator('.stage-grid').first().textContent(), /Usual range: 10–10 min/); + const historical = page.locator('.stage-grid').filter({ has: page.locator('thead .peek-link', { hasText: 'acme/widgets#9' }) }); + const headers = await historical.locator('thead .peek-link').allTextContents(); + const historicalCell = historical.locator('tbody tr').first().locator('td').nth(headers.indexOf('acme/widgets#9')); + assert.match(await historicalCell.textContent(), /Unknown · no stage times retained/); + assert.doesNotMatch(await historicalCell.textContent(), /Not reached/); + const child = page.locator('thead .peek-link').filter({ hasText: 'acme/widgets#6' }); + await child.click(); + await page.locator('#peek-content h2').waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /run-6/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#peek').isVisible(), false); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 1f496f7e..63632144 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -22,6 +22,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` | | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | +| Console Lanes and Stages, stage times | `src/logs/status/time.rs` — time views from complete status observations; `src/console/time.rs`, `src/console/time.html` — page rendering | `tests/console.rs`, `console/tests/time.test.cjs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | diff --git a/src/console.rs b/src/console.rs index bbb3cb89..2bcfc746 100644 --- a/src/console.rs +++ b/src/console.rs @@ -3,6 +3,7 @@ mod conversation; mod logs; mod peek; mod restart; +mod time; use std::io::Write; use std::net::Ipv4Addr; @@ -29,6 +30,14 @@ struct PageRoute { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/lanes", + cli: "thirdshift status --all --json", + }, + PageRoute { + path: "/stages", + cli: "thirdshift status --all --json", + }, PageRoute { path: "/", cli: "thirdshift status", @@ -168,6 +177,9 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) if matches!(page.as_str(), "logs" | "6") { return logs::index().await; } + if matches!(page.as_str(), "lanes" | "2" | "stages" | "3") { + return time::show(matches!(page.as_str(), "stages" | "3"), query).await; + } let archive_page = match page.as_str() { "history" | "5" => Some(status::ArchivePage::History), "security" | "7" => Some(status::ArchivePage::Security), diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9a30b127..9ecccb29 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -83,6 +83,26 @@ footer { margin-top: 16px; } #log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } #log-content h3 { font-size: 14px; } .diff { border-left: 3px solid var(--look); padding-left: 10px; } +.lane { border-bottom: 1px solid var(--rule); padding: 10px 0; } +.time-run h3 { font-size: 14px; margin: 4px 0; color: var(--text); } +.lane .time-run + .time-run { margin-left: 12px; border-left: 1px solid var(--rule); } +.timeline { display: block; background: var(--panel-2); margin: 8px 0; } +.timeline rect { fill: var(--panel); stroke: var(--working); } +.timeline text { fill: var(--text); font: 12px var(--font-mono); } +.stage-times { padding-left: 24px; } +.stage-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 20px 0; } +.stage-grid caption { text-align: left; font-weight: var(--head-weight); } +.stage-grid th, .stage-grid td { border: 1px solid var(--rule); padding: 8px; text-align: left; vertical-align: top; overflow-wrap: anywhere; } +.stage-grid th { font-weight: var(--head-weight); } +.usual { font-size: 12px; font-weight: normal; color: var(--dim); } +.cell-run { display: none; } +@media (max-width: 760px) { + .stage-grid, .stage-grid thead, .stage-grid tbody, .stage-grid tr, .stage-grid th, .stage-grid td { display: block; } + .stage-grid thead tr { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stage-grid thead th:first-child { grid-column: 1 / -1; } + .stage-grid tbody th { background: var(--panel-2); } + .cell-run { display: inline; font-weight: var(--head-weight); } +} @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/nav.html b/src/console/nav.html index adda64ac..ab2ebd48 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/time.html b/src/console/time.html new file mode 100644 index 00000000..7d8d803a --- /dev/null +++ b/src/console/time.html @@ -0,0 +1,55 @@ + + + + + + {{ title }} · {{ hostname }} · thirdshift + + + + + +

thirdshift / Console

{{ hostname }}

+ {% if !consoles.is_empty() %}{% endif %} +
+ {% include "console/nav.html" %} +
+

{{ title }}

thirdshift status --all --json
+ {% if title == "Lanes" %} +
+ + +
+

Stages drawn to scale from {{ views.start }} to {{ views.end }}. Times below each lane include the whole stage.

+

Older Commands are on History.

+ {% if views.lanes.is_empty() %}

No Commands in this window.

{% endif %} + {% for lane in views.lanes %} +
+ {% for run in lane %} +
+

{{ run.subject }} · {{ run.state }}

{{ run.title }}

+ + {% if run.stages.is_empty() %}

No stage times retained.

{% endif %} +
    {% for stage in run.stages %}
  1. {{ stage.name }}: {{ stage.text }}
  2. {% endfor %}
+
+ {% endfor %} +
+ {% endfor %} + {% else %} +

Live Runs first, then finished Runs newest first. Tables continue in that order, with each repository's Usual range. Repeated stages list every visit.

+ {% if views.grids.is_empty() %}

No Runs yet.

{% endif %} + {% for grid in views.grids %} + + + {% for run in grid.runs %}{% endfor %} + {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %} +
{{ grid.repository }}
Stage
Usual range
{{ run.subject }}
{{ run.state }}
{{ run.title }}
{{ row.name }}
Usual range: {{ row.usual }}
{{ grid.runs[loop.index0].subject }}: {{ cell }}
+ {% if grid.rows.is_empty() %}

No stage times retained.

{% endif %} + {% endfor %} + {% endif %} +
+ {% include "console/chrome.html" %} + + diff --git a/src/console/time.rs b/src/console/time.rs new file mode 100644 index 00000000..18addd7a --- /dev/null +++ b/src/console/time.rs @@ -0,0 +1,41 @@ +//! Lanes and Stages share the status reader's complete time observations. +use anyhow::Result; +use askama::Template; +use axum::response::Response; + +use crate::config::UserConfig; +use crate::logs::status::time::{self, Views}; + +#[derive(Template)] +#[template(path = "console/time.html")] +struct TimePage { + hostname: String, + consoles: Vec, + theme: String, + title: &'static str, + hours: String, + views: Views, +} + +pub(super) async fn show(stages: bool, query: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let hours = match super::query_value(&query, "hours").as_str() { + "12" => 12.0, + "24" => 24.0, + _ => 3.5, + }; + Ok(TimePage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + title: if stages { "Stages" } else { "Lanes" }, + hours: hours.to_string(), + views: time::load(&config, hours)?, + } + .render()?) + }) + .await, + ) +} diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index 45dea564..62225a7a 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -105,6 +105,21 @@ pub(super) struct Stage { pub session_log: Option, } +/// A retained stage interval, optionally extended to an observation of live work. +pub(super) struct StageTime<'a> { + pub stage: &'a Stage, + pub began: Option>, + pub ended: Option>, +} + +impl StageTime<'_> { + pub fn seconds(&self) -> Option { + let (began, ended) = self.began.zip(self.ended)?; + let milliseconds = ended.signed_duration_since(began).num_milliseconds(); + (milliseconds >= 0).then_some(milliseconds as f64 / 1000.0) + } +} + /// Internal transitions are typed; persisted names stay tolerant of new stages. #[derive(Clone, Copy)] pub enum StageName { @@ -167,6 +182,30 @@ impl StageName { } impl CommandRecord { + /// The next transition closes a stage; only a recorded outcome closes the + /// final stage. A live caller can supply its single sampled observation time. + pub fn stage_times( + &self, + live_until: Option>, + ) -> impl Iterator> { + self.stages.iter().enumerate().map(move |(index, stage)| { + let ended = if let Some(next) = self.stages.get(index + 1) { + DateTime::parse_from_rfc3339(&next.began_at).ok() + } else if self.outcome.is_some() { + self.ended_at + .as_deref() + .and_then(|end| DateTime::parse_from_rfc3339(end).ok()) + } else { + live_until + }; + StageTime { + stage, + began: DateTime::parse_from_rfc3339(&stage.began_at).ok(), + ended, + } + }) + } + /// Exact retained Session references, including earlier stages. Callers /// decide whether they need a deduplicated inventory. pub fn session_references(&self) -> impl Iterator { diff --git a/src/logs/reading/health.rs b/src/logs/reading/health.rs index 62753fa0..6625a5b4 100644 --- a/src/logs/reading/health.rs +++ b/src/logs/reading/health.rs @@ -81,26 +81,16 @@ impl UsualRanges { } fn collect_times(record: &CommandRecord, samples: &mut BTreeMap<(String, String), Vec>) { - for (index, stage) in record.stages.iter().enumerate() { + for timing in record.stage_times(None) { + let stage = timing.stage; if !StageName::known(&stage.name) { continue; } - let end = record - .stages - .get(index + 1) - .map(|stage| stage.began_at.as_str()) - .or_else(|| record.outcome.as_ref().and(record.ended_at.as_deref())); - if let Some((began, ended)) = DateTime::parse_from_rfc3339(&stage.began_at) - .ok() - .zip(end.and_then(|end| DateTime::parse_from_rfc3339(end).ok())) - { - let seconds = ended.signed_duration_since(began).num_milliseconds() as f64 / 1000.0; - if seconds >= 0.0 { - samples - .entry((record.repository.clone(), stage.name.clone())) - .or_default() - .push(seconds); - } + if let Some(seconds) = timing.seconds() { + samples + .entry((record.repository.clone(), stage.name.clone())) + .or_default() + .push(seconds); } } } diff --git a/src/logs/status.rs b/src/logs/status.rs index d3c2c59e..41a1704c 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -2,6 +2,7 @@ mod inbox; pub mod inspect; +pub(crate) mod time; pub(crate) use inbox::{Archive, ArchivePage, archive}; pub(crate) use inbox::{Inbox, Row, inbox}; diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs new file mode 100644 index 00000000..297bbb5d --- /dev/null +++ b/src/logs/status/time.rs @@ -0,0 +1,269 @@ +//! Time views over the same complete observations as status --all --json. +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use chrono::{DateTime, Local}; + +use crate::config::UserConfig; +use crate::logs::reading::{Command, Entry, Observation, Snapshot}; + +pub(crate) struct Views { + pub lanes: Vec>, + pub grids: Vec, + pub start: String, + pub end: String, +} + +#[derive(Clone)] +pub(crate) struct Run { + pub subject: String, + pub title: String, + pub state: String, + pub selector: String, + pub peek_url: String, + pub log_url: String, + pub stages: Vec, + repository: String, + started: Option, + ended: Option, + stage_metadata: bool, +} + +#[derive(Clone)] +pub(crate) struct Stage { + pub name: String, + pub text: String, + pub seconds: String, + pub usual: String, + pub left: f64, + pub width: f64, +} + +pub(crate) struct Grid { + pub repository: String, + pub runs: Vec, + pub rows: Vec, +} + +pub(crate) struct GridRow { + pub name: String, + pub usual: String, + pub cells: Vec, +} + +fn timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|time| time.timestamp()) +} + +fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { + let record = &command.record; + let selector = Observation::Record(command).selector().unwrap_or_default(); + let live_until = (command.state == "live").then_some(now.fixed_offset()); + let now = now.timestamp(); + let stages = record + .stage_times(live_until) + .enumerate() + .map(|(index, timing)| { + let stage = timing.stage; + let began = timing.began.map(|time| time.timestamp()); + let end = timing.ended.map(|time| time.timestamp()); + let seconds = timing.seconds(); + let state = if index + 1 < record.stages.len() { + "Complete" + } else if command.state == "live" { + "Running" + } else if command.state == "died" { + "Died" + } else if record.outcome.as_deref() == Some("merged") { + "Complete" + } else { + record.outcome.as_deref().unwrap_or("Unknown") + }; + let duration = seconds + .map(|seconds| super::age(seconds as i64)) + .unwrap_or_else(|| "time unknown".into()); + // Clip only the drawn interval; the text retains the full stage time. + let (left, width) = + began + .zip(end) + .filter(|_| seconds.is_some()) + .map_or((0.0, 0.0), |(start, end)| { + let start = start.clamp(cutoff, now); + let end = end.clamp(cutoff, now); + let scale = 100.0 / (now - cutoff) as f64; + ( + (start - cutoff) as f64 * scale, + (end - start).max(0) as f64 * scale, + ) + }); + Stage { + name: stage.name.clone(), + text: format!("{state} · {duration}"), + seconds: seconds + .map(|seconds| seconds.to_string()) + .unwrap_or_default(), + usual: command + .usual_ranges + .get(&stage.name) + .map(|range| range.text()) + .unwrap_or_else(|| "Not enough samples".into()), + left, + width, + } + }) + .collect(); + Run { + subject: record.issue.as_ref().map_or_else( + || format!("{} on {}", record.kind, record.repository), + |issue| format!("{}#{}", record.repository, issue.number), + ), + title: record + .issue + .as_ref() + .and_then(|issue| issue.title.clone()) + .unwrap_or_else(|| record.name.clone()), + state: if command.state == "ended" { + record.outcome.clone().unwrap_or_else(|| "ended".into()) + } else { + command.state.into() + }, + peek_url: super::inspect::url("/peek", &selector, None), + log_url: super::inspect::url("/log", &selector, None), + selector, + repository: record.repository.clone(), + stages, + started: timestamp(&record.started_at), + ended: record.ended_at.as_deref().and_then(timestamp), + stage_metadata: true, + } +} + +fn collect(command: &Command, now: DateTime, cutoff: i64, runs: &mut Vec) { + runs.push(run(command, now, cutoff)); + for child in command.dispatched.iter().chain(&command.children) { + collect(child, now, cutoff, runs); + } +} + +pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { + let now = Local::now(); + let cutoff = now - chrono::Duration::seconds((hours * 3600.0) as i64); + let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; + let mut lanes = Vec::new(); + let mut repositories: BTreeMap> = BTreeMap::new(); + for entry in snapshot.entries() { + let mut runs = Vec::new(); + match entry { + Entry::Record(command) => collect(command, now, cutoff.timestamp(), &mut runs), + Entry::History(command) => { + let selector = Observation::History(command).selector().unwrap_or_default(); + runs.push(Run { + subject: command.issue.as_ref().map_or_else( + || format!("{} on {}", command.kind, command.repository), + |issue| format!("{}#{}", command.repository, issue.number), + ), + title: String::new(), + state: command + .outcome + .clone() + .unwrap_or_else(|| command.state.into()), + peek_url: super::inspect::url("/peek", &selector, None), + log_url: super::inspect::url("/log", &selector, None), + selector, + repository: command.repository.clone(), + stages: Vec::new(), + started: timestamp(&command.started_at), + ended: command.ended_at.as_deref().and_then(timestamp), + stage_metadata: false, + }); + } + } + for run in &runs { + repositories + .entry(run.repository.clone()) + .or_default() + .push(run.clone()); + } + if runs.iter().any(|run| { + run.state == "live" + || run.started.is_some_and(|start| { + start <= now.timestamp() && run.ended.unwrap_or(start) > cutoff.timestamp() + }) + }) { + lanes.push(runs); + } + } + lanes.sort_by_key(|runs| std::cmp::Reverse(runs.first().and_then(|run| run.started))); + let mut ordered: Vec<_> = repositories.values().flatten().cloned().collect(); + ordered.sort_by_key(|run| { + ( + run.state != "live", + std::cmp::Reverse(run.ended.or(run.started)), + ) + }); + // Keep global ordering, continuing a repository's table whenever the next + // Run belongs elsewhere or three columns have filled the available width. + let mut batches: Vec> = Vec::new(); + for run in ordered { + if let Some(batch) = batches.last_mut() + && batch.len() < 3 + && batch[0].repository == run.repository + { + batch.push(run); + } else { + batches.push(vec![run]); + } + } + let mut grids = Vec::new(); + for batch in batches { + let repository = &batch[0].repository; + let runs = &repositories[repository]; + let names: BTreeSet<_> = runs + .iter() + .flat_map(|run| run.stages.iter().map(|stage| stage.name.clone())) + .collect(); + let rows = names + .iter() + .map(|name| GridRow { + name: name.clone(), + usual: runs + .iter() + .flat_map(|run| &run.stages) + .find(|stage| &stage.name == name) + .map(|stage| stage.usual.clone()) + .unwrap_or_default(), + cells: batch + .iter() + .map(|run| { + let times: Vec<_> = run + .stages + .iter() + .filter(|stage| &stage.name == name) + .map(|stage| stage.text.as_str()) + .collect(); + if !run.stage_metadata { + "Unknown · no stage times retained".into() + } else if times.is_empty() { + "Not reached · —".into() + } else { + times.join("; ") + } + }) + .collect(), + }) + .collect(); + grids.push(Grid { + repository: repository.clone(), + runs: batch, + rows, + }); + } + Ok(Views { + lanes, + grids, + start: cutoff.format("%Y-%m-%d %H:%M %Z").to_string(), + end: now.format("%Y-%m-%d %H:%M %Z").to_string(), + }) +} diff --git a/tests/console.rs b/tests/console.rs index 82b909d1..4e57e658 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -137,7 +137,67 @@ fn history_keeps_every_ended_command_in_time_order_and_filters() { .unwrap() .contains("acme/widgets#1") ); - assert_eq!(console.get("/?page=lanes").status(), 404); + assert_eq!(console.get("/?page=waterfall").status(), 404); +} + +#[test] +fn time_pages_pair_with_all_status_stages_and_repository_usual_ranges() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let now = chrono::Local::now(); + for number in 1..=4 { + let mut value = record(home.path(), number, Some("merged"), 0); + value["started_at"] = json!((now - chrono::Duration::hours(number as i64)).to_rfc3339()); + let began = value["started_at"].as_str().unwrap().to_owned(); + let middle = (chrono::DateTime::parse_from_rfc3339(&began).unwrap() + + chrono::Duration::minutes(10)) + .to_rfc3339(); + let end = (chrono::DateTime::parse_from_rfc3339(&middle).unwrap() + + chrono::Duration::minutes(20)) + .to_rfc3339(); + value["stages"] = json!([ + {"name": "implement", "began_at": began, "session_log": null}, + {"name": "CI watch", "began_at": middle, "session_log": null} + ]); + value["ended_at"] = json!(end); + write_record(home.path(), number, &value); + } + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let output = Command::new(binary) + .args(["status", "--all", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let status: Value = serde_json::from_slice(&output.stdout).unwrap(); + let stages = console.get("/stages").text().unwrap(); + assert!(stages.contains("

Stages

"), "{stages}"); + assert!(stages.contains("thirdshift status --all --json")); + for command in status["commands"].as_array().unwrap() { + let number = command["issue"]["number"].as_u64().unwrap(); + assert!(stages.contains(&format!("acme/widgets#{number}"))); + assert_eq!(command["usual_ranges"]["implement"]["lower_seconds"], 600.0); + assert_eq!(command["usual_ranges"]["CI watch"]["upper_seconds"], 1200.0); + } + assert!(stages.contains("Usual range")); + assert!(stages.contains("10–10 min")); + assert!(stages.contains("20–20 min")); + assert_eq!(stages.matches("Complete · 10 min").count(), 4); + assert_eq!(stages.matches("Complete · 20 min").count(), 4); + for (window, count) in [("3.5", 3), ("12", 4), ("24", 4)] { + let html = console + .get(&format!("/lanes?hours={window}")) + .text() + .unwrap(); + assert!(html.contains("

Lanes

"), "{html}"); + assert_eq!(html.matches("class=\"lane\"").count(), count); + assert_eq!(html.matches("data-seconds=\"600\"").count(), count); + assert_eq!(html.matches("data-seconds=\"1200\"").count(), count); + assert!(html.contains("History")); + } + assert_eq!(console.get("/?page=2").status(), 200); + assert_eq!(console.get("/?page=3").status(), 200); } #[test]