Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/control/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ export function createControlApp(options:ControlHttpOptions): NativeControlHttpA
app.get('/console/ingress-groups.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('ingress-groups.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/ingress-migrations.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('ingress-migrations.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/forward-diagnostics.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('forward-diagnostics.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/forward-status.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('forward-status.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/forward-preflight.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('forward-preflight.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/audit-console.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('audit-console.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/station-alerts.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('station-alerts.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
app.get('/console/notification-endpoints.js', async c => { consoleHeaders(c); return c.body(await nativeControlConsoleAsset('notification-endpoints.js'), 200, { 'Content-Type': 'text/javascript; charset=utf-8' }); });
Expand Down
3 changes: 2 additions & 1 deletion apps/control/src/compatibility/native-control-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export type NativeControlConsoleAsset = 'index.html' | 'app.js' | 'model.js' | '
'browser-native/relay-forms.js' | 'browser-native/relay-console.js' |
'probe.js' | 'transport-console.js' | 'rule-console.js' | 'tunnel-console.js' | 'path-templates.js' |
'observation-services.js' | 'path-groups.js' | 'path-publications.js' | 'ingress-groups.js' |
'ingress-migrations.js' | 'forward-diagnostics.js' | 'audit-console.js' | 'station-alerts.js' |
'ingress-migrations.js' | 'forward-diagnostics.js' | 'forward-status.js' | 'forward-preflight.js' |
'audit-console.js' | 'station-alerts.js' |
'notification-endpoints.js' | 'notification-deliveries.js' | 'probe-runs.js' | 'probe-plans.js' |
'telemetry-console.js' | 'forward-telemetry-console.js' | 'host-telemetry-console.js' |
'probe-telemetry-console.js' | 'telemetry-aggregate-console.js' | 'style.css';
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { initPathPublications } from './path-publications.js';
import { initIngressGroups } from './ingress-groups.js';
import { initIngressMigrations } from './ingress-migrations.js';
import { initForwardDiagnostics } from './forward-diagnostics.js';
import { summarizeDiagnosticRecord } from './forward-status.js';
import { initAuditConsole } from './audit-console.js';
import { initStationAlerts } from './station-alerts.js';
import { initNotificationEndpoints } from './notification-endpoints.js';
Expand All @@ -31,6 +32,6 @@ mountControlConsole({
initRelayConsole, initProbeConsole, initProbeRuns, initProbePlans, initTransportConsole, initRuleConsole, initTunnelConsole,
initTelemetryConsole, initForwardTelemetryConsole, initHostTelemetryConsole, initProbeTelemetryConsole,
initTelemetryAggregateConsole, initPathTemplates, initPathSwitch, initPathGroups, initPathPublications, initIngressGroups,
initIngressMigrations, initForwardDiagnostics, initAuditConsole, initStationAlerts, initNotificationEndpoints,
initNotificationDeliveries, initObservationServices,
initIngressMigrations, initForwardDiagnostics: options => initForwardDiagnostics({ ...options, summarizeDiagnosticRecord }),
initAuditConsole, initStationAlerts, initNotificationEndpoints, initNotificationDeliveries, initObservationServices,
});
107 changes: 103 additions & 4 deletions apps/web/browser-native/control-console.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { summarizeForwardRecord } from '../forward-status.js';
import { explainNodePreflight, summarizePreflight } from '../forward-preflight.js';

export function mountControlConsole({
validScope, parsePorts, parseCidrs, directCapable, allowedProtocols, directSpec, stateTone, pageCount, listResource, candidateChoices,
createRequests, initRelayConsole, initProbeConsole, initProbeRuns, initProbePlans, initTransportConsole, initRuleConsole,
Expand All @@ -15,6 +18,9 @@ const candidatePages = { direct: 1, relay: 1 };
const filters = { nodes: {}, forwards: {}, operations: {} };
const readVersions = {};
const totals = { nodes: 0, forwards: 0, operations: 0 };
const forwardStatusCache = new Map();
const forwardStatusCells = new Map();
let forwardStatusEpoch = 0;
const consoleRequests = createRequests({ scope: () => auth.scope, token: () => auth.token, version: () => connectionVersion });
const { connected, request, mutate } = consoleRequests;
let currentView = 'overview';
Expand Down Expand Up @@ -73,9 +79,9 @@ async function load(kind) {
if (version !== readVersions[kind]) return;
store[kind] = result.items;
totals[kind] = result.total;
if (kind === 'nodes') renderNodes();
if (kind === 'nodes') { renderNodes(); renderForwardPreflight(); renderOps(); }
if (kind === 'forwards') renderForwards();
if (kind === 'operations') renderOperations();
if (kind === 'operations') { renderOperations(); renderOps(); }
pagination(kind, result);
}
async function loadOverview() {
Expand Down Expand Up @@ -114,6 +120,33 @@ async function refresh() {
function updateOverview() {
for (const [id, key] of [['nodes', 'nodes'], ['forwards', 'forwards'], ['running', 'activeOperations'], ['failed', 'failedOperations']])
byId('metric-' + id).textContent = overview ? String(overview[key]) : '—';
renderOps();
}
function renderOps() {
const status = byId('ops-status'), failedBox = byId('ops-failed-operations'), staleBox = byId('ops-stale-nodes');
if (!status || !failedBox || !staleBox) return;
const failed = store.operations.filter(item => item.status === 'failed').slice(0, 5);
const now = Date.now();
const stale = store.nodes.filter(node => node.revoked || !node.lastReportAt ||
!Number.isFinite(Date.parse(node.lastReportAt)) || now - Date.parse(node.lastReportAt) > 90000).slice(0, 5);
status.textContent = 'Scope 全量失败 Operation ' + (overview ? overview.failedOperations : '—') +
' · 当前页失败 ' + failed.length + ' · 当前页报告过期/撤销 ' + stale.length;
failedBox.replaceChildren();
if (!failed.length) failedBox.append(element('span', '当前页无失败 Operation;缺失不等于成功。', 'subline'));
for (const item of failed) {
const row = element('div', undefined, 'cell-actions');
row.append(element('span', shortId(item.id) + ' · r' + item.targetRevision + ' · ' + date(item.updatedAt), 'subline'),
button('查看', 'operation-detail', item.id, 'secondary'));
failedBox.append(row);
}
staleBox.replaceChildren();
if (!stale.length) staleBox.append(element('span', '当前页无过期或已撤销节点;报告新鲜不代表业务健康。', 'subline'));
for (const node of stale) {
const row = element('div', undefined, 'cell-actions');
row.append(element('span', shortId(node.id) + ' · ' + (node.revoked ? '已撤销' : '报告 ' + date(node.lastReportAt)), 'subline'),
button('诊断', 'node-diagnostics', node.id, 'secondary'));
staleBox.append(row);
}
}
function pagination(kind, result) {
const container = byId(kind + '-pagination');
Expand Down Expand Up @@ -147,11 +180,39 @@ function renderNodes() {
tbody.append(tr);
}
}
function paintForwardStatus(target, summary) {
target.replaceChildren();
target.append(label(summary.label, summary.tone));
target.append(element('div', summary.detail, 'subline'));
if (summary.operation) target.append(element('div', 'Operation ' + summary.operation.status +
(summary.operation.cleanupPending ? ' · 旧资源清理中' : ''), 'subline'));
if (summary.receivedAt) target.append(element('div', '报告 ' + date(summary.receivedAt), 'subline'));
}
function renderForwardStatus(item, target) {
const key = item.id + ':' + item.revision;
const cached = forwardStatusCache.get(key);
if (cached) { paintForwardStatus(target, cached); return; }
const epoch = forwardStatusEpoch;
target.append(element('span', '读取中…', 'subline'));
request('GET', 'forwards/' + encodeURIComponent(item.id)).then(record => {
if (epoch !== forwardStatusEpoch) return;
const summary = summarizeForwardRecord(record);
forwardStatusCache.set(key, summary);
if (forwardStatusCells.get(item.id) === target) paintForwardStatus(target, summary);
}).catch(() => {
if (epoch !== forwardStatusEpoch) return;
if (forwardStatusCells.get(item.id) === target)
target.replaceChildren(element('span', '状态不可用 · 不推导成功', 'subline'));
});
}
function renderForwards() {
ruleConsole.page();
forwardStatusEpoch++;
forwardStatusCells.clear();
const keepStatuses = new Set();
const tbody = byId('forward-rows');
tbody.replaceChildren();
if (!store.forwards.length) { const tr = element('tr'); const t = cell(tr, element('span', '暂无 Forward', 'empty')); t.colSpan = 6; tbody.append(tr); }
if (!store.forwards.length) { const tr = element('tr'); const t = cell(tr, element('span', '暂无 Forward', 'empty')); t.colSpan = 7; tbody.append(tr); }
for (const item of store.forwards) {
const tr = element('tr');
const a = item.spec;
Expand All @@ -163,6 +224,10 @@ function renderForwards() {
target.append(element('div', 'r' + item.revision + (a.path ? ' · '+(a.path.via.length+1)+' 节点链路' : ' · Direct'), 'subline'));
cell(tr, label(a.protocols.join(' + ').toUpperCase(), ''));
cell(tr, label(item.recoverySuspended ? '恢复挂起' : a.enabled ? '希望启用' : '希望停用', item.recoverySuspended ? 'progress' : ''));
const status = cell(tr);
forwardStatusCells.set(item.id, status);
keepStatuses.add(item.id + ':' + item.revision);
renderForwardStatus(item, status);
const operation = cell(tr, item.lastOperation ? label(item.lastOperation.status) : label('无操作记录'));
if (item.lastOperation) operation.append(button('查看', 'operation-detail', item.lastOperation.id, 'secondary'));
const action = cell(tr);
Expand All @@ -180,6 +245,7 @@ function renderForwards() {
action.append(group);
tbody.append(tr);
}
for (const key of forwardStatusCache.keys()) if (!keepStatuses.has(key)) forwardStatusCache.delete(key);
}
function renderOperations() {
const tbody = byId('operation-rows');
Expand Down Expand Up @@ -215,6 +281,28 @@ function fillNodes(preferred) {
addOptions(select, choices.map(n => ({ value: n.id, label: shortId(n.id) + ' · ' + n.policy.capabilities.filter(x => x === 'tcp' || x === 'udp').join('/') })), preferred);
fillCapabilities();
}
function renderForwardPreflight() {
const tbody = byId('forward-preflight-rows'), summary = byId('forward-preflight-summary');
if (!tbody || !summary) return;
tbody.replaceChildren();
const protocol = byId('forward-protocol').value;
const rows = store.nodes.map(node => explainNodePreflight(node, { protocol }));
const counts = summarizePreflight(rows);
summary.textContent = '已加载节点 ' + counts.total + ' · 可选 ' + counts.supported + ' · 不可选 ' + counts.unsupported + ' · 未知 ' + counts.unknown;
if (!rows.length) {
const tr = element('tr'), td = cell(tr, element('span', '节点列表尚未加载;请先在连接后刷新节点。', 'empty'));
td.colSpan = 4; tbody.append(tr); return;
}
for (const row of rows) {
const tr = element('tr');
cell(tr, element('span', shortId(row.nodeId), 'mono'));
cell(tr, label(row.label, row.tone));
const dimensions = '平台 ' + row.dimensions.platform + ' / 角色 ' + row.dimensions.roles + ' / flag ' + row.dimensions.flags;
cell(tr, element('span', (row.reasons.length ? row.reasons.map(value => value.message).join(';') : '声明满足') + ' · ' + dimensions, 'subline'));
cell(tr, element('span', row.facts.status === 'fresh' ? '报告新鲜' : row.facts.status === 'stale' ? '报告过期' : '无报告', 'subline'));
tbody.append(tr);
}
}
function fillCapabilities(preferred) {
const node = candidates.direct.find(n => n.id === byId('forward-node').value);
const protocols = node ? allowedProtocols(node) : [];
Expand All @@ -223,6 +311,7 @@ function fillCapabilities(preferred) {
addOptions(byId('forward-port'), (node?.policy?.allowedPorts ?? []).map(p => ({ value: p })), preferred?.port);
byId('forward-capabilities').textContent = node ? '可用监听地址和端口由当前 Node Policy 决定,服务端会再次检查授权、目标 CIDR 与资源独占。' : '没有具备 ADP/3 Direct 能力和有效凭据的节点;请先接入 Agent。';
byId('save-forward').disabled = !node;
renderForwardPreflight();
}
async function editor(item, copy = false) {
byId('forward-form').hidden = true;
Expand Down Expand Up @@ -391,7 +480,7 @@ function clearConnectionData() {
byId(mode + '-candidate-prev').disabled = byId(mode + '-candidate-next').disabled = true;
}
relayConsole.clear(); probeConsole.clear(); transportConsole.clear(); ruleConsole.clear(); tunnelConsole.clear(); telemetryConsole.clear(); forwardTelemetryConsole.clear(); hostTelemetryConsole.clear(); probeTelemetryConsole.clear(); telemetryAggregateConsole.clear(); pathTemplates.clear(); pathSwitch.clear(); pathGroups.clear(); pathPublications.clear(); ingressGroups.clear(); ingressMigrations.clear(); fillNodes();
renderNodes(); renderForwards(); renderOperations(); updateOverview();
renderNodes(); renderForwards(); renderOperations(); renderForwardPreflight(); updateOverview();
}
async function refreshDirectCandidates() {
const nodeId = editing?.spec.ingress.nodeId ?? byId('forward-node').value;
Expand Down Expand Up @@ -428,6 +517,15 @@ document.querySelectorAll('.nav').forEach(node => node.addEventListener('click',
display(node.dataset.view);
if(node.dataset.view==='relay' && auth.scope) Promise.all([relayConsole.refresh(),pathTemplates.list()]).catch(e=>notify(e.message,true));
}));
for (const [id, view] of [['ops-audit', 'audit'], ['ops-alerts', 'alerts'], ['ops-notifications', 'notifications'],
['ops-operations', 'operations'], ['ops-forwards', 'forwards']])
byId(id).addEventListener('click', () => { if (view !== 'notifications') notificationEndpoints.leave(); display(view); });
for (const [id, anchor] of [['topology-templates', 'tunnel-definition-title'], ['topology-groups', 'path-group-title'],
['topology-publications', 'path-publication-title'], ['topology-ingress', 'ingress-migration-title']])
byId(id).addEventListener('click', () => {
[...document.querySelectorAll('.nav')].find(node => node.dataset.view === 'relay')?.click();
byId(anchor).scrollIntoView({ block: 'start', behavior: 'smooth' });
});
byId('connection-form').addEventListener('submit', async event => {
event.preventDefault();
const scope = byId('scope').value.trim();
Expand Down Expand Up @@ -460,6 +558,7 @@ byId('cancel-forward').addEventListener('click', () => {
editing = null; byId('forward-form').hidden = true;
});
byId('forward-node').addEventListener('change', () => fillCapabilities());
byId('forward-protocol').addEventListener('change', () => renderForwardPreflight());
byId('forward-form').addEventListener('submit', saveForward);
byId('close-detail').addEventListener('click', () => byId('detail-dialog').close());
byId('detail-dialog').addEventListener('close', () => { currentSecret = null; byId('detail-content').textContent = ''; });
Expand Down
23 changes: 22 additions & 1 deletion apps/web/forward-diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ const stamp = value => value === null ? '未知 / 无记录(不是零)' : va
const show = value => value === null || value === undefined ? '未知 / 无记录(不是零)' : typeof value === 'object' ?
JSON.stringify(value, (_, item) => instant(item) ? stamp(item) : item, 2) : instant(value) ? stamp(value) : String(value);

export function initForwardDiagnostics({ request, byId, notify, getScope, document = globalThis.document, confirm = globalThis.confirm }) {
export function initForwardDiagnostics({ request, byId, notify, getScope, summarizeDiagnosticRecord, document = globalThis.document, confirm = globalThis.confirm }) {
const el = name => byId('forward-diagnostic-' + name);
let forward = null, scope = null, epoch = 0, controller = null, displayed = null;
function controls() { el('reload').disabled = !forward || Boolean(controller); el('cancel').disabled = !controller;
Expand All @@ -223,9 +223,30 @@ export function initForwardDiagnostics({ request, byId, notify, getScope, docume
}
table.append(head, body); wrap.append(table); area.append(wrap); el('layers').append(area);
}
function statusOverview(data) {
const s = summarizeDiagnosticRecord(data), op = s.operations;
const deployment = s.deployment ? { stage: s.deployment.stage, matchesIntent: s.deployment.matchesIntent, authorizationUntil: s.deployment.authorizationUntil } : '无 Relay 部署记录(Direct 为预期)';
const cleanup = s.cleanup ? { complete: s.cleanup.complete, slots: s.cleanup.slots, retirements: s.cleanup.retirements,
leases: s.cleanup.leases, truncated: s.cleanup.slotsTruncated || s.cleanup.retirementsTruncated || s.cleanup.leasesTruncated } : '无清理投影';
const nodes = s.nodes ? { expected: s.nodes.total, fresh: s.nodes.fresh, stale: s.nodes.stale, unknown: s.nodes.unknown,
missingEvidence: s.nodes.missing, revoked: s.nodes.revoked, currentSession: s.nodes.currentSession, currentSnapshot: s.nodes.currentSnapshot } : '无节点投影';
const listeners = s.listeners ? { current: s.listeners.current, historical: s.listeners.historical, reportFresh: s.listeners.reportFresh,
nodeEvidenceMatches: s.listeners.nodeEvidenceMatches, ready: s.listeners.ready, degraded: s.listeners.degraded, error: s.listeners.error,
unknown: s.listeners.unknown, truncated: s.listeners.truncated } : '无监听投影';
const probe = s.probe ? { records: s.probe.total, withResult: s.probe.withResult, windowOpen: s.probe.windowOpen,
truncated: s.probe.truncated, historyComplete: s.probe.historyComplete } : '无 Probe 投影';
section('0 · 状态解释总览', ['Operation / 部署 / 清理','节点 / 监听 / Probe','来源与偏差'], [
[{ total: op.total, accepted: op.accepted, running: op.running, succeeded: op.succeeded, failed: op.failed,
superseded: op.superseded, cleanupPending: op.cleanupPending, truncated: op.truncated }, deployment, cleanup],
[nodes, listeners, probe],
[{ capturedAt: s.capturedAt, source: s.source, consistency: s.consistency, health: s.health },
{ partial: s.partial, stale: s.stale, unknown: s.unknown }, s.note],
], '这些是同一只读投影的计数与偏差标记,不是健康结论。Accepted/Operation、Applied 证据、Listener 发布元组、Probe 结果分别显示;truncated=true 表示当前页被截断,不表示零或已清理。');
}
function render(data) {
const i = data.intent, b = data.support.bundle;
el('meta').textContent = '采集 ' + stamp(data.capturedAt) + '\n' + data.source + ' / ' + data.consistency + '\nnetworkCheck=not-performed / health=unknown / executionAuthorized=false\nSnapshot digest 仅投影,未重算;不是全库快照或完整历史。';
statusOverview(data);
section('1 · 意图', ['Forward / 修订 / 模式','期望与隔离','路径 / 监听 / Transport'], [[i.forwardId + ' / r' + i.revision + ' / ' + i.mode,
{ enabled: i.enabled, deleting: i.deleting, recoverySuspended: i.recoverySuspended, protocols: i.protocols }, { path: i.path, listen: i.listen, transport: i.transport }]],
'期望启用、停用、删除或恢复挂起均不是运行健康。此处不执行任何命令。');
Expand Down
Loading
Loading