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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/devteam/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
.banner.updated { border-color: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 45%, var(--line)); background: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 7%, var(--surface)); }
.banner.updated button { margin: 0 0 0 8px; }
.held { position: fixed; right: 16px; bottom: 64px; z-index: 30; margin: 0; box-shadow: var(--shadow); }
/* fixed, so it never moves what is under the cursor; at the top, as both bottom corners are taken,
over the content, not the nav (below it on a phone, where staleNote measures the nav), under a
drawer or review, and never in the way of a click */
.stale-note { position: fixed; top: 12px; left: calc(232px + 16px); right: 16px; width: fit-content; margin: 0 auto; z-index: 19; pointer-events: none; padding: 4px 12px; text-align: center; border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); border-radius: 12px; background: color-mix(in srgb, var(--amber) 12%, var(--surface)); color: var(--amber-fg); font-size: 12px; box-shadow: var(--shadow); }
.banner b { color: var(--red-fg); }
.banner pre { margin: 6px 0 0; max-height: 9em; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.5 var(--mono); color: var(--fg-2); }
.banner.stale { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); background: color-mix(in srgb, var(--amber) 7%, var(--surface)); }
Expand Down Expand Up @@ -78,6 +82,7 @@
.side .brand { padding: 0 8px 0 0; }
.side .nav { display: flex; gap: 2px; }
.side .foot { display: none; }
.stale-note { top: 60px; left: 16px; }
.nav a { white-space: nowrap; }
/* a long URL or id wraps; it doesn't widen the page */
main { padding: 16px 16px 64px; overflow-wrap: anywhere; }
Expand Down Expand Up @@ -581,6 +586,7 @@
<div class="banner" id="banner" role="alert" hidden></div>
<div class="banner updated" id="updated" role="status" hidden>The dashboard was updated since this tab opened, so some of what you see may be out of date. <button id="reload-page">Reload</button></div>
<button class="held" id="held" type="button" tabindex="-1" hidden>New data. Click to refresh</button>
<div class="stale-note" id="stale" role="status" hidden></div>
<p class="loading" id="loading">Loading…</p>
<section id="overview">
<div id="fleet-strip" class="fleet-strip"></div>
Expand Down Expand Up @@ -4003,6 +4009,7 @@ <h4>${linkify(k.statement)}</h4>
// load there is nothing else to see, and after it the page would otherwise show old data as current.
function showFailure(message) {
const b = $('banner');
$('stale').hidden = true; // the banner says what is shown, and why
$('loading').hidden = true;
const first = !DATA;
const fix = /needs Node 18|is not a function|SyntaxError|Unexpected token/.test(message)
Expand Down Expand Up @@ -5066,6 +5073,19 @@ <h4>${linkify(k.statement)}</h4>
$('loading').hidden = true;
document.querySelector('main').classList.remove('waiting');
drawOrHold(d);
staleNote();
}
// The server answers a slow read with the last data it had, marked stale: say so where it shows on
// any screen, with the time of what is drawn (not of a read held while the owner types), until a
// fresh read lands. A banner at the top says more, and the note gives way to it.
function staleNote() {
const n = $('stale');
n.hidden = !STALE || !DATA || !$('banner').hidden || !$('updated').hidden;
if (n.hidden) return;
const at = new Date(DATA.generated_at);
const t = `Showing data from ${at.toDateString() === new Date().toDateString() ? at.toLocaleTimeString() : at.toLocaleString()}. Newer data is taking a while to load; this page will update by itself.`;
if (n.textContent !== t) n.textContent = t; // unchanged, a screen reader isn't told again
n.style.top = matchMedia('(max-width: 900px)').matches ? `${Math.max(0, document.querySelector('.side').getBoundingClientRect().bottom) + 8}px` : '';
}

// The tab title carries the Inbox count. Notifications are the hub's, for every instance.
Expand Down
30 changes: 30 additions & 0 deletions test/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,36 @@ test('a slow data run: a read with data to fall back on has it at once, marked s
await until(async () => (await read()).asked.includes('Second?'), 'the run landing');
});

test('a stale read is said on the page: a note at the top with the time of what is shown, on a phone too', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 120000 }, async (t) => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-stale-note-'));
t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
// waiting no time at all on a run: every read after the first is the last good data, marked stale
const env = { ...process.env, DEVTEAM_DATA_WAIT_MS: '0', DEVTEAM_HUB_PORT: String(PORTS + 42), DEVTEAM_PORT_BASE: String(PORTS + 43), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') };
for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k];
const root = bareProject(base, 'stale');
const port = await freePort();
const dash = startDashboard(root, env, ['--port', String(port)]);
t.after(() => dash.stop());
await until(() => /dashboard on http/.test(dash.out()), 'the dashboard');
const chrome = await openChrome();
t.after(() => chrome.close());
const p = await chrome.newPage();
t.after(() => p.close());
await p.send('Page.enable');
await p.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true });
await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST });
await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/` });
const soon = soonIn(p, 30000, () => dash.out());
await soon(`DATA && window.__reads?.length >= 1 && window.__reads[0].ms !== null`, 'the first read drawn');
// one check, in the page, so a read landing between two can't split them: shown, inside a phone's
// width, below the nav, naming the time of what is drawn
await soon(`(() => {
const n = $('stale'), r = n.getBoundingClientRect(), nav = document.querySelector('.side').getBoundingClientRect();
const want = 'Showing data from ' + new Date(DATA.generated_at).toLocaleTimeString() + '. Newer data is taking a while to load; this page will update by itself.';
return !n.hidden && getComputedStyle(n).display !== 'none' && r.left >= 0 && r.right <= innerWidth && r.top >= nav.bottom && n.textContent === want;
})()`, 'the note, once a read is stale, saying what it shows');
});

test('the dashboard and hub pages parse: one stray quote and nothing shows at all', () => {
for (const page of ['index.html', 'hub.html']) {
const html = fs.readFileSync(path.join(kit, 'dashboard', page), 'utf8');
Expand Down
Loading