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: 18 additions & 2 deletions src/observability/alerts/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ export const DEFAULT_ALERT_THRESHOLDS = Object.freeze({
stalledRunMinutes: 30, // run with no events for this long
});

// #491: human-scale duration for alert copy — "45m", "1h 30m", "29d 11h",
// never an unbounded minute dump like "42431 min". The studio3d freshness
// chip (ui/studio3d/model.js formatSnapshotAge) mirrors this shape; keep the
// two in step if the format ever changes.
const MS_PER_MINUTE = 60_000;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;

export function humanizeDurationMs(ms) {
const minutes = Math.floor(Math.max(0, Number(ms) || 0) / MS_PER_MINUTE);
if (minutes < MINUTES_PER_HOUR) return `${minutes}m`;
const hours = Math.floor(minutes / MINUTES_PER_HOUR);
if (hours < HOURS_PER_DAY) return `${hours}h ${minutes % MINUTES_PER_HOUR}m`;
return `${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h`;
}

export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) {
const alerts = [];
const now = Date.now();
Expand All @@ -26,7 +42,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) {
level: cost >= thresholds.costPerRunUsd * 2 ? 'critical' : 'warn',
type: 'cost_threshold',
title: 'Run cost threshold exceeded',
detail: `Run ${run.runId.slice(-12)} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`,
detail: `Run ${run.runId} cost $${cost.toFixed(4)} (limit $${thresholds.costPerRunUsd})`,
runId: run.runId,
ts: now,
});
Expand Down Expand Up @@ -67,7 +83,7 @@ export function evaluateAlerts(state, thresholds = DEFAULT_ALERT_THRESHOLDS) {
level: 'warn',
type: 'stalled_run',
title: 'Run may be stalled',
detail: `No activity in ${run.runId.slice(-12)} for ${Math.round((now - lastTs) / 60000)} min`,
detail: `No activity in ${run.runId} for ${humanizeDurationMs(now - lastTs)}`,
runId: run.runId,
ts: now,
});
Expand Down
9 changes: 8 additions & 1 deletion src/observability/dashboard/state/overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,19 @@ export function buildOverviewProjection(state) {
const run = focusRun(state.runs);
const readiness = state.readiness ?? { status: 'unknown', coverage: {}, blockers: [] };
const stale = Boolean(run?.pipelineRollup?.stale);
const title = run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.';
const summary = readiness.summary ?? null;

return {
focusRunId: run?.runId ?? null,
goal: run?.manifest?.goal ?? run?.goal ?? null,
outcome: readiness.status ?? 'unknown',
title: run ? (readiness.summary ?? 'Delivery outcome is not available.') : 'No delivery run has been evaluated.',
title,
// #493: the hero sub-line exists only when it adds information — with a
// focused run the title already IS the readiness summary, so echoing the
// summary as rationale rendered the same sentence twice. Server-owned:
// pages render `rationale`, they never re-derive it.
rationale: summary && summary !== title ? summary : null,
nextAction: pipelineAction(run, { ...readiness, actions: state.actions ?? [] }),
stages: run ? CANONICAL_SDLC_STAGES.map((stage) => stageView(run, stage)) : [],
actionCount: state.actions
Expand Down
8 changes: 7 additions & 1 deletion src/observability/dashboard/ui/pages/command-center.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@ function renderOverviewDecisionSurface(s) {
setClass('overview-state', 'overview-state ' + status);
setText('overview-goal', overview.goal || (noRun ? 'No active run' : 'Goal unavailable'));
setText('overview-outcome-title', title);
setText('overview-rationale', readiness.summary || title);
// #493: the rationale is server-owned and null when it would repeat the
// title — never re-derive it from readiness.summary (that fallback painted
// the same sentence twice).
var rationale = overview.rationale || '';
setText('overview-rationale', rationale);
var rationaleEl = document.getElementById('overview-rationale');
if (rationaleEl) rationaleEl.hidden = !rationale;
setText('overview-coverage', overviewCoverageText(readiness.coverage || overview.coverage));
setText('overview-evaluated-at', overviewTime(overview.evaluatedAt || readiness.evaluatedAt));
setText('overview-action-count', String(overview.actionCount || 0));
Expand Down
13 changes: 12 additions & 1 deletion src/observability/dashboard/ui/studio3d/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,24 @@ export function statusLabel(value) {
return status.charAt(0).toUpperCase() + status.slice(1);
}

const MS_PER_MINUTE = 60_000;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;

export function formatSnapshotAge(freshness) {
if (!freshness?.observed_at) return 'Snapshot time unavailable';
const age = Number(freshness.age_ms);
if (!Number.isFinite(age)) return `Observed ${freshness.observed_at}`;
if (age < 1_000) return 'Observed just now';
if (age < 60_000) return `Observed ${Math.floor(age / 1_000)}s ago`;
return `Observed ${Math.floor(age / 60_000)}m ago`;
// #491: hours and days tiers — a month-old snapshot must never render as
// an unbounded "42433m ago". Same shape as alerts/engine.js
// humanizeDurationMs; keep the two in step if the format ever changes.
const minutes = Math.floor(age / MS_PER_MINUTE);
if (minutes < MINUTES_PER_HOUR) return `Observed ${minutes}m ago`;
const hours = Math.floor(minutes / MINUTES_PER_HOUR);
if (hours < HOURS_PER_DAY) return `Observed ${hours}h ${minutes % MINUTES_PER_HOUR}m ago`;
return `Observed ${Math.floor(hours / HOURS_PER_DAY)}d ${hours % HOURS_PER_DAY}h ago`;
}

export function entityRef(kind, id) {
Expand Down
5 changes: 5 additions & 0 deletions src/observability/dashboard/ui/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const styles = `
--ink: #111827;
}
* { box-sizing: border-box; }
/* #492: the hidden attribute must always win. Author display rules on a
class (e.g. .run-workspace-empty's display:grid) otherwise beat the UA's
[hidden] { display:none } and the element stays visible despite
el.hidden = true. Same global guard as studio3d/styles.css. */
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; }
body {
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
Expand Down
136 changes: 136 additions & 0 deletions tests/browser/dashboard-492-workspace-empty.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* #492 — with a run scoped, the Run Workspace "Select one run" empty prompt
*
* must actually disappear — the `hidden` attribute was defeated by the
* class's own `display:grid` (author styles beat the UA [hidden] rule).
*
* Same harness as dashboard-96: real server on an ephemeral port, canonical
* fixtures, real Chromium via playwright-core; skips cleanly with no browser.
*
* owner: RStack developed by Richardson Gunde
*/
Comment on lines +1 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

3. Regression tests lack attribution fields 📜 Skill insight ⚙ Maintainability

New regression tests include issue context but omit required attribution details (date found and QA
report path). This reduces traceability for future audits and debugging of regressions.
Agent Prompt
## Issue description
Regression tests added in this PR do not include the full attribution comment block required by policy (issue ID, what broke, date found, and QA report path).

## Issue Context
These tests already mention `#491/#492/#493` and a brief description, but they do not include the "Found by ... on YYYY-MM-DD" and "Report: ..." lines.

## Fix Focus Areas
- tests/browser/dashboard-492-workspace-empty.test.js[1-11]
- tests/browser/dashboard-493-hero.test.js[1-10]
- tests/hub-alerts-copy-491.test.js[1-7]
- tests/hub-overview-rationale-493.test.js[1-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


/* global document, window */

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js';

const require = createRequire(import.meta.url);
const HERE = dirname(fileURLToPath(import.meta.url));
const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js');

let chromium = null;
try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ }

// Named per the repo's magic-number convention (PR #490).
const SERVER_BOOT_TIMEOUT_MS = 15_000;
const STATE_LOAD_TIMEOUT_MS = 10_000;

function startServer(root) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], {
cwd: root,
env: {
...process.env,
RSTACK_APPROVAL_TOKEN: undefined,
RSTACK_DASHBOARD_READ_TOKEN: undefined,
RSTACK_TLS_CERT: undefined,
RSTACK_TLS_KEY: undefined,
RSTACK_BUSINESS_PORT: undefined,
RSTACK_PROJECT_ROOT: undefined,
RSTACK_NO_BROWSER: '1',
RSTACK_REGISTRY_DIR: join(root, '.registry'),
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let settled = false;
const timer = setTimeout(() => {
if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); }
}, SERVER_BOOT_TIMEOUT_MS);
child.stdout.on('data', (chunk) => {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
out += chunk;
const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/);
if (match && !settled) {
settled = true;
clearTimeout(timer);
resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') });
}
});
child.stderr.on('data', (chunk) => { out += chunk; });
child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } });
});
}

let browser = null;
test.before(async () => {
if (!chromium) return;
try { browser = await chromium.launch({ headless: true }); } catch { browser = null; }
});
test.after(async () => { if (browser) await browser.close(); });

function browserTest(name, seed, body) {
test(name, async (t) => {
if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; }
const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-'));
let server = null;
const context = await browser.newContext();
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
try {
await seed(root);
server = await startServer(root);
await body({ page, server, t });
assert.deepEqual(pageErrors, [], 'no uncaught page errors');
} finally {
await context.close();
if (server) server.stop();
rmSync(root, { recursive: true, force: true });
}
});
}

const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); };

async function gotoDashboard(page, server) {
await page.goto(server.baseUrl, { waitUntil: 'networkidle' });
await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS });
}

browserTest('scoping a run hides the Run Workspace empty prompt for real (#492)', seedRichProject, async ({ page, server }) => {
await gotoDashboard(page, server);
await page.evaluate(() => {
const buttons = [...document.querySelectorAll('nav button[data-page="run-workspace"]')];
(buttons.find((b) => b.offsetParent !== null) || buttons[0]).click();
});
// Scope to the first concrete run via the topbar select (the real user path).
await page.selectOption('#scope-run', { index: 1 });
await page.waitForFunction(() => {
const content = document.getElementById('run-workspace-content');
return content && !content.hidden;
}, null, { timeout: 5000 });

const visibility = await page.evaluate(() => {
const rect = (el) => el ? el.getBoundingClientRect() : null;
const empty = document.getElementById('run-workspace-empty');
const emptyRect = rect(empty);
return {
emptyHiddenAttr: Boolean(empty && empty.hidden),
emptyVisiblyRendered: Boolean(emptyRect && emptyRect.height > 0 && emptyRect.width > 0),
contentVisible: Boolean(document.getElementById('run-workspace-content') && !document.getElementById('run-workspace-content').hidden),
};
});
assert.equal(visibility.contentVisible, true, 'the scoped workspace is open');
assert.equal(visibility.emptyHiddenAttr, true, 'the empty prompt carries the hidden attribute');
assert.equal(visibility.emptyVisiblyRendered, false,
'the hidden empty prompt takes no space — [hidden] must not be defeated by the class display rule');
});
124 changes: 124 additions & 0 deletions tests/browser/dashboard-493-hero.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* #493 — the Command Center hero must never render the same sentence as
*
* both headline and rationale.
*
* Same harness as dashboard-96: real server on an ephemeral port, canonical
* fixtures, real Chromium via playwright-core; skips cleanly with no browser.
*
* owner: RStack developed by Richardson Gunde
*/

/* global document, window */

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js';

const require = createRequire(import.meta.url);
const HERE = dirname(fileURLToPath(import.meta.url));
const SERVER_PATH = join(HERE, '..', '..', 'src', 'observability', 'dashboard', 'server.js');

let chromium = null;
try { ({ chromium } = require('playwright-core')); } catch { /* dep absent */ }

// Named per the repo's magic-number convention (PR #490).
const SERVER_BOOT_TIMEOUT_MS = 15_000;
const STATE_LOAD_TIMEOUT_MS = 10_000;

function startServer(root) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, [SERVER_PATH, '--port', '0', '--no-browser', '--project', root], {
cwd: root,
env: {
...process.env,
RSTACK_APPROVAL_TOKEN: undefined,
RSTACK_DASHBOARD_READ_TOKEN: undefined,
RSTACK_TLS_CERT: undefined,
RSTACK_TLS_KEY: undefined,
RSTACK_BUSINESS_PORT: undefined,
RSTACK_PROJECT_ROOT: undefined,
RSTACK_NO_BROWSER: '1',
RSTACK_REGISTRY_DIR: join(root, '.registry'),
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let settled = false;
const timer = setTimeout(() => {
if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); }
}, SERVER_BOOT_TIMEOUT_MS);
child.stdout.on('data', (chunk) => {
out += chunk;
const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/);
if (match && !settled) {
settled = true;
clearTimeout(timer);
resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') });
}
});
child.stderr.on('data', (chunk) => { out += chunk; });
child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } });
});
}

let browser = null;
test.before(async () => {
if (!chromium) return;
try { browser = await chromium.launch({ headless: true }); } catch { browser = null; }
});
test.after(async () => { if (browser) await browser.close(); });

function browserTest(name, seed, body) {
test(name, async (t) => {
if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; }
const root = mkdtempSync(join(tmpdir(), 'rstack-browser-display-'));
let server = null;
const context = await browser.newContext();
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (err) => pageErrors.push(String(err)));
try {
await seed(root);
server = await startServer(root);
await body({ page, server, t });
assert.deepEqual(pageErrors, [], 'no uncaught page errors');
} finally {
await context.close();
if (server) server.stop();
rmSync(root, { recursive: true, force: true });
}
});
}

const seedRichProject = async (root) => { await fixtureReadyRun(root); await fixtureBlockedRun(root); };

async function gotoDashboard(page, server) {
await page.goto(server.baseUrl, { waitUntil: 'networkidle' });
await page.waitForFunction(() => window.STATE != null, null, { timeout: STATE_LOAD_TIMEOUT_MS });
}

browserTest('the Command Center hero never repeats its headline as the rationale (#493)', seedRichProject, async ({ page, server }) => {
await gotoDashboard(page, server);
const hero = await page.evaluate(() => {
const norm = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : '');
const rationale = document.getElementById('overview-rationale');
return {
title: norm(document.getElementById('overview-outcome-title')),
rationale: norm(rationale),
rationaleHidden: Boolean(rationale && rationale.hidden),
};
});
assert.ok(hero.title.length > 0, 'the hero has a headline');
if (!hero.rationaleHidden && hero.rationale.length > 0) {
assert.notEqual(hero.rationale, hero.title,
'a visible rationale must say something the headline does not');
}
});
Loading
Loading