feat(ui): GPU + Live Agents dashboard, dark theme

- drop quota / repos / headless-task panels; page is now GPU board +
  Live Agents (frontend only, headless task API still available)
- Live Agents: grouped by tmux session, launch form (engine/session/cwd/
  window name), open-terminal link per agent
- compact GPU board: multi-column host cards, fixed 4-per-row GPU thumbs
  with status glow, expanded host spans full row
- permanent dark theme tuned for a monitoring dashboard; terminal-styled
  session group headers; refresh now polls three light endpoints instead
  of the full dashboard aggregate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 21:20:52 +08:00
parent cd1e9e09e7
commit e5d9799018
3 changed files with 334 additions and 523 deletions

View File

@@ -1,15 +1,7 @@
let state = {
gpus: [],
quotas: [],
repos: [],
branches: [],
agentProfiles: [],
tasks: [],
selectedRepoId: null,
selectedBranch: null,
liveAgents: { ok: false, agents: [] },
expandedGpuHosts: new Set(),
expandedTasks: new Set(),
expandedTerminals: new Set(),
settings: { gpuHosts: [] },
errors: {}
};
@@ -31,47 +23,6 @@ function escapeHtml(value) {
}[char]));
}
function formatDate(value) {
if (!value) return 'N/A';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'N/A';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function formatDuration(start, end) {
const from = new Date(start).getTime();
const to = new Date(end).getTime();
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return '';
const seconds = Math.max(1, Math.round((to - from) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours}h ${rest}m` : `${hours}h`;
}
function statusLabel(status) {
return {
running: '运行中',
completed: '已完成',
failed: '失败'
}[status] || status || '未知';
}
function canResumeTask(task) {
return Boolean(task.sessionId && task.status !== 'running');
}
function shortSession(sessionId) {
return sessionId ? sessionId.slice(0, 8) : '无 Session';
}
function isGpuIdle(gpu) {
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
return memoryPercent < 5 || gpu.gpuUtilizationPercent < 5;
@@ -113,16 +64,13 @@ async function request(path, options) {
const ERROR_LABELS = {
settings: '设置',
gpus: 'GPU',
quotas: '额度',
repos: '项目列表',
agentProfiles: 'Agent 配置',
tasks: '任务历史'
liveAgents: 'Live Agents'
};
function renderErrorBanner() {
const banner = $('errorBanner');
if (!banner) return;
const entries = Object.entries(state.errors || {});
const entries = Object.entries(state.errors || {}).filter(([key]) => ERROR_LABELS[key]);
if (!entries.length) {
banner.hidden = true;
banner.innerHTML = '';
@@ -208,246 +156,44 @@ function renderGpus() {
}).join('') : '<div class="empty-state">还没有配置 GPU 机器</div>';
}
function renderQuotas() {
const okSources = state.quotas.filter((quota) => quota.ok).length;
$('quotaSummary').textContent = state.quotas.length ? `${okSources}/${state.quotas.length} 可用` : '未配置';
$('quotaBoard').innerHTML = state.quotas.length ? state.quotas.map((quota) => {
const summary = quota.summary || {};
const limit = summary.limit ?? null;
const used = summary.used ?? null;
const remaining = summary.remaining ?? null;
const usedPercent = limit && used !== null ? percent(used, limit) : null;
return `<article class="quota-card">
<div class="host-title">
<span>${escapeHtml(quota.label)}</span>
<span class="${quota.ok ? 'status-ok' : 'status-warn'}">${quota.ok ? '已更新' : '未配置'}</span>
function renderLiveAgents() {
const agents = state.liveAgents?.agents || [];
$('liveAgentSummary').textContent = agents.length ? `${agents.length} 个运行中` : '无运行中';
if (!agents.length) {
$('liveAgentBoard').innerHTML = '<div class="empty-state">tmux 中没有正在运行的 agent</div>';
return;
}
const groups = new Map();
for (const agent of agents) {
if (!groups.has(agent.session)) groups.set(agent.session, []);
groups.get(agent.session).push(agent);
}
$('liveAgentBoard').innerHTML = [...groups.entries()].map(([session, members]) => {
const rows = members.map((agent) => {
const termUrl = `/term/?arg=${encodeURIComponent(agent.session)}&arg=${encodeURIComponent(agent.windowIndex)}`;
return `<article class="agent-card">
<span class="agent-chip ${escapeHtml(agent.engine)}">${escapeHtml(agent.engine)}</span>
<span class="agent-card-main">
<strong>:${agent.windowIndex}</strong>
<span class="muted">${escapeHtml(agent.windowName)}</span>
</span>
<span class="agent-card-cwd muted" title="${escapeHtml(agent.cwd)}">${escapeHtml(agent.cwd)}</span>
<a class="secondary-button small" href="${escapeHtml(termUrl)}" target="_blank" rel="noopener">打开终端</a>
</article>`;
}).join('');
return `<section class="agent-group">
<div class="agent-group-heading">
<strong>${escapeHtml(session)}</strong>
</div>
${quota.ok ? `
<div class="metric-row"><span class="muted">剩余</span><strong>${remaining ?? 'N/A'}</strong></div>
<div class="metric-row"><span class="muted">已用</span><strong>${used ?? 'N/A'}</strong></div>
${usedPercent === null ? '' : `<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${usedPercent}"></meter>`}
` : `<div class="repo-meta">${escapeHtml(quota.error || '尚未配置')}</div>`}
</article>`;
}).join('') : '<div class="empty-state">还没有配置额度数据源</div>';
}
function renderRepos() {
const selectedRepo = state.repos.find((repo) => repo.id === state.selectedRepoId);
$('repoSummary').textContent = state.repos.length
? (selectedRepo ? `${state.repos.length} 个 · 当前 ${selectedRepo.fullName}` : `${state.repos.length}`)
: '暂无';
$('repoBoard').innerHTML = state.repos.length ? state.repos.map((repo) => {
const selected = repo.id === state.selectedRepoId ? ' selected' : '';
return `<article class="repo-card${selected}" data-id="${escapeHtml(repo.id)}">
<div class="repo-title">
<span>${escapeHtml(repo.fullName)}</span>
<span class="chip">${escapeHtml(repo.defaultBranch)}</span>
</div>
${repo.description ? `<div class="repo-meta">${escapeHtml(repo.description)}</div>` : ''}
<div class="repo-meta">${escapeHtml(repo.cloneUrl)}</div>
</article>`;
}).join('') : '<div class="empty-state">无法读取项目,请检查 Gitea 配置</div>';
renderAgentContext();
}
function renderAgentContext() {
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
const repoEl = $('agentContextRepo');
const branchEl = $('agentContextBranch');
if (!repoEl || !branchEl) return;
if (repo) {
repoEl.textContent = repo.fullName;
repoEl.classList.remove('empty');
} else {
repoEl.textContent = '从左侧选择一个项目';
repoEl.classList.add('empty');
}
if (state.selectedBranch) {
branchEl.textContent = state.selectedBranch;
branchEl.hidden = false;
} else {
branchEl.hidden = true;
branchEl.textContent = '';
}
}
function renderBranches() {
$('branchSelect').innerHTML = state.branches.length ? state.branches.map((branch) => {
const selected = branch.name === state.selectedBranch ? 'selected' : '';
return `<option value="${escapeHtml(branch.name)}" ${selected}>${escapeHtml(branch.name)}</option>`;
}).join('') : '<option value="">无可用 Branch</option>';
renderAgentContext();
}
function renderProfiles() {
$('profileSelect').innerHTML = state.agentProfiles.map((profile) => (
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.label || profile.id)}</option>`
)).join('');
}
function taskMatchesSelection(task) {
return task.repoFullName === state.selectedRepoId && task.branch === state.selectedBranch && canResumeTask(task);
}
function renderResumeTasks() {
const options = state.tasks.filter(taskMatchesSelection);
const prev = $('resumeTaskSelect').value;
$('resumeTaskSelect').innerHTML = [
'<option value="">新 Session</option>',
...options.map((task) => {
const prompt = task.prompt ? ` · ${task.prompt.slice(0, 40)}` : '';
const label = `${formatDate(task.finishedAt || task.createdAt)} · ${statusLabel(task.status)} · ${shortSession(task.sessionId)}${prompt}`;
return `<option value="${escapeHtml(task.id)}">${escapeHtml(label)}</option>`;
})
].join('');
if (prev && options.some((task) => task.id === prev)) {
$('resumeTaskSelect').value = prev;
}
$('resumeTaskButton').disabled = options.length === 0;
}
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) {
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>
</summary>
${body}
</details>`;
}
return `<section class="terminal ${tone}">
<div class="terminal-head">
<span>${label}</span>
<span>${lineCount} lines</span>
</div>
${body}
</section>`;
}
function taskFullBody(task, { expandedTerminals }) {
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
return `
<div class="task-head">
<div>
<strong>${escapeHtml(task.profileLabel)}</strong>
<div class="task-meta">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</div>
</div>
<div class="task-actions">
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
</div>
</div>
<div class="task-facts">
<span>${formatDate(task.createdAt)}</span>
${task.finishedAt ? `<span>完成 ${formatDate(task.finishedAt)}</span>` : ''}
${duration ? `<span>耗时 ${escapeHtml(duration)}</span>` : ''}
${Number.isInteger(task.exitCode) ? `<span>Exit ${task.exitCode}</span>` : ''}
${task.sessionId ? `<span class="session-id">Session ${escapeHtml(shortSession(task.sessionId))}</span>` : ''}
${task.parentTaskId ? `<span>续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}</span>` : ''}
</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, `${task.id}:stderr`)}
${renderTerminal('stdout', task.stdout, '', expandedTerminals, `${task.id}:stdout`)}
`;
}
function renderActiveTaskCard(task) {
return `<article class="task-card active-task status-${escapeHtml(task.status)}">
${taskFullBody(task, { expandedTerminals: true })}
</article>`;
}
function renderHistoryTaskCard(task) {
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
const promptPreview = (task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 100);
const resumeButton = canResumeTask(task)
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
: '';
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">
<span class="task-summary-title">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</span>
${promptPreview ? `<span class="task-summary-prompt muted">${escapeHtml(promptPreview)}</span>` : ''}
</span>
<span class="task-summary-resume">${resumeButton}</span>
<span class="task-summary-meta">
<span class="muted task-summary-date">${formatDate(task.finishedAt || task.createdAt)}</span>
<span class="muted task-summary-duration">${duration ? escapeHtml(duration) : ''}</span>
</span>
<span class="task-summary-chevron" aria-hidden="true"></span>
</summary>
<div class="task-body">
${taskFullBody(task, { expandedTerminals: false })}
</div>
</details>`;
}
function renderTaskCard(task) {
return task.status === 'running' ? renderActiveTaskCard(task) : renderHistoryTaskCard(task);
}
function renderTasks() {
const running = state.tasks.filter((task) => task.status === 'running').length;
const completed = state.tasks.filter((task) => task.status === 'completed').length;
const failed = state.tasks.filter((task) => task.status === 'failed').length;
$('taskSummary').textContent = `${running} 运行中 · ${completed} 已完成 · ${failed} 失败`;
const activeTasks = state.tasks.filter((task) => task.status === 'running');
const historyTasks = state.tasks.filter((task) => task.status !== 'running');
const sections = [];
if (activeTasks.length) {
sections.push(`<section class="task-section">
<div class="task-section-heading"><h3>运行中</h3><span class="muted">${activeTasks.length} 个</span></div>
<div class="task-list">${activeTasks.map(renderTaskCard).join('')}</div>
</section>`);
}
if (historyTasks.length) {
sections.push(`<section class="task-section">
<div class="task-section-heading"><h3>历史 Session</h3><span class="muted">${historyTasks.length} 个</span></div>
<div class="task-list history-list">${historyTasks.map(renderTaskCard).join('')}</div>
</section>`);
}
$('taskBoard').innerHTML = sections.join('') || '<div class="empty-state">暂无 Session</div>';
<div class="agent-group-list">${rows}</div>
</section>`;
}).join('');
}
function render() {
if (!state.selectedRepoId && state.repos[0]) state.selectedRepoId = state.repos[0].id;
renderErrorBanner();
renderGpus();
renderQuotas();
renderRepos();
renderBranches();
renderProfiles();
renderResumeTasks();
renderTasks();
}
async function loadBranches() {
if (!state.selectedRepoId) {
state.branches = [];
state.selectedBranch = null;
renderBranches();
return;
}
try {
state.branches = await request(`/api/branches?repo=${encodeURIComponent(state.selectedRepoId)}`);
} catch (error) {
state.branches = [];
toast(`读取 branches 失败: ${error.message}`, 'error');
}
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
if (!state.branches.some((branch) => branch.name === state.selectedBranch)) {
state.selectedBranch = state.branches.find((branch) => branch.name === repo?.defaultBranch)?.name || state.branches[0]?.name || null;
}
renderBranches();
renderResumeTasks();
renderLiveAgents();
}
async function refresh() {
@@ -455,10 +201,25 @@ async function refresh() {
button.disabled = true;
button.classList.add('loading');
try {
const data = await request('/api/dashboard');
state = { ...state, ...data, errors: data.errors || {} };
const [settings, gpus, liveAgents] = await Promise.allSettled([
request('/api/settings'),
request('/api/gpus'),
request('/api/tmux/agents')
]);
const errors = {};
const value = (result, fallback, key) => {
if (result.status === 'fulfilled') return result.value;
errors[key] = String(result.reason?.message || result.reason || 'unknown error');
return fallback;
};
state = {
...state,
settings: value(settings, state.settings, 'settings'),
gpus: value(gpus, [], 'gpus'),
liveAgents: value(liveAgents, { ok: false, agents: [] }, 'liveAgents'),
errors
};
render();
await loadBranches();
} catch (error) {
toast(`刷新失败: ${error.message}`, 'error');
} finally {
@@ -551,105 +312,35 @@ $('gpuHostsChips').addEventListener('click', async (event) => {
await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`);
});
$('repoBoard').addEventListener('click', (event) => {
const card = event.target.closest('.repo-card');
if (!card) return;
state.selectedRepoId = card.dataset.id;
state.selectedBranch = null;
renderRepos();
loadBranches();
});
$('branchSelect').addEventListener('change', (event) => {
state.selectedBranch = event.target.value;
renderResumeTasks();
renderAgentContext();
});
async function selectResumeTask(taskId) {
const task = state.tasks.find((item) => item.id === taskId);
if (!task) return;
state.selectedRepoId = task.repoFullName;
state.selectedBranch = task.branch;
renderRepos();
await loadBranches();
$('resumeTaskSelect').value = task.id;
$('taskForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('promptInput').focus();
}
$('taskBoard').addEventListener('click', (event) => {
const button = event.target.closest('[data-resume-task]');
if (!button) return;
$('liveAgentForm').addEventListener('submit', async (event) => {
event.preventDefault();
event.stopPropagation();
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();
if (!repo) {
toast('请先从左侧选择一个项目', 'warn');
return;
}
if (!prompt) {
toast('请输入命令', 'warn');
return;
}
const payload = {
repo,
branch: $('branchSelect').value,
profileId: $('profileSelect').value,
prompt,
resumeTaskId: resume ? $('resumeTaskSelect').value : ''
engine: $('liveAgentEngine').value,
session: $('liveAgentSession').value.trim(),
cwd: $('liveAgentCwd').value.trim(),
windowName: $('liveAgentWindowName').value.trim()
};
if (resume && !payload.resumeTaskId) {
toast('没有可继续的 Session', 'warn');
if (!payload.session || !payload.cwd) {
toast('请填写 session 和工作目录', 'warn');
return;
}
const submitButtons = [$('startTaskButton'), $('resumeTaskButton')];
submitButtons.forEach((btn) => { if (btn) btn.disabled = true; });
const button = $('liveAgentLaunchButton');
button.disabled = true;
try {
await request('/api/tasks', {
const result = await request('/api/tmux/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
$('promptInput').value = '';
toast(resume ? 'Session 已继续' : '任务已启动', 'success');
toast(`已在 ${result.session} 启动 ${result.engine}`, 'success');
$('liveAgentWindowName').value = '';
await refresh();
} catch (error) {
toast(`启动任务失败: ${error.message}`, 'error');
toast(`启动 agent 失败: ${error.message}`, 'error');
} finally {
submitButtons.forEach((btn) => { if (btn) btn.disabled = false; });
button.disabled = false;
}
}
$('taskForm').addEventListener('submit', async (event) => {
event.preventDefault();
await submitTask(false);
});
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
refresh();
setInterval(refresh, 60000);