feat(ql3): surface bounded console run logs

This commit is contained in:
whyour
2026-08-28 12:27:19 +08:00
parent 920a31fb66
commit 57953ec80b
12 changed files with 391 additions and 14 deletions
@@ -577,6 +577,60 @@ input:focus-visible,
border-color: var(--danger);
}
.run-log {
margin: 28px 0;
border: 1px solid var(--line);
background: var(--deep);
color: var(--white);
}
.run-log-header {
padding: 10px 12px;
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
font: 650 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.run-log-header span,
.run-log-meta,
.run-log-placeholder {
color: rgba(255, 255, 255, 0.62);
}
.run-log-meta,
.run-log-placeholder {
margin: 0;
padding: 11px 12px;
font-size: 10px;
line-height: 1.5;
}
.run-log-content {
max-height: 320px;
margin: 0;
padding: 14px 12px 18px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 500 11px/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
tab-size: 2;
}
.run-log[data-state='pending'],
.run-log[data-state='not_started'] {
border-left: 4px solid var(--amber);
}
.run-log[data-state='retired'],
.run-log[data-state='not_found'],
.run-log[data-state='unavailable'] {
border-left: 4px solid var(--line);
}
.timeline-heading {
margin: 30px 0 14px;
font-size: 12px;
@@ -4,6 +4,7 @@
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({
created: '已创建',
@@ -103,6 +104,27 @@
: value || '—';
}
function decodeBase64Utf8(value) {
if (
typeof value !== 'string' ||
value.length > 48 * 1024 ||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(value)
) {
return null;
}
try {
const binary = window.atob(value);
if (binary.length > LOG_READ_BYTES) return null;
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new TextDecoder('utf-8').decode(bytes);
} catch {
return null;
}
}
function statusTone(status) {
if (status === 'failed' || status === 'timed_out') return 'failed';
if (
@@ -485,13 +507,100 @@
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
]);
renderRunDetail(runValue.run, eventValue, stepValue);
const logView = await readRunLog(runValue.run);
renderRunDetail(runValue.run, eventValue, stepValue, logView);
} catch (error) {
detailEmpty(describeError(error));
}
}
function renderRunDetail(run, eventPage, stepPage) {
async function readRunLog(run) {
const attempt = run?.latestAttempt;
if (!attempt || typeof attempt.id !== 'string') {
return Object.freeze({ status: 'not_started' });
}
try {
const value = await api(
`/api/v3/projects/${state.project}/runs/${run.id}/attempts/${attempt.id}/log?offset=0&length=${LOG_READ_BYTES}`,
);
if (value.status === 'pending') {
return Object.freeze({ status: 'pending', attempt });
}
const content =
value.status === 'available' && value.encoding === 'base64'
? decodeBase64Utf8(value.content)
: null;
if (
content === null ||
!value.range ||
!Number.isSafeInteger(value.range.start) ||
!Number.isSafeInteger(value.range.endExclusive) ||
!Number.isSafeInteger(value.range.totalBytes)
) {
return Object.freeze({ status: 'unavailable', attempt });
}
return Object.freeze({
status: 'available',
attempt,
content,
range: value.range,
truncation: value.truncation,
});
} catch (error) {
if (error instanceof ConsoleRequestError && error.status === 410) {
return Object.freeze({ status: 'retired', attempt });
}
if (error instanceof ConsoleRequestError && error.status === 404) {
return Object.freeze({ status: 'not_found', attempt });
}
return Object.freeze({ status: 'unavailable', attempt });
}
}
function renderRunLog(logView) {
const section = element('section', 'run-log');
section.dataset.state = logView.status;
const header = element('div', 'run-log-header');
header.append(element('strong', null, 'Bounded log'));
if (logView.attempt) {
header.append(
element(
'span',
null,
`Attempt ${logView.attempt.attempt} · ${logView.attempt.status}`,
),
);
}
section.append(header);
if (logView.status === 'available') {
const metadata = [
`${logView.range.start}${logView.range.endExclusive} / ${logView.range.totalBytes} bytes`,
];
if (logView.truncation?.truncated === true) metadata.push('执行端已截断');
if (logView.truncation?.truncated === 'unknown')
metadata.push('截断状态未知');
if (logView.range.nextOffset !== undefined)
metadata.push('后续内容可经 API 分页读取');
section.append(element('p', 'run-log-meta', metadata.join(' · ')));
section.append(
element('pre', 'run-log-content', logView.content || '(空日志)'),
);
return section;
}
const labels = {
not_started: '当前 Run 还没有可读取的执行 Attempt。',
pending: '日志尚未发布;运行中可使用“刷新”重新读取。',
retired: '日志已按保留策略清理,Run 与 Event 事实仍然保留。',
not_found: '当前 Project 下没有找到这份 Attempt 日志。',
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
};
section.append(
element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
);
return section;
}
function renderRunDetail(run, eventPage, stepPage, logView) {
const events = Array.isArray(eventPage.events) ? eventPage.events : [];
const steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
const fragment = document.createDocumentFragment();
@@ -525,6 +634,7 @@
);
fragment.append(actions);
}
fragment.append(renderRunLog(logView));
fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
if (events.length === 0) {
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));