diff --git a/README.md b/README.md
index 1f3ae21..c1e139a 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ SDK 独立发布门禁使用 `make sdk-check`,在 `GOWORK=off` 下检查完整
- [HTTP / WebSocket 接口手册](docs/api/README.md)
- [Go SDK 接入](docs/sdk/README.md)
- [通用场景示例](docs/examples/README.md)
+- [Orbit Console 控制台](docs/console.md)
- [运行与发布手册](docs/20-operations-runbook.md)
- [容器发布说明](docs/21-container-deployment.md)
- [设计文档总览](docs/README.md)
diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md
index 13b0437..1711051 100644
--- a/docs/08-development-plan.md
+++ b/docs/08-development-plan.md
@@ -34,6 +34,10 @@
已按用户要求推送仓库 `main` 及 `v0.1.0` 接入版本,独立 SDK 标签为 `sdk/go/v0.1.0`,供业务项目直接 `go get`。两个标签固定提交 `0e8b2ef2975df6b55f2240dd14eda83adf30ea94`;仓库外空模块缓存下载、固定版本、无 `replace` 编译、HTTP smoke 和校验和验证已通过,见[远端发布报告](reports/phase-7-sdk-remote-release.md)。本机验证沿用系统网络代理及公开 Go 模块代理,保持 sumdb 校验。该交付不关闭上方目标环境及完整跨版本生产验收项。
+### 控制台第一版
+
+已将 [Orbit Console](console.md) 编译进服务端,入口为 `/console/`。第一版读取健康、版本、任务、计划、流程和结果订阅接口,支持本地凭据 JSON、响应式总览和有界自动刷新;不直接访问数据库,也不绕过现有 Bearer Token 与动作范围。独立管理员身份、跨命名空间聚合及写操作在管理 API 与权限模型明确后再接入。
+
## 2. 持续推进方式
每个阶段按以下顺序推进:
diff --git a/docs/README.md b/docs/README.md
index 14a762e..76be901 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -30,6 +30,7 @@
| [流程状态时间线](api/workflow-events.md) | 原始流程/步骤/信号状态事件、分页和历史详情 |
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
+| [Orbit Console](console.md) | 内置控制台、本地打开方式与权限边界 |
## 已确认的决策
diff --git a/docs/console.md b/docs/console.md
new file mode 100644
index 0000000..52b6b86
--- /dev/null
+++ b/docs/console.md
@@ -0,0 +1,31 @@
+# Orbit Console · 星轨控制台
+
+Orbit 内置了一个不依赖 Node.js、外部 CDN 或额外文件挂载的管理控制台第一版。静态资源编译进 Orbit 二进制,入口为:
+
+```text
+http://127.0.0.1:8080/console/
+```
+
+## 当前能力
+
+- 读取 `/health/live`、`/health/ready` 和 `/version` 展示服务、Schema 与工具链状态。
+- 通过用户选择的本地凭据 JSON 或手工 Token/队列 ID 调用现有授权 API。
+- 展示任务、定时计划、流程实例和结果订阅的最近摘要。
+- 任务状态按 10 秒自动刷新;页面不会把凭据上传到独立服务,也不读取数据库。
+- 页面使用当前请求方的业务权限;没有权限的模块显示为空或等待权限,不绕过服务端授权。
+
+## 本地打开
+
+启动 Orbit 后直接访问 `/console/`。当前 Docker 开发环境可用:
+
+```text
+http://127.0.0.1:8080/console/
+```
+
+点击“选择凭据 JSON”,选择 `orbit-admin bootstrap` 生成的私有凭据文件,例如 `.local/local.credentials.json`,再点击“连接”。文件只在浏览器内解析,Token 保留在当前页面会话中,不写入 Orbit 数据库。
+
+## 安全边界
+
+当前版本是管理查询控制台,不提供独立管理员账号、SSO、跨命名空间聚合或直接操作数据库。所有任务/计划/流程/订阅读取仍经过现有 Bearer Token、动作和队列范围校验;敏感凭据不进入页面源码。
+
+下一阶段增加写操作前,应先补独立的管理身份与权限模型,再接入取消、重试、暂停、恢复、交付重试和审计操作。不能把业务 Token 长期放进浏览器本地存储,也不能让控制台直连 MySQL。
diff --git a/internal/transport/httpserver/console.go b/internal/transport/httpserver/console.go
new file mode 100644
index 0000000..d1d5692
--- /dev/null
+++ b/internal/transport/httpserver/console.go
@@ -0,0 +1,38 @@
+package httpserver
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+)
+
+// consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN
+// 或额外的文件挂载。控制台只调用版本化 HTTP API,不读取数据库。
+//
+//go:embed console
+var consoleAssets embed.FS
+
+// RegisterConsole 注册本地及生产可复用的 Orbit 控制台入口。
+// /console 不改变业务 API 的认证和路由;静态页面自身不包含凭据。
+func RegisterConsole(server *http.Server) {
+ if server == nil {
+ return
+ }
+ mux, ok := server.Handler.(*http.ServeMux)
+ if !ok {
+ return
+ }
+ assets, err := fs.Sub(consoleAssets, "console")
+ if err != nil {
+ return
+ }
+ files := http.StripPrefix("/console/", http.FileServer(http.FS(assets)))
+ mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/console/", http.StatusPermanentRedirect)
+ })
+ mux.Handle("GET /console/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ files.ServeHTTP(w, r)
+ }))
+}
diff --git a/internal/transport/httpserver/console/app.js b/internal/transport/httpserver/console/app.js
new file mode 100644
index 0000000..9330fae
--- /dev/null
+++ b/internal/transport/httpserver/console/app.js
@@ -0,0 +1,109 @@
+(() => {
+ const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false };
+ const $ = (id) => document.getElementById(id);
+ const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
+ const sessionQueue = sessionStorage.getItem('orbit.console.queue') || '';
+ $('api-url').value = location.origin;
+ $('queue-id').value = sessionQueue;
+
+ function showToast(message) {
+ const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
+ clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
+ }
+ function formatTime(value) {
+ if (!value) return '—';
+ const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
+ return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
+ }
+ 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 || []; }
+ async function request(path, options = {}) {
+ const headers = { Accept: 'application/json', ...(options.headers || {}) };
+ if (state.token) headers.Authorization = `Bearer ${state.token}`;
+ const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers });
+ const raw = await response.text(); let body = {};
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
+ if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
+ return body;
+ }
+ function setConnection(connected, detail = '') {
+ state.connected = connected;
+ const authenticated = connected && Boolean(state.token && state.queue);
+ $('connection-state').textContent = authenticated ? '已连接' : (connected ? '服务在线' : '未连接');
+ $('connection-state').classList.toggle('ready', connected);
+ $('side-status').textContent = authenticated ? '环境已连接' : (connected ? '服务在线' : '等待连接');
+ $('side-endpoint').textContent = connected ? state.base : 'API 未连接';
+ $('side-progress').style.width = connected ? '100%' : '0%';
+ document.querySelector('.status-dot').classList.toggle('ready', connected);
+ document.querySelector('.live-chip').classList.toggle('ready', connected);
+ $('live-label').textContent = connected ? '环境在线' : '本地环境';
+ if (detail) showToast(detail);
+ }
+ async function loadHealth() {
+ try {
+ const [live, version] = await Promise.all([request('/health/live'), request('/version')]);
+ text('metric-health', live?.status === 'alive' ? '在线' : '异常');
+ text('metric-health-sub', `Go ${version?.go_version || '—'} · API v${version?.http_protocol_version || '—'}`);
+ text('side-status', '环境已连接');
+ return version;
+ } catch (error) {
+ text('metric-health', '离线'); text('metric-health-sub', error.message); setConnection(false); throw error;
+ }
+ }
+ function renderTasks(items) {
+ text('task-count', `${items.length} 项`); text('metric-tasks', String(items.length));
+ const counts = items.reduce((acc, item) => { const key = stateClass(item.state); acc[key] = (acc[key] || 0) + 1; return acc; }, {});
+ const total = Math.max(items.length, 1); const width = (name) => `${(counts[name] || 0) / total * 100}%`;
+ $('task-bar-succeeded').style.width = width('succeeded'); $('task-bar-running').style.width = width('running'); $('task-bar-waiting').style.width = `${((counts.waiting || 0) + (counts.pending || 0)) / total * 100}%`; $('task-bar-other').style.width = `${((counts.failed || 0) + (counts.cancelled || 0) + (counts.needs_review || 0)) / total * 100}%`;
+ 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('');
+ }
+ function renderSchedules(items) {
+ text('metric-schedules', String(items.length));
+ const el = $('schedule-list'); if (!items.length) { el.innerHTML = '◷当前没有定时计划需要 schedules:read 权限
'; return; }
+ el.innerHTML = items.slice(0, 4).map(item => `◷
${escapeHTML(item.name || shortID(item.id))}${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}
${escapeHTML(item.enabled === false ? '已暂停' : '运行中')}
`).join('');
+ }
+ function renderWorkflows(items) {
+ text('metric-workflows', String(items.length)); text('metric-workflows-sub', items.length ? '最近实例' : '当前没有实例');
+ const el = $('workflow-list'); if (!items.length) { el.innerHTML = '⌘当前没有流程实例需要 workflows:read 权限
'; return; }
+ el.innerHTML = items.slice(0, 4).map(item => `⌘
${escapeHTML(item.business_key || shortID(item.id))}${escapeHTML(item.state || '—')}
${escapeHTML(formatTime(item.created_at))}
`).join('');
+ }
+ function renderSubscriptions(items) {
+ text('metric-subscriptions', String(items.length));
+ const active = items.filter(item => item.enabled !== false).length; $('subscription-meter-fill').style.width = `${items.length ? active / items.length * 100 : 0}%`;
+ text('metric-subscriptions-sub', items.length ? `${active} 个已启用 · 交付自动恢复` : '当前没有结果订阅');
+ const el = $('subscription-list'); if (!items.length) { el.innerHTML = '⌁当前没有结果订阅需要 events:read 权限
'; return; }
+ 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])); }
+ async function refresh() {
+ if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
+ try {
+ await loadHealth();
+ if (!state.token || !state.queue) { setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
+ const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); renderTasks(extractItems(tasks));
+ const optional = await Promise.allSettled([
+ request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
+ request('/v1/workflows?limit=20'),
+ request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
+ ]);
+ if (optional[0].status === 'fulfilled') renderSchedules(extractItems(optional[0].value));
+ if (optional[1].status === 'fulfilled') renderWorkflows(extractItems(optional[1].value));
+ if (optional[2].status === 'fulfilled') renderSubscriptions(extractItems(optional[2].value));
+ setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); text('metric-health-sub', '数据库与 Schema 正常');
+ } catch (error) { setConnection(false); showToast(`连接失败:${error.message}`); text('last-updated', '刷新失败'); }
+ finally { state.loading = false; $('refresh-button').classList.remove('spinning'); }
+ }
+ function connect() {
+ state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim();
+ 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);
+ $('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)); }));
+ refresh(); state.timer = setInterval(refresh, 10000);
+})();
diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html
new file mode 100644
index 0000000..73c85b3
--- /dev/null
+++ b/internal/transport/httpserver/console/index.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+ Orbit Console · 星轨控制台
+
+
+
+
+
+
+
+
+
+
+ Orbit/控制台
+
+ 本地环境
+ 尚未刷新
+
+
+
+
+
+
+
ORBIT CONTROL PLANE
+
让每一次执行
都清晰可见。
+
任务、定时、流程和交付状态,在一块屏幕上保持有序运转。
+
+
+
+
+
+
+
+
+
+
O
+
+
LIVE任务流持续观测
+
+
+
+
+ 服务状态✦
—等待就绪检查
+ 任务总览◇
—连接凭据后加载
+ 流程实例⌘
—等待工作流权限
+ 结果订阅⌁
—等待事件权限
+
+
+
+
+
+ PULSE / SCHEDULES
定时计划
◷等待计划数据需要 schedules:read 权限
+ WEAVE / WORKFLOWS
流程实例
⌘等待流程数据需要 workflows:read 权限
+ EVENTS / DELIVERY
结果订阅
自动刷新
+
+
+
+
+
+
+
+
+
diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css
new file mode 100644
index 0000000..d15684e
--- /dev/null
+++ b/internal/transport/httpserver/console/styles.css
@@ -0,0 +1 @@
+: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}}
diff --git a/internal/transport/httpserver/console_test.go b/internal/transport/httpserver/console_test.go
new file mode 100644
index 0000000..29762c9
--- /dev/null
+++ b/internal/transport/httpserver/console_test.go
@@ -0,0 +1,34 @@
+package httpserver
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestConsoleIsEmbeddedAndDoesNotRequireCredentials(t *testing.T) {
+ server := New("127.0.0.1:0", nil)
+ r := httptest.NewRequest(http.MethodGet, "/console/", nil)
+ w := httptest.NewRecorder()
+ server.Handler.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
+ t.Fatalf("content type = %q, want HTML", got)
+ }
+ if !strings.Contains(w.Body.String(), "Orbit Console") {
+ t.Fatal("embedded console page is missing its title")
+ }
+}
+
+func TestConsoleRedirectsWithoutTouchingAPIAuthentication(t *testing.T) {
+ server := New("127.0.0.1:0", nil)
+ r := httptest.NewRequest(http.MethodGet, "/console", nil)
+ w := httptest.NewRecorder()
+ server.Handler.ServeHTTP(w, r)
+ if w.Code != http.StatusPermanentRedirect || w.Header().Get("Location") != "/console/" {
+ t.Fatalf("redirect = %d %q", w.Code, w.Header().Get("Location"))
+ }
+}
diff --git a/internal/transport/httpserver/server.go b/internal/transport/httpserver/server.go
index 4ca64ac..050805a 100644
--- a/internal/transport/httpserver/server.go
+++ b/internal/transport/httpserver/server.go
@@ -82,7 +82,9 @@ func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
registerTasks(mux, tasks[0])
registerHistory(mux, tasks[0])
}
- return &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
+ server := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
+ RegisterConsole(server)
+ return server
}
// WrapMetrics must run after all Register* functions because those functions