399 lines
12 KiB
Markdown
399 lines
12 KiB
Markdown
|
|
---
|
||
|
|
title: Daily WSJF Triage
|
||
|
|
---
|
||
|
|
|
||
|
|
```js
|
||
|
|
import {POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||
|
|
import {injectTocTop} from "./components/toc-sidebar.js";
|
||
|
|
import {withDocHelp} from "./components/doc-overlay.js";
|
||
|
|
import {
|
||
|
|
ACTION_META,
|
||
|
|
actionMeta,
|
||
|
|
buildCandidateIndex,
|
||
|
|
buildPatternRows,
|
||
|
|
isWithinDays,
|
||
|
|
normalizeTriageReports,
|
||
|
|
resolveCandidate,
|
||
|
|
topAction,
|
||
|
|
truncateSummary,
|
||
|
|
} from "./components/wsjf-triage.js";
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
const triageState = (async function*() {
|
||
|
|
let failures = 0;
|
||
|
|
while (true) {
|
||
|
|
let events = [], workplanIndex = {workstreams: {}}, ok = false;
|
||
|
|
try {
|
||
|
|
const [reportsResp, indexResp] = await Promise.all([
|
||
|
|
apiFetch("/progress/?event_type=daily_triage&limit=14"),
|
||
|
|
apiFetch("/workstreams/workplan-index"),
|
||
|
|
]);
|
||
|
|
ok = reportsResp.ok && indexResp.ok;
|
||
|
|
events = reportsResp.ok ? await reportsResp.json() : [];
|
||
|
|
workplanIndex = indexResp.ok ? await indexResp.json() : {workstreams: {}};
|
||
|
|
} catch {}
|
||
|
|
failures = ok ? 0 : failures + 1;
|
||
|
|
yield {events, workplanIndex, ok, ts: new Date()};
|
||
|
|
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
const reports = normalizeTriageReports(triageState.events ?? []);
|
||
|
|
const candidateIndex = buildCandidateIndex(triageState.workplanIndex ?? {workstreams: {}});
|
||
|
|
const _ok = triageState.ok ?? false;
|
||
|
|
const _ts = triageState.ts;
|
||
|
|
const latestReport = reports[0] ?? null;
|
||
|
|
```
|
||
|
|
|
||
|
|
# Daily WSJF Triage
|
||
|
|
|
||
|
|
```js
|
||
|
|
function fmtDateTime(iso) {
|
||
|
|
if (!iso) return "-";
|
||
|
|
const d = new Date(iso);
|
||
|
|
return Number.isNaN(d.getTime()) ? String(iso) : d.toLocaleString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtDate(iso) {
|
||
|
|
if (!iso) return "-";
|
||
|
|
const d = new Date(iso);
|
||
|
|
return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : d.toLocaleDateString(undefined, {year: "numeric", month: "short", day: "numeric"});
|
||
|
|
}
|
||
|
|
|
||
|
|
function candidateNode(candidate, index) {
|
||
|
|
const resolved = resolveCandidate(candidate, index);
|
||
|
|
return resolved
|
||
|
|
? html`<a href="/workstreams/${resolved.id}" title=${resolved.filename ?? resolved.id}>${candidate}</a>`
|
||
|
|
: html`<span>${candidate || "-"}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function actionBadge(action, extraText = "") {
|
||
|
|
const meta = actionMeta(action);
|
||
|
|
return html`<span class="triage-action triage-action-${meta.tone}" title=${meta.description}>${meta.label}${extraText}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function recommendationTable(report, index) {
|
||
|
|
const recommendations = report.recommendations ?? [];
|
||
|
|
if (recommendations.length === 0) {
|
||
|
|
return html`<p class="triage-muted">No recommendations were recorded for this report.</p>`;
|
||
|
|
}
|
||
|
|
return html`<div>
|
||
|
|
<table class="triage-table triage-recommendations">
|
||
|
|
<thead><tr><th>#</th><th>Candidate</th><th>Action</th><th>Confidence</th><th>Why</th></tr></thead>
|
||
|
|
<tbody>${recommendations.map(rec => html`<tr>
|
||
|
|
<td>${rec.rank}</td>
|
||
|
|
<td>${candidateNode(rec.candidate, index)}</td>
|
||
|
|
<td>${actionBadge(rec.action)}</td>
|
||
|
|
<td><span class="triage-confidence">${rec.confidence}</span></td>
|
||
|
|
<td>${rec.why || "-"}</td>
|
||
|
|
</tr>`)}</tbody>
|
||
|
|
</table>
|
||
|
|
<div class="triage-legend">
|
||
|
|
${Object.values(ACTION_META).map(meta => html`<span>${actionBadge(meta.label)} ${meta.description}</span>`)}
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function reportMetadata(report) {
|
||
|
|
return html`<div class="triage-metadata">
|
||
|
|
<div><span>Scheduled for</span><strong>${fmtDateTime(report.scheduled_for)}</strong></div>
|
||
|
|
<div><span>Created</span><strong>${fmtDateTime(report.created_at)}</strong></div>
|
||
|
|
<div><span>Instruction</span><strong>${report.instruction_id ?? "-"}</strong></div>
|
||
|
|
<div><span>Activity run</span><strong>${report.activity_core_run_id ?? "-"}</strong></div>
|
||
|
|
<div><span>Activity</span><strong>${report.activity_id ?? "-"}</strong></div>
|
||
|
|
<div><span>Memory note</span><strong>${report.memory_path ?? "-"}</strong></div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderReportDetail(report, index) {
|
||
|
|
return html`<div>
|
||
|
|
<div class="triage-section-heading">
|
||
|
|
<h2>Report Detail</h2>
|
||
|
|
<span>${fmtDate(report.scheduled_for ?? report.created_at)}</span>
|
||
|
|
</div>
|
||
|
|
<section class="triage-detail-block">
|
||
|
|
<h3>Summary</h3>
|
||
|
|
<p>${report.summary || "-"}</p>
|
||
|
|
</section>
|
||
|
|
<section class="triage-detail-block">
|
||
|
|
<h3>Recommendations</h3>
|
||
|
|
${recommendationTable(report, index)}
|
||
|
|
</section>
|
||
|
|
<section class="triage-detail-block">
|
||
|
|
<h3>Run Metadata</h3>
|
||
|
|
${reportMetadata(report)}
|
||
|
|
</section>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPatterns(reports, index) {
|
||
|
|
const windowReports = reports.filter(report => isWithinDays(report.created_at, 14));
|
||
|
|
const rows = buildPatternRows(windowReports);
|
||
|
|
return html`<section class="triage-section">
|
||
|
|
<div class="triage-section-heading">
|
||
|
|
<h2>Patterns</h2>
|
||
|
|
<span>Last 14 days</span>
|
||
|
|
</div>
|
||
|
|
${rows.length === 0
|
||
|
|
? html`<p class="triage-muted">No repeated recommendations are visible in the loaded 14-day window.</p>`
|
||
|
|
: html`<table class="triage-table">
|
||
|
|
<thead><tr><th>Workstream</th><th>Times Recommended</th><th>Most Frequent Action</th></tr></thead>
|
||
|
|
<tbody>${rows.map(row => html`<tr>
|
||
|
|
<td>${candidateNode(row.candidate, index)}</td>
|
||
|
|
<td>${row.count} / ${Math.max(1, windowReports.length)} reports</td>
|
||
|
|
<td>${actionBadge(row.action, ` x${row.actionCount}`)}</td>
|
||
|
|
</tr>`)}</tbody>
|
||
|
|
</table>`}
|
||
|
|
</section>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderExplorer(reports, index) {
|
||
|
|
const root = html`<div class="triage-explorer"></div>`;
|
||
|
|
const detail = html`<section id="triage-report-detail" class="triage-section"></section>`;
|
||
|
|
const tableBody = html`<tbody></tbody>`;
|
||
|
|
const rows = [];
|
||
|
|
let selectedId = reports[0]?.id;
|
||
|
|
|
||
|
|
function selectReport(report, {scroll = true} = {}) {
|
||
|
|
selectedId = report.id;
|
||
|
|
for (const row of rows) {
|
||
|
|
row.classList.toggle("is-selected", row.dataset.reportId === selectedId);
|
||
|
|
row.setAttribute("aria-selected", row.dataset.reportId === selectedId ? "true" : "false");
|
||
|
|
}
|
||
|
|
detail.replaceChildren(renderReportDetail(report, index));
|
||
|
|
if (scroll) detail.scrollIntoView({behavior: "smooth", block: "start"});
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const report of reports) {
|
||
|
|
const top = topAction(report.recommendations);
|
||
|
|
const row = html`<tr class="triage-report-row" tabindex="0" role="button" data-report-id=${report.id} aria-selected="false">
|
||
|
|
<td>${fmtDate(report.scheduled_for ?? report.created_at)}</td>
|
||
|
|
<td>${truncateSummary(report.summary)}</td>
|
||
|
|
<td>${report.recommendations.length}</td>
|
||
|
|
<td>${top ? actionBadge(top.action, ` x${top.count}`) : "-"}</td>
|
||
|
|
</tr>`;
|
||
|
|
row.addEventListener("click", () => selectReport(report));
|
||
|
|
row.addEventListener("keydown", event => {
|
||
|
|
if (event.key === "Enter" || event.key === " ") {
|
||
|
|
event.preventDefault();
|
||
|
|
selectReport(report);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
rows.push(row);
|
||
|
|
}
|
||
|
|
tableBody.append(...rows);
|
||
|
|
|
||
|
|
root.append(
|
||
|
|
html`<section class="triage-section">
|
||
|
|
<div class="triage-section-heading">
|
||
|
|
<h2>Recent Reports</h2>
|
||
|
|
<span>${reports.length} loaded</span>
|
||
|
|
</div>
|
||
|
|
<table class="triage-table triage-reports">
|
||
|
|
<thead><tr><th>Date</th><th>Summary</th><th># Recs</th><th>Top Action</th></tr></thead>
|
||
|
|
${tableBody}
|
||
|
|
</table>
|
||
|
|
</section>`,
|
||
|
|
detail,
|
||
|
|
renderPatterns(reports, index),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (reports[0]) selectReport(reports[0], {scroll: false});
|
||
|
|
return root;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
const _liveEl = html`<div class="live-indicator">
|
||
|
|
<span class="live-dot" style="background:${_ok ? "var(--theme-foreground-focus)" : "red"}"></span>
|
||
|
|
${_ok
|
||
|
|
? `Live - updated ${_ts?.toLocaleTimeString()} - ${reports.length} triage reports`
|
||
|
|
: html`<span style="color:red">Offline - run: <code>make api</code></span>`}
|
||
|
|
</div>`;
|
||
|
|
withDocHelp(_liveEl, "/docs/live-data");
|
||
|
|
injectTocTop("live-indicator", _liveEl);
|
||
|
|
|
||
|
|
const _h1 = document.querySelector("#observablehq-main h1");
|
||
|
|
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); }
|
||
|
|
|
||
|
|
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.</p>`);
|
||
|
|
display(html`<div class="triage-latest">
|
||
|
|
<span>Last updated</span>
|
||
|
|
<strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong>
|
||
|
|
</div>`);
|
||
|
|
|
||
|
|
if (reports.length === 0) {
|
||
|
|
const empty = html`<div class="triage-empty">
|
||
|
|
<h2>No daily triage reports yet.</h2>
|
||
|
|
<p>The next run is scheduled for 07:20 Europe/Berlin (activity-core <code>daily-statehub-wsjf-triage</code>).</p>
|
||
|
|
</div>`;
|
||
|
|
withDocHelp(empty, "/docs/wsjf-triage");
|
||
|
|
display(empty);
|
||
|
|
} else {
|
||
|
|
display(renderExplorer(reports, candidateIndex));
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.live-indicator {
|
||
|
|
color: gray;
|
||
|
|
font-size: 0.8rem;
|
||
|
|
margin-bottom: 0.75rem;
|
||
|
|
padding: 0.55rem 1.8rem 0.55rem 0.7rem;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
.live-dot {
|
||
|
|
border-radius: 50%;
|
||
|
|
display: inline-block;
|
||
|
|
height: 0.5rem;
|
||
|
|
margin-right: 0.35rem;
|
||
|
|
width: 0.5rem;
|
||
|
|
}
|
||
|
|
.triage-subtitle {
|
||
|
|
color: var(--theme-foreground-muted, #666);
|
||
|
|
margin-top: -0.2rem;
|
||
|
|
max-width: 820px;
|
||
|
|
}
|
||
|
|
.triage-latest {
|
||
|
|
align-items: baseline;
|
||
|
|
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||
|
|
border-radius: 6px;
|
||
|
|
display: inline-flex;
|
||
|
|
gap: 0.45rem;
|
||
|
|
margin: 0.2rem 0 1rem;
|
||
|
|
padding: 0.35rem 0.55rem;
|
||
|
|
}
|
||
|
|
.triage-latest span,
|
||
|
|
.triage-section-heading span,
|
||
|
|
.triage-metadata span {
|
||
|
|
color: var(--theme-foreground-muted, #666);
|
||
|
|
font-size: 0.72rem;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
.triage-empty {
|
||
|
|
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||
|
|
border-radius: 6px;
|
||
|
|
margin-top: 1rem;
|
||
|
|
max-width: 760px;
|
||
|
|
padding: 1rem;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
.triage-empty h2 {
|
||
|
|
margin-top: 0;
|
||
|
|
}
|
||
|
|
.triage-section {
|
||
|
|
margin: 1.4rem 0 1.8rem;
|
||
|
|
}
|
||
|
|
.triage-section-heading {
|
||
|
|
align-items: baseline;
|
||
|
|
display: flex;
|
||
|
|
gap: 0.65rem;
|
||
|
|
justify-content: space-between;
|
||
|
|
margin-bottom: 0.45rem;
|
||
|
|
}
|
||
|
|
.triage-section-heading h2 {
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
.triage-detail-block {
|
||
|
|
margin: 0.9rem 0 1.2rem;
|
||
|
|
}
|
||
|
|
.triage-detail-block h3 {
|
||
|
|
font-size: 0.95rem;
|
||
|
|
margin-bottom: 0.35rem;
|
||
|
|
}
|
||
|
|
.triage-table {
|
||
|
|
border-collapse: collapse;
|
||
|
|
width: 100%;
|
||
|
|
}
|
||
|
|
.triage-table th,
|
||
|
|
.triage-table td {
|
||
|
|
border-bottom: 1px solid var(--theme-foreground-faint, #ddd);
|
||
|
|
padding: 0.45rem 0.5rem;
|
||
|
|
text-align: left;
|
||
|
|
vertical-align: top;
|
||
|
|
}
|
||
|
|
.triage-reports td:nth-child(2),
|
||
|
|
.triage-recommendations td:nth-child(5) {
|
||
|
|
max-width: 520px;
|
||
|
|
}
|
||
|
|
.triage-report-row {
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
.triage-report-row:hover,
|
||
|
|
.triage-report-row:focus {
|
||
|
|
background: var(--theme-background-alt, #f7f7f7);
|
||
|
|
outline: none;
|
||
|
|
}
|
||
|
|
.triage-report-row.is-selected {
|
||
|
|
background: color-mix(in srgb, var(--theme-foreground-focus, steelblue) 9%, transparent);
|
||
|
|
}
|
||
|
|
.triage-action {
|
||
|
|
border-radius: 4px;
|
||
|
|
display: inline-block;
|
||
|
|
font-size: 0.74rem;
|
||
|
|
font-weight: 700;
|
||
|
|
line-height: 1.2;
|
||
|
|
padding: 0.14rem 0.4rem;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
.triage-action-good {
|
||
|
|
background: #dcfce7;
|
||
|
|
color: #166534;
|
||
|
|
}
|
||
|
|
.triage-action-warn {
|
||
|
|
background: #fef3c7;
|
||
|
|
color: #92400e;
|
||
|
|
}
|
||
|
|
.triage-action-muted {
|
||
|
|
background: #f3f4f6;
|
||
|
|
color: #4b5563;
|
||
|
|
}
|
||
|
|
.triage-action-bad {
|
||
|
|
background: #fee2e2;
|
||
|
|
color: #991b1b;
|
||
|
|
}
|
||
|
|
.triage-confidence {
|
||
|
|
color: var(--theme-foreground-muted, #666);
|
||
|
|
font-size: 0.82rem;
|
||
|
|
}
|
||
|
|
.triage-legend {
|
||
|
|
color: var(--theme-foreground-muted, #666);
|
||
|
|
display: flex;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
gap: 0.45rem 0.8rem;
|
||
|
|
margin-top: 0.6rem;
|
||
|
|
}
|
||
|
|
.triage-legend span {
|
||
|
|
font-size: 0.76rem;
|
||
|
|
}
|
||
|
|
.triage-metadata {
|
||
|
|
display: grid;
|
||
|
|
gap: 0.65rem 1rem;
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
}
|
||
|
|
.triage-metadata div {
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
.triage-metadata strong {
|
||
|
|
display: block;
|
||
|
|
font-size: 0.86rem;
|
||
|
|
overflow-wrap: anywhere;
|
||
|
|
}
|
||
|
|
.triage-muted {
|
||
|
|
color: var(--theme-foreground-muted, #666);
|
||
|
|
}
|
||
|
|
@media (max-width: 760px) {
|
||
|
|
.triage-section-heading,
|
||
|
|
.triage-latest {
|
||
|
|
align-items: flex-start;
|
||
|
|
flex-direction: column;
|
||
|
|
}
|
||
|
|
.triage-metadata {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|