mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): surface bounded console run logs
This commit is contained in:
@@ -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', '当前窗口没有可见事件。'));
|
||||
|
||||
Reference in New Issue
Block a user