feat: add generic queue and subscription retirement
This commit is contained in:
@@ -4,7 +4,21 @@
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效';
|
||||
let tokenRequest = 0, tokenTarget = null;
|
||||
function message(error) { return error.status === 403 ? '此账号未启用应用接入管理。请在服务端配置 ORBIT_CONSOLE_APPLICATION_ADMIN 为管理员登录名。' : error.message; }
|
||||
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(() => ({}));
|
||||
@@ -12,7 +26,11 @@
|
||||
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 地址');
|
||||
@@ -30,15 +48,21 @@
|
||||
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 = $('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;
|
||||
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 => {
|
||||
@@ -47,7 +71,7 @@
|
||||
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>请在上方复制当前接入配置,更新服务、Worker 和结果消费者。确认全部切换、在途任务收尾后,也可提前点击「结束过渡,停用旧 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 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>
|
||||
@@ -63,7 +87,7 @@
|
||||
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 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;
|
||||
@@ -74,7 +98,8 @@
|
||||
closeToken();
|
||||
tokenTarget = { namespace: ui.selected, credential, queue: $('access-queue').value };
|
||||
// 只导出该凭据已授权的队列,不能拼出无法使用的配置。
|
||||
if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(tokenTarget.queue)) tokenTarget.queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || '';
|
||||
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;
|
||||
@@ -206,12 +231,29 @@
|
||||
$('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-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 };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user