Files
kanban/public/app.js
Gahow Wang 74279b9d35 feat: add frontend dashboard, login page and portal entry
Public directory contains the Kanban SPA with dashboard UI, login
page, and styles. Portal directory provides a standalone service
entry page with CSS styling.
2026-05-15 11:13:59 +08:00

629 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let state = {
gpus: [],
quotas: [],
repos: [],
branches: [],
agentProfiles: [],
tasks: [],
selectedRepoId: null,
selectedBranch: null,
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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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;
}
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',
quotas: '额度',
repos: '项目列表',
agentProfiles: 'Agent 配置',
tasks: '任务历史'
};
function renderErrorBanner() {
const banner = $('errorBanner');
if (!banner) return;
const entries = Object.entries(state.errors || {});
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 busy = !isGpuIdle(gpu);
return `<span class="gpu-thumb ${busy ? 'busy' : 'idle'}" 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 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>
</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) {
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}">
<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)}
${renderTerminal('stdout', task.stdout, '', expandedTerminals)}
`;
}
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>`
: '';
return `<details class="task-card history-task status-${escapeHtml(task.status)}">
<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>';
}
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();
}
async function refresh() {
const button = $('refreshButton');
button.disabled = true;
button.classList.add('loading');
try {
const data = await request('/api/dashboard');
state = { ...state, ...data, errors: data.errors || {} };
render();
await loadBranches();
} 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}`);
});
$('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;
event.preventDefault();
event.stopPropagation();
selectResumeTask(button.dataset.resumeTask);
});
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 : ''
};
if (resume && !payload.resumeTaskId) {
toast('没有可继续的 Session', 'warn');
return;
}
const submitButtons = [$('startTaskButton'), $('resumeTaskButton')];
submitButtons.forEach((btn) => { if (btn) btn.disabled = true; });
try {
await request('/api/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
$('promptInput').value = '';
toast(resume ? 'Session 已继续' : '任务已启动', 'success');
await refresh();
} catch (error) {
toast(`启动任务失败: ${error.message}`, 'error');
} finally {
submitButtons.forEach((btn) => { if (btn) btn.disabled = false; });
}
}
$('taskForm').addEventListener('submit', async (event) => {
event.preventDefault();
await submitTask(false);
});
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
refresh();
setInterval(refresh, 30000);