- Integrate ActionMonitor into ChatGPT GitOps API for failure tracking - Add ErrorClassifier with permission, git, runtime, system, secret violation error types - Add FailureRouter to route errors to correct recovery agents (openclaw/hermes/windows_codex_app) - Add RecoveryController orchestrating full pipeline: capture→classify→route→plan→execute→validate→record - Update error_classifier to include ClientResponseError, rate limit, and secret_violation patterns - All 8 integration tests passing (ActionMonitor, ErrorClassifier, FailureRouter, RecoveryController, API 500 scenario) - Push baseline to V1R2C2 with tag v1r2c2-baseline
463 lines
21 KiB
JavaScript
463 lines
21 KiB
JavaScript
/* AIOS GitOps Platform Control Plane
|
|
* Browser-only runtime with API-first behavior and safe local fallback.
|
|
* No credentials or secret values are stored here. Model registration accepts secret_ref only.
|
|
*/
|
|
const Platform = (() => {
|
|
const state = {
|
|
tenant: 'default',
|
|
role: 'viewer',
|
|
identity: null,
|
|
baseline: null,
|
|
versions: null,
|
|
runtime: null,
|
|
patches: null,
|
|
models: null,
|
|
approvals: [],
|
|
audit: [],
|
|
policy: null,
|
|
eventSource: null
|
|
};
|
|
|
|
const ENDPOINTS = {
|
|
identity: 'identity',
|
|
policy: 'platform/policy',
|
|
audit: 'audit',
|
|
approvals: 'approval',
|
|
approvalRequest: 'approval-request',
|
|
approvalDecision: 'approval-decision',
|
|
branch: 'branch',
|
|
plan: 'patch/plan',
|
|
base: 'base',
|
|
versions: 'ver',
|
|
diff: 'diff',
|
|
patch: 'patch',
|
|
model: 'model',
|
|
priority: 'model-priority',
|
|
runtime: 'run',
|
|
rollback: 'rollback-request',
|
|
tag: 'tag-request',
|
|
events: 'events/gitops'
|
|
};
|
|
|
|
const DEFAULT_POLICY = {
|
|
roles: {
|
|
viewer: ['read:*', 'audit:read'],
|
|
operator: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create'],
|
|
admin: ['read:*', 'audit:read', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request'],
|
|
owner: ['read:*', 'audit:*', 'branch:*', 'plan:*', 'approval:*', 'model:*', 'priority:*', 'release:*', 'rollback:*', 'runtime:*']
|
|
},
|
|
highRiskActions: ['release:execute', 'rollback:execute', 'runtime:write', 'tag:create', 'main:write'],
|
|
destructiveActions: ['delete:*', 'force_push', 'purge:*', 'clear:*'],
|
|
requiredApprovals: { low: 1, medium: 1, high: 2 },
|
|
tenantIsolation: true,
|
|
secretHygiene: {
|
|
allowSecretRefOnly: true,
|
|
forbiddenFields: ['api_key', 'token', 'password', 'secret', 'private_key', 'bearer']
|
|
}
|
|
};
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
function qs(sel) { return document.querySelector(sel); }
|
|
function qsa(sel) { return Array.from(document.querySelectorAll(sel)); }
|
|
|
|
function now() { return new Date().toISOString(); }
|
|
|
|
function safeStringify(value) {
|
|
return JSON.stringify(redact(value), null, 2);
|
|
}
|
|
|
|
function redact(value) {
|
|
if (Array.isArray(value)) return value.map(redact);
|
|
if (value && typeof value === 'object') {
|
|
const out = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
const k = key.toLowerCase();
|
|
if (['api_key', 'apikey', 'token', 'password', 'secret', 'private_key', 'authorization', 'bearer'].some(x => k.includes(x))) {
|
|
out[key] = typeof val === 'string' && val.startsWith('secret_ref://') ? val : '[REDACTED]';
|
|
} else out[key] = redact(val);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function put(id, value) {
|
|
const target = el(id);
|
|
if (target) target.textContent = typeof value === 'string' ? value : safeStringify(value);
|
|
}
|
|
|
|
async function api(path, method = 'GET', body = undefined, options = {}) {
|
|
const url = `/api/${path}`;
|
|
const headers = { 'Content-Type': 'application/json', 'X-AIOS-Tenant': state.tenant };
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
credentials: 'same-origin',
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
let data = null;
|
|
try { data = await res.json(); } catch (_) { data = { status: res.status, text: await res.text() }; }
|
|
if (!res.ok && !options.allowError) {
|
|
const err = new Error(`API ${method} ${url} failed with ${res.status}`);
|
|
err.response = data;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function has(capability) {
|
|
const policy = state.policy || DEFAULT_POLICY;
|
|
const caps = policy.roles[state.role] || [];
|
|
return caps.includes('*') || caps.includes(capability) || caps.some(c => c.endsWith(':*') && capability.startsWith(c.slice(0, -1))) || caps.includes('read:*') && capability.startsWith('read:');
|
|
}
|
|
|
|
function guardAction(capability, risk = 'low') {
|
|
const policy = state.policy || DEFAULT_POLICY;
|
|
if (policy.destructiveActions.some(pattern => matchCapability(pattern, capability))) return { allowed: false, reason: 'destructive_action_blocked' };
|
|
if (policy.highRiskActions.includes(capability)) return { allowed: false, reason: 'high_risk_requires_approval' };
|
|
if (!has(capability)) return { allowed: false, reason: 'insufficient_role' };
|
|
if (risk === 'high' && !['admin', 'owner'].includes(state.role)) return { allowed: false, reason: 'high_risk_admin_required' };
|
|
return { allowed: true };
|
|
}
|
|
|
|
function matchCapability(pattern, capability) {
|
|
if (pattern === capability) return true;
|
|
if (pattern.endsWith('*')) return capability.startsWith(pattern.slice(0, -1));
|
|
return false;
|
|
}
|
|
|
|
function audit(action, target, status, detail = {}) {
|
|
const record = redact({
|
|
ts: now(), tenant: state.tenant, role: state.role, action, target, status, detail
|
|
});
|
|
state.audit.unshift(record);
|
|
state.audit = state.audit.slice(0, 500);
|
|
localStorage.setItem('aios.gitops.audit', JSON.stringify(state.audit));
|
|
put('audit-output', record);
|
|
renderAudit();
|
|
postAudit(record);
|
|
return record;
|
|
}
|
|
|
|
async function postAudit(record) {
|
|
try { await api(ENDPOINTS.audit, 'POST', record, { allowError: true }); } catch (_) { /* local fallback */ }
|
|
}
|
|
|
|
function loadLocal() {
|
|
try { state.audit = JSON.parse(localStorage.getItem('aios.gitops.audit') || '[]'); } catch (_) { state.audit = []; }
|
|
try { state.approvals = JSON.parse(localStorage.getItem('aios.gitops.approvals') || '[]'); } catch (_) { state.approvals = []; }
|
|
}
|
|
|
|
function saveApprovals() {
|
|
localStorage.setItem('aios.gitops.approvals', JSON.stringify(state.approvals));
|
|
}
|
|
|
|
async function bootstrap() {
|
|
loadLocal();
|
|
wireTabs();
|
|
wireForms();
|
|
await loadIdentity();
|
|
await loadPolicy();
|
|
applyRbac();
|
|
await refreshAll();
|
|
await refreshApprovals();
|
|
await refreshAudit();
|
|
connectEvents();
|
|
}
|
|
|
|
async function loadIdentity() {
|
|
try {
|
|
const identity = await api(ENDPOINTS.identity, 'GET', undefined, { allowError: true });
|
|
state.identity = identity && identity.user ? identity : { user: 'unknown', role: identity.role || 'viewer', tenant: identity.tenant || 'default' };
|
|
state.role = state.identity.role || 'viewer';
|
|
state.tenant = state.identity.tenant || 'default';
|
|
} catch (_) {
|
|
state.identity = { user: 'local-preview', role: 'admin', tenant: 'default', source: 'local_fallback' };
|
|
state.role = 'admin';
|
|
state.tenant = 'default';
|
|
}
|
|
put('identity-output', state.identity);
|
|
const roleChip = el('role-chip');
|
|
if (roleChip) roleChip.textContent = `role: ${state.role}`;
|
|
}
|
|
|
|
async function loadPolicy() {
|
|
try {
|
|
const policy = await api(ENDPOINTS.policy, 'GET', undefined, { allowError: true });
|
|
state.policy = policy && policy.roles ? policy : DEFAULT_POLICY;
|
|
} catch (_) { state.policy = DEFAULT_POLICY; }
|
|
put('rbac-output', state.policy);
|
|
renderPermissionMatrix();
|
|
}
|
|
|
|
async function refreshAll() {
|
|
const tasks = [
|
|
['baseline', ENDPOINTS.base],
|
|
['versions', ENDPOINTS.versions],
|
|
['runtime', ENDPOINTS.runtime],
|
|
['patches', ENDPOINTS.patch],
|
|
['models', ENDPOINTS.model]
|
|
];
|
|
for (const [key, endpoint] of tasks) {
|
|
try { state[key] = await api(endpoint, 'GET', undefined, { allowError: true }); }
|
|
catch (e) { state[key] = { error: e.message, response: e.response }; }
|
|
}
|
|
put('baseline-output', state.baseline);
|
|
put('versions-output', state.versions);
|
|
put('runtime-output', state.runtime);
|
|
put('patch-summary-output', state.patches);
|
|
put('model-output', state.models);
|
|
updateMetrics();
|
|
renderModelGraph();
|
|
audit('dashboard.refresh', 'all', 'ok');
|
|
}
|
|
|
|
function updateMetrics() {
|
|
const runtimeStatus = state.runtime && state.runtime.status || 'unknown';
|
|
const version = state.baseline && (state.baseline.active_version || state.baseline.version) || '-';
|
|
const branches = state.patches && Array.isArray(state.patches.branches) ? state.patches.branches.filter(b => b.startsWith('patch/')).length : 0;
|
|
const pending = state.approvals.filter(a => a.status === 'pending').length;
|
|
setText('metric-runtime', runtimeStatus);
|
|
setText('metric-version', version);
|
|
setText('metric-branches', String(branches));
|
|
setText('metric-approvals', String(pending));
|
|
setText('runtime-chip', `runtime: ${runtimeStatus}`);
|
|
}
|
|
|
|
function setText(id, text) { const node = el(id); if (node) node.textContent = text; }
|
|
|
|
function applyRbac() {
|
|
qsa('.require-operator').forEach(btn => btn.disabled = !has('approval:create') && !has('branch:create'));
|
|
qsa('.require-admin').forEach(btn => btn.disabled = !['admin', 'owner'].includes(state.role));
|
|
qsa('.guarded').forEach(btn => btn.disabled = state.role !== 'owner');
|
|
renderPermissionMatrix();
|
|
}
|
|
|
|
function renderPermissionMatrix() {
|
|
const policy = state.policy || DEFAULT_POLICY;
|
|
const caps = ['read:*', 'branch:create', 'plan:create', 'approval:create', 'model:write', 'priority:write', 'release:request', 'rollback:request', 'runtime:write'];
|
|
const rows = Object.keys(policy.roles).map(role => {
|
|
const cells = caps.map(cap => `<td>${roleHas(role, cap, policy) ? '✅' : '—'}</td>`).join('');
|
|
return `<tr><th>${escapeHtml(role)}</th>${cells}</tr>`;
|
|
}).join('');
|
|
const html = `<table><thead><tr><th>Role</th>${caps.map(c => `<th>${escapeHtml(c)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
const target = el('permission-matrix'); if (target) target.innerHTML = html;
|
|
}
|
|
|
|
function roleHas(role, cap, policy) {
|
|
const caps = policy.roles[role] || [];
|
|
return caps.includes('*') || caps.includes(cap) || caps.some(c => c.endsWith(':*') && cap.startsWith(c.slice(0, -1))) || (caps.includes('read:*') && cap.startsWith('read:'));
|
|
}
|
|
|
|
function renderModelGraph() {
|
|
const target = el('model-priority-graph');
|
|
if (!target) return;
|
|
const base = state.models && Array.isArray(state.models.base) ? state.models.base : [];
|
|
const custom = state.models && Array.isArray(state.models.custom) ? state.models.custom : [];
|
|
const priority = state.models && Array.isArray(state.models.priority) ? state.models.priority : [];
|
|
const models = [...base, ...custom];
|
|
if (!models.length) { target.innerHTML = '<p class="empty">无模型数据</p>'; return; }
|
|
const byId = new Map(priority.map(p => [p.model_id || p.id, p]));
|
|
target.innerHTML = models.map((m, idx) => {
|
|
const p = byId.get(m.id) || { priority: 100 - idx, enabled: true, fallback_policy: 'next_enabled' };
|
|
return `<div class="node"><strong>${escapeHtml(m.display_name || m.model || m.id)}</strong><span>${escapeHtml(m.id || '')}</span><em>priority ${escapeHtml(String(p.priority))} · ${p.enabled === false ? 'disabled' : 'enabled'} · ${escapeHtml(p.fallback_policy || 'next_enabled')}</em></div>`;
|
|
}).join('<div class="edge">↓ fallback</div>');
|
|
}
|
|
|
|
async function refreshApprovals() {
|
|
try {
|
|
const remote = await api(ENDPOINTS.approvals, 'GET', undefined, { allowError: true });
|
|
if (remote && Array.isArray(remote.items)) state.approvals = remote.items;
|
|
else if (Array.isArray(remote)) state.approvals = remote;
|
|
} catch (_) { /* local approvals already loaded */ }
|
|
renderApprovals();
|
|
updateMetrics();
|
|
}
|
|
|
|
function renderApprovals() {
|
|
const target = el('approval-board');
|
|
if (!target) return;
|
|
if (!state.approvals.length) { target.innerHTML = '<p class="empty">暂无审批请求</p>'; return; }
|
|
target.innerHTML = state.approvals.map(item => {
|
|
const canDecide = has('approval:*') || state.role === 'owner';
|
|
return `<article class="approval ${escapeHtml(item.risk || 'medium')}">
|
|
<header><strong>${escapeHtml(item.type)}</strong><span>${escapeHtml(item.status)}</span></header>
|
|
<p>${escapeHtml(item.target)}</p><small>${escapeHtml(item.reason || '')}</small>
|
|
<footer>${canDecide && item.status === 'pending' ? `<button data-approve="${escapeHtml(item.id)}">Approve</button><button data-reject="${escapeHtml(item.id)}">Reject</button>` : ''}</footer>
|
|
</article>`;
|
|
}).join('');
|
|
qsa('[data-approve]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.approve, 'approved'));
|
|
qsa('[data-reject]').forEach(btn => btn.onclick = () => decideApproval(btn.dataset.reject, 'rejected'));
|
|
}
|
|
|
|
async function createApproval(payload) {
|
|
const guard = guardAction('approval:create', payload.risk || 'medium');
|
|
if (!guard.allowed) return { status: 'blocked', ...guard };
|
|
const item = {
|
|
id: `apr-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
tenant: state.tenant,
|
|
status: 'pending',
|
|
created_at: now(),
|
|
created_by: state.identity && state.identity.user || 'unknown',
|
|
...payload
|
|
};
|
|
try {
|
|
const remote = await api(ENDPOINTS.approvalRequest, 'POST', item, { allowError: true });
|
|
if (remote && (remote.id || remote.status)) {
|
|
audit('approval.create', item.target, 'ok', remote);
|
|
await refreshApprovals();
|
|
return remote;
|
|
}
|
|
} catch (_) { /* local fallback */ }
|
|
state.approvals.unshift(item); saveApprovals(); renderApprovals(); updateMetrics();
|
|
audit('approval.create.local', item.target, 'ok', item);
|
|
return item;
|
|
}
|
|
|
|
async function decideApproval(id, status) {
|
|
const guard = guardAction('approval:decide', 'high');
|
|
if (!guard.allowed) { audit('approval.decide', id, 'blocked', guard); return; }
|
|
try {
|
|
const remote = await api(ENDPOINTS.approvalDecision, 'POST', { id, status }, { allowError: true });
|
|
audit('approval.decide', id, 'ok', remote);
|
|
} catch (_) {
|
|
const item = state.approvals.find(a => a.id === id);
|
|
if (item) { item.status = status; item.decided_at = now(); item.decided_by = state.identity && state.identity.user || 'unknown'; }
|
|
saveApprovals(); audit('approval.decide.local', id, 'ok', { status });
|
|
}
|
|
renderApprovals(); updateMetrics();
|
|
}
|
|
|
|
async function refreshAudit() {
|
|
try {
|
|
const remote = await api(ENDPOINTS.audit, 'GET', undefined, { allowError: true });
|
|
if (remote && Array.isArray(remote.items)) state.audit = remote.items.concat(state.audit).slice(0, 500);
|
|
} catch (_) { /* local fallback */ }
|
|
renderAudit();
|
|
}
|
|
|
|
function renderAudit() {
|
|
const target = el('audit-table'); if (!target) return;
|
|
const filter = (el('audit-filter') && el('audit-filter').value || '').toLowerCase();
|
|
const rows = state.audit.filter(r => !filter || JSON.stringify(r).toLowerCase().includes(filter)).slice(0, 100).map(r =>
|
|
`<tr><td>${escapeHtml(r.ts)}</td><td>${escapeHtml(r.tenant || '')}</td><td>${escapeHtml(r.role || '')}</td><td>${escapeHtml(r.action)}</td><td>${escapeHtml(r.target)}</td><td>${escapeHtml(r.status)}</td></tr>`
|
|
).join('');
|
|
target.innerHTML = `<table><thead><tr><th>Time</th><th>Tenant</th><th>Role</th><th>Action</th><th>Target</th><th>Status</th></tr></thead><tbody>${rows || '<tr><td colspan="6">无审计记录</td></tr>'}</tbody></table>`;
|
|
}
|
|
|
|
function parseDiffTree(diffData) {
|
|
const stdout = diffData && diffData.diff && diffData.diff.stdout || diffData && diffData.stdout || '';
|
|
const files = [];
|
|
let current = null;
|
|
stdout.split('\n').forEach(line => {
|
|
if (line.startsWith('diff --git ')) {
|
|
const parts = line.split(' ');
|
|
current = { file: (parts[3] || '').replace(/^b\//, ''), additions: 0, deletions: 0, chunks: 0 };
|
|
files.push(current);
|
|
} else if (current) {
|
|
if (line.startsWith('@@')) current.chunks++;
|
|
else if (line.startsWith('+') && !line.startsWith('+++')) current.additions++;
|
|
else if (line.startsWith('-') && !line.startsWith('---')) current.deletions++;
|
|
}
|
|
});
|
|
return files;
|
|
}
|
|
|
|
function renderDiffTree(diffData) {
|
|
const target = el('diff-tree'); if (!target) return;
|
|
const files = parseDiffTree(diffData);
|
|
if (!files.length) { target.innerHTML = '<p class="empty">未解析到文件级 diff,可能是接口返回过大或为空。</p>'; return; }
|
|
target.innerHTML = files.map(f => `<div class="diff-file"><strong>${escapeHtml(f.file)}</strong><span>+${f.additions} / -${f.deletions} / ${f.chunks} chunks</span></div>`).join('');
|
|
}
|
|
|
|
function connectEvents() {
|
|
if (!window.EventSource) return;
|
|
try {
|
|
state.eventSource = new EventSource(`/api/${ENDPOINTS.events}?tenant=${encodeURIComponent(state.tenant)}`);
|
|
state.eventSource.onmessage = (event) => {
|
|
const payload = JSON.parse(event.data);
|
|
audit('event.gitops', payload.type || 'event', 'ok', payload);
|
|
if (['approval', 'patch', 'runtime', 'model'].includes(payload.type)) refreshAll();
|
|
};
|
|
state.eventSource.onerror = () => { if (state.eventSource) state.eventSource.close(); };
|
|
} catch (_) { /* server-sent events optional */ }
|
|
}
|
|
|
|
function wireTabs() {
|
|
qsa('.tabs button').forEach(button => {
|
|
button.onclick = () => {
|
|
qsa('.tabs button').forEach(b => b.classList.remove('active'));
|
|
qsa('.tab').forEach(tab => tab.classList.remove('active'));
|
|
button.classList.add('active');
|
|
const target = el(`tab-${button.dataset.tab}`);
|
|
if (target) target.classList.add('active');
|
|
};
|
|
});
|
|
}
|
|
|
|
function wireForms() {
|
|
const refreshBtn = el('refresh-all'); if (refreshBtn) refreshBtn.onclick = refreshAll;
|
|
const refreshModel = el('refresh-model'); if (refreshModel) refreshModel.onclick = refreshAll;
|
|
const refreshApprovalsBtn = el('refresh-approvals'); if (refreshApprovalsBtn) refreshApprovalsBtn.onclick = refreshApprovals;
|
|
const clearApprovals = el('clear-local-approvals'); if (clearApprovals) clearApprovals.onclick = () => { state.approvals = []; saveApprovals(); renderApprovals(); audit('approval.local.clear', 'localStorage', 'ok'); };
|
|
const refreshAuditBtn = el('refresh-audit'); if (refreshAuditBtn) refreshAuditBtn.onclick = refreshAudit;
|
|
const auditFilter = el('audit-filter'); if (auditFilter) auditFilter.oninput = renderAudit;
|
|
const exportAudit = el('export-audit'); if (exportAudit) exportAudit.onclick = () => downloadJson('aios-gitops-audit.json', state.audit);
|
|
|
|
bindForm('approval-form', async f => createApproval({ type: f.get('type'), target: f.get('target'), risk: f.get('risk'), reason: f.get('reason') }), 'approval-output');
|
|
bindForm('diff-form', async f => {
|
|
const data = await api(`${ENDPOINTS.diff}?from=${encodeURIComponent(f.get('from'))}&to=${encodeURIComponent(f.get('to'))}`, 'GET', undefined, { allowError: true });
|
|
renderDiffTree(data); return data;
|
|
}, 'diff-output');
|
|
bindForm('branch-form', async f => checkedApi('branch:create', ENDPOINTS.branch, 'POST', { branch: f.get('branch'), base: f.get('base'), message: f.get('message') }), 'branch-output');
|
|
bindForm('plan-form', async f => checkedApi('plan:create', ENDPOINTS.plan, 'POST', { branch: f.get('branch'), text: f.get('text') }), 'plan-output');
|
|
bindForm('model-form', async f => {
|
|
const payload = { provider_name: f.get('provider_name'), base_url: f.get('base_url'), model_name: f.get('model_name'), display_name: f.get('display_name'), api_key_secret_ref: f.get('api_key_secret_ref') };
|
|
if (!String(payload.api_key_secret_ref || '').startsWith('secret_ref://')) return { status: 'blocked', reason: 'api_key_secret_ref_required' };
|
|
return checkedApi('model:write', ENDPOINTS.model, 'POST', payload, 'high');
|
|
}, 'model-register-output');
|
|
bindForm('priority-form', async f => checkedApi('priority:write', ENDPOINTS.priority, 'PUT', { model_id: f.get('model_id'), priority: Number(f.get('priority')), enabled: f.get('enabled') === 'true', fallback_policy: f.get('fallback_policy') }, 'medium'), 'priority-output');
|
|
bindForm('rollback-form', async f => checkedApi('rollback:request', ENDPOINTS.rollback, 'POST', { target_version: f.get('target_version'), reason: f.get('reason'), branch: f.get('branch') }, 'high'), 'rollback-output');
|
|
bindForm('tag-form', async f => checkedApi('release:request', ENDPOINTS.tag, 'POST', { version: f.get('version'), message: f.get('message') }, 'high'), 'tag-output');
|
|
}
|
|
|
|
function bindForm(id, handler, outputId) {
|
|
const form = el(id); if (!form) return;
|
|
form.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const f = new FormData(form);
|
|
try {
|
|
const result = await handler(f);
|
|
put(outputId, result);
|
|
audit(`${id}.submit`, outputId, result && result.status === 'blocked' ? 'blocked' : 'ok', result);
|
|
} catch (err) {
|
|
const detail = { error: err.message, response: err.response };
|
|
put(outputId, detail);
|
|
audit(`${id}.submit`, outputId, 'failed', detail);
|
|
}
|
|
};
|
|
}
|
|
|
|
async function checkedApi(capability, endpoint, method, payload, risk = 'low') {
|
|
const guard = guardAction(capability, risk);
|
|
if (!guard.allowed) return { status: 'blocked', capability, ...guard };
|
|
return await api(endpoint, method, payload, { allowError: true });
|
|
}
|
|
|
|
function downloadJson(filename, value) {
|
|
const blob = new Blob([safeStringify(value)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? '' : value).replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"' }[ch]));
|
|
}
|
|
|
|
return { bootstrap, refreshAll, state };
|
|
})();
|
|
|
|
window.addEventListener('DOMContentLoaded', Platform.bootstrap);
|