feat: show application ownership and scoped console management

This commit is contained in:
2026-09-27 12:12:46 +08:00
parent ed17713ea1
commit 1f2e7230dc
26 changed files with 757 additions and 47 deletions

View File

@@ -29,7 +29,7 @@ func RegisterConsoleArchiveJobs(server *http.Server, consoleAuth RetentionConsol
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
return scopedConsoleToken(r, t), true
}
key := func(w http.ResponseWriter, r *http.Request) (string, bool) {
keys := r.Header.Values("Idempotency-Key")

View File

@@ -13,6 +13,8 @@ import (
"time"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
@@ -119,11 +121,32 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
// ConsoleSessionMiddleware 将 HttpOnly 会话转换为内部 Bearer 请求头。
// 浏览器脚本永远看不到业务令牌;现有所有 v1 handler 仍走同一个鉴权入口。
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store) http.Handler {
func ConsoleSessionMiddleware(next http.Handler, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) http.Handler {
if next == nil || consoleAuth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if values := r.Header.Values("X-Orbit-Namespace"); len(values) > 0 {
if len(values) != 1 || len(scopes) == 0 || scopes[0] == nil || len(r.Header.Values("Authorization")) > 0 || r.Header.Get("X-Orbit-Console") != "1" || !consoleScopePath(r) {
writeError(w, "", fault.Forbidden)
return
}
if err := http.NewCrossOriginProtection().Check(r); err != nil {
writeError(w, "", fault.Forbidden)
return
}
token, ok := consoleCookieToken(r)
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
writeError(w, "", fault.Unauthorized)
return
}
scoped, err := scopes[0].ResolveScope(r.Context(), token, core.ID(values[0]))
if err != nil {
writeError(w, "", err)
return
}
r = r.WithContext(context.WithValue(r.Context(), consoleScopeKey{}, scoped))
}
if len(r.Header.Values("Authorization")) == 0 {
if token, ok := consoleCookieToken(r); ok && consoleAuth.ValidateSession(r.Context(), token) == nil {
clone := r.Clone(r.Context())

View File

@@ -1,5 +1,5 @@
(() => {
const state = { token: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, retention: null, 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: '', authenticated: false, queue: '', base: location.origin, consoleConfig: null, scope: null, scopeReady: false, scopeNamespace: '', mutations: 0, retention: 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: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
@@ -26,10 +26,12 @@
document.querySelectorAll('.page-view').forEach(page => page.classList.toggle('active', page.dataset.page === next));
document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === next));
text('page-title', pageNames[next]);
$('application-scope').hidden = next === 'applications';
text('scope-hint', ['workflows', 'retention', 'archives'].includes(next) ? '当前应用环境 · 不受队列筛选影响' : '任务、计划和订阅按所选队列显示');
if (updateHash && location.hash !== `#/${next}`) location.hash = `/${next}`;
window.scrollTo({ top: 0, behavior: 'smooth' });
if (next === 'retention' && state.authenticated) loadRetention();
if (next === 'archives' && state.authenticated) loadArchives();
if (next === 'retention' && state.scopeReady) loadRetention();
if (next === 'archives' && state.scopeReady) loadArchives();
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
}
function redirectToLogin() {
@@ -43,13 +45,18 @@
state.authenticated = true; return true;
}
async function request(path, options = {}) {
const headers = { Accept: 'application/json', ...(options.headers || {}) };
const scoped = path.startsWith('/v1/') || path === '/console/scope' || path.startsWith('/console/scope?') || path.startsWith('/console/retention') || path.startsWith('/console/archive/');
const headers = { Accept: 'application/json', ...(scoped && state.scopeNamespace ? { 'X-Orbit-Namespace': state.scopeNamespace, 'X-Orbit-Console': '1' } : {}), ...(options.headers || {}) };
const mutation = options.method && options.method !== 'GET';
if (mutation) state.mutations++;
try {
const response = await fetch(`${state.base.replace(/\/$/, '')}${path}`, { ...options, headers, credentials: 'same-origin' });
const raw = await response.text(); let body = {};
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
if (response.status === 401) { state.authenticated = false; redirectToLogin(); throw new Error('登录已过期,请重新登录'); }
if (!response.ok) { const error = new Error(body?.error?.message || body?.message || `HTTP ${response.status}`); error.status = response.status; throw error; }
return body;
} finally { if (mutation) state.mutations--; }
}
async function loadConsoleConfig() {
try {
@@ -65,6 +72,97 @@
// 配置只是已登录会话的默认队列,失败时业务 API 会返回明确错误。
}
}
const scopeUI = { applications: [], nextApplication: '', nextQueue: '', busy: false };
function scopeBadge() {
const e = state.scope?.entry;
if (!e) return '';
return `<span class="application-badge" title="${escapeHTML(e.application + ' / ' + e.environment + ' / ' + e.namespace)}"><i></i><b>${escapeHTML(e.application)}</b><em>${escapeHTML(e.environment)}</em></span>`;
}
function appendScopeOptions(select, items, selected, more, kind) {
select.replaceChildren();
for (const item of items) {
const option = document.createElement('option');
option.value = kind === 'application' ? item.namespace_id : item.id;
option.textContent = kind === 'application' ? `${item.application} · ${item.environment} / ${item.namespace}${item.enabled ? '' : ' · 已停用'}` : item.name;
option.disabled = kind === 'application' && !item.enabled;
select.append(option);
}
if (!items.length) select.append(new Option('暂无队列', ''));
if (more) select.append(new Option('加载更多…', '__more__'));
select.value = selected;
}
async function loadScopeApplications() {
const page = extractData(await request(`/console/applications${scopeUI.nextApplication ? `?after=${encodeURIComponent(scopeUI.nextApplication)}` : ''}`));
for (const item of page.items || []) if (!scopeUI.applications.some(e => e.namespace_id === item.namespace_id)) scopeUI.applications.push(item);
scopeUI.nextApplication = page.next || '';
appendScopeOptions($('scope-application'), scopeUI.applications, state.scope.entry.namespace_id, scopeUI.nextApplication, 'application');
}
async function loadScope() {
const params = new URLSearchParams(location.search);
state.scopeNamespace = params.get('application') || '';
try {
const scope = extractData(await request('/console/scope'));
state.scope = scope;
// 指定管理员在默认应用内也使用显式管理范围,普通账号继续沿用原有队列权限。
if (scope.can_switch && !state.scopeNamespace) {
state.scopeNamespace = scope.entry.namespace_id;
state.scope = extractData(await request('/console/scope'));
}
const queues = state.scope.queues;
const requested = params.get('queue');
const preferred = requested || state.consoleConfig?.default_queue_id;
state.queue = queues.some(q => q.id === preferred) ? preferred : (queues[0]?.id || '');
if (requested && !queues.some(q => q.id === requested)) {
const queue = extractData(await request(`/v1/queues/${encodeURIComponent(requested)}`));
queues.push({ id: queue.id, name: queue.name }); state.queue = requested;
}
scopeUI.nextQueue = state.scope.next || '';
scopeUI.applications = [state.scope.entry];
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
$('scope-application').disabled = !scope.can_switch;
$('scope-queue').disabled = !queues.length;
if (scope.can_switch) {
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
}
document.querySelectorAll('.detail-scope').forEach(el => { el.innerHTML = scopeBadge(); });
$('retention-scope').innerHTML = scopeBadge();
text('scope-summary', `${scope.entry.application} / ${scope.entry.environment} / ${scope.entry.namespace}`);
state.scopeReady = true; renderConnectionSummary();
} catch (error) {
state.queue = ''; state.scopeReady = false;
text('scope-error', `当前应用范围不可用:${error.message}。可点击“重置范围”返回默认应用。`);
}
}
function navigateScope(namespace, queue = '') {
if (state.mutations || retentionUI.busy || archiveUI.busy || retentionUI.pendingSave || archiveUI.pending) { showToast('有操作尚未确认,请先核对完成后再切换应用'); return false; }
if (retentionUI.dirty || archiveUI.dirty || document.querySelector('.drawer-backdrop:not([hidden])')) { showToast('请先保存配置并关闭详情,再切换查看范围'); return false; }
const url = new URL(location.href); url.searchParams.delete('application'); url.searchParams.delete('queue');
if (namespace) url.searchParams.set('application', namespace);
if (queue) url.searchParams.set('queue', queue);
// 完整导航隔离不同范围的请求、详情及幂等状态;后退和刷新都恢复 URL 中的范围。
location.assign(url.href); return true;
}
$('scope-application').addEventListener('change', async event => {
const value = event.target.value; event.target.value = state.scope?.entry.namespace_id || '';
if (scopeUI.busy) return;
if (value !== '__more__') { navigateScope(value); return; }
scopeUI.busy = true;
try { await loadScopeApplications(); } catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
});
$('scope-queue').addEventListener('change', async event => {
const value = event.target.value; event.target.value = state.queue;
if (scopeUI.busy) return;
if (value !== '__more__') { navigateScope(state.scopeNamespace, value); return; }
scopeUI.busy = true;
try {
const page = extractData(await request(`/console/scope?after=${encodeURIComponent(scopeUI.nextQueue)}`));
for (const queue of page.queues) if (!state.scope.queues.some(q => q.id === queue.id)) state.scope.queues.push(queue);
scopeUI.nextQueue = page.next || '';
appendScopeOptions($('scope-queue'), state.scope.queues, state.queue, scopeUI.nextQueue, 'queue');
} catch (error) { showToast(error.message); } finally { scopeUI.busy = false; }
});
$('scope-reset').addEventListener('click', () => navigateScope(''));
const retentionUI = { busy: false, dirty: false, cursor: '', batch: 0, count: 0, done: false, download: '', pendingSave: null };
function syncRetentionControls() {
document.querySelectorAll('#retention-form input, #retention-form textarea, #retention-form select, #retention-form button').forEach(el => { el.disabled = retentionUI.busy; });
@@ -221,7 +319,7 @@
text('archive-executor', archiveUI.configured ? '已配置持久卷' : '未配置持久卷');
$('archive-executor').classList.toggle('ready', archiveUI.configured);
$('archive-plan-chips').innerHTML = archiveUI.plans.map(p => `<span class="${p.enabled ? 'enabled' : ''}">${archiveLabels[p.kind]} · ${p.enabled ? '开启' : '关闭'}</span>`).join('');
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div><code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
$('archive-job-list').innerHTML = jobs.length ? jobs.map(j => `<article class="archive-job"><div class="archive-job-head"><div><b>${escapeHTML(archiveLabels[j.kind] || j.kind)}</b><span class="state-pill ${stateClass(j.state)}">${escapeHTML(archiveStates[j.state] || j.state)}</span></div><time>${escapeHTML(formatTime(j.created_at))}</time></div>${scopeBadge()}<code class="archive-job-id">${escapeHTML(j.id)}</code><div class="archive-progress"><span><b>${j.batches}</b> 批已确认</span><span><b>${j.roots.toLocaleString('zh-CN')}</b> 条根记录</span><span><b>${archiveSize(j.bytes)}</b> / ${j.max_bytes / 1048576} MiB</span><span>策略 v${escapeHTML(j.policy_revision)}</span></div>${j.last_sha256 ? `<details><summary>查看文件校验摘要</summary><code class="archive-digest">${escapeHTML(j.last_sha256)}</code></details>` : ''}${j.error_code ? `<p class="archive-job-error">${escapeHTML(archiveErrors[j.error_code] || j.error_code)}</p>` : ''}${j.state === 'RUNNING' ? `<p class="archive-job-note">领取次数 ${j.attempts} · 租约至 ${escapeHTML(formatTime(j.lease_until))},中断后可接管。</p>` : ''}${j.state === 'FAILED' ? `<div class="archive-job-actions"><button type="button" class="secondary-button compact" data-archive-action="retry" data-id="${escapeHTML(j.id)}">从原作业重试 ↻</button><button type="button" class="text-button" data-archive-action="abandon" data-id="${escapeHTML(j.id)}">关闭此作业</button></div>` : ''}</article>`).join('') : '<div class="empty-row archive-empty"><span class="empty-icon">▤</span><b>还没有归档作业</b><small>保存并开启周期后,系统会按保留窗口生成作业。</small></div>';
syncArchiveControls();
}
async function loadArchives() {
@@ -317,8 +415,8 @@
$('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 class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><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('');
if (!items.length) { body.innerHTML = '<tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>当前队列没有任务</b><small>提交任务后,这里会自动显示最新状态</small></td></tr>'; return; }
body.innerHTML = items.map(item => `<tr class="task-row" data-task-id="${escapeHTML(item.id)}" tabindex="0" title="打开任务详情"><td><div class="task-id"><b>${escapeHTML(shortID(item.id))}</b><small>${escapeHTML(item.business_key || '无业务键')}</small></div></td><td>${scopeBadge()}</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('');
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); } });
@@ -326,8 +424,8 @@
}
function renderSchedules(items) {
text('schedule-page-hint', `${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.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><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.paused ? '已暂停' : '运行中')}</div></div>`).join('');
const el = $('schedule-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>◷</span><div><b>当前没有定时计划</b><small>当前应用与队列还没有创建计划</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row schedule-row" data-schedule-id="${escapeHTML(item.id)}" tabindex="0" title="打开计划详情"><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-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(item.paused ? '已暂停' : '运行中')}</div></div>`).join('');
el.querySelectorAll('.schedule-row').forEach(row => {
row.addEventListener('click', () => openScheduleDetail(row.dataset.scheduleId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openScheduleDetail(row.dataset.scheduleId); } });
@@ -335,8 +433,8 @@
}
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.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
const el = $('workflow-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌘</span><div><b>当前没有流程实例</b><small>当前应用环境还没有启动流程</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row workflow-row" data-workflow-id="${escapeHTML(item.id)}" tabindex="0" title="打开流程详情"><div class="list-avatar">⌘</div><div class="list-copy"><b>${escapeHTML(item.business_key || shortID(item.id))}</b><small>${escapeHTML(item.state || '—')} · ${escapeHTML(item.version || '—')}</small></div><div class="list-application">${scopeBadge()}</div><div class="list-meta">${escapeHTML(formatTime(item.created_at))}</div></div>`).join('');
el.querySelectorAll('.workflow-row').forEach(row => {
row.addEventListener('click', () => openWorkflowDetail(row.dataset.workflowId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openWorkflowDetail(row.dataset.workflowId); } });
@@ -346,8 +444,8 @@
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.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><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('');
const el = $('subscription-list'); if (!items.length) { el.innerHTML = '<div class="empty-compact"><span>⌁</span><div><b>当前没有结果订阅</b><small>当前应用与队列还没有创建订阅</small></div></div>'; return; }
el.innerHTML = items.map(item => `<div class="list-row subscription-row" data-subscription-id="${escapeHTML(item.id)}" tabindex="0" title="打开订阅详情"><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-application">${scopeBadge()}</div><div class="list-meta">${item.enabled === false ? '已暂停' : '已启用'}</div></div>`).join('');
el.querySelectorAll('.subscription-row').forEach(row => {
row.addEventListener('click', () => openSubscriptionDetail(row.dataset.subscriptionId));
row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSubscriptionDetail(row.dataset.subscriptionId); } });
@@ -672,19 +770,22 @@
try {
await loadHealth();
if (currentView() === 'applications') { await window.orbitApplications.load(); setConnection(true); text('last-updated', `刚刚更新 · ${formatTime(new Date())}`); return; }
if (!state.authenticated || !state.queue) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
const tasks = await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`); const taskItems = extractItems(tasks); renderTasks(taskItems);
if (!state.authenticated || !state.scopeReady) { renderRuntimeStats(null, null, null, null); setConnection(true); text('last-updated', `服务在线 · ${formatTime(new Date())}`); return; }
const tasks = state.queue ? await request(`/v1/tasks?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : { items: [] }; const taskItems = extractItems(tasks); renderTasks(taskItems);
const optional = await Promise.allSettled([
request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
state.queue ? request(`/v1/schedules?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
request('/v1/workflows?limit=20'),
request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`),
state.queue ? request(`/v1/subscriptions?queue_id=${encodeURIComponent(state.queue)}&limit=20`) : Promise.resolve({ items: [] }),
]);
const scheduleItems = optional[0].status === 'fulfilled' ? extractItems(optional[0].value) : null;
const workflowItems = optional[1].status === 'fulfilled' ? extractItems(optional[1].value) : null;
const subscriptionItems = optional[2].status === 'fulfilled' ? extractItems(optional[2].value) : null;
if (scheduleItems) renderSchedules(scheduleItems);
if (workflowItems) renderWorkflows(workflowItems);
if (subscriptionItems) renderSubscriptions(subscriptionItems);
renderSchedules(scheduleItems || []);
if (!scheduleItems) $('schedule-list').textContent = '定时计划读取失败,请刷新重试或检查权限';
renderWorkflows(workflowItems || []);
if (!workflowItems) $('workflow-list').textContent = '流程实例读取失败,请刷新重试或检查权限';
renderSubscriptions(subscriptionItems || []);
if (!subscriptionItems) $('subscription-list').textContent = '结果订阅读取失败,请刷新重试或检查权限';
setConnection(true); renderRuntimeStats(taskItems, scheduleItems, workflowItems, subscriptionItems); 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'); }
@@ -709,6 +810,6 @@
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);
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(() => Promise.all([loadRetention(), refresh(), currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (currentView() === 'archives') loadArchives(); } }, 10000);
ensureSession().then(ok => { if (ok) return loadConsoleConfig().then(loadScope).then(() => Promise.all([state.scopeReady ? loadRetention() : Promise.resolve(), refresh(), state.scopeReady && currentView() === 'archives' ? loadArchives() : Promise.resolve()])); }).catch(() => redirectToLogin());
state.timer = setInterval(() => { if (state.authenticated) { refresh(); if (state.scopeReady && currentView() === 'archives') loadArchives(); } }, 10000);
})();

View File

@@ -48,6 +48,13 @@
</div>
</header>
<section class="application-scope" id="application-scope" aria-label="应用筛选">
<div class="scope-heading"><span class="scope-symbol">⊞</span><div><b>应用范围</b><small id="scope-summary">正在读取…</small></div></div>
<label class="scope-field"><span>应用 / 环境</span><select id="scope-application" disabled aria-label="选择应用与环境"><option>正在读取…</option></select></label>
<label class="scope-field scope-queue"><span>队列</span><select id="scope-queue" disabled aria-label="选择队列"><option>正在读取…</option></select></label>
<div class="scope-note"><small id="scope-hint">任务、计划和订阅按所选队列显示</small><button id="scope-reset" type="button" class="text-button">重置范围 ↗</button></div>
<p id="scope-error" class="scope-error" role="status"></p>
</section>
<div class="page-view active" id="view-overview" data-page="overview">
<section class="hero" id="overview">
<div class="hero-copy">
@@ -109,7 +116,7 @@
<section class="content-grid single-column">
<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-view="overview">返回总览 <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>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>任务</th><th>应用 / 环境</th><th>处理器</th><th>状态</th><th>修订</th><th>创建时间</th></tr></thead><tbody id="task-table"><tr><td colspan="6" class="empty-row"><span class="empty-icon">◇</span><b>还没有任务数据</b><small>登录后按账号权限自动加载</small></td></tr></tbody></table></div>
</article>
</section>
</div>
@@ -172,7 +179,7 @@
<article class="panel wide-panel retention-panel">
<div class="panel-heading"><div><span class="panel-kicker">DATA / RETENTION</span><h2>历史数据保留策略</h2></div><span class="connection-state ready" id="retention-source">默认策略</span></div>
<p class="panel-description">这里配置各类历史记录的最短保留时间。保存策略只记录配置,不会立即删除数据;预览和导出会核对终态与未确认结果。当前提供只读快照,不自动清理源数据。</p>
<form id="retention-form" class="retention-form">
<div id="retention-scope" class="retention-scope"></div><form id="retention-form" class="retention-form">
<div class="retention-switch"><div><b>允许导出只读归档</b><small>默认关闭。开启并保存后,可分批导出已满足保留窗口的历史;未确认结果仍受保护。</small></div><label class="switch"><input id="retention-enabled" type="checkbox" aria-label="允许导出只读归档"><span></span></label></div>
<div class="retention-grid">
<label class="retention-field"><span>幂等记录</span><small>天</small><input id="retention-idempotency" type="number" min="90" max="3650" required></label>
@@ -230,7 +237,7 @@
</div>
<div class="drawer-backdrop" id="task-drawer-backdrop" hidden>
<aside class="task-drawer" role="dialog" aria-modal="true" aria-labelledby="detail-title">
<div class="drawer-header">
<div class="detail-scope"></div><div class="drawer-header">
<div><span class="panel-kicker">PULSE / TASK DETAIL</span><h2 id="detail-title">任务详情</h2><small class="drawer-id" id="detail-id">—</small></div>
<button class="icon-button" id="close-task-drawer" type="button" aria-label="关闭任务详情">×</button>
</div>
@@ -265,7 +272,7 @@
</div>
<div class="drawer-backdrop" id="schedule-drawer-backdrop" hidden>
<aside class="task-drawer schedule-drawer" role="dialog" aria-modal="true" aria-labelledby="schedule-detail-title">
<div class="drawer-header">
<div class="detail-scope"></div><div class="drawer-header">
<div><span class="panel-kicker">PULSE / SCHEDULE DETAIL</span><h2 id="schedule-detail-title">定时计划</h2><small class="drawer-id" id="schedule-detail-id">—</small></div>
<button class="icon-button" id="close-schedule-drawer" type="button" aria-label="关闭计划详情">×</button>
</div>
@@ -293,7 +300,7 @@
</div>
<div class="drawer-backdrop" id="workflow-drawer-backdrop" hidden>
<aside class="task-drawer workflow-drawer" role="dialog" aria-modal="true" aria-labelledby="workflow-detail-title">
<div class="drawer-header">
<div class="detail-scope"></div><div class="drawer-header">
<div><span class="panel-kicker">WEAVE / WORKFLOW DETAIL</span><h2 id="workflow-detail-title">流程实例</h2><small class="drawer-id" id="workflow-detail-id">—</small></div>
<button class="icon-button" id="close-workflow-drawer" type="button" aria-label="关闭流程详情">×</button>
</div>
@@ -350,7 +357,7 @@
</div>
<div class="drawer-backdrop" id="subscription-drawer-backdrop" hidden>
<aside class="task-drawer subscription-drawer" role="dialog" aria-modal="true" aria-labelledby="subscription-detail-title">
<div class="drawer-header">
<div class="detail-scope"></div><div class="drawer-header">
<div><span class="panel-kicker">EVENTS / SUBSCRIPTION DETAIL</span><h2 id="subscription-detail-title">结果订阅</h2><small class="drawer-id" id="subscription-detail-id">—</small></div>
<button class="icon-button" id="close-subscription-drawer" type="button" aria-label="关闭订阅详情">×</button>
</div>

View File

@@ -18,3 +18,8 @@
@media(max-width:1100px){.access-grid{grid-template-columns:1fr}.access-guide{gap:14px;flex-wrap:wrap}.access-list{display:grid;grid-template-columns:1fr 1fr}.access-heading h1{font-size:28px}}
@media(max-width:600px){.access-heading{align-items:flex-start;flex-direction:column}.access-config-grid,.access-list{grid-template-columns:1fr}.access-dialog{padding:22px}.access-guide{flex-direction:column;align-items:flex-start;gap:10px}.access-guide>i{display:none}.access-heading p{font-size:13px}}
.access-permission-details{margin-top:14px;font-size:11px;color:#8ba5bc}.access-permission-details summary{cursor:pointer}.access-permission-details p{font-size:10px;line-height:2}
/* 应用范围在页面导航间保持;列表归属信息使用同一组数据库元数据。 */
.application-scope{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin:24px 0 8px;padding:20px 24px;background:linear-gradient(110deg,rgba(39,105,112,.16),rgba(13,25,43,.68));border:1px solid var(--line);border-radius:16px}.application-scope[hidden]{display:none}.scope-heading{display:flex;align-items:center;gap:12px;min-width:140px}.scope-symbol{display:grid;place-items:center;width:36px;height:36px;background:rgba(98,230,212,.09);color:var(--cyan);border-radius:10px;font-size:23px}.scope-heading b{font-size:13px}.scope-heading small{display:block;color:var(--muted);font-size:10px;margin-top:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scope-field{flex:1;min-width:170px;max-width:340px}.scope-field>span{display:block;color:var(--muted);font-size:10px;margin-bottom:7px}.scope-field select{width:100%;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#0b1829;color:var(--text);font:inherit;font-size:12px;text-overflow:ellipsis}.scope-field select:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}.scope-queue{max-width:240px}.scope-note{margin-left:auto;display:grid;gap:9px;text-align:right}.scope-note small{color:var(--muted);font-size:10px}.scope-note button{justify-self:end;font-size:11px}.scope-error{width:100%;margin:0;color:var(--orange);font-size:12px}.scope-error:empty{display:none}.application-badge{display:inline-flex;align-items:center;gap:7px;max-width:260px;padding:5px 8px;border:1px solid rgba(98,230,212,.13);border-radius:7px;background:rgba(98,230,212,.055);font-size:11px;white-space:nowrap}.application-badge i{width:5px;height:5px;border-radius:50%;background:var(--cyan);flex-shrink:0}.application-badge b{overflow:hidden;text-overflow:ellipsis;font-weight:500;color:#d4eae8;max-width:170px}.application-badge em{font-style:normal;font-size:10px;color:var(--muted);border-left:1px solid var(--line);padding-left:7px}.list-application{margin-left:auto;margin-right:18px}.list-copy{min-width:0}.list-copy b{overflow:hidden;text-overflow:ellipsis}.archive-job>.application-badge{margin:12px 0 0}.detail-scope,.retention-scope{margin-bottom:14px}.scope-field select:disabled{opacity:.7}.task-processor{max-width:260px;overflow-wrap:anywhere}
@media(max-width:1250px){.scope-note{width:100%;display:flex;align-items:center;justify-content:space-between;text-align:left}.application-scope{gap:16px}.scope-heading{min-width:100px}.scope-heading small{max-width:150px}}
@media(max-width:700px){.application-scope{padding:16px;gap:14px}.scope-heading{width:100%}.scope-heading small{max-width:260px}.scope-field,.scope-queue{min-width:0;max-width:none;flex-basis:100%}.list-row{flex-wrap:wrap}.list-application{order:3;flex-basis:100%;margin:8px 0 0 42px}.scope-note{align-items:flex-start;gap:12px}.application-badge{max-width:220px}}

View File

@@ -0,0 +1,60 @@
package httpserver
import (
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"net/http"
"strings"
)
type consoleScopeKey struct{}
func scopedConsoleToken(r *http.Request, original auth.Token) auth.Token {
if t, ok := r.Context().Value(consoleScopeKey{}).(auth.Token); ok && t.Console != nil && t.Console.SessionNamespaceID == original.NamespaceID && t.CredentialID == original.CredentialID && t.Digest == original.Digest {
return t
}
return original
}
// consoleScopePath 仅开放已有管理读写,不允许借控制台范围启动 Worker/结果消费或创建业务资源。
func consoleScopePath(r *http.Request) bool {
p := r.URL.Path
if p == "/console/scope" {
return r.Method == "GET"
}
if p == "/console/retention" || strings.HasPrefix(p, "/console/retention/") || strings.HasPrefix(p, "/console/archive/") {
return true
}
for _, resource := range []string{"tasks", "schedules", "workflows", "subscriptions", "queues"} {
prefix := "/v1/" + resource
if p == prefix {
return r.Method == "GET"
}
if strings.HasPrefix(p, prefix+"/") {
return r.Method == "GET" || (resource != "queues" && r.Method == "POST")
}
}
return false
}
func RegisterConsoleScopes(server *http.Server, store consoleauth.Scopes) {
mux, ok := server.Handler.(*http.ServeMux)
if !ok || store == nil {
return
}
mux.HandleFunc("GET /console/scope", func(w http.ResponseWriter, r *http.Request) {
t, ok := consoleCookieToken(r)
if !ok {
writeError(w, "", fault.Unauthorized)
return
}
out, err := store.GetScope(r.Context(), scopedConsoleToken(r, t), core.ID(r.URL.Query().Get("after")))
if err != nil {
writeError(w, "", err)
return
}
writeConsoleJSON(w, 200, map[string]any{"data": out})
})
}

View File

@@ -0,0 +1,78 @@
package httpserver
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type fakeConsoleScopes struct{ calls int }
func (s *fakeConsoleScopes) ResolveScope(_ context.Context, t auth.Token, target core.ID) (auth.Token, error) {
s.calls++
t.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: "admin", UserID: t.CredentialID}
t.NamespaceID = target
return t, nil
}
func (*fakeConsoleScopes) GetScope(context.Context, auth.Token, core.ID) (consoleauth.Scope, error) {
return consoleauth.Scope{}, nil
}
func TestConsoleScopeOnlyBridgesCookieManagementRequests(t *testing.T) {
ns, _ := core.NewID()
cred, _ := core.NewID()
target, _ := core.NewID()
raw, _, _ := auth.Issue(ns, cred)
for _, tc := range []struct {
name, method, path, origin, bearer string
cookie, marker bool
status int
}{
{"read", "GET", "/v1/tasks", "", "", true, true, 200},
{"control", "POST", "/v1/tasks/task:cancel", "http://example.com", "", true, true, 200},
{"SDK", "GET", "/v1/tasks", "", "Bearer " + raw, false, true, 403},
{"ambiguous", "GET", "/v1/tasks", "", "Bearer " + raw, true, true, 403},
{"anonymous", "GET", "/v1/tasks", "", "", false, true, 401},
{"missing-marker", "GET", "/v1/tasks", "", "", true, false, 403},
{"cross-origin", "POST", "/v1/tasks/task:cancel", "https://other.invalid", "", true, true, 403},
{"worker", "GET", "/v1/workers/connect", "", "", true, true, 403},
{"create", "POST", "/v1/tasks", "", "", true, true, 403},
{"onboarding", "POST", "/console/applications/prepare", "", "", true, true, 403},
} {
t.Run(tc.name, func(t *testing.T) {
scope := &fakeConsoleScopes{}
handler := ConsoleSessionMiddleware(wrap(func(r *http.Request, tok auth.Token) (any, int, error) {
if tok.NamespaceID != target || tok.Console == nil || tok.Console.SessionNamespaceID != ns {
t.Fatal("scope lost")
}
return map[string]bool{"ok": true}, 200, nil
}), &fakeConsoleAuth{valid: true}, scope)
req := httptest.NewRequest(tc.method, tc.path, nil)
req.Header.Set("X-Orbit-Namespace", string(target))
if tc.marker {
req.Header.Set("X-Orbit-Console", "1")
}
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
if tc.bearer != "" {
req.Header.Set("Authorization", tc.bearer)
}
if tc.cookie {
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
}
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("HTTP %d: %s", res.Code, res.Body.String())
}
if tc.status != 200 && scope.calls != 0 {
t.Fatal("rejected request reached scope resolver")
}
})
}
}

View File

@@ -26,7 +26,7 @@ func RegisterConsoleRetention(server *http.Server, consoleAuth RetentionConsoleA
writeError(w, "", fault.Unauthorized)
return auth.Token{}, false
}
return t, true
return scopedConsoleToken(r, t), true
}
mux.HandleFunc("GET /console/retention", func(w http.ResponseWriter, r *http.Request) {
t, ok := check(w, r)

View File

@@ -100,11 +100,11 @@ func newServer(addr string, ready Checker, consoleAuth consoleauth.Store, tasks
// WrapConsoleAuth 必须在所有 Register* 路由完成后调用,因为路由注册器需要
// 直接访问底层 ServeMux;包装完成后,浏览器会话才会转换为内部 Bearer 请求。
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store) {
func WrapConsoleAuth(server *http.Server, consoleAuth consoleauth.Store, scopes ...consoleauth.Scopes) {
if server == nil || consoleAuth == nil {
return
}
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth)
server.Handler = ConsoleSessionMiddleware(server.Handler, consoleAuth, scopes...)
}
// WrapMetrics must run after all Register* functions because those functions

View File

@@ -207,7 +207,7 @@ func wrap(next endpoint) http.HandlerFunc {
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
data, status, err := next(r.WithContext(ctx), token)
data, status, err := next(r.WithContext(ctx), scopedConsoleToken(r, token))
if err != nil {
if fault.Code(err) == "UNAVAILABLE" {
slog.Error("Orbit API operation failed", "request_id", id, "code", "UNAVAILABLE")