diff --git a/Cargo.lock b/Cargo.lock index 36097d92..4da9157f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1610,6 +1610,7 @@ dependencies = [ "signal-hook", "tempfile", "tokio", + "tokio-stream", "toml", "toml_edit", "tower-layer", @@ -1689,6 +1690,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index b3303421..2885993b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,8 @@ signal-hook = "0.4.4" tempfile = "3.27.0" toml = "1.1.6" toml_edit = "0.25.15" -tokio = { version = "1.53.1", features = ["rt", "net", "time"] } +tokio = { version = "1.53.1", features = ["rt", "net", "time", "sync"] } +tokio-stream = { version = "0.1.18", features = ["sync"] } [dev-dependencies] tower-layer = "0.3.3" diff --git a/console/README.md b/console/README.md index fe1b06b7..322c06af 100644 --- a/console/README.md +++ b/console/README.md @@ -27,9 +27,18 @@ theme = "focus-auto" # also focus-light or focus-dark ``` The header names this machine and links to those addresses, without fetching -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. +them. The Inbox and its `/events` stream pair with `thirdshift status`. One +server observer checks records and log lengths every second; the page morphs +after a change notice, preserving the selected row, folds and scroll. If the +stream drops or is unavailable, the page polls every five seconds until it +reconnects. The heartbeat only beats for observed log growth, stays flat while +Silent and respects reduced motion. The tab counts Needs you and its favicon +marks urgent items (Slow alone is not urgent). + +Folds and the last-look time stay in this browser. Commands started since the +previous visit are marked new, including those arriving while the page is open. +Missing or malformed storage hides nothing. Replacing the installed binary +closes open streams and re-executes the Console with its original arguments. The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), Waterfall (`4`), History (`5`), Logs (`6`) and Security (`7`). Links keep the page in `?page=`. History includes every ended Command, including old diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index eb619c1a..e023b032 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -2,7 +2,7 @@ const { test, before, after } = require("node:test"); const assert = require("node:assert/strict"); const { spawn } = require("node:child_process"); const { once } = require("node:events"); -const { mkdtemp, mkdir, writeFile, rm } = require("node:fs/promises"); +const { mkdtemp, mkdir, readFile, writeFile, appendFile, utimes, rm } = require("node:fs/promises"); const { tmpdir } = require("node:os"); const { join, resolve } = require("node:path"); const { createInterface } = require("node:readline"); @@ -124,6 +124,239 @@ test("explicit Focus light and dark override the system preference", async t => } finally { await writeFile(config, "[console]\nport = 0\n"); } }); +test("push morphs keep the selected row, folds and scroll without a reload", async t => { + const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#1$/ }) }); + await page.locator('[data-group="done"] > summary').click(); + await row.click(); + await page.evaluate(() => { + window.visitMarker = "same document"; + window.selectedRow = document.activeElement; + document.querySelector('.commands').scrollTop = 120; + }); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const fixture = JSON.parse(await readFile(path, 'utf8')); + fixture.issue.title = 'Advanced while you watched'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Advanced while you watched', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement === window.selectedRow && document.activeElement.matches('.command')), true); + assert.equal(await page.evaluate(() => window.visitMarker), "same document"); + assert.equal(await page.locator('[data-group="done"][open]').count(), 1); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 1); + assert.equal(await page.locator('[data-group="needs you"] .commands').evaluate(el => el.scrollTop), 120); + assert.deepEqual(errors, []); +}); + +test("keyboard selection and the open peek survive an Inbox push", async t => { + const page = await pageFor(t); + await page.goto(`${origin}/?page=inbox`); + await page.keyboard.press('j'); + await page.keyboard.press('j'); + const row = page.locator('.is-selected.command'); + const id = await row.getAttribute('id'); + const selector = await row.getAttribute('data-command'); + const path = selector.split('::')[0]; + const original = await readFile(path, 'utf8'); + try { + await page.keyboard.press('Space'); + await page.locator('#peek dt').first().waitFor(); + const fixture = JSON.parse(original); + fixture.issue.title = 'Keyboard-selected Command after push'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText(fixture.issue.title, { exact: true }).waitFor(); + assert.equal(await row.getAttribute('id'), id); + assert.equal(await row.getAttribute('aria-current'), 'true'); + assert.equal(await page.locator('#peek').isVisible(), true); + await page.keyboard.press('Space'); + assert.equal(await page.locator('#peek').isVisible(), false); + await page.keyboard.press('g'); + await page.keyboard.press('w'); + assert.equal(await page.locator('.is-selected').evaluate(el => el.parentElement.dataset.group), 'working'); + } finally { await writeFile(path, original); } +}); + +test("tab title and favicon follow Needs you through pushes", async t => { + const page = await pageFor(t); + await page.goto(origin); + assert.equal(await page.title(), '10 need you · thirdshift Console'); + assert.equal(await page.locator('link[rel="icon"]').getAttribute('href'), '/assets/favicon-urgent.svg'); + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const fixtures = []; + try { + for (let number = 1; number <= 10; number++) { + const path = join(dir, `${number}-fixture.command.json`); + const original = await readFile(path, 'utf8'); + fixtures.push([path, original]); + const fixture = JSON.parse(original); + fixture.outcome = 'merged'; + await writeFile(path, JSON.stringify(fixture)); + } + await page.waitForFunction(() => document.title === '0 need you · thirdshift Console'); + assert.equal(await page.locator('link[rel="icon"]').getAttribute('href'), '/assets/favicon.svg'); + } finally { for (const [path, original] of fixtures) await writeFile(path, original); } +}); + +test("new since the last visit survives browser storage and clears after looking", async t => { + const page = await pageFor(t); + await page.goto(origin); + await page.waitForFunction(() => localStorage.getItem('thirdshift:console:last-look') !== null); + await page.goto('about:blank'); + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const path = join(dir, '201-fixture.command.json'); + try { + const fixture = JSON.parse(await readFile(join(dir, '1-fixture.command.json'), 'utf8')); + fixture.issue.number = 201; + fixture.started_at = fixture.ended_at = new Date().toISOString(); + fixture.cause = 'New problem after the last visit'; + await writeFile(path, JSON.stringify(fixture)); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#201$/ }) }); + await row.locator('.new').waitFor(); + assert.equal(await page.locator('.new:visible').count(), 1); + await page.reload(); + assert.equal(await page.locator('.new:visible').count(), 0); + } finally { await rm(path, { force: true }); } +}); + +test("cutting the open stream falls back to polling and catches up", async t => { + const page = await pageFor(t); + await page.addInitScript(() => { + const NativeSource = EventSource; + window.EventSource = class extends NativeSource { + constructor(...args) { super(...args); window.inboxStream = this; } + }; + }); + await page.goto(origin); + await page.waitForFunction(() => window.inboxStream?.readyState === EventSource.OPEN); + await page.evaluate(() => { + window.inboxStream.close(); + window.inboxStream.dispatchEvent(new Event('error')); + }); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const fixture = JSON.parse(original); + fixture.issue.title = 'Caught up by polling'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Caught up by polling', { exact: true }).waitFor({ timeout: 12000 }); + assert.equal(await page.evaluate(() => window.inboxStream.readyState), 2); + } finally { await writeFile(path, original); } +}); + +for (const storage of ['blocked', 'malformed']) { + test(`live Inbox works with ${storage} browser storage`, async t => { + const page = await pageFor(t); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(mode => { + if (mode === 'blocked') Object.defineProperty(window, 'localStorage', { get() { throw new Error('Storage blocked'); } }); + else { + localStorage.setItem('thirdshift:console:folded', 'false'); + localStorage.setItem('thirdshift:console:last-look', '{broken'); + } + }, storage); + await page.goto(origin); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const fixture = JSON.parse(original); + fixture.issue.title = `Still live with ${storage} storage`; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText(fixture.issue.title, { exact: true }).waitFor(); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 1); + assert.deepEqual(errors, []); + } finally { await writeFile(path, original); } + }); +} + +test("heartbeat beats only for server-observed log growth and is flat when Silent", { skip: process.platform !== 'linux' }, async t => { + // This fixture borrows the Console's real native process identity. HTTP + // coverage uses an actual held Run on both Linux and macOS. + const stat = await readFile(`/proc/${server.pid}/stat`, 'utf8'); + const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`; + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const path = join(dir, '202-fixture.command.json'); + const log = join(dir, '202-fixture.log'); + const session = join(home, 'heartbeat-session.log'); + const fixture = JSON.parse(await readFile(join(dir, '1-fixture.command.json'), 'utf8')); + Object.assign(fixture, { kind: 'Run', process_id: server.pid, process_start: start, outcome: null, ended_at: null, session_log: session }); + fixture.issue.number = 202; + const began = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + fixture.stages = [{ name: 'implement', began_at: began, session_log: session }]; + await writeFile(path, JSON.stringify(fixture)); + await writeFile(log, 'command'); + await writeFile(session, 'session'); + try { + const page = await pageFor(t); + await page.goto(origin); + await page.locator('[data-group="working"] > summary').click(); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#202$/ }) }); + const id = await row.getAttribute('id'); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + await appendFile(session, ' growth'); + await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().some(animation => animation.playState === 'running'), id); + await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().length === 0, id); + const old = new Date(Date.now() - 20 * 60 * 1000); + await utimes(log, old, old); + await utimes(session, old, old); + await page.waitForFunction(id => document.getElementById(id).dataset.state === 'Silent', id); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await appendFile(log, ' more'); + await page.waitForFunction(id => document.getElementById(id).dataset.logging === 'true', id); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + } finally { await Promise.all([path, log, session].map(path => rm(path, { force: true }))); } +}); + +test("a push keeps a Pass dispatch selected instead of its parent", async t => { + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/security'); + await mkdir(dir, { recursive: true }); + const path = join(dir, '20261019T110000+0000~security.command.json'); + const now = new Date().toISOString(); + const fixture = { + version: 1, kind: 'Security run', name: 'secure-fixture', repository: 'acme/widgets', hostname: 'fixture', + issue: null, process_id: 0, process_start: 'gone', harness: 'codex', model: null, effort: null, + started_at: now, ended_at: now, stages: [], session_log: null, outcome: 'failed', cause: 'Pass failure', issue_branch: null, pull_request: null + }; + fixture.dispatched = [{ ...fixture, kind: 'Run', name: 'issue-303', issue: { number: 303, url: 'https://github.com/acme/widgets/issues/303', title: 'Dispatch before push' } }]; + await writeFile(path, JSON.stringify(fixture)); + try { + const page = await pageFor(t); + await page.goto(origin); + const child = page.locator('.command').filter({ has: page.locator(':scope > .row-head > .subject', { hasText: /^acme\/widgets#303$/ }) }); + await child.click(); + fixture.dispatched[0].issue.title = 'Dispatch after push'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Dispatch after push', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement.querySelector(':scope > .row-head > .subject')?.textContent), 'acme/widgets#303'); + const ids = await page.locator('.command').evaluateAll(rows => rows.map(row => row.id)); + assert.equal(new Set(ids).size, ids.length); + } finally { await rm(path, { force: true }); } +}); + +test("selection survives a move into folded Done and regains focus when opened", async t => { + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const page = await pageFor(t); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator(':scope > .row-head > .subject', { hasText: /^acme\/widgets#1$/ }) }); + await row.click(); + const id = await row.getAttribute('id'); + const fixture = JSON.parse(original); + fixture.outcome = 'merged'; + await writeFile(path, JSON.stringify(fixture)); + await page.waitForFunction(id => document.getElementById(id)?.closest('details').dataset.group === 'done', id); + assert.equal(await page.evaluate(() => document.querySelector('.command[aria-current="true"]')?.id ?? document.activeElement.id), id); + assert.equal(await page.locator('[data-group="done"][open]').count(), 0); + await page.locator('[data-group="done"] > summary').click(); + await page.waitForFunction(id => document.activeElement.id === id, id); + } finally { await writeFile(path, original); } +}); + test("every keyboard action preserves list nodes and Escape closes one layer at a time", async t => { const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); await page.goto(origin); @@ -226,7 +459,12 @@ test("page keys, jump letters and URLs survive reload and the peek is a phone bo test('History filters and Security findings and sessions work from a phone', async t => { const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); + const streams = []; + const errors = []; + page.on('request', request => { if (new URL(request.url()).pathname === '/events') streams.push(request.url()); }); + page.on('pageerror', error => errors.push(error.message)); await page.goto(`${origin}/?page=history`); + assert.match(await page.title(), /^History ·/); assert.equal(await page.locator('.command').count(), 11); await page.locator('select[name=outcome]').selectOption('failed'); await page.locator('select[name=outcome]').focus(); @@ -244,6 +482,9 @@ test('History filters and Security findings and sessions work from a phone', asy await page.locator('.peek-link').click(); await page.locator('#peek dt').first().waitFor(); assert.match(await page.locator('#peek').textContent(), /Bound before storage/); + assert.match(await page.title(), /^Security ·/); + assert.deepEqual(streams, []); + assert.deepEqual(errors, []); await page.locator('#peek a').filter({ hasText: 'Session log:' }).click(); await page.waitForURL('**/log?*'); assert.match(await page.locator('body').textContent(), /Security Session output/); diff --git a/src/console.rs b/src/console.rs index 66acc8a5..529ad7e5 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,5 +1,6 @@ //! The machine's Console. Async execution is confined to this module. mod conversation; +mod live; mod logs; mod peek; mod restart; @@ -28,6 +29,18 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); struct PageRoute { path: &'static str, cli: &'static [&'static str], + view: View, +} + +enum View { + Inbox, + Inspect, + Log, + Changes, + Logs, + CommandLogs, + Reader, + LogStream, } const PAGES: &[PageRoute] = &[ @@ -37,50 +50,67 @@ const PAGES: &[PageRoute] = &[ "thirdshift show widgets#7 --json", "thirdshift logs widgets#7", ], + view: View::Inbox, }, PageRoute { path: "/lanes", cli: &["thirdshift status --all --json"], + view: View::Inbox, }, PageRoute { path: "/stages", cli: &["thirdshift status --all --json"], + view: View::Inbox, }, PageRoute { path: "/", cli: &["thirdshift status"], + view: View::Inbox, + }, + PageRoute { + path: "/events", + cli: &["thirdshift status"], + view: View::Changes, }, PageRoute { path: "/history", cli: &["thirdshift status --all --json"], + view: View::Inbox, }, PageRoute { path: "/security", cli: &["thirdshift status --all --json"], + view: View::Inbox, }, PageRoute { path: "/peek", cli: &["thirdshift show widgets#7 --json"], + view: View::Inspect, }, PageRoute { path: "/log", cli: &["thirdshift logs widgets#7"], + view: View::Log, }, PageRoute { path: "/logs", cli: &["thirdshift logs --json"], + view: View::Logs, }, PageRoute { path: "/logs/command/{id}", cli: &["thirdshift logs --command id --json"], + view: View::CommandLogs, }, PageRoute { path: "/logs/{id}", cli: &["thirdshift logs --file id"], + view: View::Reader, }, PageRoute { path: "/logs/{id}/stream/{offset}", cli: &["thirdshift logs --file id --follow"], + view: View::LogStream, }, ]; @@ -101,11 +131,18 @@ pub fn serve(no_sign_in: bool) -> Result<()> { println!("http://{}", listener.local_addr()?); std::io::stdout().flush()?; let watched = executable.clone(); + let (changes, observer) = live::observe(render_inbox("thirdshift status").await?); axum::serve( listener, - router().layer(axum::Extension(executable.clone())), + router(changes).layer(axum::Extension(executable.clone())), ) - .with_graceful_shutdown(async move { watched.replaced().await }) + .with_graceful_shutdown(async move { + watched.replaced().await; + // Dropping the sole sender closes every SSE response before + // graceful shutdown waits for the connections to finish. + observer.abort(); + let _ = observer.await; + }) .await .context("Console server failed")?; Ok::<_, anyhow::Error>(()) @@ -115,22 +152,27 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } -fn router() -> Router { +fn router(changes: tokio::sync::watch::Receiver) -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli[0]; - router = match page.path { - "/peek" => router.route(page.path, get(inspect)), - "/log" => router.route(page.path, get(log)), - "/logs" => router.route(page.path, get(logs::index)), - "/logs/command/{id}" => router.route(page.path, get(logs::command)), - "/logs/{id}" => router.route(page.path, get(logs::reader)), - "/logs/{id}/stream/{offset}" => router.route(page.path, get(logs::stream)), - path => router.route(path, get(move |query| inbox(cli, path, query))), - }; + let path = page.path; + router = router.route( + page.path, + match page.view { + View::Inbox => get(move |query| inbox(cli, path, query)), + View::Inspect => get(inspect), + View::Log => get(log), + View::Changes => get(live::events), + View::Logs => get(logs::index), + View::CommandLogs => get(logs::command), + View::Reader => get(logs::reader), + View::LogStream => get(logs::stream), + }, + ); } router = router.route("/assets/{*path}", get(asset)); - router + router.with_state(changes) } #[derive(Template)] @@ -148,6 +190,17 @@ struct InboxPage { } impl InboxPage { + fn needs_you(&self) -> usize { + self.inbox.sections[0].rows.len() + } + + fn urgent(&self) -> bool { + self.inbox.sections[0] + .rows + .iter() + .any(|row| row.state != "Slow") + } + fn row<'a>(&self, row: &'a Row) -> RowTemplate<'a> { RowTemplate { row } } @@ -178,27 +231,35 @@ fn query_value(query: &Option, key: &str) -> String { } async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) -> Response { - let mut page = query_value(&query, "page"); - if page.is_empty() { - page = path.trim_start_matches('/').into(); + let mut requested_page = query_value(&query, "page"); + if requested_page.is_empty() { + requested_page = path.trim_start_matches('/').into(); } - if matches!(page.as_str(), "logs" | "6") { + if matches!(requested_page.as_str(), "logs" | "6") { return logs::index().await; } - if matches!(page.as_str(), "waterfall" | "4") { + if matches!(requested_page.as_str(), "waterfall" | "4") { return waterfall::show(query).await; } - if matches!(page.as_str(), "lanes" | "2" | "stages" | "3") { - return time::show(matches!(page.as_str(), "stages" | "3"), query).await; + if matches!(requested_page.as_str(), "lanes" | "2" | "stages" | "3") { + return time::show(matches!(requested_page.as_str(), "stages" | "3"), query).await; } - let archive_page = match page.as_str() { + let archive_page = match requested_page.as_str() { "history" | "5" => Some(status::ArchivePage::History), "security" | "7" => Some(status::ArchivePage::Security), "" | "inbox" | "1" => None, _ => return StatusCode::NOT_FOUND.into_response(), }; + page(Ok(render_page(cli, archive_page, query).await)) +} + +async fn render_page( + cli: &'static str, + archive_page: Option, + query: Option, +) -> Result { // The shared observations and templates stay blocking, off the async executor. - match tokio::task::spawn_blocking(move || -> Result { + tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; let outcome = query_value(&query, "outcome"); let repository = query_value(&query, "repository"); @@ -225,18 +286,7 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) } .render()?) }) - .await - { - Ok(Ok(html)) => html_response(html), - error => { - eprintln!("thirdshift Console: can't read Inbox: {error:?}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Can't read the Inbox; check the Console's stderr.", - ) - .into_response() - } - } + .await? } async fn inspect(RawQuery(query): RawQuery) -> Response { @@ -298,6 +348,10 @@ fn page(result: std::result::Result, tokio::task::JoinError>) -> } } +async fn render_inbox(cli: &'static str) -> Result { + render_page(cli, None, None).await +} + async fn asset(Path(path): Path) -> Response { let Some(file) = ASSETS.get_file(&path) else { return StatusCode::NOT_FOUND.into_response(); @@ -308,6 +362,7 @@ async fn asset(Path(path): Path) -> Response { { Some("css") => "text/css; charset=utf-8", Some("js") => "text/javascript; charset=utf-8", + Some("svg") => "image/svg+xml", Some("woff2") => "font/woff2", _ => "text/plain; charset=utf-8", }; @@ -330,6 +385,11 @@ mod tests { use std::sync::{Arc, Mutex}; use tower_layer::Layer; + fn router() -> Router { + let (_, changes) = tokio::sync::watch::channel(0); + super::router(changes) + } + // Router::route_layer walks every registered method service, including // routes added directly instead of through PAGES. Nothing is dispatched. #[derive(Clone)] @@ -351,8 +411,14 @@ mod tests { fn assert_cli_parity(router: Router) { // Measure the GET registration footprint through axum's public layer - // interface, so this does not depend on its implicit HEAD/fallback count. - let get_methods = registered_methods(Router::new().route("/known", get(|| async {}))); + // interface after attaching state, as the production router does, so + // this does not depend on its implicit HEAD/fallback count. + let (_, changes) = tokio::sync::watch::channel(0_u64); + let get_methods = registered_methods( + Router::>::new() + .route("/known", get(|| async {})) + .with_state(changes), + ); let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route assert_eq!( registered_methods(router), @@ -383,14 +449,12 @@ mod tests { #[test] fn every_console_route_has_a_cli_equivalent() { - assert_cli_parity(super::router()); + assert_cli_parity(router()); } #[test] #[should_panic(expected = "registered routes must all have a CLI pair")] fn parity_rejects_a_route_registered_without_a_cli_pair() { - assert_cli_parity( - super::router().route("/unpaired", get(|| async { "page with no CLI pair" })), - ); + assert_cli_parity(router().route("/unpaired", get(|| async { "page with no CLI pair" }))); } } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index b318b17b..8a377ffa 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -36,8 +36,10 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } .commands, .children, .plan { list-style: none; padding: 0; margin: 0; } .commands { max-height: 300px; overflow: auto; } .command { padding: 8px 10px; margin-bottom: 4px; border-radius: var(--radius); color: var(--dim); overflow-wrap: anywhere; } +.command:focus, .command[aria-current="true"] { outline: 2px solid var(--head); outline-offset: -2px; } .row-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } .state { color: var(--text); } +.heartbeat { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--working); opacity: .4; } .age { margin-left: auto; white-space: nowrap; } .issue-title { flex: 1 1 160px; } .command p { margin: 4px 0 0; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index d37d4cfd..05d1a3f9 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -1,15 +1,126 @@ -// Folded sections are a per-browser convenience. A fresh visit opens Needs you. -const key = "thirdshift:console:folded"; -let folded = {}; -try { folded = JSON.parse(localStorage.getItem(key) ?? "{}"); } catch { /* storage is optional */ } +// Browser preferences are conveniences; malformed or unavailable storage hides nothing. +function stored(key, fallback) { + try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } +} +function remember(key, value) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage is optional */ } +} +const visitKey = "thirdshift:console:last-look"; +const visit = stored(visitKey, Date.now()); +const lastLook = typeof visit === "number" && Number.isFinite(visit) && visit >= 0 && visit <= Date.now() ? visit : Date.now(); +function markNew() { + for (const row of document.querySelectorAll("#inbox .command")) { + row.querySelector(":scope > .row-head > .new").hidden = Number(row.dataset.startedMs) <= lastLook; + } +} +markNew(); +remember(visitKey, Date.now()); +window.addEventListener("pagehide", () => remember(visitKey, Date.now())); +const foldKey = "thirdshift:console:folded"; +let folded = stored(foldKey, {}); +if (typeof folded !== "object" || Array.isArray(folded)) folded = {}; for (const section of document.querySelectorAll("details[data-group]")) { - if (typeof folded?.[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; - section.addEventListener("toggle", () => { - folded ??= {}; - folded[section.dataset.group] = section.open; - try { localStorage.setItem(key, JSON.stringify(folded)); } catch { /* storage is optional */ } + if (typeof folded[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; +} +document.addEventListener("toggle", event => { + const section = event.target; + if (!section.matches("details[data-group]")) return; + folded[section.dataset.group] = section.open; + remember(foldKey, folded); + if (section.open && selected && section.contains(selected)) selected.focus({ preventScroll: true }); +}, true); +document.addEventListener("focusin", event => { + const row = event.target.closest(".command"); + if (row) select(row, { scroll: false }); +}); +document.addEventListener("click", event => { + if (!event.target.closest('a, button, input, select, textarea')) { + event.target.closest(".command")?.focus({ preventScroll: true }); + } +}); + +function logLengths(row) { + return row.dataset.logGrowth.split(":").map(Number); +} +function growth() { + return new Map([...document.querySelectorAll(".command")].map(row => [row.id, logLengths(row)])); +} + +function morph(html) { + const next = new DOMParser().parseFromString(html, "text/html"); + const current = document.getElementById("inbox"); + const replacement = next.getElementById("inbox"); + if (!replacement) throw new Error("Missing Inbox"); + const active = document.activeElement; + const focused = active?.id || active?.closest("details")?.id; + const wasSummary = active?.matches("summary"); + const scroll = [...current.querySelectorAll("details")].map(section => [section.id, section.querySelector(".commands").scrollTop]); + const position = [scrollX, scrollY]; + const selection = selected?.id || selected?.closest('details')?.id; + const selectedSummary = selected?.matches('summary'); + const previousGrowth = growth(); + Idiomorph.morph(current, replacement, { + callbacks: { + beforeAttributeUpdated(name, node) { + if (name === "open" && node.matches("details[data-group]")) return false; + } + } }); + const focusedNode = document.getElementById(focused); + (wasSummary ? focusedNode?.querySelector("summary") : focusedNode)?.focus({ preventScroll: true }); + // Focus cannot enter a folded section. Keep the logical row selected so + // it remains selected, and regains focus, when that section is reopened. + const selectedNode = document.getElementById(selection); + select(selectedSummary ? selectedNode?.querySelector('summary') : selectedNode, { scroll: false }); + for (const [id, top] of scroll) document.getElementById(id).querySelector(".commands").scrollTop = top; + window.scrollTo(...position); + document.title = next.title; + document.querySelector('link[rel="icon"]').href = next.querySelector('link[rel="icon"]').getAttribute("href"); + markNew(); + for (const row of document.querySelectorAll('.command[data-logging="true"]')) { + const previous = previousGrowth.get(row.id) ?? [0, 0]; + const increased = logLengths(row).some((size, index) => size > previous[index]); + if (increased && !matchMedia("(prefers-reduced-motion: reduce)").matches) { + row.querySelector(":scope > .row-head > .heartbeat")?.animate([ + { transform: "scale(1)", opacity: 0.4 }, + { transform: "scale(1.8)", opacity: 1, offset: 0.3 }, + { transform: "scale(1)", opacity: 0.4 } + ], { duration: 900 }); + } + } +} + +// Coalesce notices, but fetch again if a change arrives during the request. +let refreshing = false, pending = false; +async function refresh() { + pending = true; + if (refreshing) return; + refreshing = true; + try { + while (pending) { + pending = false; + const response = await fetch("/", { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + morph(await response.text()); + } + } catch { + startPolling(); + } finally { refreshing = false; } +} +let polling; +function startPolling() { + polling ??= setInterval(refresh, 5000); } +if (document.getElementById('inbox') && typeof EventSource === "function") { + const stream = new EventSource("/events"); + stream.addEventListener("commands", refresh); + stream.addEventListener("error", startPolling); + stream.addEventListener("open", () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); +} else if (document.getElementById('inbox')) { startPolling(); } const peek = document.querySelector('#peek'); const content = document.querySelector('#peek-content'); @@ -19,11 +130,13 @@ let selected = null; let peekCommand = null; let pendingPeek = null; -function select(element) { +function select(element, { scroll = true } = {}) { selected?.classList.remove('is-selected'); + selected?.removeAttribute('aria-current'); selected = element; selected?.classList.add('is-selected'); - selected?.scrollIntoView({ block: 'nearest' }); + if (selected?.matches('.command')) selected.setAttribute('aria-current', 'true'); + if (scroll) selected?.scrollIntoView({ block: 'nearest' }); } function closePeek() { @@ -75,7 +188,11 @@ for (const section of document.querySelectorAll('.group')) { const letter = sectionLetters[section.dataset.group]; if (letter) targets.set(letter, { label: section.querySelector('summary span').textContent, - go: () => { section.open = true; select(section.querySelector('summary')); } + go: () => { + const current = section.id ? document.getElementById(section.id) : section; + current.open = true; + select(current.querySelector('summary')); + } }); } for (const [letter, target] of targets) { @@ -94,7 +211,10 @@ document.addEventListener('click', event => { const row = event.target.closest('main .command, main .log-row'); if (link) { event.preventDefault(); select(row); togglePeek(row); } else if (row && !event.target.closest('a, button, input, select, textarea')) select(row); - else if (event.target.closest('main .group > summary')) select(event.target.closest('summary')); + else if (event.target.closest('main .group > summary')) { + const summary = event.target.closest('summary'); + if (summary.parentElement.open || !selected?.matches('.command') || !summary.parentElement.contains(selected)) select(summary); + } }); document.addEventListener('keydown', event => { diff --git a/src/console/assets/favicon-urgent.svg b/src/console/assets/favicon-urgent.svg new file mode 100644 index 00000000..cfea7e8e --- /dev/null +++ b/src/console/assets/favicon-urgent.svg @@ -0,0 +1 @@ + diff --git a/src/console/assets/favicon.svg b/src/console/assets/favicon.svg new file mode 100644 index 00000000..e5fa9f5b --- /dev/null +++ b/src/console/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/console/inbox.html b/src/console/inbox.html index 3871085b..8df1510b 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -4,8 +4,13 @@ + {% if title == "Inbox" %} + {{ self.needs_you() }} need you · thirdshift Console + + {% else %} {{ title }} · {{ hostname }} · thirdshift + {% endif %} @@ -17,7 +22,7 @@ {% if !consoles.is_empty() %}{% endif %} {% include "console/nav.html" %} -
+

{{ title }}

{{ cli }}
{% match archive %}{% when Some with (archive) %} {% if title == "History" %} @@ -35,7 +40,7 @@ {% when None %} {% match inbox.warning %}{% when Some with (warning) %}

Warning: {{ warning }}

{% when None %}{% endmatch %} {% for section in inbox.sections %} -
+
{{ section.title }}{{ section.rows.len() }} {% if section.rows.is_empty() && section.references.is_empty() %}

Nothing here.

{% endif %} {# Only the rendered, auto-escaped RowTemplate is trusted as HTML. #} diff --git a/src/console/live.rs b/src/console/live.rs new file mode 100644 index 00000000..618f4376 --- /dev/null +++ b/src/console/live.rs @@ -0,0 +1,40 @@ +//! One reader per Console; slow browsers only need the latest change notice. +use std::{convert::Infallible, time::Duration}; + +use axum::extract::State; +use axum::response::{ + IntoResponse, Response, Sse, + sse::{Event, KeepAlive}, +}; +use tokio::sync::watch; +use tokio_stream::{StreamExt, wrappers::WatchStream}; + +pub(super) fn observe(initial: String) -> (watch::Receiver, tokio::task::JoinHandle<()>) { + let (sender, changes) = watch::channel(0); + let observer = tokio::spawn(async move { + let mut previous = initial; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + match super::render_inbox("thirdshift status").await { + Ok(html) if html != previous => { + previous = html; + sender.send_modify(|revision| *revision += 1); + } + Ok(_) => {} + Err(error) => eprintln!("thirdshift Console: can't observe Inbox: {error:#}"), + } + } + }); + (changes, observer) +} + +pub(super) async fn events(State(changes): State>) -> Response { + // WatchStream emits immediately, so every reconnect also catches up. + let stream = WatchStream::new(changes) + .map(|_| Ok::<_, Infallible>(Event::default().event("commands").data("changed"))); + ( + [("x-accel-buffering", "no"), ("cache-control", "no-store")], + Sse::new(stream).keep_alive(KeepAlive::default()), + ) + .into_response() +} diff --git a/src/console/row.html b/src/console/row.html index 6cb15eee..7551504c 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,5 +1,5 @@ -
  • -
    {{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}
    +
  • +
    {% if row.live %}{% endif %}{{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}

    {{ row.now_line }}

    Run and Session logs {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index 37c9e50d..fe51fd58 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -1,11 +1,12 @@ //! Owned Inbox presentation for the Console, from the same observations as status. use anyhow::Result; use chrono::{DateTime, Local}; +use std::path::Path; use super::{age, stage_text, ticket_text}; use crate::config::UserConfig; use crate::logs::command_record::Issue; -use crate::logs::reading::{Command, Entry, Group, Observation, Snapshot}; +use crate::logs::reading::{Command, Entry, Group, Health, Observation, Snapshot}; pub(crate) struct Inbox { pub sections: Vec
    , @@ -22,6 +23,11 @@ pub(crate) struct Section { } pub(crate) struct Row { + pub id: String, + pub started_ms: i64, + pub live: bool, + pub logging: bool, + pub log_growth: String, pub selector: String, pub logs_url: String, pub subject: String, @@ -133,11 +139,19 @@ pub(crate) fn archive( fn entry_row(entry: &Entry, now: DateTime, root: &std::path::Path) -> Result { match entry { - Entry::Record(command) => row(command, now, root), + Entry::Record(command) => row(command, now, None, root), Entry::History(command) => { let (subject, title) = subject(&command.repository, &command.kind, command.issue.as_ref()); Ok(Row { + id: identity(&format!( + "history:{}:{}:{}", + command.repository, command.kind, command.started_at + )), + started_ms: timestamp(&command.started_at), + live: false, + logging: false, + log_growth: String::new(), selector: Observation::History(command).selector().unwrap_or_default(), logs_url: command .command_log @@ -247,10 +261,35 @@ fn subject(repository: &str, name: &str, issue: Option<&Issue>) -> (String, Stri } } -fn row(command: &Command, now: DateTime, root: &std::path::Path) -> Result { +fn row( + command: &Command, + now: DateTime, + outer_log: Option<&Path>, + root: &Path, +) -> Result { let facts = &command.record; + let log = outer_log.unwrap_or(&command.command_log); + let live = command.state == "live"; let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); Ok(Row { + // A Pass's dispatched Runs share its storage path. Their immutable + // Command facts distinguish them without tying identity to ordering. + id: identity(&format!( + "{}\0{}\0{}\0{}\0{}", + command.record_path.to_string_lossy(), + facts.kind, + facts.name, + facts.issue.as_ref().map_or("", |issue| issue.url.as_str()), + facts.started_at + )), + started_ms: timestamp(&facts.started_at), + live, + logging: live && !matches!(command.health, Some(Health::Silent { .. })), + log_growth: format!( + "{}:{}", + log_length(Some(log)), + log_length(facts.session_log.as_deref()) + ), selector: Observation::Record(command).selector().unwrap_or_default(), logs_url: logs_url(&command.record_path, root), subject, @@ -276,7 +315,7 @@ fn row(command: &Command, now: DateTime, root: &std::path::Path) -> Resul .dispatched .iter() .chain(&command.children) - .map(|child| row(child, now, root)) + .map(|child| row(child, now, Some(log), root)) .collect::>()?, findings: facts.security_findings.clone(), sessions: command.sessions()?, @@ -289,6 +328,26 @@ fn logs_url(path: &std::path::Path, root: &std::path::Path) -> String { .unwrap_or_else(|_| "/?page=logs".into()) } +fn log_length(path: Option<&Path>) -> u64 { + path.and_then(|path| std::fs::metadata(path).ok()) + .map_or(0, |file| file.len()) +} + +fn timestamp(value: &str) -> i64 { + DateTime::parse_from_rfc3339(value).map_or(0, |time| time.timestamp_millis()) +} + +fn identity(value: &str) -> String { + // Encode the exact storage identity: issue numbers and timestamps alone + // can collide, and DOM IDs cannot contain whitespace. + let encoded: String = value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("command-{encoded}") +} + fn elapsed(time: &str, now: DateTime) -> String { DateTime::parse_from_rfc3339(time) .map(|time| age(now.signed_duration_since(time).num_seconds().max(0))) diff --git a/tests/console.rs b/tests/console.rs index 4fec42f6..d194e76b 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -1,4 +1,6 @@ //! The Console's public seam: the binary, fixture records and HTTP. +mod support; + use std::fs; use std::io::{BufRead, BufReader}; use std::path::Path; @@ -16,6 +18,21 @@ struct Console { reader: Option>, } +struct HeldFixture { + run: Option, + release: std::path::PathBuf, +} + +impl Drop for HeldFixture { + fn drop(&mut self) { + // Release and reap the fixture even when a regression assertion fails. + let _ = fs::write(&self.release, ""); + if let Some(run) = self.run.take() { + run.finish(); + } + } +} + impl Console { fn start(binary: &Path, home: &Path) -> Self { let mut child = Command::new(binary) @@ -104,6 +121,119 @@ fn write_record(home: &Path, number: u64, record: &Value) { .unwrap(); } +#[test] +fn command_changes_push_events_over_http() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let response = console.get("/events"); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + assert_eq!(response.headers()["x-accel-buffering"], "no"); + let mut stream = BufReader::new(response); + fn changed(stream: &mut impl BufRead) { + let mut event = String::new(); + loop { + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0, "stream ended"); + if line.trim().is_empty() && event.contains("event: commands") { + assert!(event.contains("data: changed"), "{event}"); + return; + } + if line.trim().is_empty() { + event.clear(); + } else { + event.push_str(&line); + } + } + } + changed(&mut stream); // Reconnects always catch up, even if an event was missed. + let mut fixture = record(home.path(), 81, None, 0); + changed(&mut stream); + assert!(console.get("/").text().unwrap().contains("acme/widgets#81")); + fixture["stages"][0]["name"] = json!("CI watch"); + write_record(home.path(), 81, &fixture); + changed(&mut stream); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("died at CI watch") + ); + fixture["outcome"] = json!("merged"); + fixture["ended_at"] = json!(chrono::Local::now().to_rfc3339()); + write_record(home.path(), 81, &fixture); + changed(&mut stream); + assert!(console.get("/").text().unwrap().contains(">merged

    ")); +} + +#[test] +fn heartbeat_observes_log_lengths_and_is_flat_for_silent_runs() { + let scenario = support::Scenario::new(); + scenario.user_config_is("[console]\nport = 0\n"); + scenario.agent_does(&format!( + "touch '{}'\nwhile ! test -f '{}'; do sleep 0.02; done\nexit 1\n", + scenario.path("started").display(), + scenario.path("release").display() + )); + let _held = HeldFixture { + run: Some(scenario.run_until(&[&scenario.issue_url(7)], &[], "started")), + release: scenario.path("release"), + }; + let dir = scenario.path("home/.thirdshift/logs/acme/widgets/commands/issue"); + let path = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.to_string_lossy().ends_with(".command.json")) + .unwrap(); + let mut fixture: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let session = std::path::PathBuf::from(fixture["session_log"].as_str().unwrap()); + let log = path.with_file_name( + path.file_name() + .unwrap() + .to_str() + .unwrap() + .replace(".command.json", ".log"), + ); + let began = (chrono::Local::now() - chrono::Duration::minutes(20)).to_rfc3339(); + fixture["stages"] = json!([{"name": "implement", "began_at": began, "session_log": session}]); + fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + fs::write(&log, "a").unwrap(); + fs::write(&session, "ab").unwrap(); + let console = Console::start( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + &scenario.path("home"), + ); + let html = console.get("/").text().unwrap(); + assert!(html.contains("data-log-growth=\"1:2\""), "{html}"); + assert!(html.contains("data-logging=\"true\"")); + fs::write(&session, "abcde").unwrap(); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("data-log-growth=\"1:5\"") + ); + for file in [&log, &session] { + fs::File::options() + .write(true) + .open(file) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(20 * 60)), + ) + .unwrap(); + } + let html = console.get("/").text().unwrap(); + assert!(html.contains("data-state=\"Silent\"")); + assert!(html.contains("data-logging=\"false\"")); + assert!(html.contains("data-log-growth=\"1:5\"")); + drop(console); +} + #[test] fn history_keeps_every_ended_command_in_time_order_and_filters() { let home = tempfile::tempdir().unwrap(); @@ -988,6 +1118,7 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { assert!(copied.success()); let console = Console::start(&binary, home.path()); assert_eq!(console.get("/").status(), 200); + let _open_stream = console.get("/events"); // A reader must not keep graceful shutdown waiting on an infinite stream. #[cfg(target_os = "linux")] let _stream = {