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 = `
部分数据加载失败
${entries.map(([key, message]) => (
`- ${escapeHtml(ERROR_LABELS[key] || key)} · ${escapeHtml(message)}
`
)).join('')}
`;
}
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);