mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
624 lines
21 KiB
JavaScript
624 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
(function () {
|
|
const schema = 'qinglong/cluster-copilot-console-read-request@v1';
|
|
const capabilityRequestSchema =
|
|
'qinglong/cluster-copilot-console-capabilities-request@v1';
|
|
const capabilitySchema = 'qinglong/cluster-copilot-console-capabilities@v1';
|
|
const routes = Object.freeze({
|
|
inspect: '/api/v1/copilot/inspect',
|
|
output: '/api/v1/copilot/output',
|
|
run_cancellation_status: '/api/v1/run-management/cancellation-status',
|
|
run_cancellation_blocked_list:
|
|
'/api/v1/run-management/blocked-cancellations',
|
|
run_cancellation_inspect: '/api/v1/run-management/cancellation-inspect',
|
|
worker_list: '/api/v1/worker-management/workers',
|
|
worker_inspect: '/api/v1/worker-management/worker',
|
|
package_list: '/api/v1/package-management/installations',
|
|
package_inspect: '/api/v1/package-management/installation',
|
|
run_list: '/api/v1/observe/run-list',
|
|
run_read: '/api/v1/observe/run',
|
|
run_event_list: '/api/v1/observe/run-events',
|
|
run_step_list: '/api/v1/observe/run-steps',
|
|
task_list: '/api/v1/observe/task-list',
|
|
task_read: '/api/v1/observe/task',
|
|
workflow_list: '/api/v1/observe/workflow-list',
|
|
workflow_run_list: '/api/v1/observe/workflow-run-list',
|
|
workflow_run_read: '/api/v1/observe/workflow-run',
|
|
workflow_event_list: '/api/v1/observe/workflow-events',
|
|
workflow_step_list: '/api/v1/observe/workflow-steps',
|
|
});
|
|
const labels = Object.freeze({
|
|
inspect: 'Copilot 诊断状态',
|
|
output: 'Copilot 诊断内容',
|
|
run_cancellation_status: '取消可用性',
|
|
run_cancellation_blocked_list: 'Blocked Cancellations',
|
|
run_cancellation_inspect: '取消诊断',
|
|
worker_list: 'Worker 目录',
|
|
worker_inspect: 'Worker 详情',
|
|
package_list: 'Package 安装目录',
|
|
package_inspect: 'Package 安装详情',
|
|
run_list: 'Run 目录',
|
|
run_read: 'Run 详情',
|
|
run_event_list: 'Run Events',
|
|
run_step_list: 'Run Steps',
|
|
task_list: 'Task 目录',
|
|
task_read: 'Task 当前修订',
|
|
workflow_list: 'Workflow 目录',
|
|
workflow_run_list: 'Workflow Run 目录',
|
|
workflow_run_read: 'Workflow Run 详情',
|
|
workflow_event_list: 'Workflow Run Events',
|
|
workflow_step_list: 'Workflow Run Steps',
|
|
});
|
|
const bundleApi = globalThis.QingLongEvidenceBundle;
|
|
const sessionForm = document.getElementById('session-form');
|
|
const sessionInput = document.getElementById('session-token');
|
|
const controls = document.getElementById('console-controls');
|
|
const ledger = document.getElementById('ledger');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const message = document.getElementById('message');
|
|
const statusChip = document.getElementById('status-chip');
|
|
const authoritySummary = document.getElementById('authority-summary');
|
|
const ledgerMeta = document.getElementById('ledger-meta');
|
|
const exportButton = document.getElementById('export-evidence');
|
|
const clearButton = document.getElementById('clear-evidence');
|
|
const evidenceRecords = [];
|
|
let sessionToken = '';
|
|
let allowedOperations = new Set();
|
|
let busy = false;
|
|
let exporting = false;
|
|
let evidenceBytes = 0;
|
|
|
|
const value = function (id) {
|
|
return document.getElementById(id).value.trim();
|
|
};
|
|
|
|
const requestId = function () {
|
|
return 'console-' + crypto.randomUUID();
|
|
};
|
|
|
|
const setMessage = function (text, tone) {
|
|
message.textContent = text;
|
|
message.dataset.tone = tone || 'neutral';
|
|
};
|
|
|
|
const updateLedgerState = function () {
|
|
const count = evidenceRecords.length;
|
|
ledgerMeta.textContent =
|
|
String(count) +
|
|
'/' +
|
|
String(bundleApi.limits.maximumRecords) +
|
|
' 条 · ' +
|
|
String(Math.ceil(evidenceBytes / 1024)) +
|
|
' KiB 原始事实';
|
|
emptyState.hidden = count !== 0;
|
|
ledger.hidden = count === 0;
|
|
exportButton.disabled = busy || exporting || count === 0;
|
|
clearButton.disabled = busy || exporting || count === 0;
|
|
};
|
|
|
|
const setBusy = function (next) {
|
|
busy = next;
|
|
document.querySelectorAll('[data-read]').forEach(function (button) {
|
|
button.disabled = next || !allowedOperations.has(button.dataset.read);
|
|
});
|
|
if (next) {
|
|
statusChip.textContent = '读取中';
|
|
statusChip.dataset.tone = 'busy';
|
|
} else if (statusChip.dataset.tone === 'busy') {
|
|
statusChip.textContent = '只读就绪';
|
|
statusChip.dataset.tone = 'success';
|
|
}
|
|
updateLedgerState();
|
|
};
|
|
|
|
const applyCapabilities = function (capabilities) {
|
|
allowedOperations = new Set(capabilities.operations);
|
|
document.querySelectorAll('[data-read]').forEach(function (button) {
|
|
const available = allowedOperations.has(button.dataset.read);
|
|
button.hidden = !available;
|
|
button.disabled = !available;
|
|
});
|
|
document.querySelectorAll('.mode-tab').forEach(function (tab) {
|
|
const panel = document.getElementById(tab.dataset.panel);
|
|
const available = Array.from(panel.querySelectorAll('[data-read]')).some(
|
|
function (button) {
|
|
return allowedOperations.has(button.dataset.read);
|
|
},
|
|
);
|
|
tab.hidden = !available;
|
|
tab.classList.toggle('active', tab.dataset.panel === 'runtime-panel');
|
|
tab.setAttribute(
|
|
'aria-pressed',
|
|
String(tab.dataset.panel === 'runtime-panel'),
|
|
);
|
|
panel.hidden = tab.dataset.panel !== 'runtime-panel';
|
|
});
|
|
const enabled = ['Run', 'Task', 'Workflow', 'Copilot'];
|
|
if (allowedOperations.has('run_cancellation_status')) {
|
|
enabled.push('取消诊断');
|
|
}
|
|
if (allowedOperations.has('worker_list')) enabled.push('Worker');
|
|
if (allowedOperations.has('package_list')) enabled.push('Package');
|
|
authoritySummary.textContent = enabled.join(' · ');
|
|
};
|
|
|
|
const discoverCapabilities = async function () {
|
|
const response = await fetch('/api/v1/session/capabilities', {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
credentials: 'omit',
|
|
redirect: 'error',
|
|
referrerPolicy: 'no-referrer',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
Authorization: 'QL3-Console ' + sessionToken,
|
|
},
|
|
body: JSON.stringify({ schema: capabilityRequestSchema }),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
typeof body.code === 'string' ? body.code : 'capability_read_failed',
|
|
);
|
|
}
|
|
if (
|
|
body.schema !== capabilitySchema ||
|
|
!Array.isArray(body.operations) ||
|
|
body.operations.length !== new Set(body.operations).size ||
|
|
body.operations.some(function (operation) {
|
|
return (
|
|
typeof operation !== 'string' || !Object.hasOwn(routes, operation)
|
|
);
|
|
})
|
|
) {
|
|
throw new Error('capability_response_invalid');
|
|
}
|
|
return body;
|
|
};
|
|
|
|
const base = function (operation) {
|
|
const projectId = value('project-id');
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
|
|
throw new Error('project_id_invalid');
|
|
}
|
|
return {
|
|
schema: schema,
|
|
operation: operation,
|
|
projectId: projectId,
|
|
requestId: requestId(),
|
|
};
|
|
};
|
|
|
|
const payload = function (operation) {
|
|
const result = base(operation);
|
|
if (operation === 'inspect' || operation === 'output') {
|
|
result.sourceRunId = value('source-run-id');
|
|
result.requestId = value('diagnosis-request-id');
|
|
} else if (operation === 'run_cancellation_status') {
|
|
return result;
|
|
} else if (operation === 'run_cancellation_blocked_list') {
|
|
result.cursor = null;
|
|
} else if (operation === 'run_cancellation_inspect') {
|
|
result.runId = value('cancellation-run-id');
|
|
} else if (operation === 'worker_list') {
|
|
result.afterWorkerId = null;
|
|
} else if (operation === 'worker_inspect') {
|
|
result.workerId = value('worker-id');
|
|
} else if (operation === 'package_list') {
|
|
result.afterPackageName = null;
|
|
} else if (operation === 'package_inspect') {
|
|
result.packageName = value('installation-package-name');
|
|
} else if (operation === 'run_list') {
|
|
result.afterCreatedAtMs = null;
|
|
result.afterRunId = null;
|
|
result.limit = 32;
|
|
} else if (operation === 'run_read') {
|
|
result.runId = value('run-id');
|
|
} else if (operation === 'run_event_list') {
|
|
result.runId = value('run-id');
|
|
result.afterSequence = 0;
|
|
result.limit = 32;
|
|
} else if (operation === 'run_step_list') {
|
|
result.runId = value('run-id');
|
|
result.afterStepKey = null;
|
|
result.afterStepRunId = null;
|
|
result.limit = 32;
|
|
} else if (operation === 'task_list') {
|
|
result.afterTaskId = null;
|
|
result.limit = 32;
|
|
} else if (operation === 'task_read') {
|
|
result.taskId = value('task-id');
|
|
} else if (operation === 'workflow_list') {
|
|
result.packageName = value('package-name');
|
|
} else {
|
|
result.packageName = value('package-name');
|
|
result.workflowId = value('workflow-id');
|
|
if (operation === 'workflow_run_list') {
|
|
result.afterAdmittedAtMs = null;
|
|
result.afterRunId = null;
|
|
result.limit = 32;
|
|
} else {
|
|
result.runId = value('workflow-run-id');
|
|
if (operation === 'workflow_event_list') {
|
|
result.afterSequence = 0;
|
|
result.limit = 32;
|
|
} else if (operation === 'workflow_step_list') {
|
|
result.afterStepKey = null;
|
|
result.afterStepRunId = null;
|
|
result.limit = 32;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const nextPage = function (operation, prior, fact) {
|
|
const next = Object.assign({}, prior, { requestId: requestId() });
|
|
if (
|
|
operation === 'run_cancellation_blocked_list' &&
|
|
fact.truncated === true &&
|
|
typeof fact.nextCursor === 'string'
|
|
) {
|
|
next.cursor = fact.nextCursor;
|
|
} else if (
|
|
operation === 'worker_list' &&
|
|
typeof fact.nextAfterWorkerId === 'string'
|
|
) {
|
|
next.afterWorkerId = fact.nextAfterWorkerId;
|
|
} else if (
|
|
operation === 'package_list' &&
|
|
typeof fact.nextAfterPackageName === 'string'
|
|
) {
|
|
next.afterPackageName = fact.nextAfterPackageName;
|
|
} else if (operation === 'run_list' && fact.hasMore === true && fact.next) {
|
|
next.afterCreatedAtMs = fact.next.createdAtMs;
|
|
next.afterRunId = fact.next.runId;
|
|
} else if (
|
|
operation === 'task_list' &&
|
|
fact.hasMore === true &&
|
|
fact.next
|
|
) {
|
|
next.afterTaskId = fact.next.taskId;
|
|
} else if (operation === 'run_event_list' && fact.hasMore === true) {
|
|
next.afterSequence = fact.nextAfterSequence;
|
|
} else if (
|
|
operation === 'run_step_list' &&
|
|
fact.hasMore === true &&
|
|
fact.next
|
|
) {
|
|
next.afterStepKey = fact.next.stepKey;
|
|
next.afterStepRunId = fact.next.stepRunId;
|
|
} else if (
|
|
operation === 'workflow_run_list' &&
|
|
fact.truncated === true &&
|
|
fact.next
|
|
) {
|
|
next.afterAdmittedAtMs = fact.next.admittedAtMs;
|
|
next.afterRunId = fact.next.runId;
|
|
} else if (
|
|
operation === 'workflow_event_list' &&
|
|
fact.truncated === true &&
|
|
fact.nextAfterSequence !== null
|
|
) {
|
|
next.afterSequence = fact.nextAfterSequence;
|
|
} else if (
|
|
operation === 'workflow_step_list' &&
|
|
fact.truncated === true &&
|
|
fact.next
|
|
) {
|
|
next.afterStepKey = fact.next.stepKey;
|
|
next.afterStepRunId = fact.next.id;
|
|
} else {
|
|
return null;
|
|
}
|
|
return next;
|
|
};
|
|
|
|
const appendDrilldownControls = function (entry, operation, fact) {
|
|
if (
|
|
operation === 'run_cancellation_status' &&
|
|
fact.operatorAction === 'inspect'
|
|
) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '显式读取 Blocked Runs';
|
|
button.addEventListener('click', function () {
|
|
void execute('run_cancellation_blocked_list');
|
|
});
|
|
entry.append(button);
|
|
return;
|
|
}
|
|
if (operation === 'worker_list' && Array.isArray(fact.workers)) {
|
|
fact.workers.forEach(function (worker) {
|
|
if (!worker || typeof worker.workerId !== 'string') return;
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '显式检查 ' + worker.workerId;
|
|
button.addEventListener('click', function () {
|
|
document.getElementById('worker-id').value = worker.workerId;
|
|
void execute('worker_inspect');
|
|
});
|
|
entry.append(button);
|
|
});
|
|
return;
|
|
}
|
|
if (operation === 'package_list' && Array.isArray(fact.installations)) {
|
|
fact.installations.forEach(function (installation) {
|
|
if (!installation || typeof installation.packageName !== 'string') {
|
|
return;
|
|
}
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '显式检查 ' + installation.packageName;
|
|
button.addEventListener('click', function () {
|
|
document.getElementById('installation-package-name').value =
|
|
installation.packageName;
|
|
void execute('package_inspect');
|
|
});
|
|
entry.append(button);
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
operation !== 'run_cancellation_blocked_list' ||
|
|
!Array.isArray(fact.items)
|
|
) {
|
|
return;
|
|
}
|
|
fact.items.forEach(function (item) {
|
|
if (!item || typeof item.runId !== 'string') return;
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '显式检查 ' + item.runId;
|
|
button.addEventListener('click', function () {
|
|
document.getElementById('cancellation-run-id').value = item.runId;
|
|
void execute('run_cancellation_inspect');
|
|
});
|
|
entry.append(button);
|
|
});
|
|
};
|
|
|
|
const appendEvidence = function (operation, request, response) {
|
|
const fact = response.result.result;
|
|
const observedAtMs = Date.now();
|
|
const record = {
|
|
operation: operation,
|
|
observedAtMs: observedAtMs,
|
|
request: request,
|
|
fact: fact,
|
|
};
|
|
const recordBytes = bundleApi.measureClusterConsoleEvidenceRecord(record);
|
|
const entry = document.createElement('li');
|
|
entry.className = 'ledger-entry';
|
|
const header = document.createElement('header');
|
|
const title = document.createElement('h3');
|
|
const time = document.createElement('time');
|
|
const output = document.createElement('pre');
|
|
title.textContent = labels[operation];
|
|
time.textContent = new Intl.DateTimeFormat('zh-CN', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'medium',
|
|
}).format(new Date(observedAtMs));
|
|
output.tabIndex = 0;
|
|
output.textContent = JSON.stringify(fact, null, 2);
|
|
header.append(title, time);
|
|
entry.append(header, output);
|
|
const next = nextPage(operation, request, fact);
|
|
if (next) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '显式读取下一页';
|
|
button.addEventListener('click', function () {
|
|
void execute(operation, next);
|
|
});
|
|
entry.append(button);
|
|
}
|
|
appendDrilldownControls(entry, operation, fact);
|
|
ledger.prepend(entry);
|
|
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
|
|
evidenceBytes += recordBytes;
|
|
let evicted = 0;
|
|
while (
|
|
evidenceRecords.length > bundleApi.limits.maximumRecords ||
|
|
evidenceBytes > bundleApi.limits.maximumRawBytes
|
|
) {
|
|
const oldest = evidenceRecords.shift();
|
|
evidenceBytes -= oldest.bytes;
|
|
oldest.entry.remove();
|
|
evicted += 1;
|
|
}
|
|
updateLedgerState();
|
|
return evicted;
|
|
};
|
|
|
|
const execute = async function (operation, prepared) {
|
|
if (busy || !allowedOperations.has(operation)) return;
|
|
setBusy(true);
|
|
setMessage('正在读取 ' + labels[operation] + '…');
|
|
try {
|
|
const body = prepared || payload(operation);
|
|
const response = await fetch(routes[operation], {
|
|
method: 'POST',
|
|
cache: 'no-store',
|
|
credentials: 'omit',
|
|
redirect: 'error',
|
|
referrerPolicy: 'no-referrer',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
Authorization: 'QL3-Console ' + sessionToken,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const responseBody = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
typeof responseBody.code === 'string'
|
|
? responseBody.code
|
|
: 'console_request_failed',
|
|
);
|
|
}
|
|
const evicted = appendEvidence(operation, body, responseBody);
|
|
setMessage(
|
|
labels[operation] +
|
|
' 已加入本页证据账本。' +
|
|
(evicted === 0
|
|
? '刷新页面会清空。'
|
|
: '为保持容量上限,已淘汰最旧记录。'),
|
|
'success',
|
|
);
|
|
} catch (error) {
|
|
statusChip.textContent = '读取失败';
|
|
statusChip.dataset.tone = 'failed';
|
|
setMessage(
|
|
'无法读取:' +
|
|
(error instanceof Error ? error.message : 'console_request_failed'),
|
|
'error',
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const clearEvidence = function () {
|
|
for (const evidence of evidenceRecords) evidence.entry.remove();
|
|
evidenceRecords.length = 0;
|
|
evidenceBytes = 0;
|
|
statusChip.textContent = '账本已清空';
|
|
statusChip.dataset.tone = 'success';
|
|
updateLedgerState();
|
|
setMessage('本页证据账本已清空;没有向服务端发送请求。', 'success');
|
|
};
|
|
|
|
const exportEvidence = async function () {
|
|
if (busy || exporting || evidenceRecords.length === 0) return;
|
|
exporting = true;
|
|
updateLedgerState();
|
|
setMessage('正在本页内存中生成脱敏证据包…');
|
|
try {
|
|
const generatedAtMs = Date.now();
|
|
const bundle = await bundleApi.createClusterConsoleEvidenceBundle(
|
|
evidenceRecords.map(function (evidence) {
|
|
return evidence.record;
|
|
}),
|
|
generatedAtMs,
|
|
);
|
|
const encoded = bundleApi.serializeClusterConsoleEvidenceBundle(bundle);
|
|
const blob = new Blob([encoded], {
|
|
type: 'application/json;charset=utf-8',
|
|
});
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
try {
|
|
const anchor = document.createElement('a');
|
|
anchor.download =
|
|
'qinglong-cluster-evidence-' +
|
|
new Date(generatedAtMs).toISOString().replaceAll(':', '-') +
|
|
'.json';
|
|
anchor.href = objectUrl;
|
|
anchor.rel = 'noopener';
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
} finally {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
statusChip.textContent = '脱敏包已生成';
|
|
statusChip.dataset.tone = 'success';
|
|
setMessage(
|
|
'已下载 ' +
|
|
String(bundle.source.entryCount) +
|
|
' 条脱敏事实;未发起额外 Cluster 读取。',
|
|
'success',
|
|
);
|
|
} catch (error) {
|
|
statusChip.textContent = '导出失败';
|
|
statusChip.dataset.tone = 'failed';
|
|
setMessage(
|
|
'无法导出:' +
|
|
(error instanceof Error
|
|
? error.message
|
|
: 'cluster_evidence_bundle_failed'),
|
|
'error',
|
|
);
|
|
} finally {
|
|
exporting = false;
|
|
updateLedgerState();
|
|
}
|
|
};
|
|
|
|
sessionForm.addEventListener('submit', async function (event) {
|
|
event.preventDefault();
|
|
const candidate = sessionInput.value.trim();
|
|
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
|
|
setMessage('浏览器访问密钥格式无效。', 'error');
|
|
return;
|
|
}
|
|
const submitButton = sessionForm.querySelector('button[type="submit"]');
|
|
submitButton.disabled = true;
|
|
sessionToken = candidate;
|
|
setMessage('正在核验会话并读取本机能力边界…');
|
|
try {
|
|
const capabilities = await discoverCapabilities();
|
|
applyCapabilities(capabilities);
|
|
sessionInput.value = '';
|
|
sessionForm.hidden = true;
|
|
controls.hidden = false;
|
|
statusChip.textContent = '只读就绪';
|
|
statusChip.dataset.tone = 'success';
|
|
setMessage(
|
|
'本页已解锁并仅显示服务端启用的 ' +
|
|
String(capabilities.operations.length) +
|
|
' 个只读操作;Cluster credential 仍只存在于服务端。',
|
|
'success',
|
|
);
|
|
document.getElementById('project-id').focus();
|
|
} catch (error) {
|
|
sessionToken = '';
|
|
setMessage(
|
|
'无法解锁:' +
|
|
(error instanceof Error ? error.message : 'capability_read_failed'),
|
|
'error',
|
|
);
|
|
} finally {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.mode-tab').forEach(function (tab) {
|
|
tab.addEventListener('click', function () {
|
|
document.querySelectorAll('.mode-tab').forEach(function (candidate) {
|
|
const selected = candidate === tab;
|
|
candidate.classList.toggle('active', selected);
|
|
candidate.setAttribute('aria-pressed', String(selected));
|
|
});
|
|
document.querySelectorAll('.mode-panel').forEach(function (panel) {
|
|
panel.hidden = panel.id !== tab.dataset.panel;
|
|
});
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-read]').forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
void execute(button.dataset.read);
|
|
});
|
|
});
|
|
|
|
exportButton.addEventListener('click', function () {
|
|
void exportEvidence();
|
|
});
|
|
|
|
clearButton.addEventListener('click', clearEvidence);
|
|
|
|
window.addEventListener('pagehide', function () {
|
|
sessionToken = '';
|
|
allowedOperations.clear();
|
|
evidenceRecords.length = 0;
|
|
evidenceBytes = 0;
|
|
ledger.textContent = '';
|
|
});
|
|
|
|
updateLedgerState();
|
|
})();
|