- 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>
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
let state = {
|
|
gpus: [],
|
|
liveAgents: { ok: false, agents: [] },
|
|
expandedGpuHosts: new Set(),
|
|
settings: { gpuHosts: [] },
|
|
errors: {}
|
|
};
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function percent(value, total) {
|
|
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
|
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}[char]));
|
|
}
|
|
|
|
function isGpuIdle(gpu) {
|
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
|
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 = '') {
|
|
if (!message) return;
|
|
const host = $('toastHost');
|
|
if (!host) return;
|
|
const node = document.createElement('div');
|
|
node.className = `toast ${tone}`.trim();
|
|
node.textContent = message;
|
|
host.appendChild(node);
|
|
setTimeout(() => {
|
|
node.style.transition = 'opacity 200ms ease';
|
|
node.style.opacity = '0';
|
|
setTimeout(() => node.remove(), 220);
|
|
}, 4500);
|
|
}
|
|
|
|
async function request(path, options) {
|
|
const response = await fetch(path, options);
|
|
if (response.status === 401) {
|
|
window.location.href = '/login';
|
|
throw new Error('未登录');
|
|
}
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
const ERROR_LABELS = {
|
|
settings: '设置',
|
|
gpus: 'GPU',
|
|
liveAgents: 'Live Agents'
|
|
};
|
|
|
|
function renderErrorBanner() {
|
|
const banner = $('errorBanner');
|
|
if (!banner) return;
|
|
const entries = Object.entries(state.errors || {}).filter(([key]) => ERROR_LABELS[key]);
|
|
if (!entries.length) {
|
|
banner.hidden = true;
|
|
banner.innerHTML = '';
|
|
return;
|
|
}
|
|
banner.hidden = false;
|
|
banner.innerHTML = `
|
|
<div><strong>部分数据加载失败</strong></div>
|
|
<ul>${entries.map(([key, message]) => (
|
|
`<li><strong>${escapeHtml(ERROR_LABELS[key] || key)}</strong> · ${escapeHtml(message)}</li>`
|
|
)).join('')}</ul>
|
|
`;
|
|
}
|
|
|
|
function renderGpuHostsChips() {
|
|
const chips = $('gpuHostsChips');
|
|
if (!chips) return;
|
|
const hosts = state.settings?.gpuHosts || [];
|
|
if (!hosts.length) {
|
|
chips.innerHTML = '<span class="host-chip-empty">未配置任何机器</span>';
|
|
return;
|
|
}
|
|
const statusByHost = new Map(state.gpus.map((host) => [host.host, host.ok]));
|
|
chips.innerHTML = hosts.map((host) => {
|
|
const ok = statusByHost.get(host);
|
|
const cls = ok === true ? 'ok' : ok === false ? 'bad' : '';
|
|
const safe = escapeHtml(host);
|
|
return `<span class="host-chip ${cls}">
|
|
<span class="host-chip-dot" aria-hidden="true"></span>
|
|
<span class="host-chip-label">${safe}</span>
|
|
<button type="button" class="host-chip-remove" data-remove-host="${safe}" aria-label="删除 ${safe}" title="删除">
|
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 2.5l5 5M7.5 2.5l-5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>
|
|
</button>
|
|
</span>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderGpus() {
|
|
const okHosts = state.gpus.filter((host) => host.ok).length;
|
|
$('gpuSummary').textContent = state.gpus.length ? `${okHosts}/${state.gpus.length} 在线` : '尚未配置';
|
|
renderGpuHostsChips();
|
|
$('gpuBoard').innerHTML = state.gpus.length ? state.gpus.map((host) => {
|
|
if (!host.ok) {
|
|
return `<article class="host-card">
|
|
<div class="host-title"><span>${escapeHtml(host.host)}</span><span class="status-bad">离线</span></div>
|
|
<div class="repo-meta">${escapeHtml(host.error || '无法获取 GPU 状态')}</div>
|
|
</article>`;
|
|
}
|
|
const idleCount = host.gpus.filter(isGpuIdle).length;
|
|
const totalCount = host.gpus.length;
|
|
const expanded = state.expandedGpuHosts.has(host.host);
|
|
const thumbRows = host.gpus.map((gpu) => {
|
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
|
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);
|
|
return `<article class="gpu-detail-card">
|
|
<div class="metric-row">
|
|
<strong>#${gpu.index} ${escapeHtml(gpu.name)}</strong>
|
|
<span>${gpu.gpuUtilizationPercent}% GPU</span>
|
|
</div>
|
|
<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${gpu.gpuUtilizationPercent}" title="GPU Util"></meter>
|
|
<div class="metric-row muted">
|
|
<span>${gpu.memoryUsedMiB} / ${gpu.memoryTotalMiB} MiB</span>
|
|
<span>${memoryPercent}% 显存</span>
|
|
</div>
|
|
<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${memoryPercent}" title="Memory"></meter>
|
|
</article>`;
|
|
}).join('');
|
|
return `<article class="host-card ${expanded ? 'expanded' : ''}">
|
|
<button class="host-summary" type="button" data-host="${escapeHtml(host.host)}" aria-expanded="${expanded}">
|
|
<span>
|
|
<strong>${escapeHtml(host.host)}</strong>
|
|
<span class="muted">${idleCount}/${totalCount} 空闲</span>
|
|
</span>
|
|
<span class="gpu-thumbs">${thumbRows}</span>
|
|
<span class="chip">${expanded ? '收起' : '展开'}</span>
|
|
</button>
|
|
${expanded ? `<div class="gpu-list">${gpuRows || '<span class="muted">未发现 GPU</span>'}</div>` : ''}
|
|
</article>`;
|
|
}).join('') : '<div class="empty-state">还没有配置 GPU 机器</div>';
|
|
}
|
|
|
|
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>
|
|
<div class="agent-group-list">${rows}</div>
|
|
</section>`;
|
|
}).join('');
|
|
}
|
|
|
|
function render() {
|
|
renderErrorBanner();
|
|
renderGpus();
|
|
renderLiveAgents();
|
|
}
|
|
|
|
async function refresh() {
|
|
const button = $('refreshButton');
|
|
button.disabled = true;
|
|
button.classList.add('loading');
|
|
try {
|
|
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();
|
|
} catch (error) {
|
|
toast(`刷新失败: ${error.message}`, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
button.classList.remove('loading');
|
|
}
|
|
}
|
|
|
|
$('refreshButton').addEventListener('click', refresh);
|
|
|
|
$('logoutButton').addEventListener('click', async () => {
|
|
try {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
} finally {
|
|
window.location.href = '/login';
|
|
}
|
|
});
|
|
|
|
$('gpuBoard').addEventListener('click', (event) => {
|
|
const button = event.target.closest('.host-summary');
|
|
if (!button) return;
|
|
const host = button.dataset.host;
|
|
if (state.expandedGpuHosts.has(host)) state.expandedGpuHosts.delete(host);
|
|
else state.expandedGpuHosts.add(host);
|
|
renderGpus();
|
|
});
|
|
|
|
async function saveGpuHosts(gpuHosts, successMessage) {
|
|
try {
|
|
const settings = await request('/api/settings/gpu-hosts', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ gpuHosts })
|
|
});
|
|
state.settings = settings;
|
|
renderGpuHostsChips();
|
|
if (successMessage) toast(successMessage, 'success');
|
|
await refresh();
|
|
return true;
|
|
} catch (error) {
|
|
toast(`保存失败: ${error.message}`, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function setHostAdding(adding) {
|
|
const trigger = $('hostAddTrigger');
|
|
const form = $('hostAddForm');
|
|
const input = $('hostAddInput');
|
|
if (!trigger || !form) return;
|
|
form.hidden = !adding;
|
|
trigger.hidden = adding;
|
|
if (adding && input) {
|
|
input.value = '';
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
$('hostAddTrigger').addEventListener('click', () => setHostAdding(true));
|
|
$('hostAddCancel').addEventListener('click', () => setHostAdding(false));
|
|
$('hostAddInput').addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
setHostAdding(false);
|
|
}
|
|
});
|
|
|
|
$('hostAddForm').addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const name = $('hostAddInput').value.trim();
|
|
if (!name) {
|
|
toast('请输入主机名', 'warn');
|
|
return;
|
|
}
|
|
const current = state.settings?.gpuHosts || [];
|
|
if (current.includes(name)) {
|
|
toast(`${name} 已存在`, 'warn');
|
|
return;
|
|
}
|
|
const ok = await saveGpuHosts([...current, name], `已添加 ${name}`);
|
|
if (ok) setHostAdding(false);
|
|
});
|
|
|
|
$('gpuHostsChips').addEventListener('click', async (event) => {
|
|
const button = event.target.closest('[data-remove-host]');
|
|
if (!button) return;
|
|
const host = button.dataset.removeHost;
|
|
const current = state.settings?.gpuHosts || [];
|
|
if (!current.includes(host)) return;
|
|
await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`);
|
|
});
|
|
|
|
$('liveAgentForm').addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const payload = {
|
|
engine: $('liveAgentEngine').value,
|
|
session: $('liveAgentSession').value.trim(),
|
|
cwd: $('liveAgentCwd').value.trim(),
|
|
windowName: $('liveAgentWindowName').value.trim()
|
|
};
|
|
if (!payload.session || !payload.cwd) {
|
|
toast('请填写 session 和工作目录', 'warn');
|
|
return;
|
|
}
|
|
const button = $('liveAgentLaunchButton');
|
|
button.disabled = true;
|
|
try {
|
|
const result = await request('/api/tmux/agents', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
toast(`已在 ${result.session} 启动 ${result.engine}`, 'success');
|
|
$('liveAgentWindowName').value = '';
|
|
await refresh();
|
|
} catch (error) {
|
|
toast(`启动 agent 失败: ${error.message}`, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
refresh();
|
|
setInterval(refresh, 60000);
|