feat: add embedded Orbit console
This commit is contained in:
@@ -97,6 +97,7 @@ SDK 独立发布门禁使用 `make sdk-check`,在 `GOWORK=off` 下检查完整
|
|||||||
- [HTTP / WebSocket 接口手册](docs/api/README.md)
|
- [HTTP / WebSocket 接口手册](docs/api/README.md)
|
||||||
- [Go SDK 接入](docs/sdk/README.md)
|
- [Go SDK 接入](docs/sdk/README.md)
|
||||||
- [通用场景示例](docs/examples/README.md)
|
- [通用场景示例](docs/examples/README.md)
|
||||||
|
- [Orbit Console 控制台](docs/console.md)
|
||||||
- [运行与发布手册](docs/20-operations-runbook.md)
|
- [运行与发布手册](docs/20-operations-runbook.md)
|
||||||
- [容器发布说明](docs/21-container-deployment.md)
|
- [容器发布说明](docs/21-container-deployment.md)
|
||||||
- [设计文档总览](docs/README.md)
|
- [设计文档总览](docs/README.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 校验。该交付不关闭上方目标环境及完整跨版本生产验收项。
|
已按用户要求推送仓库 `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. 持续推进方式
|
## 2. 持续推进方式
|
||||||
|
|
||||||
每个阶段按以下顺序推进:
|
每个阶段按以下顺序推进:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
| [流程状态时间线](api/workflow-events.md) | 原始流程/步骤/信号状态事件、分页和历史详情 |
|
| [流程状态时间线](api/workflow-events.md) | 原始流程/步骤/信号状态事件、分页和历史详情 |
|
||||||
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
|
| [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 |
|
||||||
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
|
| [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 |
|
||||||
|
| [Orbit Console](console.md) | 内置控制台、本地打开方式与权限边界 |
|
||||||
|
|
||||||
## 已确认的决策
|
## 已确认的决策
|
||||||
|
|
||||||
|
|||||||
31
docs/console.md
Normal file
31
docs/console.md
Normal file
@@ -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。
|
||||||
38
internal/transport/httpserver/console.go
Normal file
38
internal/transport/httpserver/console.go
Normal file
@@ -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)
|
||||||
|
}))
|
||||||
|
}
|
||||||
109
internal/transport/httpserver/console/app.js
Normal file
109
internal/transport/httpserver/console/app.js
Normal file
@@ -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 = '<tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
|
||||||
|
body.innerHTML = items.slice(0, 8).map(item => `<tr><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td class="task-processor">${escapeHTML(item.handler || '—')}<br><small>${escapeHTML(item.handler_version || '')}</small></td><td><span class="state-pill ${stateClass(item.state)}">${escapeHTML(item.state || 'UNKNOWN')}</span></td><td>${escapeHTML(item.revision || '—')}</td><td>${escapeHTML(formatTime(item.created_at))}</td></tr>`).join('');
|
||||||
|
}
|
||||||
|
function renderSchedules(items) {
|
||||||
|
text('metric-schedules', String(items.length));
|
||||||
|
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>需要 schedules:read 权限</small></div></div>'; return; }
|
||||||
|
el.innerHTML = items.slice(0, 4).map(item => `<div class="list-row"><div class="list-avatar">◷</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>${escapeHTML(item.queue_id ? shortID(item.queue_id) : '计划')}</small></div><div class="list-meta">${escapeHTML(item.enabled === false ? '已暂停' : '运行中')}</div></div>`).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 = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>需要 workflows:read 权限</small></div></div>'; return; }
|
||||||
|
el.innerHTML = items.slice(0, 4).map(item => `<div class="list-row"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).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 = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>需要 events:read 权限</small></div></div>'; return; }
|
||||||
|
el.innerHTML = items.slice(0, 4).map(item => `<div class="list-row"><div class="list-avatar">⌁</div><div class="list-copy"><b>${escapeHTML(item.name || shortID(item.id))}</b><small>待处理 ${escapeHTML(String(item.pending_count ?? 0))} · 上限 ${escapeHTML(String(item.max_pending ?? '—'))}</small></div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).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);
|
||||||
|
})();
|
||||||
102
internal/transport/httpserver/console/index.html
Normal file
102
internal/transport/httpserver/console/index.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Orbit Console · 星轨控制台</title>
|
||||||
|
<link rel="stylesheet" href="/console/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient ambient-one"></div>
|
||||||
|
<div class="ambient ambient-two"></div>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||||
|
<div><strong>Orbit</strong><small>星轨控制台</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="workspace-label">CONTROL PLANE</div>
|
||||||
|
<nav class="nav-list" aria-label="控制台导航">
|
||||||
|
<button class="nav-item active" data-scroll="overview"><span class="nav-icon">⌂</span><span>总览</span><em>01</em></button>
|
||||||
|
<button class="nav-item" data-scroll="tasks"><span class="nav-icon">◇</span><span>任务执行</span><em>02</em></button>
|
||||||
|
<button class="nav-item" data-scroll="schedules"><span class="nav-icon">◷</span><span>定时计划</span><em>03</em></button>
|
||||||
|
<button class="nav-item" data-scroll="workflows"><span class="nav-icon">⌘</span><span>流程实例</span><em>04</em></button>
|
||||||
|
<button class="nav-item" data-scroll="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<div class="rail-card">
|
||||||
|
<div class="rail-card-top"><span class="status-dot"></span><span id="side-status">等待连接</span></div>
|
||||||
|
<p id="side-endpoint">API 未连接</p>
|
||||||
|
<div class="rail-progress"><span id="side-progress"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-foot"><span class="pulse-dot"></span> Pulse · Weave <span class="foot-version">v1</span></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="crumbs"><span>Orbit</span><b>/</b><strong>控制台</strong></div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||||
|
<span class="last-updated" id="last-updated">尚未刷新</span>
|
||||||
|
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero" id="overview">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="eyebrow"><span class="eyebrow-line"></span> ORBIT CONTROL PLANE</div>
|
||||||
|
<h1>让每一次执行<br><span>都清晰可见。</span></h1>
|
||||||
|
<p>任务、定时、流程和交付状态,在一块屏幕上保持有序运转。</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button class="primary-button" data-scroll="tasks">查看任务 <span>↗</span></button>
|
||||||
|
<button class="quiet-button" id="connect-hero">连接环境</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual" aria-hidden="true">
|
||||||
|
<div class="orbit-ring ring-one"></div>
|
||||||
|
<div class="orbit-ring ring-two"></div>
|
||||||
|
<div class="orbit-ring ring-three"></div>
|
||||||
|
<div class="orbit-core"><span>O</span></div>
|
||||||
|
<div class="orbit-node node-a"></div><div class="orbit-node node-b"></div><div class="orbit-node node-c"></div>
|
||||||
|
<div class="hero-visual-label"><b>LIVE</b><span>任务流持续观测</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metric-grid" aria-label="运行概览">
|
||||||
|
<article class="metric-card metric-highlight"><div class="metric-top"><span>服务状态</span><span class="metric-icon green">✦</span></div><strong id="metric-health">—</strong><small id="metric-health-sub">等待就绪检查</small><div class="sparkline green-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-top"><span>任务总览</span><span class="metric-icon purple">◇</span></div><strong id="metric-tasks">—</strong><small id="metric-tasks-sub">连接凭据后加载</small><div class="mini-bar"><i id="task-bar-succeeded"></i><i id="task-bar-running"></i><i id="task-bar-waiting"></i><i id="task-bar-other"></i></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-top"><span>流程实例</span><span class="metric-icon blue">⌘</span></div><strong id="metric-workflows">—</strong><small id="metric-workflows-sub">等待工作流权限</small><div class="sparkline blue-line"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></article>
|
||||||
|
<article class="metric-card"><div class="metric-top"><span>结果订阅</span><span class="metric-icon orange">⌁</span></div><strong id="metric-subscriptions">—</strong><small id="metric-subscriptions-sub">等待事件权限</small><div class="subscription-meter"><span id="subscription-meter-fill"></span></div></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<article class="panel wide-panel" id="tasks">
|
||||||
|
<div class="panel-heading"><div><span class="panel-kicker">PULSE / TASKS</span><h2>任务执行流</h2></div><div class="panel-tools"><span class="table-count" id="task-count">0 项</span><button class="text-button" data-scroll="tasks">全部任务 <span>→</span></button></div></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>任务</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="5" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>连接一个具备 tasks:read 权限的凭据后自动加载</small></td></tr></tbody></table></div>
|
||||||
|
</article>
|
||||||
|
<article class="panel connect-panel" id="connection-panel">
|
||||||
|
<div class="panel-heading"><div><span class="panel-kicker">ENVIRONMENT</span><h2>连接环境</h2></div><span class="connection-state" id="connection-state">未连接</span></div>
|
||||||
|
<p class="panel-description">凭据只在当前浏览器会话中使用,不会上传到 Orbit Console。</p>
|
||||||
|
<label class="field-label" for="api-url">API 地址</label><div class="input-wrap"><span class="input-prefix">↗</span><input id="api-url" type="url" value="" placeholder="http://127.0.0.1:8080"></div>
|
||||||
|
<label class="field-label" for="queue-id">队列 ID</label><div class="input-wrap"><span class="input-prefix">#</span><input id="queue-id" type="text" placeholder="从凭据文件自动读取"></div>
|
||||||
|
<label class="field-label" for="token">Bearer Token</label><div class="input-wrap"><span class="input-prefix">●</span><input id="token" type="password" placeholder="粘贴开发凭据 token"><button class="reveal-button" id="reveal-token" type="button">显示</button></div>
|
||||||
|
<div class="connect-row"><label class="file-button"><input id="credential-file" type="file" accept="application/json">选择凭据 JSON</label><button class="primary-button compact" id="connect-button">连接 <span>→</span></button></div>
|
||||||
|
<div class="security-note"><span>▣</span><span>当前页面只调用公开 API 和授权接口,不读取数据库。</span></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lower-grid">
|
||||||
|
<article class="panel" id="schedules"><div class="panel-heading"><div><span class="panel-kicker">PULSE / SCHEDULES</span><h2>定时计划</h2></div><button class="text-button" data-scroll="schedules">查看全部 <span>→</span></button></div><div class="schedule-list" id="schedule-list"><div class="empty-compact"><span>◷</span><div><b>等待计划数据</b><small>需要 schedules:read 权限</small></div></div></div></article>
|
||||||
|
<article class="panel" id="workflows"><div class="panel-heading"><div><span class="panel-kicker">WEAVE / WORKFLOWS</span><h2>流程实例</h2></div><button class="text-button" data-scroll="workflows">查看全部 <span>→</span></button></div><div class="workflow-list" id="workflow-list"><div class="empty-compact"><span>⌘</span><div><b>等待流程数据</b><small>需要 workflows:read 权限</small></div></div></div></article>
|
||||||
|
<article class="panel" id="subscriptions"><div class="panel-heading"><div><span class="panel-kicker">EVENTS / DELIVERY</span><h2>结果订阅</h2></div><span class="live-label-small"><i></i>自动刷新</span></div><div class="subscription-list" id="subscription-list"><div class="empty-compact"><span>⌁</span><div><b>等待订阅数据</b><small>需要 events:read 权限</small></div></div></div></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="page-footer"><span><span class="footer-orbit">◉</span> Orbit Console · 星轨控制台</span><span>数据每 10 秒刷新 · <a href="/version" target="_blank" rel="noreferrer">查看服务版本</a></span></footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div class="toast" id="toast" role="status"></div>
|
||||||
|
<script src="/console/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
internal/transport/httpserver/console/styles.css
Normal file
1
internal/transport/httpserver/console/styles.css
Normal file
File diff suppressed because one or more lines are too long
34
internal/transport/httpserver/console_test.go
Normal file
34
internal/transport/httpserver/console_test.go
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,9 @@ func New(addr string, ready Checker, tasks ...*service.Service) *http.Server {
|
|||||||
registerTasks(mux, tasks[0])
|
registerTasks(mux, tasks[0])
|
||||||
registerHistory(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
|
// WrapMetrics must run after all Register* functions because those functions
|
||||||
|
|||||||
Reference in New Issue
Block a user