From 74279b9d3550414d22df51f257acec8da6bc0447 Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Fri, 15 May 2026 11:13:59 +0800 Subject: [PATCH] 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. --- portal/index.html | 64 +++ portal/portal.css | 166 +++++++ public/app.js | 628 +++++++++++++++++++++++ public/index.html | 118 +++++ public/login.html | 23 + public/login.js | 18 + public/styles.css | 1213 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2230 insertions(+) create mode 100644 portal/index.html create mode 100644 portal/portal.css create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/login.js create mode 100644 public/styles.css diff --git a/portal/index.html b/portal/index.html new file mode 100644 index 0000000..8e147c9 --- /dev/null +++ b/portal/index.html @@ -0,0 +1,64 @@ + + + + + + gahow-pc services + + + +
+
+
+

gahow-pc

+

Local services

+
+
+ +
+
+

Services

+
+ +
+ +
+ 后端 API + +
+
+ + diff --git a/portal/portal.css b/portal/portal.css new file mode 100644 index 0000000..29f184a --- /dev/null +++ b/portal/portal.css @@ -0,0 +1,166 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --ink: #17202c; + --muted: #697586; + --line: #d7dee8; + --accent: #2563eb; + --soft: #eef4ff; + --shadow: 0 16px 40px rgba(23, 32, 44, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.12), transparent 28%), + radial-gradient(circle at 90% 18%, rgba(18, 128, 92, 0.11), transparent 26%), + var(--bg); + color: var(--ink); + font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 42px 0 56px; +} + +.topbar { + margin-bottom: 30px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 34px; + line-height: 1.08; + letter-spacing: 0; +} + +h2 { + font-size: 14px; + margin-bottom: 0; + text-transform: uppercase; + color: var(--muted); +} + +.topbar p, +.service-desc { + color: var(--muted); +} + +.topbar p { + margin-top: 6px; +} + +.service-section { + margin-top: 18px; +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.section-heading span { + color: var(--muted); +} + +.service-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.service-card, +.api-section { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.service-card { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 14px; + min-height: 168px; + padding: 18px; + color: inherit; + text-decoration: none; + overflow: hidden; +} + +.service-card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.service-card.primary { + border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); +} + +.service-name { + grid-column: 1 / -1; + font-size: 22px; + font-weight: 700; +} + +.service-icon { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 8px; + background: var(--soft); + color: var(--accent); + font-weight: 800; +} + +.service-topline { + align-self: center; + color: var(--muted); + font-weight: 650; +} + +.service-desc { + grid-column: 1 / -1; +} + +.service-port { + grid-column: 1 / -1; + justify-self: start; + border-radius: 999px; + background: var(--soft); + color: #1849a9; + padding: 3px 9px; +} + +.api-section { + margin-top: 18px; + padding: 14px 16px; + box-shadow: none; +} + +.api-section summary { + cursor: pointer; + font-size: 15px; + font-weight: 700; +} + +.api-section .service-grid { + margin-top: 14px; +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7de4bf0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,628 @@ +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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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 = ` +
部分数据加载失败
+ + `; +} + +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 busy = !isGpuIdle(gpu); + 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 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 `
+
+ ${escapeHtml(quota.label)} + ${quota.ok ? '已更新' : '未配置'} +
+ ${quota.ok ? ` +
剩余${remaining ?? 'N/A'}
+
已用${used ?? 'N/A'}
+ ${usedPercent === null ? '' : ``} + ` : `
${escapeHtml(quota.error || '尚未配置')}
`} +
`; + }).join('') : '
还没有配置额度数据源
'; +} + +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 `
+
+ ${escapeHtml(repo.fullName)} + ${escapeHtml(repo.defaultBranch)} +
+ ${repo.description ? `
${escapeHtml(repo.description)}
` : ''} +
${escapeHtml(repo.cloneUrl)}
+
`; + }).join('') : '
无法读取项目,请检查 Gitea 配置
'; + 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 ``; + }).join('') : ''; + renderAgentContext(); +} + +function renderProfiles() { + $('profileSelect').innerHTML = state.agentProfiles.map((profile) => ( + `` + )).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 = [ + '', + ...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 ``; + }) + ].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 = `
${escapeHtml(content)}
`; + if (!expanded) { + return `
+ + ${label} + ${lineCount} lines + + ${body} +
`; + } + return `
+
+ ${label} + ${lineCount} lines +
+ ${body} +
`; +} + +function taskFullBody(task, { expandedTerminals }) { + const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : ''; + return ` +
+
+ ${escapeHtml(task.profileLabel)} +
${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}
+
+
+ ${escapeHtml(statusLabel(task.status))} +
+
+
+ ${formatDate(task.createdAt)} + ${task.finishedAt ? `完成 ${formatDate(task.finishedAt)}` : ''} + ${duration ? `耗时 ${escapeHtml(duration)}` : ''} + ${Number.isInteger(task.exitCode) ? `Exit ${task.exitCode}` : ''} + ${task.sessionId ? `Session ${escapeHtml(shortSession(task.sessionId))}` : ''} + ${task.parentTaskId ? `续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}` : ''} +
+
${escapeHtml(task.workspace)}
+
${escapeHtml(task.prompt)}
+ ${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals)} + ${renderTerminal('stdout', task.stdout, '', expandedTerminals)} + `; +} + +function renderActiveTaskCard(task) { + return `
+ ${taskFullBody(task, { expandedTerminals: true })} +
`; +} + +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) + ? `` + : ''; + return `
+ + ${escapeHtml(statusLabel(task.status))} + + ${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)} + ${promptPreview ? `${escapeHtml(promptPreview)}` : ''} + + ${resumeButton} + + ${formatDate(task.finishedAt || task.createdAt)} + ${duration ? escapeHtml(duration) : ''} + + + +
+ ${taskFullBody(task, { expandedTerminals: false })} +
+
`; +} + +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(`
+

运行中

${activeTasks.length} 个
+
${activeTasks.map(renderTaskCard).join('')}
+
`); + } + if (historyTasks.length) { + sections.push(`
+

历史 Session

${historyTasks.length} 个
+
${historyTasks.map(renderTaskCard).join('')}
+
`); + } + $('taskBoard').innerHTML = sections.join('') || '
暂无 Session
'; +} + +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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..48c55fa --- /dev/null +++ b/public/index.html @@ -0,0 +1,118 @@ + + + + + + Local Kanban + + + + +
+
+
+ +
+

Local Kanban

+

GPU · 额度 · 项目 · Agent

+
+
+
+ + +
+
+ + + +
+
+
+
+

GPU

+ +
+
+
+
+ + +
+
+
+ +
+
+
+

AI 额度

+ +
+
+
+
+
+ +
+
+
+
+

项目

+ +
+
+
+
+ +
+
+
+

Agent

+ +
+
+
+ 当前项目 + 未选择 + +
+
+
+ + + +
+ +
+ + +
+
+
+
+
+
+
+ + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..77602f8 --- /dev/null +++ b/public/login.html @@ -0,0 +1,23 @@ + + + + + + Login · Local Kanban + + + +
+ +
+ + + diff --git a/public/login.js b/public/login.js new file mode 100644 index 0000000..5bb44e3 --- /dev/null +++ b/public/login.js @@ -0,0 +1,18 @@ +const form = document.getElementById('loginForm'); +const tokenInput = document.getElementById('tokenInput'); +const loginError = document.getElementById('loginError'); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + loginError.textContent = ''; + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: tokenInput.value }) + }); + if (response.ok) { + window.location.href = '/'; + return; + } + loginError.textContent = response.status === 429 ? '尝试次数过多' : 'Token 无效'; +}); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..1b96f5f --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1213 @@ +: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); + --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; } + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + min-height: 100vh; + position: relative; + overflow-x: hidden; +} + +.bg-glow { + position: fixed; + inset: -10% -10% auto -10%; + height: 60vh; + 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); + z-index: 0; +} + +button, input, select, textarea { + font: inherit; + color: inherit; +} + +button { cursor: pointer; } + +.shell { + position: relative; + z-index: 1; + width: min(1440px, calc(100vw - 32px)); + margin: 0 auto; + padding: 24px 0 24px; +} + +h1, h2, h3, p { margin: 0; } + +h1 { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.15; +} + +h2 { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +h3 { + font-size: 13px; + font-weight: 600; + color: var(--ink-soft); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.muted { color: var(--muted); } + +/* Topbar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-mark { + width: 44px; + height: 44px; + border-radius: 12px; + display: grid; + place-items: center; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.06em; + color: #fff; + background: linear-gradient(135deg, #6366f1, #8b5cf6 55%, #06b6d4); + box-shadow: 0 6px 18px rgba(99, 102, 241, 0.35); +} + +.top-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.icon-button { + width: 40px; + height: 40px; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--bg-elev); + color: var(--ink-soft); + display: grid; + place-items: center; + transition: transform 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease; + box-shadow: var(--shadow-sm); +} + +.icon-button svg { width: 18px; height: 18px; } + +.icon-button:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 22%, transparent); +} + +.icon-button:active { transform: translateY(1px); } + +.icon-button:disabled { opacity: 0.6; cursor: progress; } + +.icon-button.loading svg { animation: spin 700ms linear infinite; } + +@keyframes spin { to { transform: rotate(360deg); } } + +.ghost-button { + height: 40px; + padding: 0 14px; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--bg-elev); + color: var(--ink-soft); + transition: border-color 120ms ease, color 120ms ease, background 120ms ease; + box-shadow: var(--shadow-sm); +} + +.ghost-button:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); +} + +/* Grid layout */ +.grid { + display: grid; + gap: 18px; +} + +.dashboard-grid { + grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); + margin-bottom: 18px; +} + +.work-grid { + grid-template-columns: minmax(260px, 1fr) minmax(0, 2.4fr); + align-items: start; +} + +/* Panels */ +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px 18px 20px; + min-width: 0; + box-shadow: var(--shadow-md); +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-title { + display: flex; + align-items: center; + gap: 10px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: var(--radius-pill); + background: var(--accent-soft); + color: var(--accent-ink); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; +} + +/* Error banner */ +.error-banner { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--bad) 35%, transparent); + background: var(--bad-soft); + color: var(--bad); + font-size: 13px; +} + +.error-banner ul { margin: 4px 0 0; padding-left: 20px; } +.error-banner li { line-height: 1.5; } + +/* GPU board */ +.gpu-board, +.quota-board, +.repo-board, +.task-board { display: grid; gap: 12px; } + +.host-card, +.quota-card, +.repo-card, +.task-card { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 14px; + background: var(--panel); + min-width: 0; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +.host-card:hover, +.quota-card:hover { border-color: var(--line-strong); } + +.host-title, +.repo-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + font-weight: 600; +} + +.host-summary { + width: 100%; + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(120px, 2fr) auto; + align-items: center; + gap: 14px; + border: 0; + background: transparent; + color: var(--ink); + padding: 0; + text-align: left; +} + +.host-summary > span:first-child { + display: grid; + gap: 3px; +} + +.host-summary strong { font-weight: 650; } + +.gpu-thumbs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18px, 1fr)); + gap: 4px; +} + +.gpu-thumb { + height: 16px; + border-radius: 5px; + background: var(--chip); + border: 1px solid var(--line); +} + +.gpu-thumb.idle { + background: color-mix(in srgb, var(--good) 22%, transparent); + border-color: color-mix(in srgb, var(--good) 45%, transparent); +} + +.gpu-thumb.busy { + background: color-mix(in srgb, var(--bad) 22%, transparent); + border-color: color-mix(in srgb, var(--bad) 50%, transparent); +} + +.gpu-list { display: grid; gap: 10px; margin-top: 14px; } + +.gpu-detail-card { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 12px; + background: var(--panel-soft); +} + +.metric-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 6px; + font-size: 13px; +} + +.metric-row strong { font-weight: 650; color: var(--ink); } + +/* Meters */ +.meter { + display: block; + width: 100%; + height: 8px; + appearance: none; + border: 0; + background: transparent; + margin-top: 4px; +} + +.meter::-webkit-meter-bar { + height: 8px; + border: 0; + border-radius: var(--radius-pill); + background: var(--chip); +} + +.meter::-webkit-meter-optimum-value { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 65%, var(--accent))); +} + +.meter::-webkit-meter-suboptimum-value { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--warn), #f59e0b); +} + +.meter::-webkit-meter-even-less-good-value { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--bad), #f87171); +} + +.meter::-moz-meter-bar { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--good), color-mix(in srgb, var(--good) 65%, var(--accent))); +} + +/* Status text */ +.status-ok { color: var(--good); font-weight: 600; } +.status-warn { color: var(--warn); font-weight: 600; } +.status-bad { color: var(--bad); font-weight: 600; } + +/* Chip */ +.chip { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 9px; + border-radius: var(--radius-pill); + background: var(--chip); + color: var(--muted); + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +/* Repos */ +.project-panel { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; +} + +.project-panel > .panel-heading { flex: 0 0 auto; } + +.project-panel .repo-board { + grid-template-columns: 1fr; + flex: 0 1 auto; + min-height: 0; + overflow: auto; + padding: 2px 6px 2px 2px; + scroll-padding-block: 4px; +} + +.project-panel .repo-card { + display: grid; + gap: 6px; + padding: 12px 14px; + cursor: pointer; +} + +.project-panel .repo-title { margin-bottom: 0; } + +.repo-card.selected { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.repo-card:hover { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); } + +.repo-meta, +.task-meta { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +/* Task form */ +.task-form { + display: grid; + gap: 14px; + margin-bottom: 18px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.task-form label, +.inline-form label { + display: grid; + gap: 6px; +} + +.field-label { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--muted); +} + +.task-controls { + display: grid; + grid-template-columns: repeat(3, minmax(160px, 1fr)); + gap: 12px; + align-items: end; +} + +.agent-context { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: linear-gradient(180deg, var(--accent-soft), transparent); +} + +.agent-context-label { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.agent-context-repo { + font-weight: 650; + color: var(--ink); + overflow-wrap: anywhere; +} + +.agent-context-repo.empty { color: var(--muted); font-weight: 500; } + +.prompt-field { max-width: 980px; } + +.inline-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 12px; + margin-bottom: 14px; +} + +.gpu-hosts-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 14px; +} + +.gpu-hosts-chips { + display: contents; +} + +.host-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 4px 0 10px; + height: 26px; + border-radius: var(--radius-pill); + background: var(--chip); + color: var(--ink-soft); + font-size: 12px; + font-weight: 500; + line-height: 1; + transition: background 120ms ease; +} + +.host-chip:hover { + background: color-mix(in srgb, var(--ink) 6%, var(--chip)); +} + +.host-chip-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--muted); + flex: 0 0 auto; +} + +.host-chip.ok .host-chip-dot { + background: var(--good); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--good) 22%, transparent); +} + +.host-chip.bad .host-chip-dot { + background: var(--bad); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bad) 22%, transparent); +} + +.host-chip-label { + white-space: nowrap; + display: inline-flex; + align-items: center; + height: 100%; +} + +.host-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + border: 0; + background: transparent; + color: var(--muted); + padding: 0; + flex: 0 0 auto; + transition: color 120ms ease, background 120ms ease; +} + +.host-chip-remove svg { + width: 10px; + height: 10px; + display: block; +} + +.host-chip-remove:hover { + color: var(--bad); + background: color-mix(in srgb, var(--bad) 18%, transparent); +} + +.host-chip-empty { + color: var(--muted); + font-size: 13px; +} + +.host-add-button { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 12px; + height: 26px; + border-radius: var(--radius-pill); + border: 1px dashed var(--line-strong); + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 500; + line-height: 1; + transition: color 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.host-add-button > span:first-child { + font-size: 14px; + line-height: 1; + margin-top: -1px; +} + +.host-add-button:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); +} + +.host-add-form { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.host-add-form input { + width: 160px; + height: 26px; + padding: 0 10px; + font-size: 12px; + line-height: 1; +} + +.host-add-form .primary-button.small, +.host-add-form .secondary-button.small { + height: 26px; + padding: 0 12px; + line-height: 1; +} + +.link-button { + border: 0; + background: transparent; + color: var(--muted); + font-size: 12px; + padding: 4px 8px; + border-radius: 6px; + transition: color 120ms ease, background 120ms ease; +} + +.link-button:hover { + color: var(--accent); + background: var(--accent-soft); +} + +.primary-button.small, +.secondary-button.small { + padding: 6px 10px; + font-size: 12px; +} + +select, input, textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--bg-elev); + color: var(--ink); + padding: 10px 12px; + transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +select:hover, input:hover, textarea:hover { border-color: var(--line-strong); } + +select:focus, input:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 14px; + cursor: pointer; + text-overflow: ellipsis; +} + +select::-ms-expand { display: none; } + +select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +select option { + background: var(--bg-elev); + color: var(--ink); +} + +@media (prefers-color-scheme: dark) { + select { + background-image: url("data:image/svg+xml;utf8,"); + } +} + +.select-field { position: relative; } + +textarea { + resize: vertical; + min-height: 120px; + line-height: 1.55; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; +} + +/* Buttons */ +.primary-button, +.secondary-button { + border-radius: var(--radius-sm); + padding: 10px 16px; + font-weight: 600; + border: 1px solid transparent; + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.primary-button { + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + color: #fff; + box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.primary-button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.primary-button:active { transform: translateY(0); } + +.primary-button:disabled, +.secondary-button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.secondary-button { + background: var(--bg-elev); + color: var(--ink-soft); + border-color: var(--line); +} + +.secondary-button:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); +} + +.secondary-button.small { padding: 6px 10px; font-size: 12px; } + +.inline-form button { justify-self: start; } + +.form-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +/* Task cards */ +.task-board, .task-section, .task-list { display: grid; gap: 12px; } + +.task-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.task-card { padding: 14px 16px; } + +article.task-card { display: grid; gap: 10px; } + +details.task-card { + display: block; + padding: 0; + overflow: hidden; +} + +details.task-card > summary { + list-style: none; + cursor: pointer; + padding: 12px 14px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 12px; + min-width: 0; +} + +details.task-card > summary::-webkit-details-marker { display: none; } +details.task-card > summary::marker { display: none; } + +details.task-card[open] > summary { + border-bottom: 1px solid var(--line); +} + +details.task-card > .task-body { + display: grid; + gap: 10px; + padding: 14px 16px; +} + +.task-summary-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; +} + +.task-summary-title { + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-summary-prompt { + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-summary-resume { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 88px; +} + +.task-summary-meta { + display: grid; + grid-template-columns: 92px 60px; + align-items: center; + column-gap: 10px; + font-size: 12px; + white-space: nowrap; + text-align: right; +} + +.task-summary-duration { text-align: right; } + +.task-summary-chevron { + width: 14px; + color: var(--muted); + font-size: 18px; + line-height: 1; + text-align: center; + transition: transform 120ms ease; +} + +details.task-card[open] > summary .task-summary-chevron { + transform: rotate(90deg); +} + +.active-task { + border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent), var(--shadow-sm); +} + +.history-task { background: var(--panel-soft); } + +.history-task:hover { border-color: var(--line-strong); } + +@media (max-width: 720px) { + details.task-card > summary { + grid-template-columns: auto minmax(0, 1fr) auto; + } + .task-summary-meta { display: none; } + .task-summary-resume { display: none; } +} + +.task-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.task-head strong { font-weight: 650; font-size: 14px; } + +.task-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.task-facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.task-facts span { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 2px 9px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--bg-elev); +} + +.session-id { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } + +.workspace-path { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + color: var(--muted); +} + +.task-prompt { + color: var(--ink); + overflow-wrap: anywhere; + font-size: 13px; + line-height: 1.55; + padding: 10px 12px; + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + + +/* Status pill colors */ +.status-chip { + padding: 3px 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.status-running .status-chip { + background: var(--accent-soft); + color: var(--accent-ink); +} + +.status-completed .status-chip { + background: var(--good-soft); + color: var(--good); +} + +.status-failed .status-chip { + background: var(--bad-soft); + color: var(--bad); +} + +.status-running .status-chip::before, +.status-completed .status-chip::before, +.status-failed .status-chip::before { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + border-radius: 999px; + margin-right: 6px; + background: currentColor; +} + +.status-running .status-chip::before { + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.6); opacity: 0.4; } +} + +/* Empty state */ +.empty-state { + border: 1px dashed var(--line-strong); + border-radius: var(--radius-sm); + padding: 28px 20px; + color: var(--muted); + text-align: center; + background: var(--panel-soft); +} + +/* Terminal */ +.terminal { + margin: 10px 0 0; + border: 1px solid #1f2940; + border-radius: var(--radius-sm); + background: #0b1020; + overflow: hidden; + min-width: 0; +} + +.terminal.stderr { border-color: #5b2b2b; } + +.terminal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid #1f2940; + background: linear-gradient(180deg, #131a2e, #0f1426); + color: #9ca3af; + font: 11px/1.3 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.terminal > summary.terminal-head { cursor: pointer; list-style: none; } +.terminal > summary.terminal-head::-webkit-details-marker { display: none; } +.terminal > summary.terminal-head::after { + content: "▸"; + margin-left: 8px; + transition: transform 120ms ease; +} +.terminal[open] > summary.terminal-head::after { transform: rotate(90deg); } + +.terminal.stderr .terminal-head { + border-bottom-color: #5b2b2b; + background: linear-gradient(180deg, #25131b, #1a0e14); + color: #fca5a5; +} + +.terminal-body { + display: block; + width: 100%; + max-height: min(52vh, 520px); + margin: 0; + overflow: auto; + padding: 12px 14px; + background: #0b1020; + color: #e5e7eb; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 2; +} + +.terminal-body:focus { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* Toasts */ +.toast-host { + position: fixed; + bottom: 24px; + right: 24px; + display: grid; + gap: 10px; + z-index: 50; + max-width: min(360px, calc(100vw - 32px)); +} + +.toast { + background: var(--panel); + border: 1px solid var(--line); + border-left: 4px solid var(--accent); + border-radius: var(--radius-sm); + padding: 12px 14px; + box-shadow: var(--shadow-lg); + font-size: 13px; + color: var(--ink-soft); + animation: slideIn 200ms ease; +} + +.toast.error { border-left-color: var(--bad); color: var(--bad); } +.toast.success { border-left-color: var(--good); color: var(--good); } +.toast.warn { border-left-color: var(--warn); color: var(--warn); } + +@keyframes slideIn { + from { transform: translateY(10px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* Login */ +.login-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 20px; + position: relative; + z-index: 1; +} + +.login-panel { + width: min(420px, 100%); + display: grid; + gap: 18px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 18px; + padding: 28px; + box-shadow: var(--shadow-lg); +} + +.login-panel h1 { + font-size: 26px; + background: linear-gradient(135deg, var(--accent), #06b6d4); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.login-panel label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +.login-panel button { + border: 0; + border-radius: var(--radius-sm); + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + color: #fff; + padding: 12px 14px; + font-weight: 600; + box-shadow: 0 6px 14px color-mix(in srgb, var(--accent) 35%, transparent); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.login-panel button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.login-panel p { min-height: 18px; font-size: 13px; } + +/* Responsive */ +@media (max-width: 1180px) { + .work-grid { grid-template-columns: 1fr; } + .project-panel { + position: static; + max-height: none; + display: block; + } + .project-panel .repo-board { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + max-height: 320px; + min-height: 0; + flex: initial; + } +} + +@media (max-width: 1024px) { + .task-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 920px) { + .dashboard-grid { grid-template-columns: 1fr; } + .shell { width: min(100vw - 24px, 760px); padding-top: 18px; } +} + +@media (max-width: 560px) { + .task-controls { grid-template-columns: 1fr; } + .topbar, .panel-heading, .task-head { + align-items: flex-start; + flex-direction: column; + } + .task-actions { justify-content: flex-start; } + .inline-form { grid-template-columns: 1fr; } + .inline-form button { justify-self: stretch; } + .agent-context { flex-wrap: wrap; } +}