diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 1711051..3916c40 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -36,7 +36,9 @@ ### 控制台第一版 -已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份、跨命名空间聚合及写操作在管理 API 与权限模型明确后再接入。 +已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份和跨命名空间聚合仍不在控制台内提供。 + +控制台任务详情已接入 Pulse 的任务快照、执行尝试、操作审计、取消和安全重试接口。页面操作沿用 `expected_revision`、`Idempotency-Key`、原因、证据和服务端权限校验;`NEEDS_REVIEW` 仍要求通过核查 API/SDK 提交最终结论。下一阶段继续补充分页详情、计划控制和管理身份,不把页面操作视为绕过服务端事务边界的快捷入口。 ## 2. 持续推进方式 diff --git a/docs/console.md b/docs/console.md index 52b6b86..fb8b390 100644 --- a/docs/console.md +++ b/docs/console.md @@ -11,6 +11,8 @@ http://127.0.0.1:8080/console/ - 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。 - 通过用户选择的本地凭据 JSON 或手工 Token/队列 ID 调用现有授权 API。 - 展示任务、定时计划、流程实例和结果订阅的最近摘要。 +- 点击任务查看任务详情、执行尝试时间线、最终结果和操作审计摘要。 +- 在具备对应权限时提交任务取消和安全重试,沿用服务端的版本校验、幂等键、队列范围和审计规则。 - 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。 - 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。 @@ -26,6 +28,6 @@ http://127.0.0.1:8080/console/ ## 安全边界 -当前版本是管理查询控制台,不提供独立管理员账号、SSO、跨命名空间聚合或直接操作数据库。所有任务/计划/流程/订阅读取仍经过现有 Bearer Token、动作和队列范围校验;敏感凭据不进入页面源码。 +控制台不直接访问数据库,也不绕过 Bearer Token。取消和人工重试会使用当前任务版本生成一次性幂等键;网络结果不明时不要重复点击,应使用原始操作身份通过 API/SDK 核对。`NEEDS_REVIEW` 任务仍须使用带证据的核查 API/SDK 完成最终裁决。控制台不创建独立管理员身份,不支持跨命名空间聚合,不代替平台的权限与审计 API。 -下一阶段增加写操作前,应先补独立的管理身份与权限模型,再接入取消、重试、暂停、恢复、交付重试和审计操作。不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。 +不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。后续再补独立管理身份、计划控制和交付重试等更高风险操作。 diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js index 9330fae..5579f90 100644 --- a/internal/transport/httpserver/console/app.js +++ b/internal/transport/httpserver/console/app.js @@ -1,5 +1,5 @@ (() => { - const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false }; + const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '' }; const $ = (id) => document.getElementById(id); const text = (id, value) => { const el = $(id); if (el) el.textContent = value; }; const sessionQueue = sessionStorage.getItem('orbit.console.queue') || ''; @@ -18,6 +18,7 @@ function shortID(id) { return id && id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : (id || '—'); } function stateClass(value) { return String(value || '').toLowerCase(); } function extractItems(payload) { return payload?.data?.items || payload?.items || []; } + function extractData(payload) { return payload?.data ?? payload ?? {}; } async function request(path, options = {}) { const headers = { Accept: 'application/json', ...(options.headers || {}) }; if (state.token) headers.Authorization = `Bearer ${state.token}`; @@ -59,7 +60,11 @@ text('metric-tasks-sub', `${counts.succeeded || 0} 成功 · ${counts.running || 0} 执行中`); const body = $('task-table'); if (!items.length) { body.innerHTML = '◇当前队列没有任务提交任务后,这里会自动显示最新状态'; return; } - body.innerHTML = items.slice(0, 8).map(item => `
${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')}
${escapeHTML(item.handler || '—')}
${escapeHTML(item.handler_version || '')}${escapeHTML(item.state || 'UNKNOWN')}${escapeHTML(item.revision || '—')}${escapeHTML(formatTime(item.created_at))}`).join(''); + body.innerHTML = items.slice(0, 8).map(item => `
${escapeHTML(shortID(item.id))}${escapeHTML(item.business_key || '无业务键')}
${escapeHTML(item.handler || '—')}
${escapeHTML(item.handler_version || '')}${escapeHTML(item.state || 'UNKNOWN')}${escapeHTML(item.revision || '—')}${escapeHTML(formatTime(item.created_at))}`).join(''); + body.querySelectorAll('.task-row').forEach(row => { + row.addEventListener('click', () => openTaskDetail(row.dataset.taskId)); + row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(row.dataset.taskId); } }); + }); } function renderSchedules(items) { text('metric-schedules', String(items.length)); @@ -79,6 +84,77 @@ el.innerHTML = items.slice(0, 4).map(item => `
⌁
${escapeHTML(item.name || shortID(item.id))}待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}
${item.enabled === false ? '已暂停' : '已启用'}
`).join(''); } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } + function formatJSON(value) { + if (value === undefined || value === null || value === '') return ''; + try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2); } catch { return String(value); } + } + function operationKey(prefix) { + const id = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `console-${prefix}-${id}`.slice(0, 128); + } + function terminalState(value) { return ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(value); } + function showTaskDrawer() { $('task-drawer-backdrop').hidden = false; document.body.classList.add('drawer-open'); } + function closeTaskDrawer() { $('task-drawer-backdrop').hidden = true; document.body.classList.remove('drawer-open'); closeActionForm(); } + function renderDetailList(id, items, emptyText, renderItem) { + const el = $(id); + if (!items.length) { el.innerHTML = `
${escapeHTML(emptyText)}
`; return; } + el.innerHTML = items.map(renderItem).join(''); + } + function renderTaskDetail(task) { + state.selectedTask = task; + text('detail-title', task.handler || '任务详情'); text('detail-id', task.id || '—'); text('detail-state', task.state || 'UNKNOWN'); + $('detail-state').className = `state-pill ${stateClass(task.state)}`; + text('detail-updated', `最后更新 ${formatTime(task.updated_at)}`); text('detail-handler', `${task.handler || '—'} · ${task.handler_version || '—'}`); + text('detail-revision', task.revision || '—'); text('detail-attempt-count', task.attempt_count ?? '0'); text('detail-effect', task.effect_uncertain ? '待核查' : '已确认'); + text('detail-created', formatTime(task.created_at)); text('detail-deadline', formatTime(task.deadline_at)); + const resultBlock = $('detail-result-block'); resultBlock.hidden = !task.result; if (task.result) $('detail-result').textContent = formatJSON(task.result); + const canCancel = !terminalState(task.state) && !task.cancel_requested_at; + const canRetry = (task.state === 'FAILED' || task.state === 'CANCELLED') && !task.effect_uncertain; + $('detail-actions').hidden = !(canCancel || canRetry); $('task-cancel-button').hidden = !canCancel; $('task-retry-button').hidden = !canRetry; + text('detail-action-note', task.state === 'NEEDS_REVIEW' ? '此任务需要先完成带证据的人工核查,确认旧执行已经结清后再决定最终状态。' : '写操作需要对应的管理权限;服务端会再次校验版本、状态、幂等键和队列范围。'); + } + function renderAttempts(items, error) { + text('detail-attempts-count', error ? '权限不足' : `${items.length} 次`); + renderDetailList('detail-attempts', items, error ? '当前凭据没有 tasks:read 历史权限。' : '还没有执行尝试。', item => `
第 ${escapeHTML(item.epoch)} 次 · ${escapeHTML(item.state || 'UNKNOWN')}${escapeHTML(item.error_code || '无错误')} · ${escapeHTML(formatTime(item.created_at))}
${item.retryable ? '可重试' : '不可重试'}
`); + } + function renderOperations(items, error) { + text('detail-operations-count', error ? '权限不足' : `${items.length} 条`); + renderDetailList('detail-operations', items, error ? '当前凭据没有 tasks:audit:read 权限。' : '还没有操作审计。', item => `
${escapeHTML(item.action || '操作')}${escapeHTML(item.actor_id || '—')} · ${escapeHTML(formatTime(item.created_at))}
${escapeHTML(shortID(item.id))}
`); + } + async function openTaskDetail(id) { + if (!id) return; + if (!state.token) { showToast('请先连接具备 tasks:read 权限的凭据'); return; } + showTaskDrawer(); text('detail-title', '正在读取任务…'); text('detail-id', shortID(id)); text('detail-state', '读取中'); $('detail-actions').hidden = true; + $('detail-attempts').innerHTML = '
正在读取尝试历史…
'; $('detail-operations').innerHTML = '
正在读取操作审计…
'; + const encoded = encodeURIComponent(id); const [taskResult, attemptsResult, operationsResult] = await Promise.allSettled([request(`/v1/tasks/${encoded}`), request(`/v1/tasks/${encoded}/attempts?limit=20`), request(`/v1/tasks/${encoded}/operations?limit=20`)]); + if (taskResult.status !== 'fulfilled') { text('detail-title', '任务读取失败'); text('detail-action-note', taskResult.reason?.message || '无法读取任务详情'); return; } + const task = extractData(taskResult.value); renderTaskDetail(task); + renderAttempts(attemptsResult.status === 'fulfilled' ? extractItems(attemptsResult.value) : [], attemptsResult.status === 'rejected'); + renderOperations(operationsResult.status === 'fulfilled' ? extractItems(operationsResult.value) : [], operationsResult.status === 'rejected'); + } + function closeActionForm() { state.actionMode = ''; $('task-action-form').hidden = true; $('action-error').textContent = ''; } + function openActionForm(mode) { + if (!state.selectedTask) return; + state.actionMode = mode; $('task-action-form').hidden = false; $('action-error').textContent = ''; $('action-reason').value = ''; $('action-evidence').value = `evidence:console-${Date.now()}`; $('action-safe').checked = false; + const retry = mode === 'retry'; text('action-kicker', retry ? 'TASK RECOVERY' : 'TASK CONTROL'); text('action-title', retry ? '安全重试任务' : '取消任务'); text('action-description', retry ? '只有确认旧执行不会继续产生外部效果时,才能创建新的关联任务。' : '取消会记录持久化取消意图;运行中的处理器仍需上报结果或等待恢复。'); + $('action-evidence-wrap').hidden = !retry; $('action-timeout-wrap').hidden = !retry; $('action-safe-wrap').hidden = !retry; $('action-submit').textContent = retry ? '创建重试任务 →' : '提交取消 →'; $('action-reason').focus(); + } + async function submitAction(event) { + event.preventDefault(); if (!state.selectedTask || !state.actionMode) return; + const reason = $('action-reason').value.trim(); if (!reason) { $('action-error').textContent = '请填写操作原因。'; return; } + const task = state.selectedTask; const retry = state.actionMode === 'retry'; const body = { expected_revision: String(task.revision), reason }; + const path = `/v1/tasks/${encodeURIComponent(task.id)}:${retry ? 'retry' : 'cancel'}`; + if (retry) { + const timeout = Number($('action-timeout').value); if (!$('action-safe').checked) { $('action-error').textContent = '请先确认安全重试声明。'; return; } + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2592000000) { $('action-error').textContent = '任务总时限必须是 1 到 2592000000 毫秒。'; return; } + const evidence = $('action-evidence').value.trim(); if (!evidence) { $('action-error').textContent = '请填写证据引用。'; return; } + Object.assign(body, { evidence_ref: evidence, safe_to_retry: true, task_timeout_ms: timeout }); + } + const button = $('action-submit'); button.disabled = true; $('action-error').textContent = ''; + try { const response = await request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': operationKey(retry ? 'retry' : 'cancel') }, body: JSON.stringify(body) }); showToast(retry ? '已创建关联重试任务' : '取消意图已提交'); closeActionForm(); await refresh(); await openTaskDetail(task.id); if (response?.data?.task?.id && retry) showToast(`重试任务:${shortID(response.data.task.id)}`); } + catch (error) { $('action-error').textContent = error.message || '操作失败'; } + finally { button.disabled = false; } + } async function refresh() { if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning'); try { @@ -102,6 +178,7 @@ sessionStorage.setItem('orbit.console.queue', state.queue); refresh(); } $('connect-button').addEventListener('click', connect); $('connect-hero').addEventListener('click', () => $('connection-panel').scrollIntoView({ behavior: 'smooth', block: 'center' })); $('refresh-button').addEventListener('click', refresh); + $('close-task-drawer').addEventListener('click', closeTaskDrawer); $('task-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'task-drawer-backdrop') closeTaskDrawer(); }); $('action-cancel').addEventListener('click', closeActionForm); $('task-action-form').addEventListener('submit', submitAction); $('task-cancel-button').addEventListener('click', () => openActionForm('cancel')); $('task-retry-button').addEventListener('click', () => openActionForm('retry')); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); } }); $('reveal-token').addEventListener('click', () => { const field = $('token'); field.type = field.type === 'password' ? 'text' : 'password'; $('reveal-token').textContent = field.type === 'password' ? '显示' : '隐藏'; }); $('credential-file').addEventListener('change', event => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const credential = JSON.parse(reader.result); $('token').value = credential.token || ''; $('queue-id').value = credential.queue_id || ''; showToast('凭据已读取,只保留在当前页面内'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); }); document.querySelectorAll('[data-scroll]').forEach(button => button.addEventListener('click', () => { const target = $(button.dataset.scroll); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.scroll === button.dataset.scroll)); })); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 73c85b3..568ceda 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -96,6 +96,41 @@ +
diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index d15684e..12b702d 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -1 +1,2 @@ :root{--bg:#08111f;--bg-deep:#050b14;--panel:rgba(13,25,43,.78);--panel-strong:#101f34;--line:rgba(156,183,218,.13);--text:#eef5ff;--muted:#8d9db5;--faint:#5c6d85;--cyan:#62e6d4;--blue:#70a6ff;--purple:#b29aff;--orange:#ffb36b;--red:#ff788c;--radius:22px;--shadow:0 20px 70px rgba(0,0,0,.22);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);background:var(--bg)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:1100px;background:radial-gradient(circle at 72% -5%,#173a52 0,transparent 31rem),linear-gradient(145deg,var(--bg-deep),var(--bg) 58%,#071625);min-height:100vh}.ambient{position:fixed;border-radius:999px;filter:blur(110px);opacity:.14;pointer-events:none;z-index:0}.ambient-one{width:23rem;height:23rem;background:#3c8f9c;right:8%;top:8%}.ambient-two{width:19rem;height:19rem;background:#6651a8;left:13%;bottom:0}.app-shell{display:flex;position:relative;z-index:1;min-height:100vh}.sidebar{width:254px;position:fixed;inset:0 auto 0 0;padding:31px 19px 22px;border-right:1px solid var(--line);background:rgba(5,13,25,.72);backdrop-filter:blur(28px);display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;padding:0 13px 42px}.brand strong{display:block;font-size:20px;letter-spacing:.02em}.brand small{display:block;margin-top:4px;color:var(--muted);font-size:11px;letter-spacing:.14em}.brand-mark{width:32px;height:32px;position:relative;border:1px solid rgba(98,230,212,.5);border-radius:11px;transform:rotate(30deg);display:flex;align-items:center;justify-content:center;gap:3px;box-shadow:0 0 26px rgba(98,230,212,.17)}.brand-mark span{width:3px;border-radius:3px;background:var(--cyan);transform:rotate(-30deg)}.brand-mark span:nth-child(1){height:9px;opacity:.45}.brand-mark span:nth-child(2){height:17px}.brand-mark span:nth-child(3){height:11px;opacity:.7}.workspace-label{padding:0 13px 13px;color:#53657e;font-size:10px;letter-spacing:.18em;font-weight:700}.nav-list{display:grid;gap:5px}.nav-item{width:100%;border:0;color:#9aabc2;background:transparent;border-radius:13px;display:flex;align-items:center;gap:12px;padding:12px 13px;font-size:13px;text-align:left;cursor:pointer;transition:.25s}.nav-item:hover{background:rgba(117,151,191,.09);color:#e7f3ff}.nav-item.active{color:var(--text);background:linear-gradient(100deg,rgba(72,173,177,.2),rgba(74,96,159,.09));box-shadow:inset 2px 0 0 var(--cyan)}.nav-icon{font-size:18px;width:20px;text-align:center;color:#7290ac}.nav-item.active .nav-icon{color:var(--cyan)}.nav-item em{font-style:normal;font-size:10px;color:#50637e;margin-left:auto}.sidebar-bottom{margin-top:auto}.rail-card{padding:16px;margin:0 4px 23px;border:1px solid var(--line);background:rgba(15,31,51,.62);border-radius:16px}.rail-card-top{display:flex;align-items:center;gap:8px;font-size:12px}.status-dot,.live-chip i,.live-label-small i{display:inline-block;width:7px;height:7px;border-radius:50%;background:#687991}.status-dot.ready,.live-chip.ready i,.live-label-small i{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1),0 0 12px var(--cyan)}.rail-card p{margin:9px 0 13px;color:var(--faint);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rail-progress{height:3px;border-radius:9px;background:#253650;overflow:hidden}.rail-progress span{display:block;width:0;height:100%;background:linear-gradient(90deg,var(--cyan),var(--blue));border-radius:9px;transition:.6s}.sidebar-foot{color:#71829a;font-size:10px;letter-spacing:.05em;padding:0 9px}.pulse-dot{display:inline-block;width:5px;height:5px;background:var(--purple);border-radius:50%;margin-right:5px;vertical-align:1px}.foot-version{float:right;color:#4b5f79}.main-content{width:calc(100% - 254px);margin-left:254px;padding:0 48px 35px}.topbar{height:88px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(156,183,218,.08)}.crumbs{display:flex;gap:10px;align-items:center;color:#61758f;font-size:12px}.crumbs b{font-weight:400;color:#354862}.crumbs strong{font-weight:500;color:#b6c5d9}.topbar-actions{display:flex;align-items:center;gap:19px}.live-chip{display:flex;align-items:center;gap:7px;padding:7px 11px;background:rgba(99,129,156,.1);border:1px solid var(--line);border-radius:999px;color:#9eb1c7;font-size:11px}.last-updated{color:#61748f;font-size:11px}.icon-button{width:31px;height:31px;border:1px solid var(--line);background:rgba(128,157,195,.06);border-radius:9px;color:#9eb5ce;font-size:18px;cursor:pointer;transition:.2s}.icon-button:hover{background:rgba(98,230,212,.14);color:var(--cyan);transform:rotate(45deg)}.hero{min-height:330px;display:flex;position:relative;overflow:hidden;padding:52px 0 34px}.hero-copy{position:relative;z-index:2}.eyebrow,.panel-kicker{font-size:10px;color:#7995b2;letter-spacing:.2em;font-weight:700}.eyebrow{display:flex;align-items:center;gap:9px}.eyebrow-line{width:24px;height:1px;background:var(--cyan)}h1{font-size:48px;line-height:1.14;letter-spacing:-.055em;margin:18px 0 14px;font-weight:700}h1 span{color:transparent;background:linear-gradient(90deg,#eafcff,var(--cyan) 65%,#92b9ff);-webkit-background-clip:text;background-clip:text} .hero-copy p{max-width:410px;color:var(--muted);font-size:14px;line-height:1.8;margin:0}.hero-actions{display:flex;gap:13px;margin-top:27px}.primary-button,.quiet-button,.text-button{font:inherit;cursor:pointer}.primary-button{border:0;border-radius:10px;padding:11px 16px;color:#06212b;font-size:12px;font-weight:700;background:linear-gradient(120deg,var(--cyan),#9af2dc);box-shadow:0 9px 24px rgba(98,230,212,.16);transition:.2s}.primary-button:hover{transform:translateY(-2px);box-shadow:0 13px 32px rgba(98,230,212,.24)}.primary-button span{margin-left:13px}.primary-button.compact{padding:10px 15px}.quiet-button{border:1px solid var(--line);border-radius:10px;padding:10px 15px;color:#abc0d5;background:rgba(112,152,196,.08);font-size:12px}.quiet-button:hover{color:var(--text);border-color:rgba(98,230,212,.4)}.hero-visual{position:absolute;right:7%;top:18px;width:370px;height:300px}.orbit-ring{position:absolute;left:50%;top:50%;border:1px solid rgba(106,205,203,.24);border-radius:50%;transform:translate(-50%,-50%) rotate(-19deg)}.ring-one{width:230px;height:100px}.ring-two{width:314px;height:138px;border-color:rgba(112,166,255,.19);transform:translate(-50%,-50%) rotate(32deg)}.ring-three{width:360px;height:168px;border-color:rgba(178,154,255,.12);transform:translate(-50%,-50%) rotate(-47deg)}.orbit-core{position:absolute;width:86px;height:86px;left:50%;top:50%;transform:translate(-50%,-50%);display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle at 35% 30%,#42cdbd,#145c68 44%,#0b293d);box-shadow:0 0 0 9px rgba(98,230,212,.06),0 0 55px rgba(98,230,212,.33)}.orbit-core span{font-weight:800;font-size:32px;color:#dafefa;letter-spacing:-.12em;margin-left:-4px}.orbit-node{position:absolute;width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 16px var(--cyan)}.node-a{left:34px;top:111px}.node-b{right:43px;top:75px;background:var(--purple);box-shadow:0 0 16px var(--purple)}.node-c{right:91px;bottom:45px;background:var(--orange);box-shadow:0 0 16px var(--orange)}.hero-visual-label{position:absolute;right:7px;bottom:12px;display:flex;flex-direction:column;gap:4px;text-align:right}.hero-visual-label b{font-size:10px;letter-spacing:.15em;color:var(--cyan)}.hero-visual-label span{color:#627894;font-size:10px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:17px}.metric-card,.panel{border:1px solid var(--line);border-radius:var(--radius);background:linear-gradient(145deg,rgba(19,37,61,.8),rgba(10,24,42,.74));box-shadow:var(--shadow)}.metric-card{position:relative;min-height:146px;padding:19px 20px;overflow:hidden}.metric-card.metric-highlight{background:linear-gradient(145deg,rgba(24,66,73,.72),rgba(12,35,49,.86));border-color:rgba(98,230,212,.17)}.metric-top{display:flex;justify-content:space-between;align-items:center;color:#8598b0;font-size:11px}.metric-icon{width:24px;height:24px;display:grid;place-items:center;border-radius:8px;font-size:15px;background:rgba(255,255,255,.05)}.metric-icon.green{color:var(--cyan);background:rgba(98,230,212,.1)}.metric-icon.purple{color:var(--purple);background:rgba(178,154,255,.1)}.metric-icon.blue{color:var(--blue);background:rgba(112,166,255,.1)}.metric-icon.orange{color:var(--orange);background:rgba(255,179,107,.1)}.metric-card strong{display:block;margin-top:14px;font-size:28px;letter-spacing:-.04em}.metric-card small{display:block;margin-top:5px;color:#71859f;font-size:10px}.sparkline{display:flex;align-items:end;gap:3px;height:18px;position:absolute;right:20px;bottom:21px}.sparkline i{display:block;width:3px;border-radius:3px;background:currentColor;opacity:.55}.sparkline i:nth-child(1){height:7px}.sparkline i:nth-child(2){height:12px}.sparkline i:nth-child(3){height:9px}.sparkline i:nth-child(4){height:16px}.sparkline i:nth-child(5){height:11px}.sparkline i:nth-child(6){height:17px}.sparkline i:nth-child(7){height:14px}.sparkline i:nth-child(8){height:18px}.green-line{color:var(--cyan)}.blue-line{color:var(--blue)}.mini-bar{display:flex;gap:2px;height:4px;position:absolute;left:20px;right:20px;bottom:20px;background:#1c304a;border-radius:4px;overflow:hidden}.mini-bar i{width:0;transition:.45s}.mini-bar i:first-child{background:var(--cyan)}.mini-bar i:nth-child(2){background:var(--blue)}.mini-bar i:nth-child(3){background:var(--purple)}.mini-bar i:nth-child(4){background:var(--orange)}.subscription-meter{height:4px;position:absolute;left:20px;right:20px;bottom:20px;background:#1d3049;border-radius:4px;overflow:hidden}.subscription-meter span{display:block;width:0;height:100%;background:linear-gradient(90deg,var(--orange),var(--purple));transition:.45s}.content-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(300px,.75fr);gap:17px;margin-bottom:17px}.panel{padding:23px}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:15px;margin-bottom:22px}.panel-kicker{display:block;color:#687f9c;font-size:9px;margin-bottom:8px}.panel h2{margin:0;font-size:17px;letter-spacing:-.025em}.panel-tools{display:flex;align-items:center;gap:12px}.table-count,.connection-state{font-size:10px;color:#6d829d}.text-button{border:0;background:none;color:#87a5c3;font-size:11px;padding:4px 0}.text-button:hover{color:var(--cyan)}.text-button span{margin-left:6px}.table-wrap{overflow:auto}.table-wrap table{width:100%;border-collapse:collapse;min-width:540px}.table-wrap th{padding:0 10px 11px;text-align:left;color:#637896;font-size:10px;font-weight:500;border-bottom:1px solid var(--line)}.table-wrap td{padding:14px 10px;color:#b2c1d3;font-size:11px;border-bottom:1px solid rgba(156,183,218,.07)}.table-wrap tr:last-child td{border-bottom:0}.task-id{display:flex;flex-direction:column;gap:4px}.task-id b{color:#e2edf9;font-weight:550}.task-id small,.task-processor{color:#6c819b;font-size:10px}.state-pill{display:inline-flex;padding:4px 8px;border-radius:999px;background:rgba(110,140,170,.12);font-size:9px;color:#aebfd2}.state-pill.succeeded{color:var(--cyan);background:rgba(98,230,212,.1)}.state-pill.running{color:var(--blue);background:rgba(112,166,255,.1)}.state-pill.waiting,.state-pill.pending{color:var(--purple);background:rgba(178,154,255,.1)}.state-pill.failed,.state-pill.needs_review{color:var(--red);background:rgba(255,120,140,.1)}.empty-row{text-align:center!important;padding:38px 10px!important}.empty-row b{display:block;margin:7px 0 4px;color:#9aacbf;font-weight:500}.empty-row small{color:#627791}.empty-icon{display:grid;margin:auto;width:36px;height:36px;place-items:center;border-radius:12px;background:rgba(112,166,255,.08);color:var(--blue);font-size:20px}.connect-panel{background:linear-gradient(155deg,rgba(24,40,64,.9),rgba(12,24,41,.86))}.connection-state{border:1px solid rgba(255,179,107,.25);color:var(--orange);padding:5px 8px;border-radius:999px}.connection-state.ready{color:var(--cyan);border-color:rgba(98,230,212,.25);background:rgba(98,230,212,.07)}.panel-description{color:#73869e;font-size:11px;line-height:1.6;margin:-7px 0 19px}.field-label{display:block;color:#8295ad;font-size:10px;margin:13px 0 7px}.input-wrap{height:38px;display:flex;align-items:center;border:1px solid rgba(156,183,218,.15);background:rgba(4,12,23,.35);border-radius:10px;transition:.2s}.input-wrap:focus-within{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.input-prefix{width:32px;text-align:center;color:#58728e;font-size:13px}.input-wrap input{width:100%;height:100%;border:0;outline:0;background:none;color:#dceafb;font:inherit;font-size:11px}.input-wrap input::placeholder{color:#4f637c}.reveal-button{border:0;background:none;color:#6e879f;font-size:10px;cursor:pointer;margin-right:10px}.connect-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:18px}.file-button{color:#7f9ab6;font-size:10px;border:1px dashed rgba(126,158,193,.3);padding:10px 11px;border-radius:9px;cursor:pointer;white-space:nowrap}.file-button:hover{color:var(--cyan);border-color:rgba(98,230,212,.5)}.file-button input{display:none}.security-note{display:flex;gap:7px;margin-top:20px;color:#647b96;font-size:9px;line-height:1.55}.security-note span:first-child{color:var(--cyan)}.lower-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:17px}.schedule-list,.workflow-list,.subscription-list{display:grid;gap:10px}.empty-compact{display:flex;align-items:center;gap:12px;border:1px dashed rgba(126,158,193,.16);border-radius:13px;padding:18px 14px;color:#5e748f}.empty-compact>span{font-size:22px;color:#7899bc}.empty-compact b{display:block;color:#9aacc0;font-size:11px;font-weight:500}.empty-compact small{display:block;margin-top:5px;color:#607590;font-size:9px}.list-row{display:flex;align-items:center;gap:11px;padding:10px 0;border-bottom:1px solid rgba(156,183,218,.08)}.list-row:last-child{border-bottom:0}.list-avatar{width:29px;height:29px;border-radius:10px;display:grid;place-items:center;background:rgba(112,166,255,.1);color:var(--blue);font-size:13px}.list-copy{min-width:0;flex:1}.list-copy b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#c6d4e4;font-size:11px;font-weight:500}.list-copy small{display:block;margin-top:4px;color:#657b95;font-size:9px}.list-meta{text-align:right;color:#7990a9;font-size:10px}.live-label-small{display:flex;align-items:center;gap:6px;color:#7592aa;font-size:10px}.page-footer{display:flex;justify-content:space-between;padding:26px 3px 0;color:#526985;font-size:10px}.page-footer a{color:#80a7ca;text-decoration:none}.page-footer a:hover{color:var(--cyan)}.footer-orbit{color:var(--cyan);font-size:13px;margin-right:6px}.toast{position:fixed;right:28px;bottom:25px;z-index:5;transform:translateY(20px);opacity:0;pointer-events:none;padding:11px 15px;border:1px solid rgba(98,230,212,.25);background:#122b3b;color:#c9faf2;border-radius:10px;box-shadow:var(--shadow);font-size:11px;transition:.25s}.toast.show{transform:translateY(0);opacity:1}@media(max-width:1200px){body{min-width:0}.main-content{padding:0 28px}.hero-visual{right:0;opacity:.65}.lower-grid{grid-template-columns:1fr 1fr}.lower-grid .panel:last-child{grid-column:span 2}}@media(max-width:780px){.sidebar{width:70px;padding:24px 10px}.brand{padding:0 8px 32px}.brand>div:last-child,.workspace-label,.nav-item span:not(.nav-icon),.nav-item em,.sidebar-bottom{display:none}.nav-item{justify-content:center;padding:12px}.main-content{width:calc(100% - 70px);margin-left:70px;padding:0 17px}.topbar{height:70px}.last-updated{display:none}.hero{min-height:300px}.hero-visual{right:-120px;opacity:.35}h1{font-size:38px}.metric-grid{grid-template-columns:1fr 1fr}.content-grid,.lower-grid{grid-template-columns:1fr}.lower-grid .panel:last-child{grid-column:auto}} +[hidden]{display:none!important}.task-row{cursor:pointer;transition:background .2s}.task-row:hover,.task-row:focus{outline:0;background:rgba(98,230,212,.055)}.task-row:focus td:first-child{box-shadow:inset 2px 0 0 var(--cyan)}.drawer-open{overflow:hidden}.drawer-backdrop{position:fixed;inset:0;z-index:20;background:rgba(2,8,16,.68);backdrop-filter:blur(8px)}.task-drawer{position:absolute;right:0;top:0;height:100%;width:min(570px,100%);overflow-y:auto;padding:29px 28px 34px;background:linear-gradient(165deg,#122943,#091828 70%);border-left:1px solid rgba(98,230,212,.18);box-shadow:-22px 0 70px rgba(0,0,0,.35);animation:drawer-in .24s ease-out}@keyframes drawer-in{from{transform:translateX(35px);opacity:.4}to{transform:translateX(0);opacity:1}}.drawer-header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.drawer-header h2{margin:0 0 8px;font-size:22px;letter-spacing:-.03em}.drawer-id{display:block;max-width:390px;color:#6f87a2;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.drawer-status-line{display:flex;align-items:center;gap:11px;margin:22px 0 19px;color:#748aa4;font-size:10px}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;border:1px solid var(--line);border-radius:15px;overflow:hidden;margin-bottom:18px}.detail-grid>div{padding:13px 14px;background:rgba(5,15,27,.34);min-width:0}.detail-grid span{display:block;color:#637b96;font-size:9px;margin-bottom:6px}.detail-grid b{display:block;color:#d4e2f1;font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-block{border-top:1px solid rgba(156,183,218,.1);padding-top:19px;margin-top:19px}.detail-block-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;color:#b4c7db;font-size:12px}.detail-block-heading small{color:#617995;font-size:9px}.detail-list{display:grid;gap:0}.detail-list-row{position:relative;display:flex;align-items:center;gap:11px;padding:11px 0;border-bottom:1px solid rgba(156,183,218,.08)}.detail-list-row:last-child{border-bottom:0}.detail-list-row>div{min-width:0;flex:1}.detail-list-row b{display:block;color:#bdcfe1;font-size:10px;font-weight:500}.detail-list-row small{display:block;margin-top:4px;color:#6d829c;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail-list-row em{font-style:normal;color:#778da7;font-size:9px}.timeline-dot{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:#657991;box-shadow:0 0 0 4px rgba(101,121,145,.1)}.timeline-dot.succeeded{background:var(--cyan);box-shadow:0 0 0 4px rgba(98,230,212,.1)}.timeline-dot.running{background:var(--blue);box-shadow:0 0 0 4px rgba(112,166,255,.1)}.timeline-dot.failed,.timeline-dot.unknown{background:var(--red);box-shadow:0 0 0 4px rgba(255,120,140,.1)}.timeline-dot.audit{background:var(--purple);box-shadow:0 0 0 4px rgba(178,154,255,.1)}.detail-loading,.detail-empty{padding:17px 0;color:#607895;font-size:10px}.detail-block pre{max-height:180px;overflow:auto;margin:0;padding:12px;border:1px solid rgba(156,183,218,.1);border-radius:11px;background:rgba(4,11,20,.48);color:#a7e9df;font:10px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}.drawer-actions{display:flex;gap:10px;margin-top:23px;padding-top:19px;border-top:1px solid rgba(156,183,218,.1)}.danger-button{color:#ff9baa;border-color:rgba(255,120,140,.25);background:rgba(255,120,140,.07)}.danger-button:hover{color:#ffd5db;border-color:rgba(255,120,140,.5)}.drawer-note{color:#687f99;font-size:9px;line-height:1.7;margin:15px 0 0}.action-form{margin-top:21px;padding:19px;border:1px solid rgba(98,230,212,.17);border-radius:16px;background:rgba(4,14,25,.45)}.action-form-heading h3{margin:0;color:#d7e6f4;font-size:15px}.action-form p{color:#7890a9;font-size:10px;line-height:1.6;margin:10px 0 4px}.action-form textarea,.action-form input{width:100%;border:1px solid rgba(156,183,218,.15);outline:0;border-radius:9px;background:rgba(4,12,23,.45);color:#dceafb;font:inherit;font-size:10px;padding:10px;resize:vertical}.action-form textarea:focus,.action-form input:focus{border-color:rgba(98,230,212,.58);box-shadow:0 0 0 3px rgba(98,230,212,.07)}.check-row{display:flex;gap:9px;align-items:flex-start;margin-top:15px;color:#9eb3c9;font-size:10px;line-height:1.5}.check-row input{accent-color:var(--cyan);margin-top:2px}.action-form-buttons{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.action-error{min-height:15px;color:#ff9baa;font-size:10px;margin-top:9px}.action-form .quiet-button{padding:10px 14px}@media(max-width:780px){.task-drawer{width:100%;padding:24px 18px 28px}.detail-grid{grid-template-columns:1fr 1fr}}