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 = `
部分数据加载失败
`; } function renderGpuHostsChips() { const chips = $('gpuHostsChips'); if (!chips) return; const hosts = state.settings?.gpuHosts || []; if (!hosts.length) { chips.innerHTML = '未配置任何机器'; 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 ` ${safe} `; }).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 `
${escapeHtml(host.host)}离线
${escapeHtml(host.error || '无法获取 GPU 状态')}
`; } 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 ``; }).join(''); const gpuRows = host.gpus.map((gpu) => { const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB); return `
#${gpu.index} ${escapeHtml(gpu.name)} ${gpu.gpuUtilizationPercent}% GPU
${gpu.memoryUsedMiB} / ${gpu.memoryTotalMiB} MiB ${memoryPercent}% 显存
`; }).join(''); return `
${expanded ? `
${gpuRows || '未发现 GPU'}
` : ''}
`; }).join('') : '
还没有配置 GPU 机器
'; } function renderLiveAgents() { const agents = state.liveAgents?.agents || []; $('liveAgentSummary').textContent = agents.length ? `${agents.length} 个运行中` : '无运行中'; if (!agents.length) { $('liveAgentBoard').innerHTML = '
tmux 中没有正在运行的 agent
'; 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 `
${escapeHtml(agent.engine)} :${agent.windowIndex} ${escapeHtml(agent.windowName)} ${escapeHtml(agent.cwd)} 打开终端
`; }).join(''); return `
${escapeHtml(session)}
${rows}
`; }).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);