Files
orbit/internal/transport/httpserver/console/applications.js

260 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const $ = id => document.getElementById(id);
const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' };
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
let tokenRequest = 0, tokenTarget = null;
const resourceMessages = {
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
'queue has unfinished tasks, including tasks needing review':'队列还有未完成或待核查任务,请先结清。',
'queue has unfinished or uncertain tasks':'队列还有未完成或结果不确定的任务,请先核查。',
'queue has active execution attempts':'队列仍有在途执行,请等待结束。',
'queue has unreleased execution permits':'队列仍有未释放的执行许可,请先核查在途工作。',
'queue has enabled schedules; pause them first':'队列还有启用中的定时计划,请先暂停计划。',
'queue is referenced by unfinished workflows':'队列仍被未结束的流程引用,请先结清流程。',
'queue has subscriptions; retire them first':'队列还有未退役订阅,请先结清并退役订阅。',
'subscription has unacknowledged deliveries; drain or resolve expired deliveries first':'订阅还有未确认结果,请先消费并确认;过期交付需要核查后重试。',
'subscription has unacknowledged deliveries':'订阅还有未确认结果,请先结清。',
'subscription has unconsumed results; resume consumption and acknowledge them before retirement':'还有尚未投递的结果,请恢复消费并全部确认后再退役。'
};
window.orbitResourceMessage = text => resourceMessages[text] || text;
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : window.orbitResourceMessage(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;
}
const queueMemory = new Map();
function rememberedQueue(namespace) { try { return localStorage.getItem(`orbit.access.queue.${namespace}`) || queueMemory.get(namespace) || ''; } catch { return queueMemory.get(namespace) || ''; } }
function rememberQueue() { const value = $('access-queue').value; queueMemory.set(ui.selected, value); try { localStorage.setItem(`orbit.access.queue.${ui.selected}`, value); } catch {} renderConfig(); }
function config(token = '••••••••', queue = $('access-queue').value, namespace = ui.selected) {
if (!queue) throw new Error('请先选择接入队列');
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_APPLICATION_ID=${ui.detail?.entry.application_id || ''}\nORBIT_NAMESPACE_ID=${namespace}\nORBIT_QUEUE_ID=${queue}\nORBIT_TOKEN=${token}\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 && !$('access-token-dialog').open) await select(ui.selected);
} catch (error) { $('access-error').textContent = message(error); }
finally { ui.loading = false; }
}
async function select(namespace) {
if (namespace !== ui.selected) { retirement = null; $('access-retire-form').hidden = true; }
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 = rememberedQueue(namespace);
const usable = detail.queues.filter(q => !q.retired_at);
$('access-queue').innerHTML = '<option value="">请选择接入队列</option>' + detail.queues.map(q => `<option value="${escape(q.id)}" ${q.retired_at ? 'disabled' : ''}>${escape(q.name)} · ${q.retired_at ? '已退役' : q.paused ? '已暂停' : '可用'} · ${escape(q.id.slice(0, 8))}</option>`).join('');
$('access-queue').value = usable.some(q => q.id === previous) ? previous : (!detail.truncated && usable.length === 1 ? usable[0].id : '');
$('access-capacity').textContent = `订阅名额 ${detail.active_subscriptions ?? '—'} / ${detail.subscription_limit ?? '—'} · 暂停仍占名额,结清后退役释放名额。`;
const availableCredential = detail.credentials.find(c => !c.revoked_at);
$('access-copy-template').textContent = availableCredential?.token_stored ? '查看完整接入配置 ↗' : '补存当前 Token ↗';
$('access-config-note').textContent = availableCredential?.token_stored ? '选择正确队列后,可查看、复制含当前 Token 的完整配置。' : '当前 Token 尚未补存。请从已保存的新配置导入当前 Token,再查看或复制;过渡期旧 Token 不能用于补存。';
$('access-truncated').hidden = !detail.truncated;
const now = Date.now();
$('access-credentials').innerHTML = detail.credentials.map(c => {
const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效';
const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now;
const blocked = !!c.revoked_at || transitioning;
const helpID = `access-rotation-help-${c.id}`;
const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。';
const rotationHelp = transitioning ? `<aside class="access-rotation-notice" id="${escape(helpID)}"><strong>旧 Token 仍在过渡期</strong><p>${escape(reason)}</p><p>过渡截止:<time datetime="${escape(c.previous_valid_until)}">${escape(time(c.previous_valid_until))}</time>。到期后,换证按钮随页面刷新恢复可用。</p><p>${c.token_stored ? '请在上方复制当前接入配置,更新服务、Worker 和结果消费者。' : '当前新 Token 尚未补存,请先从已保存的新配置导入;过渡期旧 Token 不能替代当前新 Token。'}确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 Token」。</p></aside>` : c.revoked_at ? `<p id="${escape(helpID)}">${escape(reason)}</p>` : c.previous_valid_until ? `<p>旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。</p>` : '';
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 ${blocked ? 'access-rotation-blocked' : ''}" ${blocked ? `data-access-help="${escape(c.id)}" title="${escape(reason)}" aria-describedby="${escape(helpID)}"` : `data-access-rotate="${escape(c.id)}"`} aria-haspopup="dialog">${c.revoked_at ? '已撤销 · 查看原因 ⓘ' : transitioning ? '暂不可换证 · 查看原因 ⓘ' : '平滑换证 ↗'}</button></div>
<p>凭据 ID:${escape(c.id)}<br>有效期:${escape(time(c.expires_at))} · 凭据版本 ${escape(c.revision)}</p>
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
${rotationHelp}
<div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
<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>';
if ($('access-help-dialog').open) renderCredentialHelp();
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() { const q = ui.detail?.queues.find(q => q.id === $('access-queue').value); $('access-queue-note').textContent = !q ? '请明确选择接入队列,避免复制到错误的目标。' : `当前目标:${q.name} · ${q.paused ? '已暂停,任务暂不执行' : '可用'}。选择只影响复制配置,不修改服务端路由。`; $('access-retire-queue').disabled = !q || !!q.retired_at; try { $('access-config').textContent = config(); } catch (error) { $('access-config').textContent = error.message; } }
function closeToken() {
tokenRequest++; tokenTarget = null;
$('access-token-import-submit').disabled = false;
$('access-token-config').value = ''; $('access-token-input').value = '';
$('access-token-dialog').close();
}
async function showToken(credential, copyImmediately = false) {
closeToken();
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
if (!tokenTarget.queue) { $('access-error').textContent = '请先选择要接入的队列。'; return; }
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) { $('access-error').textContent = '当前凭据没有所选队列权限,请选择已授权队列。'; return; }
$('access-token-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · 凭据版本 ${credential.revision}`;
$('access-token-error').textContent = '';
$('access-token-import').hidden = credential.token_stored;
$('access-token-output').hidden = true;
$('access-token-dialog').showModal();
if (!ui.detail.token_vault_enabled) { $('access-token-error').textContent = '服务端尚未配置 Token 保管密钥,请联系部署管理员。'; return; }
if (credential.token_stored) await readToken(false, copyImmediately);
else $('access-token-input').focus();
}
async function readToken(importCurrent, copyImmediately = false) {
const target = tokenTarget; if (!target) return;
const request = ++tokenRequest;
$('access-token-error').textContent = '正在读取接入配置…'; $('access-token-import-submit').disabled = true;
try {
if (!target.queue) throw new Error('没有可用的授权队列,请先配置队列权限。');
const value = await api(`/${encodeURIComponent(target.namespace)}/credentials/${encodeURIComponent(target.credential.id)}/token/${importCurrent ? 'import' : 'reveal'}`, { expected_revision: String(target.credential.revision), ...(importCurrent ? { token: $('access-token-input').value.trim() } : {}) });
if (request !== tokenRequest || ui.selected !== target.namespace || !$('access-token-dialog').open) return;
if (value.namespace_id !== target.namespace || value.credential_id !== target.credential.id || String(value.revision) !== String(target.credential.revision)) throw new Error('凭据已更新,请刷新后重试。');
$('access-token-config').value = config(value.token, target.queue, target.namespace);
$('access-token-import').hidden = true; $('access-token-input').value = '';
$('access-token-output').hidden = false; $('access-token-error').textContent = '';
if (importCurrent) target.credential.token_stored = true;
if (copyImmediately) await copy($('access-token-config').value, $('access-token-copy'));
} catch (error) { if (request === tokenRequest) $('access-token-error').textContent = message(error); }
finally { if (request === tokenRequest) $('access-token-import-submit').disabled = false; }
}
function renderCredentialHelp() {
const credential = ui.detail?.credentials.find(c => c.id === ui.helpCredential);
if (!credential) { $('access-help-dialog').close(); return; }
const transitioning = !credential.revoked_at && Date.parse(credential.previous_valid_until) > Date.now();
$('access-help-scope').textContent = `${ui.detail.entry.application} · ${ui.detail.entry.environment} / ${ui.detail.entry.namespace} · ${credential.name} · 凭据版本 ${credential.revision}`;
$('access-help-state').textContent = credential.revoked_at ? '凭据已撤销,不能再用于接入或换证。' : transitioning ? `上一轮换证已生效。旧 Token 过渡截止:${time(credential.previous_valid_until)}。期间暂不能再次换证,以免中断还在用旧 Token 的服务。` : '当前没有有效的旧 Token 过渡期,可从凭据卡片发起换证。';
$('access-help-transition').hidden = !transitioning;
}
function showCredentialHelp(credential) {
ui.helpCredential = credential.id; renderCredentialHelp(); $('access-help-dialog').showModal();
}
function open(credential = null, mode = 'ROTATE') {
if (ui.busy) return;
ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; 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; });
const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' };
$('access-form-title').textContent = titles[ui.mode];
$('access-valid-field').hidden = ui.mode === 'FINALIZE';
$('access-grace-field').hidden = ui.mode !== 'ROTATE';
$('access-form-note').textContent = {
CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。',
ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。',
SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。',
FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。'
}[ui.mode];
$('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode);
$('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`;
$('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus();
}
function close() {
if (ui.busy) return;
if (ui.pending && !window.confirm('操作结果尚未确认,请先保存待核对操作。仍要关闭吗?')) 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-token-dialog').open ? $('access-token-error') : $('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.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' };
if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value);
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 = true;
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 = op.token ? config(op.token, queue, op.namespace_id) : '';
ui.result = op; ui.pending = null;
$('access-form').hidden = true; $('access-result').hidden = false;
$('access-token-once-note').hidden = true;
$('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind];
$('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token;
$('access-result-config').value = preparedConfig;
$('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`;
} 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) { closeToken(); select(el.dataset.accessId); } });
$('access-credentials').addEventListener('click', e => {
const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return;
const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig;
const credential = ui.detail?.credentials.find(c => c.id === id);
if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; }
if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; }
if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE');
});
$('access-help-close').addEventListener('click', () => $('access-help-dialog').close());
$('access-help-done').addEventListener('click', () => $('access-help-dialog').close());
$('access-token-close').addEventListener('click', () => { closeToken(); load(); });
$('access-token-dialog').addEventListener('cancel', e => { e.preventDefault(); closeToken(); load(); });
$('access-token-import').addEventListener('submit', e => { e.preventDefault(); readToken(true); });
$('access-token-copy').addEventListener('click', () => copy($('access-token-config').value, $('access-token-copy')));
$('access-token-download').addEventListener('click', () => download('orbit.env', $('access-token-config').value));
window.addEventListener('hashchange', closeToken);
window.addEventListener('pagehide', closeToken);
$('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', rememberQueue);
$('access-copy-template').addEventListener('click', () => { const credential = ui.detail?.credentials.find(c => !c.revoked_at); if (credential) showToken(credential); });
$('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 = ''; } });
let retirement = null;
async function queueAPI(path, body, key, namespace = ui.selected) {
const response = await fetch(path, {method: body ? 'POST' : 'GET', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json', 'X-Orbit-Console':'1','X-Orbit-Namespace':namespace,...(key ? {'Idempotency-Key':key} : {})}, ...(body ? {body:JSON.stringify(body)} : {})});
const value = await response.json(); if (!response.ok) throw new Error(value.error?.message || '操作失败'); return value.data;
}
$('access-retire-queue').addEventListener('click', async () => {
const id = $('access-queue').value, namespace = ui.selected; if (!id) return;
$('access-retire-form').hidden=false; $('access-retire-error').textContent='正在核对队列…'; retirement=null;
try { const q=await queueAPI(`/v1/queues/${id}`,null,null,namespace); if (namespace!==ui.selected || id!==$('access-queue').value) return; retirement={id,namespace,revision:q.revision,key:crypto.randomUUID(),body:null}; $('access-retire-reason').value=''; $('access-retire-error').textContent=''; } catch(error) { $('access-retire-error').textContent=message(error); }
});
$('access-retire-cancel').addEventListener('click',()=>{retirement=null;$('access-retire-form').hidden=true;});
$('access-retire-form').addEventListener('submit', async event=>{
event.preventDefault(); const target=retirement; if (!target || target.namespace!==ui.selected || target.id!==$('access-queue').value) { $('access-retire-error').textContent='队列已变化,请重新打开退役操作。'; return; }
target.body ||= {expected_revision:String(target.revision),reason:$('access-retire-reason').value.trim()};
$('access-retire-submit').disabled=true;
try { await queueAPI(`/v1/queues/${target.id}:retire`,target.body,target.key,target.namespace); $('access-retire-form').hidden=true; retirement=null; await load(); } catch(error) { $('access-retire-error').textContent=message(error); } finally { $('access-retire-submit').disabled=false; }
});
window.orbitApplications = { load };
})();