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:
443
public/app.js
443
public/app.js
@@ -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);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Local Kanban</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=20260514">
|
||||
<link rel="stylesheet" href="/styles.css?v=20260728d">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow" aria-hidden="true"></div>
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="brand-mark" aria-hidden="true">LK</div>
|
||||
<div>
|
||||
<h1>Local Kanban</h1>
|
||||
<p id="subtitle" class="muted">GPU · 额度 · 项目 · Agent</p>
|
||||
<p id="subtitle" class="muted">GPU · Live Agents</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
@@ -27,92 +27,61 @@
|
||||
|
||||
<div id="errorBanner" class="error-banner" hidden></div>
|
||||
|
||||
<section class="grid dashboard-grid">
|
||||
<section class="panel span-2">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>GPU</h2>
|
||||
<span id="gpuSummary" class="badge"></span>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>GPU</h2>
|
||||
<span id="gpuSummary" class="badge"></span>
|
||||
</div>
|
||||
<div class="gpu-hosts-row">
|
||||
<div id="gpuHostsChips" class="gpu-hosts-chips"></div>
|
||||
<button id="hostAddTrigger" type="button" class="host-add-button" title="添加机器">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span>添加</span>
|
||||
</button>
|
||||
<form id="hostAddForm" class="host-add-form" hidden>
|
||||
<input id="hostAddInput" type="text" placeholder="主机名" autocomplete="off" spellcheck="false">
|
||||
<button type="submit" class="primary-button small">添加</button>
|
||||
<button id="hostAddCancel" type="button" class="link-button">取消</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="gpuBoard" class="gpu-board"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>AI 额度</h2>
|
||||
<span id="quotaSummary" class="badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="quotaBoard" class="quota-board"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="gpu-hosts-row">
|
||||
<div id="gpuHostsChips" class="gpu-hosts-chips"></div>
|
||||
<button id="hostAddTrigger" type="button" class="host-add-button" title="添加机器">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span>添加</span>
|
||||
</button>
|
||||
<form id="hostAddForm" class="host-add-form" hidden>
|
||||
<input id="hostAddInput" type="text" placeholder="主机名" autocomplete="off" spellcheck="false">
|
||||
<button type="submit" class="primary-button small">添加</button>
|
||||
<button id="hostAddCancel" type="button" class="link-button">取消</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="gpuBoard" class="gpu-board"></div>
|
||||
</section>
|
||||
|
||||
<section class="grid work-grid">
|
||||
<section class="panel project-panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>项目</h2>
|
||||
<span id="repoSummary" class="badge"></span>
|
||||
</div>
|
||||
<section class="panel live-agent-panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>Live Agents</h2>
|
||||
<span id="liveAgentSummary" class="badge"></span>
|
||||
</div>
|
||||
<div id="repoBoard" class="repo-board"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel agent-panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">
|
||||
<h2>Agent</h2>
|
||||
<span id="taskSummary" class="badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="agentContext" class="agent-context">
|
||||
<span class="agent-context-label">当前项目</span>
|
||||
<span id="agentContextRepo" class="agent-context-repo">未选择</span>
|
||||
<span id="agentContextBranch" class="chip" hidden></span>
|
||||
</div>
|
||||
<form id="taskForm" class="task-form">
|
||||
<div class="task-controls">
|
||||
<label class="select-field">
|
||||
<span class="field-label">Branch</span>
|
||||
<select id="branchSelect" required></select>
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span class="field-label">AI 配置</span>
|
||||
<select id="profileSelect" required></select>
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span class="field-label">历史 Session</span>
|
||||
<select id="resumeTaskSelect"></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="prompt-field">
|
||||
<span class="field-label">命令</span>
|
||||
<textarea id="promptInput" rows="5" required placeholder="输入要交给 agent 的任务"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="startTaskButton" type="submit" class="primary-button">启动任务</button>
|
||||
<button id="resumeTaskButton" type="button" class="secondary-button">继续 Session</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="taskBoard" class="task-board"></div>
|
||||
</section>
|
||||
</div>
|
||||
<form id="liveAgentForm" class="live-agent-form">
|
||||
<label class="select-field">
|
||||
<span class="field-label">Engine</span>
|
||||
<select id="liveAgentEngine">
|
||||
<option value="claude">Claude</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span class="field-label">Session</span>
|
||||
<input id="liveAgentSession" type="text" placeholder="tmux session 名" autocomplete="off" spellcheck="false" required>
|
||||
</label>
|
||||
<label class="select-field live-agent-cwd">
|
||||
<span class="field-label">工作目录</span>
|
||||
<input id="liveAgentCwd" type="text" placeholder="/home/gahow/..." autocomplete="off" spellcheck="false" required>
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span class="field-label">Window 名 <span class="muted">(可选)</span></span>
|
||||
<input id="liveAgentWindowName" type="text" placeholder="默认 engine:目录名" autocomplete="off" spellcheck="false">
|
||||
</label>
|
||||
<button id="liveAgentLaunchButton" type="submit" class="primary-button">启动 Agent</button>
|
||||
</form>
|
||||
<div id="liveAgentBoard" class="live-agent-board"></div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||
<script type="module" src="/app.js?v=20260514"></script>
|
||||
<script type="module" src="/app.js?v=20260728d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,61 +1,33 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f4f6fb;
|
||||
--bg-elev: #ffffff;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f8fafc;
|
||||
--ink: #0f172a;
|
||||
--ink-soft: #334155;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--line-strong: #cbd5e1;
|
||||
--accent: #6366f1;
|
||||
--accent-strong: #4f46e5;
|
||||
--accent-soft: #eef2ff;
|
||||
--accent-ink: #3730a3;
|
||||
--good: #059669;
|
||||
--good-soft: #d1fae5;
|
||||
--warn: #d97706;
|
||||
--warn-soft: #fef3c7;
|
||||
--bad: #dc2626;
|
||||
--bad-soft: #fee2e2;
|
||||
--chip: #f1f5f9;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
--shadow-md: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 16px 48px rgba(15, 23, 42, 0.06);
|
||||
color-scheme: dark;
|
||||
--bg: #0a0e1a;
|
||||
--bg-elev: #111827;
|
||||
--panel: #101626;
|
||||
--panel-soft: #151d31;
|
||||
--ink: #e5eaf3;
|
||||
--ink-soft: #c3cbdc;
|
||||
--muted: #8b96ad;
|
||||
--line: #1f2940;
|
||||
--line-strong: #2c3854;
|
||||
--accent: #7c8cf8;
|
||||
--accent-strong: #a0adfc;
|
||||
--accent-soft: rgba(124, 140, 248, 0.13);
|
||||
--accent-ink: #c7d2fe;
|
||||
--good: #34d399;
|
||||
--good-soft: rgba(52, 211, 153, 0.14);
|
||||
--warn: #fbbf24;
|
||||
--warn-soft: rgba(251, 191, 36, 0.14);
|
||||
--bad: #f87171;
|
||||
--bad-soft: rgba(248, 113, 113, 0.14);
|
||||
--chip: #1c2540;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45);
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--radius-pill: 999px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--bg-elev: #131a2e;
|
||||
--panel: #161e36;
|
||||
--panel-soft: #1b2342;
|
||||
--ink: #e2e8f0;
|
||||
--ink-soft: #cbd5e1;
|
||||
--muted: #94a3b8;
|
||||
--line: #232c4a;
|
||||
--line-strong: #2d3760;
|
||||
--accent: #818cf8;
|
||||
--accent-strong: #a5b4fc;
|
||||
--accent-soft: rgba(129, 140, 248, 0.12);
|
||||
--accent-ink: #c7d2fe;
|
||||
--good: #34d399;
|
||||
--good-soft: rgba(52, 211, 153, 0.14);
|
||||
--warn: #fbbf24;
|
||||
--warn-soft: rgba(251, 191, 36, 0.14);
|
||||
--bad: #f87171;
|
||||
--bad-soft: rgba(248, 113, 113, 0.14);
|
||||
--chip: #1e2742;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 16px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
@@ -77,13 +49,13 @@ body {
|
||||
.bg-glow {
|
||||
position: fixed;
|
||||
inset: -10% -10% auto -10%;
|
||||
height: 60vh;
|
||||
height: 70vh;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(40% 60% at 15% 20%, rgba(99, 102, 241, 0.18), transparent 70%),
|
||||
radial-gradient(35% 50% at 85% 10%, rgba(16, 185, 129, 0.14), transparent 70%),
|
||||
radial-gradient(50% 60% at 50% 0%, rgba(56, 189, 248, 0.10), transparent 70%);
|
||||
filter: blur(8px);
|
||||
radial-gradient(45% 65% at 12% 15%, rgba(99, 102, 241, 0.14), transparent 70%),
|
||||
radial-gradient(38% 55% at 88% 8%, rgba(16, 185, 129, 0.10), transparent 70%),
|
||||
radial-gradient(55% 65% at 50% 0%, rgba(56, 189, 248, 0.07), transparent 70%);
|
||||
filter: blur(10px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -227,7 +199,7 @@ h3 {
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, #2a3554), var(--panel) 120px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 18px 20px;
|
||||
@@ -235,6 +207,8 @@ h3 {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.panel + .panel { margin-top: 18px; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -329,7 +303,7 @@ h3 {
|
||||
|
||||
.gpu-thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18px, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -721,7 +695,7 @@ select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding-right: 36px;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2364748b' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>");
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 14px;
|
||||
@@ -741,11 +715,6 @@ select option {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 4.75l3 3 3-3'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
.select-field { position: relative; }
|
||||
|
||||
@@ -1216,3 +1185,185 @@ details.task-card[open] > summary .task-summary-chevron {
|
||||
.inline-form button { justify-self: stretch; }
|
||||
.agent-context { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.live-agent-panel { margin-top: 18px; }
|
||||
|
||||
/* status-dot glow for GPU thumbs */
|
||||
.gpu-thumb.idle { box-shadow: 0 0 6px color-mix(in srgb, var(--good) 30%, transparent); }
|
||||
.gpu-thumb.busy { box-shadow: 0 0 6px color-mix(in srgb, var(--bad) 30%, transparent); }
|
||||
.gpu-thumb.abnormal { box-shadow: 0 0 6px color-mix(in srgb, var(--warn) 30%, transparent); }
|
||||
|
||||
/* Compact GPU board: hosts flow into columns, expanded host spans the row */
|
||||
.gpu-board {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gpu-board .host-card { padding: 8px 12px; }
|
||||
|
||||
.gpu-board .host-card.expanded { grid-column: 1 / -1; }
|
||||
|
||||
.gpu-board .host-summary {
|
||||
grid-template-columns: minmax(110px, auto) minmax(80px, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gpu-board .host-summary > span:first-child {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gpu-board .gpu-thumb { height: 12px; border-radius: 4px; }
|
||||
|
||||
.gpu-board .gpu-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
.live-agent-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 0.6fr) minmax(150px, 0.9fr) minmax(240px, 1.8fr) minmax(150px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.live-agent-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.live-agent-form .primary-button { white-space: nowrap; }
|
||||
|
||||
.live-agent-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-soft);
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.agent-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.agent-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--chip);
|
||||
color: var(--ink-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-chip.claude { color: #fbbf24; background: color-mix(in srgb, #f59e0b 14%, var(--chip)); }
|
||||
.agent-chip.codex { color: #93c5fd; background: color-mix(in srgb, #3b82f6 14%, var(--chip)); }
|
||||
|
||||
.agent-card-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-card-cwd {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-card .secondary-button {
|
||||
flex-shrink: 0;
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.agent-card .secondary-button:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.agent-group {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--panel-soft)), var(--panel-soft));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.agent-group-heading::before {
|
||||
content: '❯';
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-group-heading strong {
|
||||
font-weight: 650;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.agent-group-heading .muted { font-size: 12px; }
|
||||
|
||||
.agent-group-list { display: flex; flex-direction: column; }
|
||||
|
||||
.agent-group-list .agent-card {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-group-list .agent-card + .agent-card { border-top: 1px solid var(--line); }
|
||||
|
||||
.agent-group-list .agent-card:hover {
|
||||
box-shadow: none;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.live-agent-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.live-agent-form { grid-template-columns: 1fr; }
|
||||
.agent-card { flex-wrap: wrap; }
|
||||
.agent-card-cwd { flex-basis: 100%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user