feat(ui): persist expand state, GPU abnormal indicator, slower auto-refresh

- Remember history task and stdout/stderr terminal open state across the 60s refresh so they no longer collapse on every poll.
- Mark GPUs with allocated memory but near-zero utilization as `abnormal` (orange) — covers stuck processes that previously rendered as busy.
- Bump dashboard auto-refresh from 30s to 60s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:08:40 +08:00
parent 7ab826ac7e
commit bf926e327f
2 changed files with 41 additions and 9 deletions

View File

@@ -8,6 +8,8 @@ let state = {
selectedRepoId: null,
selectedBranch: null,
expandedGpuHosts: new Set(),
expandedTasks: new Set(),
expandedTerminals: new Set(),
settings: { gpuHosts: [] },
errors: {}
};
@@ -72,7 +74,12 @@ function shortSession(sessionId) {
function isGpuIdle(gpu) {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
return memoryPercent < 5 && gpu.gpuUtilizationPercent < 5;
return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5;
}
function isGpuAbnormal(gpu) {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
return memoryPercent >= 5 && gpu.gpuUtilizationPercent < 5;
}
function toast(message, tone = '') {
@@ -169,8 +176,8 @@ function renderGpus() {
const expanded = state.expandedGpuHosts.has(host.host);
const thumbRows = host.gpus.map((gpu) => {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
const busy = !isGpuIdle(gpu);
return `<span class="gpu-thumb ${busy ? 'busy' : 'idle'}" title="#${gpu.index} GPU ${gpu.gpuUtilizationPercent}% · MEM ${memoryPercent}%"></span>`;
const thumbClass = isGpuAbnormal(gpu) ? 'abnormal' : isGpuIdle(gpu) ? 'idle' : 'busy';
return `<span class="gpu-thumb ${thumbClass}" title="#${gpu.index} GPU ${gpu.gpuUtilizationPercent}% · MEM ${memoryPercent}%"></span>`;
}).join('');
const gpuRows = host.gpus.map((gpu) => {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
@@ -299,12 +306,13 @@ function renderResumeTasks() {
$('resumeTaskButton').disabled = options.length === 0;
}
function renderTerminal(label, content, tone = '', expanded = true) {
function renderTerminal(label, content, tone = '', expanded = true, terminalKey = '') {
if (!content) return '';
const lineCount = content.split('\n').length;
const body = `<pre class="terminal-body" tabindex="0">${escapeHtml(content)}</pre>`;
if (!expanded) {
return `<details class="terminal ${tone}">
const openAttr = terminalKey && state.expandedTerminals.has(terminalKey) ? ' open' : '';
return `<details class="terminal ${tone}" data-terminal-key="${escapeHtml(terminalKey)}"${openAttr}>
<summary class="terminal-head">
<span>${label}</span>
<span>${lineCount} lines</span>
@@ -343,8 +351,8 @@ function taskFullBody(task, { expandedTerminals }) {
</div>
<div class="task-meta workspace-path">${escapeHtml(task.workspace)}</div>
<div class="task-prompt">${escapeHtml(task.prompt)}</div>
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals)}
${renderTerminal('stdout', task.stdout, '', expandedTerminals)}
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals, `${task.id}:stderr`)}
${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)}
`;
}
@@ -360,7 +368,8 @@ function renderHistoryTaskCard(task) {
const resumeButton = canResumeTask(task)
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
: '';
return `<details class="task-card history-task status-${escapeHtml(task.status)}">
const openAttr = state.expandedTasks.has(task.id) ? ' open' : '';
return `<details class="task-card history-task status-${escapeHtml(task.status)}" data-task-id="${escapeHtml(task.id)}"${openAttr}>
<summary class="task-summary">
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
<span class="task-summary-main">
@@ -577,6 +586,24 @@ $('taskBoard').addEventListener('click', (event) => {
selectResumeTask(button.dataset.resumeTask);
});
$('taskBoard').addEventListener('toggle', (event) => {
const target = event.target;
if (!(target instanceof HTMLDetailsElement)) return;
if (target.matches('details.history-task')) {
const id = target.dataset.taskId;
if (!id) return;
if (target.open) state.expandedTasks.add(id);
else state.expandedTasks.delete(id);
return;
}
if (target.matches('details.terminal')) {
const key = target.dataset.terminalKey;
if (!key) return;
if (target.open) state.expandedTerminals.add(key);
else state.expandedTerminals.delete(key);
}
}, true);
async function submitTask(resume) {
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
const prompt = $('promptInput').value.trim();
@@ -625,4 +652,4 @@ $('taskForm').addEventListener('submit', async (event) => {
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
refresh();
setInterval(refresh, 30000);
setInterval(refresh, 60000);

View File

@@ -345,6 +345,11 @@ h3 {
border-color: color-mix(in srgb, var(--good) 45%, transparent);
}
.gpu-thumb.abnormal {
background: color-mix(in srgb, var(--warn) 22%, transparent);
border-color: color-mix(in srgb, var(--warn) 50%, transparent);
}
.gpu-thumb.busy {
background: color-mix(in srgb, var(--bad) 22%, transparent);
border-color: color-mix(in srgb, var(--bad) 50%, transparent);