feat: add single-admin application onboarding and token rotation
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
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 $ = (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: '归档作业' };
|
||||
const pageNames = { overview: '总览', tasks: '任务执行', schedules: '定时计划', workflows: '流程实例', subscriptions: '结果订阅', retention: '保留策略', archives: '归档作业', applications: '应用接入' };
|
||||
|
||||
function showToast(message) {
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
@@ -30,6 +30,7 @@
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (next === 'retention' && state.authenticated) loadRetention();
|
||||
if (next === 'archives' && state.authenticated) loadArchives();
|
||||
if (next === 'applications' && state.authenticated) window.orbitApplications.load();
|
||||
}
|
||||
function redirectToLogin() {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
@@ -670,6 +671,7 @@
|
||||
if (state.loading) return; state.loading = true; $('refresh-button').classList.add('spinning');
|
||||
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);
|
||||
const optional = await Promise.allSettled([
|
||||
|
||||
125
internal/transport/httpserver/console/applications.js
Normal file
125
internal/transport/httpserver/console/applications.js
Normal file
@@ -0,0 +1,125 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
async function api(path = '', body) {
|
||||
const response = await fetch(`/console/applications${path}`, { method: body ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json', 'X-Orbit-Console': '1' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) location.replace(`/console/login?return=${encodeURIComponent('/console/#/applications')}`);
|
||||
if (!response.ok) { const error = new Error(value.error?.message || `请求失败 (${response.status})`); error.status = response.status; throw error; }
|
||||
return value.data;
|
||||
}
|
||||
function config(token = '<填入已保存的 Token>', queue = $('access-queue').value, namespace = ui.selected) {
|
||||
const url = $('access-url').value.trim() || location.origin;
|
||||
let parsed; try { parsed = new URL(url); } catch { throw new Error('请输入完整的 HTTP / HTTPS 服务地址'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || /[\s'"`$\\]/.test(url)) throw new Error('服务地址必须是不含账号、查询参数或特殊字符的 HTTP / HTTPS 地址');
|
||||
return `ORBIT_URL=${url.replace(/\/$/, '')}\nORBIT_TOKEN=${token}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\n`;
|
||||
}
|
||||
async function load() {
|
||||
if (ui.loading || ui.busy) return;
|
||||
ui.loading = true; $('access-error').textContent = '';
|
||||
try {
|
||||
const page = await api(ui.after ? `?after=${encodeURIComponent(ui.after)}` : ''); ui.next = page.next || ''; $('access-next').disabled = !ui.next;
|
||||
$('access-list').innerHTML = page.items.length ? page.items.map(entry => `<button class="access-item ${entry.namespace_id === ui.selected ? 'selected' : ''}" data-access-id="${escape(entry.namespace_id)}"><span class="access-avatar">${escape(entry.application.slice(0, 1).toUpperCase())}</span><span><strong>${escape(entry.application)}</strong><small>${escape(entry.environment)} / ${escape(entry.namespace)} · ${entry.enabled ? '已启用' : '已停用'}</small></span></button>`).join('') : '<p class="panel-description">还没有接入应用,点击右上角新建。</p>';
|
||||
if (!ui.selected && page.items.length) await select(page.items[0].namespace_id);
|
||||
else if (ui.selected && !$('access-dialog').open) await select(ui.selected);
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
finally { ui.loading = false; }
|
||||
}
|
||||
async function select(namespace) {
|
||||
const requested = namespace; ui.selected = namespace;
|
||||
try {
|
||||
const detail = await api(`/${encodeURIComponent(namespace)}`); if (ui.selected !== requested) return; ui.detail = detail;
|
||||
$('access-empty').hidden = true; $('access-detail').hidden = false;
|
||||
$('access-title').textContent = detail.entry.application; $('access-status').textContent = detail.entry.enabled ? '已启用' : '已停用';
|
||||
$('access-app-id').textContent = detail.entry.application_id; $('access-ns-id').textContent = namespace; $('access-scope').textContent = `${detail.entry.environment} / ${detail.entry.namespace}`;
|
||||
const previous = $('access-queue').value;
|
||||
$('access-queue').innerHTML = detail.queues.map(q => `<option value="${escape(q.id)}">${escape(q.name)} · ${escape(q.id.slice(0, 8))}</option>`).join('');
|
||||
if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous;
|
||||
$('access-truncated').hidden = !detail.truncated;
|
||||
$('access-credentials').innerHTML = detail.credentials.map(c => {
|
||||
const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效';
|
||||
const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name);
|
||||
return `<section class="access-credential"><div class="access-credential-header"><b>${escape(c.name)} · ${status}</b><button class="text-button" data-access-rotate="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>重新生成 Token ↗</button></div><p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}<br>Token 原文不保存在数据库;已有服务继续使用原配置。</p><div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig();
|
||||
} catch (error) { $('access-error').textContent = message(error); }
|
||||
}
|
||||
function renderConfig() { try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
|
||||
function open(credential = null) {
|
||||
if (ui.busy) return;
|
||||
ui.rotating = credential; ui.pending = null; ui.result = null; ui.saved = true;
|
||||
$('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = '';
|
||||
$('access-result-config').value = ''; $('access-clipboard-status').textContent = '';
|
||||
$('access-create-fields').hidden = !!credential;
|
||||
$('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; });
|
||||
$('access-form-title').textContent = credential ? '重新生成 Token' : '新建应用';
|
||||
$('access-form-note').textContent = credential ? `正在更新 ${ui.detail.entry.application} 的凭据。确认后旧 Token 立即失效,请同步更新业务服务配置;应用、队列、订阅和历史记录保持原有归属。` : '为应用创建独立的环境、队列与调用凭据。';
|
||||
$('access-submit').textContent = credential ? '确认重新生成 →' : '创建并生成凭据 →';
|
||||
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
|
||||
}
|
||||
function close() {
|
||||
if (ui.busy) return;
|
||||
if ((ui.pending || !ui.saved) && !window.confirm(ui.pending ? '操作结果尚未确认,请先保存待核对操作。仍要关闭吗?' : '请确认已保存新 Token。关闭后无法再次查看原文。')) return;
|
||||
$('access-dialog').close(); ui.pending = null; ui.result = null; ui.saved = true; $('access-result-config').value = ''; load();
|
||||
}
|
||||
function download(name, content, type = 'text/plain') {
|
||||
const url = URL.createObjectURL(new Blob([content], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
async function copy(value, button) {
|
||||
try { await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = '已复制 ✓'; setTimeout(() => { button.textContent = old; }, 2000); return true; }
|
||||
catch { ($('access-dialog').open ? $('access-clipboard-status') : $('access-error')).textContent = '浏览器未允许复制,请选中配置后手动复制,或下载 .env 文件。'; return false; }
|
||||
}
|
||||
async function submit(event) {
|
||||
event.preventDefault(); if (ui.busy) return; ui.busy = true; $('access-form-error').textContent = '';
|
||||
const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]);
|
||||
try {
|
||||
if (!ui.pending) {
|
||||
const input = { kind: ui.rotating ? 'ROTATE' : 'CREATE', valid_days: Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
|
||||
if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id });
|
||||
else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) });
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
ui.pending = await api('/prepare', input);
|
||||
}
|
||||
form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; });
|
||||
const op = ui.pending;
|
||||
await api('/apply', op);
|
||||
ui.saved = false;
|
||||
let queue = op.queue_id || $('access-queue').value;
|
||||
// 先确认当前凭据版本,旧操作回执不能冒充仍有效的最新 Token。
|
||||
await select(op.namespace_id);
|
||||
const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id);
|
||||
const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n);
|
||||
if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。');
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
const preparedConfig = config(op.token, queue, op.namespace_id);
|
||||
ui.result = op; ui.pending = null;
|
||||
$('access-form').hidden = true; $('access-result').hidden = false;
|
||||
$('access-result-title').textContent = op.input.kind === 'CREATE' ? '应用已创建,可以接入' : '新 Token 已生效';
|
||||
$('access-result-config').value = preparedConfig;
|
||||
$('access-result-expiry').textContent = `有效期至 ${time(op.expires_at)}。业务服务读取这些配置后交给 SDK。`;
|
||||
} catch (error) {
|
||||
$('access-form-error').textContent = `${message(error)}${ui.pending ? '。保留当前窗口,点击“重试确认”会使用原操作核对结果。' : ''}`;
|
||||
} finally {
|
||||
ui.busy = false; disabled.forEach(([el, value]) => { el.disabled = value; });
|
||||
$('access-backup').hidden = !ui.pending;
|
||||
if (ui.pending) { form.querySelectorAll('input,select').forEach(el => { el.disabled = true; }); $('access-submit').textContent = '重试确认 →'; }
|
||||
}
|
||||
}
|
||||
$('access-url').value = location.origin;
|
||||
$('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) select(el.dataset.accessId); });
|
||||
$('access-credentials').addEventListener('click', e => { const el = e.target.closest('[data-access-rotate]'); const credential = ui.detail?.credentials.find(c => c.id === el?.dataset.accessRotate); if (credential && !credential.revoked_at) open(credential); });
|
||||
$('access-create').addEventListener('click', () => open()); $('access-close').addEventListener('click', close);
|
||||
$('access-dialog').addEventListener('cancel', e => { e.preventDefault(); close(); });
|
||||
$('access-refresh').addEventListener('click', load); $('access-next').addEventListener('click', () => { ui.after = ui.next; load(); }); $('access-first').addEventListener('click', () => { ui.after = ''; load(); });
|
||||
$('access-url').addEventListener('input', renderConfig); $('access-queue').addEventListener('change', renderConfig);
|
||||
$('access-copy-template').addEventListener('click', async () => { try { await copy(config(), $('access-copy-template')); } catch (error) { $('access-error').textContent = error.message; } });
|
||||
$('access-form').addEventListener('submit', submit);
|
||||
$('access-copy').addEventListener('click', async () => { if (await copy($('access-result-config').value, $('access-copy'))) ui.saved = true; });
|
||||
$('access-download').addEventListener('click', () => { download('orbit.env', $('access-result-config').value); ui.saved = true; });
|
||||
$('access-backup').addEventListener('click', () => { if (ui.pending) download(`orbit-operation-${ui.pending.operation_id}.json`, JSON.stringify(ui.pending, null, 2), 'application/json'); });
|
||||
window.addEventListener('beforeunload', e => { if (ui.pending || !ui.saved) { e.preventDefault(); e.returnValue = ''; } });
|
||||
window.orbitApplications = { load };
|
||||
})();
|
||||
@@ -25,6 +25,7 @@
|
||||
<button class="nav-item" data-view="subscriptions"><span class="nav-icon">⌁</span><span>结果订阅</span><em>05</em></button>
|
||||
<button class="nav-item" data-view="retention"><span class="nav-icon">⌛</span><span>保留策略</span><em>06</em></button>
|
||||
<button class="nav-item" data-view="archives"><span class="nav-icon">▤</span><span>归档作业</span><em>07</em></button>
|
||||
<button class="nav-item" data-view="applications"><span class="nav-icon">⊞</span><span>应用接入</span><em>08</em></button>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="rail-card">
|
||||
@@ -199,6 +200,31 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page-view" id="view-applications" data-page="applications">
|
||||
<div class="access-heading"><div><span class="panel-kicker">CONNECT / APPLICATIONS</span><h1>让应用,轻松接入。</h1><p>创建应用、管理凭据,把任务交给正确的队列。</p></div><button class="primary-button compact" id="access-create">新建应用 <span>+</span></button></div>
|
||||
<div class="access-guide"><span><b>01</b> 创建应用与队列</span><i>→</i><span><b>02</b> 复制 SDK 配置</span><i>→</i><span><b>03</b> 注册 Worker 开始执行</span></div>
|
||||
<div id="access-error" class="action-error" role="alert"></div>
|
||||
<section class="access-grid">
|
||||
<article class="panel access-list-panel"><div class="panel-heading"><div><span class="panel-kicker">YOUR APPLICATIONS</span><h2>接入应用</h2></div><button class="text-button" id="access-refresh">刷新 ↻</button></div>
|
||||
<div id="access-list" class="access-list"><p class="panel-description">正在读取应用…</p></div>
|
||||
<div class="access-pager"><button class="text-button" id="access-first">第一页</button><button class="text-button" id="access-next" disabled>下一页 →</button></div>
|
||||
</article>
|
||||
<article class="panel access-detail-panel"><div id="access-empty" class="empty-row">选择应用,查看队列和接入配置。</div>
|
||||
<div id="access-detail" hidden>
|
||||
<div class="panel-heading"><div><span class="panel-kicker">APPLICATION DETAILS</span><h2 id="access-title"></h2></div><span class="connection-state" id="access-status"></span></div>
|
||||
<div class="access-meta"><div><span>应用 ID</span><code id="access-app-id"></code></div><div><span>环境 / 命名空间</span><b id="access-scope"></b></div><div><span>命名空间 ID</span><code id="access-ns-id"></code></div></div>
|
||||
<div class="access-config-grid"><label>服务地址<input id="access-url" type="url" autocomplete="off" spellcheck="false"></label><label>任务队列<select id="access-queue"></select></label></div>
|
||||
<p class="panel-description">本机默认使用当前页面地址;容器内可填写 http://orbit-test:8080,部署后可换成服务域名。</p>
|
||||
<div class="access-credentials" id="access-credentials"></div>
|
||||
<p class="panel-description" id="access-truncated" hidden>队列或凭据超过 100 项,此页只展示前 100 项。</p>
|
||||
<div class="access-config-heading"><h3>SDK 接入配置</h3><button class="text-button" id="access-copy-template">复制配置模板 ↗</button></div>
|
||||
<pre class="access-code" id="access-config"></pre>
|
||||
<div class="access-help">Token 确定应用和权限,队列决定任务去向。业务服务注册相同名称与版本的 handler 后,即可执行任务。</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -360,6 +386,25 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><span class="panel-kicker">ORBIT / CONNECT</span><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
<form id="access-form">
|
||||
<div id="access-create-fields">
|
||||
<label>应用名称<input id="access-name" maxlength="128" placeholder="例如 order-service" autocomplete="off" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<div class="access-config-grid"><label>环境<select id="access-environment"><option value="test">测试 · test</option><option value="development">开发 · development</option><option value="production">生产 · production</option></select></label><label>命名空间<input id="access-namespace" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label></div>
|
||||
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||
</div>
|
||||
<div class="access-config-grid"><label>凭据有效期<select id="access-valid-days"><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||
<div class="access-form-actions"><button type="submit" class="primary-button compact" id="access-submit">创建并生成凭据 →</button><button type="button" class="text-button" id="access-backup" hidden>保存待核对操作</button></div>
|
||||
</form>
|
||||
<p id="access-clipboard-status" class="panel-description" role="status"></p>
|
||||
<section id="access-result" hidden><div class="access-success">✓ <span id="access-result-title">应用已创建</span></div><p class="panel-description">完整 Token 仅在本次操作中显示。请复制或下载到服务配置中;页面关闭后只能重新生成。</p><label>完整 SDK 配置<textarea id="access-result-config" readonly rows="7" spellcheck="false"></textarea></label><p class="panel-description" id="access-result-expiry"></p><div class="access-form-actions"><button type="button" class="primary-button compact" id="access-copy">复制配置 ↗</button><button type="button" class="secondary-button compact" id="access-download">下载 .env ↓</button></div></section>
|
||||
</dialog>
|
||||
|
||||
<script src="/console/applications.js"></script>
|
||||
<script src="/console/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,3 +12,9 @@
|
||||
.retention-tool-buttons{flex-wrap:wrap}.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid rgba(156,183,218,.18);border-radius:9px;background:rgba(23,46,65,.6);color:#b9cddd;font:inherit;font-size:10px;cursor:pointer}.secondary-button:hover{border-color:#62e6d4;color:#9beade}.retention-form button:disabled{opacity:.45;cursor:not-allowed}.retention-export-result{display:flex;flex-wrap:wrap;gap:10px;color:#9eb4cb;font-size:10px;line-height:1.6}.retention-export-result a{color:#62e6d4}.retention-export-result code{width:100%;font-size:9px;overflow-wrap:anywhere;color:#7189a3}.retention-export-result [hidden]{display:none}.retention-tools{flex-wrap:wrap}
|
||||
|
||||
.archive-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:22px 0}.archive-heading h1{font-size:28px;letter-spacing:-.7px;margin:12px 0;color:#e4effb}.archive-heading p{color:#849bb3;font-size:12px;line-height:1.7}.archive-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:20px}.archive-stats article{padding:22px 24px;border:1px solid #20354a;border-radius:18px;background:linear-gradient(125deg,#112439,#0a1728)}.archive-stats article:first-child{background:linear-gradient(125deg,#13373e,#0b202d);border-color:#24535a}.archive-stats span,.archive-stats small{display:block;color:#8ba2b8;font-size:11px}.archive-stats strong{display:block;font-size:30px;margin:10px 0;color:#e5f9f5}.archive-grid{display:grid;grid-template-columns:minmax(290px,360px) minmax(0,1fr);gap:20px;align-items:start}.archive-config,.archive-history{padding:24px;min-width:0}.archive-config .panel-heading,.archive-history .panel-heading{padding:0;margin-bottom:20px}.archive-config input:not([type=checkbox]),.archive-config select,#archive-action-form input{width:100%;border:1px solid #294157;border-radius:9px;padding:11px 12px;background:#09192a;color:#dbe8f5;font:inherit;font-size:12px;outline:0}.archive-config input:focus,.archive-config select:focus,#archive-action-form input:focus{border-color:#61dfd0;box-shadow:0 0 0 3px #61dfd010}.archive-config .field-label,#archive-action-form .field-label{display:block;margin:18px 0 9px;font-size:11px}.archive-config .retention-switch{margin-top:19px;padding:13px 12px}.archive-input-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;color:#94adc4;font-size:11px}.archive-input-grid input{margin-top:9px}.archive-plan-meta{display:grid;gap:9px;margin:17px 0;color:#7893ae;font-size:10px;line-height:1.6}.archive-config .primary-button{width:100%;margin:17px 0 6px}.archive-config .panel-description{line-height:1.8;font-size:10px}.archive-plan-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.archive-plan-chips span{padding:6px 8px;border:1px solid #24374a;border-radius:7px;font-size:9px;color:#7590aa}.archive-plan-chips .enabled{color:#85e5d8;border-color:#2d6564;background:#184b4b44}.archive-history>.panel-description{margin-bottom:18px;line-height:1.8}.archive-job{border:1px solid #243a50;border-radius:13px;padding:18px;margin:12px 0;background:#09172766}.archive-job-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.archive-job-head>div{display:flex;align-items:center;gap:12px}.archive-job-head b{font-size:13px;color:#d2e4f1}.archive-job-head time{font-size:10px;color:#7c98b1}.archive-job-id{display:block;margin:10px 0;color:#65849f;font-size:10px;overflow-wrap:anywhere}.archive-progress{display:flex;flex-wrap:wrap;gap:14px;margin:14px 0;color:#8eaac1;font-size:10px}.archive-progress b{color:#ccdee9;font-size:12px;font-weight:550}.archive-job details{font-size:10px;color:#7297b5;line-height:1.7}.archive-job summary{cursor:pointer}.archive-digest{display:block;overflow-wrap:anywhere;margin-top:8px;color:#9cbad0}.archive-job-error{padding:10px 12px;background:#59412338;border-radius:8px;color:#d9b484;font-size:11px;line-height:1.8}.archive-job-note{font-size:10px;color:#7698b2}.archive-job-actions,.archive-pager{display:flex;gap:14px;align-items:center;margin-top:16px}.archive-pager{justify-content:space-between}.archive-empty{padding:60px 20px}.archive-empty b,.archive-empty small{display:block;margin-top:15px}.archive-grid button:disabled{opacity:.4;cursor:not-allowed}.archive-history [hidden]{display:none}#archive-action-form{border:1px solid #36736f;border-radius:13px;padding:20px;margin-top:20px;background:#102c3544}#archive-action-form h3{font-size:16px;color:#cdeae6}.state-pill.abandoned{color:#9bb0c2;background:#52637933}@media(max-width:1150px){.archive-grid{grid-template-columns:1fr}.archive-stats{gap:10px}.archive-heading h1{font-size:24px}}@media(max-width:600px){.archive-stats{grid-template-columns:1fr}.archive-heading{align-items:flex-start;flex-direction:column}.archive-config,.archive-history{padding:18px}.archive-job-head{align-items:flex-start;flex-direction:column}}
|
||||
|
||||
/* 应用接入:独立的应用目录、凭据详情和一次性配置交付。 */
|
||||
.access-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:20px 0 26px}.access-heading h1{font-size:34px;margin:12px 0}.access-heading p{color:#91a6be;margin:0}.access-guide{display:flex;align-items:center;gap:26px;padding:20px 26px;margin-bottom:26px;border:1px solid #223748;background:linear-gradient(110deg,#12303770,#0d192b80);border-radius:18px;color:#a9bed0;font-size:13px}.access-guide b{color:#68e6d7;margin-right:10px;font-size:12px}.access-guide i{font-style:normal;color:#50647b}.access-grid{display:grid;grid-template-columns:minmax(250px,.8fr) minmax(0,1.6fr);gap:24px;align-items:start}.access-list{display:flex;flex-direction:column;gap:10px}.access-item{display:flex;align-items:center;gap:14px;width:100%;padding:18px;border:1px solid #233448;border-radius:14px;background:#0a1626;color:#d4e2f1;text-align:left;cursor:pointer}.access-item:hover,.access-item.selected{border-color:#55cdbf;background:#12303860}.access-avatar{display:grid;place-items:center;width:42px;height:42px;flex:0 0 auto;border-radius:12px;background:#163b4050;color:#75e9da;font-size:17px}.access-item strong{display:block;overflow-wrap:anywhere;font-size:14px}.access-item small{display:block;margin-top:6px;color:#869db5;font-size:11px}.access-item>span:last-child{min-width:0}.access-pager{display:flex;justify-content:space-between;margin-top:18px}.access-meta{display:grid;gap:14px;margin-bottom:24px;padding:18px;background:#08152380;border:1px solid #203246;border-radius:14px}.access-meta>div{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}.access-meta span{font-size:12px;color:#8299b0}.access-meta b,.access-meta code{font-size:12px;font-weight:400;overflow-wrap:anywhere;color:#c0d5e7}.access-config-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.access-grid label,.access-dialog label{display:block;color:#9fb3c7;font-size:12px}.access-grid input,.access-grid select,.access-dialog input:not([type=checkbox]),.access-dialog select,.access-dialog textarea{display:block;box-sizing:border-box;width:100%;margin-top:9px;padding:12px 14px;border-radius:10px;border:1px solid #2a3b50;background:#081422;color:#d8e6f5;font:inherit;min-width:0}.access-dialog input:focus,.access-dialog select:focus,.access-dialog textarea:focus,.access-grid input:focus,.access-grid select:focus{outline:2px solid #56ccbf;outline-offset:2px}.access-credential{margin:20px 0;padding:18px;border:1px solid #283c53;border-radius:14px}.access-credential-header{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-credential-header b{font-size:13px}.access-credential p{font-size:12px;color:#8da4bc;overflow-wrap:anywhere;line-height:1.8;margin:10px 0}.access-permissions{display:flex;flex-wrap:wrap;gap:5px}.access-permissions span{padding:4px 7px;border-radius:5px;background:#18293a;color:#9bb6ca;font-size:10px}.access-config-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.access-config-heading h3{font-size:14px}.access-code{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.9;font-size:12px;padding:18px;border:1px solid #24384c;border-radius:12px;background:#06131e;color:#a5d7d3}.access-help{margin-top:18px;padding-left:14px;border-left:2px solid #529d99;color:#809bb0;font-size:12px;line-height:1.9}.access-dialog{box-sizing:border-box;width:min(640px,calc(100vw - 32px));max-height:90vh;overflow:auto;border:1px solid #334c60;border-radius:22px;padding:30px;background:linear-gradient(145deg,#112238,#081522);color:#dceafa;box-shadow:0 32px 100px #0008}.access-dialog::backdrop{background:#020a14cc;backdrop-filter:blur(6px)}.access-dialog-top{display:flex;align-items:center;justify-content:space-between}.access-dialog h2{margin:16px 0 10px;font-size:26px}.access-dialog form>div,.access-dialog form>label,#access-create-fields>label,#access-create-fields>.access-config-grid{margin-top:18px}.access-capabilities{margin:20px 0;padding:12px 0;border:0;display:grid;grid-template-columns:1fr 1fr;gap:14px}.access-capabilities legend{font-size:12px;color:#c1d4e4;padding-bottom:12px}.access-capabilities small{font-size:10px;color:#7c96ad;display:block;margin-top:6px}.access-capabilities label{display:flex;align-items:center;gap:8px}.access-capabilities input{accent-color:#68dfd0;width:15px;height:15px}.access-form-actions{display:flex;align-items:center;gap:12px;margin-top:22px;flex-wrap:wrap}.access-success{background:#153e3d;color:#80eed7;padding:15px;border-radius:12px;margin:22px 0}.access-dialog textarea{font-family:monospace;font-size:12px;line-height:1.8;resize:vertical}.access-dialog [hidden]{display:none!important}.access-dialog button:disabled{opacity:.45;cursor:not-allowed}.access-grid [hidden]{display:none!important}
|
||||
@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}
|
||||
|
||||
105
internal/transport/httpserver/console_applications.go
Normal file
105
internal/transport/httpserver/console_applications.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// RegisterConsoleApplications 只接受登录 Cookie;业务 Bearer 不能访问跨应用管理。
|
||||
func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.Store, store consoleauth.Applications) {
|
||||
if server == nil || consoleAuth == nil || store == nil {
|
||||
return
|
||||
}
|
||||
mux, ok := server.Handler.(*http.ServeMux)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
check := func(w http.ResponseWriter, r *http.Request) (auth.Token, bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
token, ok := consoleCookieToken(r)
|
||||
if !ok || consoleAuth.ValidateSession(r.Context(), token) != nil {
|
||||
writeError(w, "", fault.Unauthorized)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
// 自定义请求头触发跨域预检;不开放 CORS,另校验浏览器 Origin 和 Fetch Metadata。
|
||||
if r.Method != "GET" {
|
||||
if r.Header.Get("X-Orbit-Console") != "1" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||
writeError(w, "", fault.Forbidden)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || !strings.EqualFold(u.Host, r.Host) || u.User != nil {
|
||||
writeError(w, "", fault.Forbidden)
|
||||
return auth.Token{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := store.ListApplications(r.Context(), token, core.ID(r.URL.Query().Get("after")))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("GET /console/applications/{namespace}", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := store.GetApplication(r.Context(), token, core.ID(r.PathValue("namespace")))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("POST /console/applications/prepare", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consoleauth.ApplicationInput
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
out, err := store.PrepareApplication(r.Context(), token, in)
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
mux.HandleFunc("POST /console/applications/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in consoleauth.ApplicationOperation
|
||||
if err := decodeBody(r, &in); err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
out, err := store.ApplyApplication(r.Context(), token, in)
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
121
internal/transport/httpserver/console_applications_test.go
Normal file
121
internal/transport/httpserver/console_applications_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
type fakeApplications struct{ calls int }
|
||||
|
||||
func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationPage{}, nil
|
||||
}
|
||||
func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) (consoleauth.ApplicationDetail, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationDetail{}, nil
|
||||
}
|
||||
func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationOperation{}, nil
|
||||
}
|
||||
func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) {
|
||||
f.calls++
|
||||
return consoleauth.ApplicationReceipt{}, nil
|
||||
}
|
||||
func TestConsoleApplicationSessionAndCSRF(t *testing.T) {
|
||||
ns, _ := core.NewID()
|
||||
id, _ := core.NewID()
|
||||
raw, _, _ := auth.Issue(ns, id)
|
||||
cases := []struct {
|
||||
name, method, path, origin, header string
|
||||
cookie bool
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{name: "no login", method: "GET", path: "/console/applications", status: 401},
|
||||
{name: "SDK bearer only", method: "POST", path: "/console/applications/prepare", header: "1", body: `{}`, status: 401},
|
||||
{name: "missing csrf header", method: "POST", path: "/console/applications/prepare", cookie: true, body: `{}`, status: 403},
|
||||
{name: "cross origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "https://evil.example", body: `{}`, status: 403},
|
||||
{name: "null origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "null", body: `{}`, status: 403},
|
||||
{name: "same origin", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", origin: "http://example.com", body: `{}`, status: 200},
|
||||
{name: "unknown field", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", body: `{"admin":true}`, status: 400},
|
||||
{name: "read", method: "GET", path: "/console/applications", cookie: true, status: 200},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authStore := &fakeConsoleAuth{valid: true}
|
||||
store := &fakeApplications{}
|
||||
server := NewWithConsoleAuth("", nil, authStore)
|
||||
RegisterConsoleApplications(server, authStore, store)
|
||||
req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Orbit-Console", tt.header)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
if tt.cookie {
|
||||
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(res, req)
|
||||
if res.Code != tt.status {
|
||||
t.Fatalf("status=%d wanted=%d", res.Code, tt.status)
|
||||
}
|
||||
if tt.status != 200 && store.calls != 0 {
|
||||
t.Fatal("rejected request reached store")
|
||||
}
|
||||
if res.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("management response cacheable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 准备接口生成的内容必须可被严格解码器原样提交,覆盖可选 ID 和空能力组。
|
||||
func TestConsolePreparedApplicationRoundTrip(t *testing.T) {
|
||||
ns, _ := core.NewID()
|
||||
cred, _ := core.NewID()
|
||||
app, _ := core.NewID()
|
||||
queue, _ := core.NewID()
|
||||
opID, _ := core.NewID()
|
||||
token, _, _ := auth.Issue(ns, cred)
|
||||
for _, kind := range []string{"CREATE", "ROTATE"} {
|
||||
op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: time.Now().UTC()}
|
||||
if kind == "CREATE" {
|
||||
op.QueueID = queue
|
||||
op.Input.Application = "sample"
|
||||
op.Input.Namespace = "default"
|
||||
op.Input.Environment = "test"
|
||||
op.Input.Queue = "work"
|
||||
} else {
|
||||
op.Input.NamespaceID = ns
|
||||
op.Input.CredentialID = cred
|
||||
op.Input.ExpectedRevision = 1
|
||||
}
|
||||
raw, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/console/applications/apply", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var got consoleauth.ApplicationOperation
|
||||
if err := decodeBody(req, &got); err != nil {
|
||||
t.Fatalf("%s prepared output rejected: %v", kind, err)
|
||||
}
|
||||
if got.Token != token || got.Input.Kind != kind {
|
||||
t.Fatal("prepared operation changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user