diff --git a/README-ZH.md b/README-ZH.md
index 6b6e88bc..1916c0e0 100644
--- a/README-ZH.md
+++ b/README-ZH.md
@@ -25,7 +25,7 @@

-新版看板按轮次展示工作汇报、成果、下一步和历史记录,并提供独立的用量与日志视图。截图使用已完成的 TableDelta 真实运行记录,以只读归档模式展示,不代表各 Agent 的实时活动。用量仅覆盖已记录的数据,缺失值保持未知,不等于完整账单。
+新版看板用纵向时间轴串联各轮次,展示简洁工作记录、检查结果、交付成果、用量与日志。截图使用 Outline Desk 连续三轮的真实记录,以只读归档模式展示。标题和摘要来自模型按固定格式填写的工作记录;运行事实和已接入检查的结果由程序记录。缺失或过期的信息明确标注,已记录用量不等于完整账单。采集约定与支持范围见[运行记录说明](docs/runtime-observability.md)。
## 这是什么?
@@ -232,7 +232,7 @@ Auto-Company 并非简单调用 LLM API,而是一个高度解耦的 **多智
### 第 5 层:监控与人机交互层 (Observability & HITL)
* **基于文件的操纵杆 (File-based Steering)**:人类只需编辑 `memories/consensus.md`,修改 `Next Action`,下一个周期醒来的 AI 团队就会立刻“转舵”,实现极简的宏观控制。
-* **日志与看板 (Dashboard)**:`logs/` 保存引擎实际输出(对已知凭据进行脱敏),以及每轮结果和可用的用量记录。输出详细程度取决于引擎,不保证包含完整思考链。`dashboard/` 按当前与历史轮次组织工作汇报、成果和下一步,并提供运行控制、状态、用量、预算与日志,不跟踪各个 Agent 的活跃度。
+* **日志与看板 (Dashboard)**:`logs/` 保存引擎实际输出(对已知凭据进行脱敏),以及每轮结果和可用的用量记录。输出详细程度取决于引擎,不保证包含完整思考链。`dashboard/` 按当前与历史轮次组织工作汇报和成果,并提供运行控制、状态、用量、预算与日志,不跟踪各个 Agent 的活跃度。
### 第 4 层:工作流路由层 (Workflow Routing & Teaming)
* **动态组队路由 (Dynamic Squad Formation)**:系统利用 Agent Teams 功能,根据当前 `consensus.md` 中的 "Next Action",从 14 人池子中动态挑选 2-5 名最适合的专家,并在当前循环中将它们“实例化”为子代理。
diff --git a/README.md b/README.md
index d2aaf236..355e662c 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Optional Cursor and OpenAI-compatible adapters require explicit configuration. S

-The current dashboard shows per-cycle work reports, results, next steps, and history, with separate usage and log views. This screenshot uses real records from a completed TableDelta run in read-only archive mode; it does not show live agent activity. Usage covers recorded data only; missing values remain unknown and do not represent a complete bill.
+The dashboard connects cycles in a vertical timeline, with concise work records, check results, deliverables, usage, and logs. This screenshot shows three real Outline Desk cycles in read-only archive mode. Titles and summaries are structured model reports; runtime facts and supported check results are recorded by the program. Missing or expired evidence stays explicit, and recorded usage is not a complete bill. See the [collection contract and supported tools](docs/runtime-observability.md).
## What Is This?
@@ -231,7 +231,7 @@ Auto-Company is not a simple LLM API wrapper, but a highly decoupled **Multi-Age
### Layer 5: Observability & HITL (Human-In-The-Loop)
* **File-based Steering**: Humans only need to edit `memories/consensus.md` and modify the `Next Action`. The AI team waking up in the next cycle will immediately pivot, enabling minimalist macro-control.
-* **Logs & Dashboard**: `logs/` saves engine-emitted output after known credential redaction, together with per-cycle results and available usage records. Output detail depends on the engine; complete reasoning traces are not guaranteed. `dashboard/` organizes current and historical cycle reports, results, and next steps, alongside runtime controls, status, usage, budgets, and logs. It does not track individual agents' activity.
+* **Logs & Dashboard**: `logs/` saves engine-emitted output after known credential redaction, together with per-cycle results and available usage records. Output detail depends on the engine; complete reasoning traces are not guaranteed. `dashboard/` organizes current and historical cycle reports and results, alongside runtime controls, status, usage, budgets, and logs. It does not track individual agents' activity.
### Layer 4: Workflow Routing & Teaming
* **Dynamic Squad Formation**: Powered by Agent Teams, the system dynamically selects 2-5 of the most suitable experts from the 14-person pool based on the "Next Action" in `consensus.md`, instantiating them as sub-agents for the current loop.
diff --git a/dashboard/LICENSE-lucide.txt b/dashboard/LICENSE-lucide.txt
new file mode 100644
index 00000000..1dd77f7c
--- /dev/null
+++ b/dashboard/LICENSE-lucide.txt
@@ -0,0 +1,20 @@
+Lucide SVG subset from 0.468.0, commit f12b0de177fbc2a6795e99be065887e72b237123
+Source: https://github.com/lucide-icons/lucide/tree/f12b0de177fbc2a6795e99be065887e72b237123/icons
+Icons: notebook-pen, chart-no-axes-column, terminal, play, square, sliders-horizontal, refresh-cw, file-text, list-checks, panels-top-left, external-link, chevron-right, x
+Stored inline in app.js; original geometry, decorative aria-hidden rendering.
+
+ISC License
+
+Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/dashboard/app.js b/dashboard/app.js
index 43b21780..67f26431 100644
--- a/dashboard/app.js
+++ b/dashboard/app.js
@@ -1,8 +1,33 @@
+/*
+Lucide SVG subset from 0.468.0, commit f12b0de177fbc2a6795e99be065887e72b237123
+Source: https://github.com/lucide-icons/lucide/tree/f12b0de177fbc2a6795e99be065887e72b237123/icons
+Icons: notebook-pen, chart-no-axes-column, terminal, play, square, sliders-horizontal, refresh-cw, file-text, list-checks, panels-top-left, external-link, chevron-right, x
+Stored inline in app.js; original geometry, decorative aria-hidden rendering.
+
+ISC License
+
+Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+*/
(() => {
'use strict';
+ // Lucide 0.468.0, pinned f12b0de; ISC/MIT notices in LICENSE-lucide.txt.
+ const ICONS = {"notebook-pen": "\n \n \n \n \n ", "chart-no-axes-column": "\n \n ", "terminal": "\n ", "play": "", "square": "", "sliders-horizontal": "\n \n \n \n \n \n \n \n ", "refresh-cw": "\n \n \n ", "file-text": "\n \n \n \n ", "list-checks": "\n \n \n \n ", "panels-top-left": "\n \n ", "external-link": "\n \n ", "chevron-right": "", "x": "\n "};
const $ = (id) => document.getElementById(id);
- const state = { data: null, language: 'zh-CN', tab: 'work', expanded: new Set(), older: false, selectedLog: 'runtime', logText: '', logLoadedId: '', logRequest: 0, logPending: null, refreshPending: null, signature: '', statusFailed: true, action: '', languageState: null, languageSaving: false, languageLoading: false, languageRevision: 0, languageError: '', languageSaved: false, timer: null, autoChanged: false, currentCycle: null };
+ const state = { data: null, language: 'zh-CN', tab: 'work', expanded: new Set(), older: false, selectedLog: 'runtime', logText: '', logLoadedId: '', logRequest: 0, logPending: null, refreshPending: null, signature: '', statusFailed: true, action: '', languageState: null, languageSaving: false, languageLoading: false, languageRevision: 0, languageError: '', languageSaved: false, timer: null, autoChanged: false, currentCycle: null, receivedAt: 0, elapsedTimer: null };
const message = (key, values = {}) => {
const dictionary = window.JOURNAL_MESSAGES[state.language] || window.JOURNAL_MESSAGES.en;
return Object.entries(values).reduce((result, [name, value]) => result.replaceAll(`{${name}}`, String(value)), dictionary[key] || key);
@@ -57,6 +82,16 @@
if (!Number.isFinite(seconds) || seconds < 0) return '';
return seconds < 60 ? message('seconds', { seconds }) : message('minutes', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 });
}
+ function liveDuration(cycle, now = performance.now()) {
+ const runtime = state.data?.runtime;
+ const age = now - state.receivedAt;
+ if (!cycle.active || cycle.status !== 'running' || state.statusFailed || state.action === 'stop' || state.data?.control?.stopUnconfirmed || runtime?.processState !== 'running' || (runtime?.currentCycleId && runtime.currentCycleId !== cycle.id) || runtime?.elapsedReliable !== true || !knownNumber(runtime.elapsedSeconds) || !Number.isFinite(age) || age < 0 || age > 15000) return message('elapsedUnconfirmed');
+ const seconds = Math.floor(runtime.elapsedSeconds + age / 1000);
+ return message('runningElapsed', { time: seconds < 60 ? message('seconds', { seconds }) : message('minutes', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) });
+ }
+ function updateElapsed() {
+ document.querySelectorAll('.live-elapsed').forEach((node) => { if (node.dataset.cycleId === state.currentCycle?.id) node.textContent = liveDuration(state.currentCycle); });
+ }
function cycleTitle(cycle) {
if (cycle.workReport) return cycle.workReport.title;
if (cycle.status === 'interrupted' && cycle.events?.length) return message('interruptedSummary');
@@ -71,14 +106,20 @@
}
function metadata(cycle) {
const row = element('div', 'cycle-meta');
- row.append(element('span', cycle.status === 'failed' ? 'status-failed' : '', statusLabel(cycle.status)));
+ if (cycle.workReport?.phase) row.append(element('span', 'reported-phase', message(`workPhase_${cycle.workReport.phase}`)));
+ if (['failed', 'interrupted', 'unknown', 'completed_with_timeout'].includes(cycle.status)) row.append(element('span', cycle.status === 'failed' ? 'status-failed' : '', statusLabel(cycle.status)));
row.append(element('span', '', message('startAt', { time: formatTime(cycle.startedAt) })));
const elapsed = duration(cycle);
if (elapsed) row.append(element('span', '', elapsed));
- if (cycle.durationReliable === false || cycle.status === 'interrupted') row.title = message('recoveredEnd');
+ if (cycle.active && cycle.status === 'running') {
+ const live = element('span', 'live-elapsed'); live.dataset.cycleId = cycle.id;
+ live.textContent = liveDuration(cycle); row.append(live);
+ }
+ if ((!cycle.active && cycle.durationReliable === false) || cycle.status === 'interrupted') { row.title = message('recoveredEnd'); row.append(element('span', '', message('durationUnknown'))); }
return row;
}
function bindDisclosure(details, key) {
+ details.dataset.disclosureKey = key;
details.open = state.expanded.has(key);
details.addEventListener('toggle', () => {
if (details.open) state.expanded.add(key);
@@ -96,6 +137,7 @@
if (!cycle.logAvailable) return element('p', 'sidebar-note cycle-log-link', message('noLog'));
const button = element('button', 'text-button cycle-log-link', message('viewLog'));
button.type = 'button';
+ button.dataset.focusKey = `log:${cycle.id}`;
button.addEventListener('click', () => {
state.selectedLog = cycle.id;
$('logSelect').value = cycle.id;
@@ -123,49 +165,109 @@
}
return list;
}
- function fullReport(cycle) {
- if (!cycle.report) return null;
- const details = bindDisclosure(element('details', 'report-disclosure'), `report:${cycle.id}`);
- const summary = element('summary', '', message('fullReport'));
- const body = element('div', 'full-report');
- for (const block of String(cycle.report).split(/\n\s*\n/)) {
- if (!block.trim()) continue;
- if (block.trim().startsWith('```')) {
- body.append(element('pre', 'report-code', block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')));
- } else if (block.trim().startsWith('|')) {
- const rows = reportRows({ report: block }, Infinity);
- if (rows.length) body.append(resultList(rows));
- } else if (/^\s*[-*]\s/.test(block)) {
- const list = element('ul');
- block.split('\n').filter((line) => line.trim()).forEach((line) => list.append(element('li', '', clean(line))));
- body.append(list);
- } else {
- body.append(element('p', '', clean(block)));
+ function checkCounts(tests) {
+ if (!tests) return {};
+ const result = { ...tests };
+ const keys = ['tests', 'failures', 'errors', 'skipped'];
+ if (keys.every((key) => Number.isInteger(tests[key]) && tests[key] >= 0) && tests.failures + tests.errors + tests.skipped <= tests.tests) result.passed = tests.tests - tests.failures - tests.errors - tests.skipped;
+ return result;
+ }
+ function progressState(status) {
+ if (status === 'completed') return 'completed';
+ if (status === 'running') return 'running';
+ if (['paused', 'waiting_limit', 'circuit_break', 'interrupted', 'completed_with_timeout', 'stopped'].includes(status)) return 'paused';
+ if (status === 'failed') return 'failed';
+ if (['pending', 'not_started'].includes(status)) return 'pending';
+ return 'unknown';
+ }
+ function progressIcon(status, label = statusLabel(status)) {
+ const state = progressState(status);
+ const node = element('span', `progress-node progress-${state}`);
+ if (state === 'pending') label = message('pendingCycle');
+ node.setAttribute('role', 'img'); node.setAttribute('aria-label', label); node.title = label;
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ for (const [key, value] of Object.entries({ viewBox: '0 0 32 32', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.7', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(key, value);
+ // Original status geometry: the sector indicates activity, never a measured percentage.
+ const marks = {
+ completed: '',
+ running: '',
+ paused: '',
+ pending: '',
+ failed: '',
+ unknown: '',
+ };
+ svg.innerHTML = `${marks[state]}`;
+ node.append(svg);
+ return node;
+ }
+ function checkPresentation(cycle) {
+ const check = cycle.latestCheck;
+ if (!check) return { status: 'unknown', detail: message(cycle.checkStatus === 'invalid' ? 'check_invalid' : 'noRegisteredCheck') };
+ const stale = cycle.checkStatus === 'stale' || check.freshness === 'stale' || ['changed', 'missing', 'stale'].includes(check.evidenceStatus);
+ if (stale) return { status: 'unknown', detail: message('check_stale') };
+ const status = cycle.checkStatus || check.state;
+ if (status === 'running') return { status, detail: message('check_running') };
+ if (status === 'interrupted') return { status, detail: message('check_interrupted') };
+ if (status === 'invalid') return { status: 'unknown', detail: message('check_invalid') };
+ const counts = checkCounts(check.tests);
+ const valid = Number.isInteger(counts.passed);
+ const failed = check.exitCode > 0 || counts.failures > 0 || counts.errors > 0;
+ const completed = check.evidenceStatus === 'completed' && status === 'completed';
+ const result = completed && failed ? 'failed' : completed && check.exitCode === 0 && valid && counts.passed > 0 ? 'completed' : 'unknown';
+ const parts = [];
+ if (valid && completed) {
+ for (const [key, label] of [['passed', 'countPassed'], ['failures', 'countFailed'], ['errors', 'countErrors'], ['skipped', 'countSkipped']]) {
+ if (counts[key] > 0 || key === 'passed') parts.push(message(label, { count: number(counts[key]) }));
}
}
- details.append(summary, body);
- return details;
- }
- function eventList(cycle) {
- const events = (cycle.events || []).filter((event) => event.kind !== 'report');
- if (!events.length) return null;
- const section = bindDisclosure(element('details', 'observed-events report-disclosure'), `events:${cycle.id}`);
- section.append(element('summary', '', message('observedEvents')));
- section.append(element('p', 'sidebar-note', message('eventTimeNote')));
- const list = element('ol', 'event-list');
- for (const event of events.slice(-12)) {
- const row = element('li');
- let label = message(`event_${event.kind}`);
- if (event.kind === 'command') {
- const ended = event.phase === 'completed';
- label = `${message(ended ? 'commandEnded' : 'commandStarted')} ${event.command || ''}`;
- if (ended) label += ` · ${message('exitCode')}: ${Number.isInteger(event.exitCode) ? event.exitCode : message('unknown')}`;
- } else if (event.kind === 'files') label = `${message('fileChanges')}: ${(event.paths || []).join(', ')}`;
- row.append(element('time', '', formatTime(event.observedAt)), element('span', '', label));
- list.append(row);
+ if (result === 'failed' && !(counts.failures > 0 || counts.errors > 0)) parts.push(message('checkFailedRun'));
+ if (!valid) parts.push(message('checkCountsUnknown'));
+ else if (!completed || result === 'unknown') parts.push(message('checkUnconfirmed'));
+ return { status: result, detail: parts.join(' · ') };
+ }
+ function cycleRecords(cycle) {
+ const section = element('section', 'cycle-records');
+ section.append(element('h3', 'content-heading', message('cycleRecords')));
+ if (cycle.projectStatus === 'other' || cycle.projectStatus === 'unknown') {
+ section.append(element('p', 'sidebar-note', message(cycle.projectStatus === 'other' ? 'otherProjectCycle' : 'unknownProjectCycle')));
+ return section;
+ }
+ const rows = [];
+ const check = cycle.latestCheck;
+ const presentation = checkPresentation(cycle);
+ rows.push({ kind: 'check', title: message('latestCheckRecord'), status: presentation.status, detail: presentation.detail,
+ time: check?.endedAt || check?.recordedAt || check?.startedAt,
+ path: check?.available === true ? check.path : null });
+ for (const artifact of cycle.artifacts || []) {
+ if (artifact.kind !== 'document') continue;
+ rows.push({ kind: 'document', time: artifact.recordedAt, title: message(artifact.available === true ? 'documentRecorded' : 'documentUnavailable'),
+ detail: String(artifact.path || artifact.label || '').split(/[\\/]/).pop() });
+ }
+ if (cycle.workReport && cycle.workReportStatus === 'valid') rows.push({ kind: 'report', time: cycle.workReport.recorded_at, title: message('workRecorded') });
+ // Missing timestamps remain unknown, rather than borrowing a cycle or consensus time.
+ rows.sort((a, b) => (Date.parse(a.time) || Infinity) - (Date.parse(b.time) || Infinity));
+ const list = element('ol', 'record-list');
+ for (const record of rows) {
+ const row = element('li', `record-row record-${record.kind}`);
+ const time = element('time', '', record.time ? formatTime(record.time) : '—');
+ if (record.time) { time.dateTime = record.time; time.title = formatTime(record.time, true); }
+ const body = element('div', 'record-body');
+ const title = element('div', 'record-title', record.title);
+ if (record.status) title.append(progressIcon(record.status, record.detail));
+ body.append(title);
+ if (record.detail || record.path) {
+ const detail = element('div', `record-detail${record.status === 'failed' ? ' status-failed' : ''}`);
+ if (record.detail) detail.append(element('span', 'record-counts', record.detail));
+ if (record.path) {
+ const link = element('a', 'check-report', message('checkReport'));
+ link.href = `/api/journal/document?path=${encodeURIComponent(record.path)}`; link.target = '_blank'; link.rel = 'noopener';
+ link.append(icon('external-link')); detail.append(link);
+ }
+ body.append(detail);
+ }
+ row.append(time, body); list.append(row);
}
section.append(list);
- if (cycle.eventStatus === 'partial') section.append(element('p', 'sidebar-note', message('partialEvents')));
return section;
}
function workReportDetails(cycle) {
@@ -176,9 +278,7 @@
section.append(element('p', 'sidebar-note', message(cycle.workReportStatus === 'invalid' ? 'invalidWorkReport' : 'missingWorkReport')));
return section;
}
- section.append(element('p', 'report-source', `${message('reportedPhase')} · ${message(`workPhase_${work.phase}`)}`));
if (work.blocker) section.append(element('p', 'work-blocker', `${message('workBlocker')}:${work.blocker}`));
- section.append(element('p', 'report-source', message('structuredReportSource')));
if (!cycle.active && !work.final) section.append(element('p', 'sidebar-note', message('unfinishedWorkReport')));
return section;
}
@@ -191,8 +291,9 @@
return;
}
const article = element('article', 'current-cycle');
+ article.setAttribute('aria-current', 'step');
+ article.setAttribute('aria-labelledby', 'cycleTitle');
const gutter = element('div', 'cycle-gutter');
- gutter.append(element('span', 'cycle-word', 'CYCLE'));
const cycleNumber = element('span', 'cycle-number', String(cycle.number ?? '—').padStart(2, '0'));
cycleNumber.id = 'cycleNumber';
gutter.append(cycleNumber);
@@ -217,8 +318,7 @@
const workDetails = workReportDetails(cycle);
if (workDetails) report.append(workDetails);
body.append(report);
- const observed = eventList(cycle);
- if (observed) body.append(observed);
+ body.append(cycleRecords(cycle));
const rows = cycle.workReport ? [] : reportRows(cycle);
if (rows.length) {
const results = element('section', 'results-section');
@@ -228,10 +328,8 @@
body.append(results);
}
if (cycle.report && !cycle.workReport) body.append(element('p', 'report-source', message('reportSource')));
- const disclosure = fullReport(cycle);
- if (disclosure) body.append(disclosure);
body.append(logButton(cycle));
- article.append(gutter, body);
+ article.append(gutter, progressIcon(cycle.status), body);
container.append(article);
}
function renderHistory() {
@@ -242,15 +340,14 @@
const row = bindDisclosure(element('details', 'history-row'), `cycle:${cycle.id}`);
row.dataset.cycleId = cycle.id;
const summary = element('summary');
- const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}`;
- summary.append(element('span', 'history-number', String(cycle.number ?? '—').padStart(2, '0')), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing));
- const arrow = element('span', 'history-chevron', '›');
+ const association = cycle.projectStatus === 'other' ? message('otherProjectCycle') : cycle.projectStatus === 'unknown' ? message('unknownProjectCycle') : '';
+ const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}${association ? ` · ${association}` : ''}`;
+ summary.append(element('span', 'history-number', String(cycle.number ?? '—').padStart(2, '0')), progressIcon(cycle.status), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing));
+ const arrow = icon('chevron-right'); arrow.classList.add('history-chevron');
arrow.setAttribute('aria-hidden', 'true');
summary.append(arrow);
const content = element('div', 'history-content');
content.append(metadata(cycle));
- const observed = eventList(cycle);
- if (observed) content.append(observed);
let report = cycle.workReport?.summary || clean(cycle.summary || '');
if (/^[\[{]/.test(report)) report = cycleTitle(cycle);
content.append(element('p', '', report || message('noSummary')));
@@ -258,8 +355,7 @@
if (workDetails) content.append(workDetails);
const results = cycle.workReport ? [] : reportRows(cycle);
if (results.length) content.append(resultList(results));
- const disclosure = fullReport(cycle);
- if (disclosure) content.append(disclosure);
+ content.append(cycleRecords(cycle));
content.append(logButton(cycle));
row.append(summary, content);
history.append(row);
@@ -267,6 +363,7 @@
$('olderButton').hidden = older.length <= 2;
$('olderButton').textContent = state.older ? message('fewer') : message('older', { count: older.length - 2 });
document.querySelector('.history-section').hidden = !older.length;
+ document.querySelector('.journal-layout').classList.toggle('no-history', !older.length);
}
function aggregate(cycles) {
const result = { inputTokens: null, outputTokens: null, totalTokens: null, known: 0, count: cycles.length, partial: false };
@@ -278,19 +375,17 @@
result.partial = result.known < cycles.length || cycles.some((cycle) => cycle.usage?.status === 'partial');
return result;
}
- function fileIcon() {
+ function icon(name) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('viewBox', '0 0 20 24');
- svg.setAttribute('class', 'artifact-icon');
- svg.setAttribute('fill', 'none');
- svg.setAttribute('stroke', 'currentColor');
- svg.setAttribute('stroke-width', '1.4');
- svg.setAttribute('aria-hidden', 'true');
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', 'M4 2h8l5 5v15H4zM12 2v6h5M7 12h7M7 16h7');
- svg.append(path);
+ for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', class: 'ui-icon', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.7', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' })) svg.setAttribute(key, value);
+ // Only trusted, pinned library geometry is parsed here; all runtime content uses textContent.
+ svg.innerHTML = ICONS[name] || ICONS['file-text'];
return svg;
}
+ function iconLabel(node, name, text) {
+ node.replaceChildren(icon(name), element('span', '', text));
+ return node;
+ }
function runtimeRows(entries) {
const list = element('dl', 'runtime-details');
for (const [label, value] of entries) list.append(element('dt', '', label), element('dd', '', !value || value === 'unknown' ? message('unknown') : value));
@@ -315,8 +410,8 @@
$('runtimeState').dataset.state = unavailable ? 'unavailable' : runtime.state || 'unknown';
$('startButton').disabled = locked || retryStop || !['stopped', 'inactive'].includes(process);
$('stopButton').disabled = locked || (!retryStop && process !== 'running');
- $('startButton').textContent = message(action === 'start' ? 'starting' : 'start');
- $('stopButton').textContent = message(action === 'stop' ? 'stopping' : 'stop');
+ iconLabel($('startButton'), 'play', message(action === 'start' ? 'starting' : 'start'));
+ iconLabel($('stopButton'), 'square', message(action === 'stop' ? 'stopping' : 'stop'));
$('startButton').title = $('stopButton').title = readOnly() ? message('readOnly') : unavailable ? message('statusUnavailable') : '';
$('refreshButton').disabled = Boolean(state.refreshPending || state.action);
$('modeNote').textContent = data ? message(readOnly() ? 'preview' : 'live') : '';
@@ -327,6 +422,7 @@
const paused = ['paused', 'waiting_limit', 'circuit_break'].includes(runtime.state);
$('runtimeNotice').hidden = unavailable || (!reason && !paused && !data?.budgetPause);
$('runtimeNotice').textContent = reason ? message('pauseReason', { reason: pauseLabel(reason) }) : paused ? message('pauseReview') : data?.budgetPause ? message('budgetPause') : '';
+ updateElapsed();
renderDiagnostics();
}
function renderDiagnostics() {
@@ -398,45 +494,43 @@
} catch (_) { state.languageError = 'languageSaveFailed'; }
finally { state.languageSaving = false; renderLanguage(); }
}
+ function unavailableArtifact(artifact) {
+ if (artifact.kind === 'preview') {
+ return message(artifact.state === 'stopped' ? 'previewEnded' : artifact.state === 'interrupted' ? 'previewInterrupted' : 'previewUnavailable');
+ }
+ return message(artifact.evidenceStatus === 'stale' ? 'artifactStale' : 'artifactUnavailable');
+ }
function renderSidebar() {
const sidebar = clear($('projectSidebar'));
const data = state.data;
- const next = element('section', 'sidebar-block');
- const heading = element('div', 'section-heading-row');
- heading.append(element('h2', '', message('nextAction')));
- next.append(heading);
- const work = state.currentCycle?.workReport;
- const nextTime = work?.recorded_at || data.consensus?.updatedAt;
- if (nextTime) heading.append(element('span', 'sidebar-update', message('updated', { time: formatTime(nextTime) })));
- next.append(element('p', 'next-action', work ? (work.next_action || message('noPlannedAction')) : clean(data.consensus?.nextAction) || message('noNextAction')));
- if (work?.next_action_kind === 'human_input') next.append(element('p', 'sidebar-note', message('humanInput')));
- next.append(element('p', 'sidebar-note', message(readOnly() || (!state.statusFailed && data.runtime?.processState === 'stopped') ? 'stoppedNotice' : work ? 'workPlanNotice' : 'planNotice')));
+ const overview = element('section', 'sidebar-block project-overview');
+ const name = element('h1', '', data.project?.name || message('noProject')); name.id = 'projectName';
+ const description = element('p', 'project-description', clean(data.project?.description)); description.id = 'projectDescription'; description.title = description.textContent;
+ const latest = state.currentCycle;
+ const date = element('p', 'sidebar-note', latest?.active ? message('currentRun') : latest ? message('latestRun', { date: formatDate(latest.startedAt) }) : message(readOnly() ? 'archived' : 'ready')); date.id = 'runHeading';
+ overview.append(name, description, date);
const artifacts = element('section', 'sidebar-block');
artifacts.append(element('h2', '', message('artifacts')));
- if (data.artifacts?.length) {
+ const visibleArtifacts = (data.artifacts || []).filter((artifact) => artifact.kind !== 'check');
+ if (visibleArtifacts.length) {
const list = element('ul', 'artifact-list');
- for (const artifact of data.artifacts) {
+ for (const artifact of visibleArtifacts) {
const item = element('li');
- const label = artifact.kind === 'preview' ? message('productPreview') : artifact.label || artifact.path;
+ const label = artifact.kind === 'preview' ? message(artifact.available === false ? 'previewName' : 'productPreview') : artifact.path?.split(/[\\/]/).pop()?.toUpperCase() === 'DELIVERY.MD' ? message('deliveryDocument') : String(artifact.label || artifact.path || '').split(/[\\/]/).pop();
if (artifact.available === false || (!artifact.path && !artifact.url)) {
- item.append(element('span', '', `${label} · ${message('artifactUnavailable')}`));
+ item.append(element('span', '', `${label} · ${unavailableArtifact(artifact)}`));
} else {
const link = element('a', 'artifact-link');
link.href = artifact.url || `/api/journal/document?path=${encodeURIComponent(artifact.path)}`;
link.target = '_blank';
link.rel = 'noopener';
link.title = artifact.path || artifact.url;
- link.append(fileIcon(), element('span', '', label));
- const arrow = element('span', 'artifact-arrow', '↗');
+ link.append(icon(artifact.kind === 'preview' ? 'panels-top-left' : 'file-text'), element('span', '', label));
+ const arrow = icon('external-link'); arrow.classList.add('artifact-arrow');
arrow.setAttribute('aria-hidden', 'true');
link.append(arrow);
item.append(link);
}
- if (artifact.kind === 'check') {
- const tests = artifact.tests;
- const result = `${message('exitCode')}: ${Number.isInteger(artifact.exitCode) ? artifact.exitCode : message('unknown')}`;
- item.append(element('p', 'sidebar-note', tests ? `${result} · ${message('testCounts', { tests: tests.tests, failures: tests.failures + tests.errors, skipped: tests.skipped })}` : result));
- }
if (artifact.recordedAt) item.append(element('p', 'sidebar-note', message('recordedAt', { time: formatTime(artifact.recordedAt) })));
list.append(item);
}
@@ -448,16 +542,19 @@
const usage = `${compactNumber(sum.totalTokens)}${knownNumber(sum.totalTokens) ? ' tokens' : ''}${sum.partial && sum.known ? message('usagePartialShort') : ''}`;
const config = data.runtime || {};
runtime.append(runtimeRows([[message('engine'), config.engine], [message('model'), config.model], [message('reasoning'), config.reasoning === 'unknown' ? message('unknown') : config.reasoning], [message('language'), languageLabel(config.language || data.language)], [message('recordedUsage'), usage]]));
- runtime.append(element('p', 'sidebar-note', message(config.configSource === 'session_context' ? 'observedSession' : 'unconfirmedSession')));
const details = bindDisclosure(element('details', 'runtime-disclosure'), 'runtime');
- details.append(element('summary', '', message('moreRuntime')), runtimeRows([[message('state'), runtimeLabel()], [message('phase'), clean(data.consensus?.phase) || message('phaseUnknown')], [message('source'), data.sourceName]]));
+ details.append(element('summary', '', message('moreRuntime')), runtimeRows([[message('state'), runtimeLabel()], [message('source'), data.sourceName]]));
+ details.append(element('p', 'sidebar-note', message(config.configSource === 'session_context' ? 'observedSession' : 'unconfirmedSession')));
runtime.append(details);
- sidebar.append(next, artifacts, runtime);
+ sidebar.append(overview, artifacts, runtime);
}
function applyLanguage() {
document.documentElement.lang = state.language;
document.title = `${state.data?.project?.name || 'Auto Company'} · ${message('work')}`;
document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = message(node.dataset.i18n); });
+ for (const [id, name] of [['tab-work', 'notebook-pen'], ['tab-usage', 'chart-no-axes-column'], ['tab-logs', 'terminal'], ['settingsButton', 'sliders-horizontal']]) iconLabel($(id), name, $(id).textContent);
+ $('refreshButton').replaceChildren(icon('refresh-cw'));
+ $('closeSettingsButton').replaceChildren(icon('x'));
$('refreshButton').title = message('refresh');
$('refreshButton').setAttribute('aria-label', message('refresh'));
$('closeSettingsButton').setAttribute('aria-label', message('close'));
@@ -465,21 +562,30 @@
document.querySelector('.table-scroll').setAttribute('aria-label', message('usageDetail'));
$('projectSidebar').setAttribute('aria-label', message('artifacts'));
}
+ function rememberFocus() {
+ const active = document.activeElement;
+ if (!active || active === document.body) return null;
+ const disclosure = active.closest('details[data-disclosure-key]');
+ return { id: active.id, key: active.dataset.focusKey, disclosure: active.tagName === 'SUMMARY' ? disclosure?.dataset.disclosureKey : null };
+ }
+ function restoreFocus(saved) {
+ if (!saved) return;
+ const node = saved.id ? $(saved.id) : saved.key ? [...document.querySelectorAll('[data-focus-key]')].find((item) => item.dataset.focusKey === saved.key) : saved.disclosure ? [...document.querySelectorAll('details[data-disclosure-key]')].find((item) => item.dataset.disclosureKey === saved.disclosure)?.querySelector(':scope > summary') : null;
+ if (node && !node.disabled) node.focus({ preventScroll: true });
+ }
+ function latestCycle(data) {
+ const scoped = Boolean(data.project?.id) && Object.hasOwn(data, 'latestProjectCycleId');
+ return data.cycles.find((cycle) => cycle.active && (!scoped || cycle.projectStatus === 'current'))
+ || (scoped ? data.cycles.find((cycle) => cycle.id === data.latestProjectCycleId && cycle.projectStatus === 'current') : data.cycles[0]);
+ }
function render() {
+ const focus = rememberFocus();
const data = state.data;
applyLanguage();
- $('projectName').textContent = data.project?.name || message('noProject');
- const fullDescription = clean(data.project?.description);
- let description = fullDescription;
- if (data.project?.name && description.startsWith(data.project.name)) {
- description = description.slice(data.project.name.length).replace(/^[\s,,::·—-]+/, '');
- }
- $('projectDescription').textContent = description.split(/[;;]/)[0];
- $('projectDescription').title = fullDescription;
- let latest = data.cycles.find((cycle) => cycle.active) || (liveProcess() ? { id: '__current__', number: data.runtime?.currentCycleNumber, startedAt: data.runtime?.lastRun, status: data.runtime?.state || 'running', active: true, synthetic: true } : data.cycles[0]);
+ let latest = latestCycle(data);
+ if (!latest?.active && liveProcess() && data.runtime?.state === 'running') latest = { id: '__current__', number: data.runtime?.currentCycleNumber, startedAt: data.runtime?.lastRun, status: 'running', active: true, synthetic: true };
if (latest?.active && state.statusFailed) latest = { ...latest, active: false, status: 'unknown' };
state.currentCycle = latest;
- $('runHeading').textContent = latest?.active ? message('currentRun') : latest ? message('latestRun', { date: formatDate(latest.startedAt) }) : message(readOnly() ? 'archived' : 'ready');
renderCurrent(latest);
renderHistory();
renderSidebar();
@@ -498,6 +604,7 @@
renderUsage();
renderLogOptions();
selectTab(state.tab);
+ restoreFocus(focus);
}
function filterUsage() {
const period = $('usagePeriod').value;
@@ -652,7 +759,9 @@
const data = await fetchJSON('/api/journal');
if (!data.ok || !Array.isArray(data.cycles)) throw new Error('invalid journal response');
const signature = JSON.stringify({ ...data, generatedAt: undefined, status: data.status ? { ...data.status, timestamp: undefined, elapsedMs: undefined } : undefined });
+ if (state.data && Number.isFinite(Date.parse(data.generatedAt)) && Date.parse(data.generatedAt) < Date.parse(state.data.generatedAt)) throw new Error('Out-of-order journal snapshot');
state.data = data;
+ state.receivedAt = performance.now();
state.statusFailed = data.runtime?.available === false || data.status?.ok === false || !data.runtime || ['unknown', 'unavailable'].includes(data.runtime.state);
if (languageRevision === state.languageRevision && !state.languageSaving && !state.languageLoading) {
if (data.languageState) {
@@ -680,7 +789,7 @@
$('loadingState').hidden = true;
$('connectionError').hidden = false;
$('connectionError').textContent = message(state.data ? 'stale' : 'readFailed');
- $('refreshStatus').textContent = '';
+ $('refreshStatus').textContent = state.data?.generatedAt ? message('lastUpdated', { time: formatTime(state.data.generatedAt, true) }) : '';
if (state.data) render();
else renderRuntime();
} finally {
@@ -730,6 +839,7 @@
if (tabs[next] === 'logs') loadLog();
});
});
+ state.elapsedTimer = setInterval(updateElapsed, 1000);
$('refreshButton').addEventListener('click', refresh);
$('startButton').addEventListener('click', () => runAction('start'));
$('stopButton').addEventListener('click', () => runAction('stop'));
diff --git a/dashboard/i18n.js b/dashboard/i18n.js
index b89471dd..71415dab 100644
--- a/dashboard/i18n.js
+++ b/dashboard/i18n.js
@@ -1,6 +1,19 @@
/* The journal follows the shared preference and the current product language lock. */
window.JOURNAL_MESSAGES = {
"zh-CN": {
+ "pendingCycle": "尚未开始",
+ "checkUnconfirmed": "检查结果尚未确认",
+ "cycleRecords": "本轮记录",
+ "latestCheckRecord": "最近一次检查",
+ "noRegisteredCheck": "暂无已登记检查",
+ "documentRecorded": "文档已登记",
+ "documentUnavailable": "文档记录已失效",
+ "workRecorded": "更新工作记录",
+ "deliveryDocument": "查看交付说明",
+ "countPassed": "{count} 项通过",
+ "countFailed": "{count} 项失败",
+ "countErrors": "{count} 项错误",
+ "countSkipped": "{count} 项跳过",
"reportedPhase": "记录的工作阶段",
"workPhase_planning": "梳理方案",
"workPhase_implementing": "执行任务",
@@ -12,9 +25,6 @@ window.JOURNAL_MESSAGES = {
"invalidWorkReport": "本轮工作记录格式有误,暂用已有汇报。",
"missingWorkReport": "本轮未提交规范工作记录,暂用已有汇报。",
"unfinishedWorkReport": "本轮未提交最终工作记录,以上为最后一次有效更新。",
- "noPlannedAction": "当前未记录下一步动作。",
- "humanInput": "需要人工输入或确认;尚未确认开始执行。",
- "workPlanNotice": "这是最近记录的下一步计划,尚未确认开始执行。",
"observedEvents": "实际执行记录",
"eventTimeNote": "时间为系统接收到事件的时间;命令退出不代表功能验收通过。",
"event_process.started": "启动本轮执行",
@@ -77,9 +87,6 @@ window.JOURNAL_MESSAGES = {
"noReport": "本轮没有可读取的工作汇报。",
"viewLog": "查看本轮日志 →",
"noLog": "本轮日志未保存或已清理",
- "nextAction": "最近记录的下一步",
- "noNextAction": "共识文件尚未记录下一步。",
- "updated": "更新于 {time}",
"artifacts": "项目成果",
"noArtifacts": "尚无可读取的成果文档。",
"runtime": "运行信息",
@@ -92,7 +99,6 @@ window.JOURNAL_MESSAGES = {
"phase": "最近记录的阶段",
"source": "数据来源",
"state": "运行状态",
- "stoppedNotice": "本次自主运行已停止。下一步是历史记录,不表示正在执行。",
"older": "更早记录 · {count} 轮",
"fewer": "收起更早记录",
"ledger": "运行用量账本",
@@ -187,7 +193,6 @@ window.JOURNAL_MESSAGES = {
"runningSummary": "本轮正在进行",
"unknownSummary": "本轮状态尚未确认",
"runningNoReport": "本轮尚未保存工作汇报。可查看运行日志了解过程。",
- "planNotice": "这是最近保存的共识计划,不代表实时任务或子代理活动。",
"chooseLog": "选择日志",
"runtimeLog": "全局运行日志",
"viewRuntimeLog": "查看运行日志 →",
@@ -246,9 +251,63 @@ window.JOURNAL_MESSAGES = {
"budget_unknown": "未知",
"budget_complete": "完整记录",
"budget_partial": "部分记录",
- "budget_unavailable": "未记录"
+ "budget_unavailable": "未记录",
+ "recentExecution": "最近执行",
+ "recentChecks": "最近检查",
+ "noEvents": "本轮尚无已采集的执行事件。",
+ "commandStartedLabel": "运行命令",
+ "commandEndedLabel": "命令结束",
+ "fileChangesLabel": "文件变更",
+ "exactCommand": "查看记录的命令",
+ "sourceAndScope": "来源与范围",
+ "elapsedUnconfirmed": "运行耗时待确认",
+ "runningElapsed": "已运行 {time}",
+ "lastUpdated": "上次更新 {time}",
+ "check_unregistered": "本轮暂无已登记检查;不表示通过或失败。",
+ "check_running": "检查运行中",
+ "check_completed": "检查已结束",
+ "check_interrupted": "检查已中断",
+ "check_invalid": "检查记录无效",
+ "check_stale": "上次检查结果已过期",
+ "checkCountsUnknown": "检查计数尚未确认。",
+ "checkPassed": "通过",
+ "checkFailed": "失败",
+ "checkErrors": "错误",
+ "checkSkipped": "跳过",
+ "checkTotal": "总计",
+ "checkReport": "打开检查报告",
+ "reportAvailability": "报告状态",
+ "checkScopeNote": "只包含此项目、本轮已登记检查;命令成功不代表业务验收。",
+ "checkFailedRun": "检查失败",
+ "report_fresh": "已验证当前报告",
+ "report_recorded": "已记录报告",
+ "report_missing_or_stale": "报告缺失或已变更",
+ "report_unsupported": "尚无此格式的计数解析",
+ "report_unavailable": "无可用报告",
+ "otherProjectCycle": "其他项目",
+ "unknownProjectCycle": "项目归属未记录",
+ "durationUnknown": "耗时未确认",
+ "commandTruncated": "命令记录已截断。",
+ "artifactStale": "文件已变更或缺失",
+ "previewEnded": "预览已结束",
+ "previewInterrupted": "预览已中断",
+ "previewUnavailable": "预览不可用",
+ "previewName": "产品预览"
},
"en": {
+ "pendingCycle": "Not started",
+ "checkUnconfirmed": "Check result is not confirmed",
+ "cycleRecords": "Cycle record",
+ "latestCheckRecord": "Latest check",
+ "noRegisteredCheck": "No registered check",
+ "documentRecorded": "Document registered",
+ "documentUnavailable": "Document record unavailable",
+ "workRecorded": "Work record updated",
+ "deliveryDocument": "View delivery document",
+ "countPassed": "{count} passed",
+ "countFailed": "{count} failed",
+ "countErrors": "{count} errors",
+ "countSkipped": "{count} skipped",
"reportedPhase": "Reported work phase",
"workPhase_planning": "Planning",
"workPhase_implementing": "Working",
@@ -260,9 +319,6 @@ window.JOURNAL_MESSAGES = {
"invalidWorkReport": "This cycle's work report is invalid. Showing the existing report.",
"missingWorkReport": "No structured work report was submitted for this cycle. Showing the existing report.",
"unfinishedWorkReport": "No final work report was submitted; showing the last valid update.",
- "noPlannedAction": "No next action is currently recorded.",
- "humanInput": "Human input or confirmation is needed; execution has not been confirmed.",
- "workPlanNotice": "This is the last recorded next action; execution has not been confirmed.",
"observedEvents": "Observed execution",
"eventTimeNote": "Times show when events were received; command exit is not feature acceptance.",
"event_process.started": "Cycle process started",
@@ -325,9 +381,6 @@ window.JOURNAL_MESSAGES = {
"noReport": "No readable work report is available for this cycle.",
"viewLog": "View cycle log →",
"noLog": "The cycle log was not saved or has been cleared",
- "nextAction": "Last recorded next step",
- "noNextAction": "No next step has been recorded in the consensus file.",
- "updated": "Updated {time}",
"artifacts": "Project files",
"noArtifacts": "No project documents are available yet.",
"runtime": "Runtime",
@@ -340,7 +393,6 @@ window.JOURNAL_MESSAGES = {
"phase": "Last recorded phase",
"source": "Data source",
"state": "Run state",
- "stoppedNotice": "This autonomous run has stopped. The next step is a historical record, not a live task.",
"older": "Earlier records · {count} cycles",
"fewer": "Hide earlier records",
"ledger": "Run usage ledger",
@@ -435,7 +487,6 @@ window.JOURNAL_MESSAGES = {
"runningSummary": "This cycle is in progress",
"unknownSummary": "Cycle state is unconfirmed",
"runningNoReport": "No work report has been saved for this cycle yet. Open the runtime log to follow its progress.",
- "planNotice": "This is the last saved consensus plan, not a live task or subagent activity feed.",
"chooseLog": "Select log",
"runtimeLog": "Global runtime log",
"viewRuntimeLog": "View runtime log →",
@@ -494,6 +545,47 @@ window.JOURNAL_MESSAGES = {
"budget_unknown": "Unknown",
"budget_complete": "Complete records",
"budget_partial": "Partial records",
- "budget_unavailable": "Not recorded"
+ "budget_unavailable": "Not recorded",
+ "recentExecution": "Recent execution",
+ "recentChecks": "Recent checks",
+ "noEvents": "No execution events collected for this cycle.",
+ "commandStartedLabel": "Command started",
+ "commandEndedLabel": "Command finished",
+ "fileChangesLabel": "Files changed",
+ "exactCommand": "Show recorded command",
+ "sourceAndScope": "Source and scope",
+ "elapsedUnconfirmed": "Elapsed time unconfirmed",
+ "runningElapsed": "Running for {time}",
+ "lastUpdated": "Last updated {time}",
+ "check_unregistered": "No registered checks for this cycle; no pass or failure is implied.",
+ "check_running": "Check running",
+ "check_completed": "Check finished",
+ "check_interrupted": "Check interrupted",
+ "check_invalid": "Check record invalid",
+ "check_stale": "Previous check result is stale",
+ "checkCountsUnknown": "Check counts are not confirmed.",
+ "checkPassed": "Passed",
+ "checkFailed": "Failed",
+ "checkErrors": "Errors",
+ "checkSkipped": "Skipped",
+ "checkTotal": "Total",
+ "checkReport": "Open check report",
+ "reportAvailability": "Report status",
+ "checkScopeNote": "Only registered checks for this project and cycle; command success is not business acceptance.",
+ "checkFailedRun": "Check failed",
+ "report_fresh": "Current report verified",
+ "report_recorded": "Report recorded",
+ "report_missing_or_stale": "Report missing or changed",
+ "report_unsupported": "Count parsing unavailable for this format",
+ "report_unavailable": "No available report",
+ "otherProjectCycle": "Other project",
+ "unknownProjectCycle": "Project not recorded",
+ "durationUnknown": "Duration unconfirmed",
+ "commandTruncated": "The recorded command is truncated.",
+ "artifactStale": "File changed or missing",
+ "previewEnded": "Preview ended",
+ "previewInterrupted": "Preview interrupted",
+ "previewUnavailable": "Preview unavailable",
+ "previewName": "Product preview"
}
};
diff --git a/dashboard/index.html b/dashboard/index.html
index 04290588..e178f06b 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -15,10 +15,6 @@