feat: auto configure console queue

This commit is contained in:
2026-09-21 03:49:38 +08:00
parent 3c8a9ac47c
commit 45a2b361f2
7 changed files with 77 additions and 10 deletions

View File

@@ -2,8 +2,10 @@ package httpserver
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"os"
)
// consoleAssets 将控制台静态资源编译进 Orbit,避免运行容器依赖 Node.js、外部 CDN
@@ -30,6 +32,16 @@ func RegisterConsole(server *http.Server) {
mux.HandleFunc("GET /console", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/console/", http.StatusPermanentRedirect)
})
mux.HandleFunc("GET /console/config", func(w http.ResponseWriter, r *http.Request) {
// 队列 ID 只是控制台的默认范围,不是凭据。Token 仍由每个授权 API
// 自己校验;这里不返回任何秘密,也不访问数据库。
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
_ = json.NewEncoder(w).Encode(struct {
DefaultQueueID string `json:"default_queue_id"`
}{DefaultQueueID: os.Getenv("ORBIT_CONSOLE_QUEUE_ID")})
})
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")

View File

@@ -1,5 +1,5 @@
(() => {
const state = { token: '', queue: '', base: location.origin, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const state = { token: '', queue: '', base: location.origin, consoleConfig: null, timer: null, connected: false, loading: false, selectedTask: null, actionMode: '', selectedSchedule: null, selectedWorkflow: null, workflowActionMode: '', workflowRecoveryMode: '', workflowRecoveryContext: null, selectedSubscription: null, subscriptionActionMode: '', selectedDelivery: null };
const $ = (id) => document.getElementById(id);
const text = (id, value) => { const el = $(id); if (el) el.textContent = value; };
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅' };
@@ -41,6 +41,22 @@
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
return body;
}
async function loadConsoleConfig() {
try {
const response = await fetch('/console/config', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) return;
state.consoleConfig = await response.json();
const defaultQueue = String(state.consoleConfig?.default_queue_id || '').trim();
if (!state.queue && defaultQueue) {
state.queue = defaultQueue;
$('queue-id').value = defaultQueue;
sessionStorage.setItem('orbit.console.queue', defaultQueue);
text('queue-hint', '已使用服务端默认队列配置;也可选择凭据 JSON 覆盖。');
}
} catch {
// 连接表单仍可使用手工 API 地址和凭据,不因可选配置失败而阻断页面。
}
}
function setConnection(connected, detail = '') {
state.connected = connected;
const authenticated = connected && Boolean(state.token && state.queue);
@@ -412,17 +428,21 @@
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();
state.base = $('api-url').value.trim().replace(/\/$/, '') || location.origin; state.token = $('token').value.trim(); state.queue = $('queue-id').value.trim() || String(state.consoleConfig?.default_queue_id || '').trim();
$('queue-id').value = state.queue;
sessionStorage.setItem('orbit.console.queue', state.queue);
if (!state.token) showToast('服务状态可以直接查看;选择凭据 JSON 后可读取业务数据');
else if (!state.queue) showToast('队列会从凭据 JSON 自动读取,也可配置 ORBIT_CONSOLE_QUEUE_ID');
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')); $('close-schedule-drawer').addEventListener('click', closeScheduleDrawer); $('schedule-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'schedule-drawer-backdrop') closeScheduleDrawer(); }); $('schedule-control-button').addEventListener('click', openScheduleAction); $('schedule-action-cancel').addEventListener('click', closeScheduleAction); $('schedule-action-form').addEventListener('submit', submitScheduleAction); $('close-workflow-drawer').addEventListener('click', closeWorkflowDrawer); $('workflow-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'workflow-drawer-backdrop') closeWorkflowDrawer(); }); $('workflow-control-button').addEventListener('click', () => openWorkflowAction(state.selectedWorkflow?.paused ? 'RESUME' : 'PAUSE')); $('workflow-cancel-button').addEventListener('click', () => openWorkflowAction('CANCEL')); $('workflow-action-cancel').addEventListener('click', closeWorkflowAction); $('workflow-action-form').addEventListener('submit', submitWorkflowAction); $('close-subscription-drawer').addEventListener('click', closeSubscriptionDrawer); $('subscription-drawer-backdrop').addEventListener('click', event => { if (event.target.id === 'subscription-drawer-backdrop') closeSubscriptionDrawer(); }); $('subscription-control-button').addEventListener('click', openSubscriptionAction); $('subscription-action-cancel').addEventListener('click', closeSubscriptionAction); $('subscription-action-form').addEventListener('submit', submitSubscriptionAction); $('subscription-retry-cancel').addEventListener('click', closeSubscriptionRetry); $('subscription-retry-form').addEventListener('submit', submitSubscriptionRetry); document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!$('task-action-form').hidden) closeActionForm(); else if (!$('schedule-action-form').hidden) closeScheduleAction(); else if (!$('workflow-action-form').hidden) closeWorkflowAction(); else if (!$('subscription-action-form').hidden) closeSubscriptionAction(); else if (!$('subscription-retry-form').hidden) closeSubscriptionRetry(); else if (!$('task-drawer-backdrop').hidden) closeTaskDrawer(); else if (!$('schedule-drawer-backdrop').hidden) closeScheduleDrawer(); else if (!$('workflow-drawer-backdrop').hidden) closeWorkflowDrawer(); else if (!$('subscription-drawer-backdrop').hidden) closeSubscriptionDrawer(); } });
$('workflow-recovery-actions').addEventListener('click', event => { const button = event.target.closest('[data-recovery-action]'); if (!button) return; let context = {}; try { context = JSON.parse(button.dataset.recoveryContext || '{}'); } catch { showToast('恢复按钮上下文无效,请重新读取流程'); return; } openWorkflowRecovery(button.dataset.recoveryAction, context); }); $('workflow-recovery-cancel').addEventListener('click', closeWorkflowRecovery); $('workflow-recovery-form').addEventListener('submit', submitWorkflowRecovery);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('workflow-recovery-form').hidden) closeWorkflowRecovery(); });
$('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); });
$('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); const queue = String(credential.queue_id || state.consoleConfig?.default_queue_id || '').trim(); $('token').value = credential.token || ''; $('queue-id').value = queue; state.queue = queue; sessionStorage.setItem('orbit.console.queue', queue); text('queue-hint', queue ? '队列已从凭据 JSON 自动读取;无需手工填写。' : '凭据未附带队列,请配置 ORBIT_CONSOLE_QUEUE_ID。'); showToast(queue ? '凭据已读取,队列已自动填充' : '凭据已读取,但缺少队列配置'); } catch { showToast('凭据 JSON 无法解析'); } }; reader.readAsText(file); });
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', event => { event.preventDefault(); setView(button.dataset.view); }));
window.addEventListener('hashchange', () => setView(currentView(), false));
setView(currentView(), false);
refresh(); state.timer = setInterval(refresh, 10000);
loadConsoleConfig().finally(() => refresh()); state.timer = setInterval(refresh, 10000);
})();

View File

@@ -76,8 +76,8 @@
<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="api-url">API 地址 <small>自动使用当前页面</small></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">队列范围 <small>自动读取</small></label><div class="input-wrap"><span class="input-prefix">#</span><input id="queue-id" type="text" autocomplete="off" placeholder="凭据 JSON 或服务端配置会自动填充"></div><div class="field-hint" id="queue-hint">选择凭据 JSON 后自动填充;也可由服务端配置默认队列。</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>

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
package httpserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -32,3 +33,26 @@ func TestConsoleRedirectsWithoutTouchingAPIAuthentication(t *testing.T) {
t.Fatalf("redirect = %d %q", w.Code, w.Header().Get("Location"))
}
}
func TestConsoleConfigReadsOnlyDefaultQueue(t *testing.T) {
t.Setenv("ORBIT_CONSOLE_QUEUE_ID", "queue-default")
server := New("127.0.0.1:0", nil)
r := httptest.NewRequest(http.MethodGet, "/console/config", 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("Cache-Control"); got != "no-store" {
t.Fatalf("cache control = %q, want no-store", got)
}
var payload struct {
DefaultQueueID string `json:"default_queue_id"`
}
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
t.Fatalf("decode config: %v", err)
}
if payload.DefaultQueueID != "queue-default" {
t.Fatalf("default queue = %q, want queue-default", payload.DefaultQueueID)
}
}