diff --git a/apps/control/src/api/routes.ts b/apps/control/src/api/routes.ts index 8afc62bb..7219853b 100644 --- a/apps/control/src/api/routes.ts +++ b/apps/control/src/api/routes.ts @@ -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' }); }); diff --git a/apps/control/src/compatibility/native-control-http.ts b/apps/control/src/compatibility/native-control-http.ts index 15c7fbc9..f8301b0a 100644 --- a/apps/control/src/compatibility/native-control-http.ts +++ b/apps/control/src/compatibility/native-control-http.ts @@ -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'; diff --git a/apps/web/app.js b/apps/web/app.js index efd0d23b..fd29805b 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -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'; @@ -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, }); diff --git a/apps/web/browser-native/control-console.js b/apps/web/browser-native/control-console.js index e2fe7642..45a40a7c 100644 --- a/apps/web/browser-native/control-console.js +++ b/apps/web/browser-native/control-console.js @@ -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, @@ -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'; @@ -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() { @@ -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'); @@ -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; @@ -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); @@ -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'); @@ -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) : []; @@ -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; @@ -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; @@ -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(); @@ -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 = ''; }); diff --git a/apps/web/forward-diagnostics.js b/apps/web/forward-diagnostics.js index 77a42f70..e8cc3490 100644 --- a/apps/web/forward-diagnostics.js +++ b/apps/web/forward-diagnostics.js @@ -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; @@ -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 }]], '期望启用、停用、删除或恢复挂起均不是运行健康。此处不执行任何命令。'); diff --git a/apps/web/forward-preflight.js b/apps/web/forward-preflight.js new file mode 100644 index 00000000..0be626fe --- /dev/null +++ b/apps/web/forward-preflight.js @@ -0,0 +1,81 @@ +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const isArray = value => Array.isArray(value); +const isTime = value => typeof value === 'string' && Number.isFinite(Date.parse(value)); +const REPORT_FRESH_MS = 5 * 60 * 1000; +const BASE_CAPABILITIES = ['adp.3', 'native.direct.v1']; + +function normalizeProtocol(value) { + return value === 'tcp' || value === 'udp' ? [value] : value === 'both' ? ['tcp', 'udp'] : []; +} + +function reason(code, message) { + return { code, message }; +} + +export function explainNodePreflight(node, options = {}) { + const now = Number.isFinite(options.now) ? options.now : Date.now(); + const protocols = normalizeProtocol(options.protocol); + const required = [...BASE_CAPABILITIES, ...protocols]; + const nodeId = typeof node?.id === 'string' ? node.id : 'unknown'; + const policy = isObject(node?.policy) ? node.policy : null; + const capabilities = isArray(policy?.capabilities) ? policy.capabilities.filter(value => typeof value === 'string') : null; + const reasons = []; + let status = 'supported'; + const fail = (code, message) => { reasons.push(reason(code, message)); status = 'unsupported'; }; + const unknown = (code, message) => { reasons.push(reason(code, message)); if (status === 'supported') status = 'unknown'; }; + + if (node?.revoked === true) fail('NODE_REVOKED', '节点已撤销'); + if (!node?.credentialUntil) unknown('CREDENTIAL_MISSING', '无有效凭据记录'); + else if (!isTime(node.credentialUntil)) unknown('CREDENTIAL_INVALID', '凭据时间格式无效'); + else if (Date.parse(node.credentialUntil) <= now) fail('CREDENTIAL_EXPIRED', '凭据已过期'); + + let facts = { status: 'unknown', reportAgeMs: null, lastReportAt: null }; + if (!node?.lastReportAt) { + unknown('REPORT_MISSING', '无 Agent 报告时间'); + } else if (!isTime(node.lastReportAt)) { + unknown('REPORT_INVALID', 'Agent 报告时间格式无效'); + } else { + const age = Math.max(0, now - Date.parse(node.lastReportAt)); + const fresh = age <= REPORT_FRESH_MS; + facts = { status: fresh ? 'fresh' : 'stale', reportAgeMs: age, lastReportAt: node.lastReportAt }; + if (!fresh) unknown('REPORT_STALE', 'Agent 报告超过 5 分钟'); + } + + if (!policy) { + unknown('POLICY_MISSING', '缺少 Node Policy 能力声明'); + } else if (capabilities === null) { + unknown('CAPABILITIES_MISSING', 'Node Policy 未包含能力数组'); + } else { + for (const capability of required) { + if (!capabilities.includes(capability)) fail('CAPABILITY_MISSING', '缺少能力 ' + capability); + } + } + + const declared = capabilities ?? []; + return { + nodeId, + status, + tone: status === 'supported' ? 'good' : status === 'unsupported' ? 'bad' : 'progress', + label: status === 'supported' ? '可选(声明满足)' : status === 'unsupported' ? '不可选' : '未知 / 需人工确认', + reasons, + required, + capabilities: declared, + dimensions: { + platform: 'unknown', + roles: 'unknown', + flags: 'unknown', + facts: facts.status, + }, + facts, + }; +} + +export function summarizePreflight(rows) { + const items = isArray(rows) ? rows : []; + return { + total: items.length, + supported: items.filter(row => row.status === 'supported').length, + unsupported: items.filter(row => row.status === 'unsupported').length, + unknown: items.filter(row => row.status === 'unknown').length, + }; +} diff --git a/apps/web/forward-status.js b/apps/web/forward-status.js new file mode 100644 index 00000000..41c3d9af --- /dev/null +++ b/apps/web/forward-status.js @@ -0,0 +1,169 @@ +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const isArray = value => Array.isArray(value); +const isTime = value => typeof value === 'string' && Number.isFinite(Date.parse(value)); + +function countBy(items, key) { + const counts = {}; + for (const item of items) { + const value = item && item[key]; + if (typeof value === 'string') counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} + +function operationSummary(operations) { + const items = isArray(operations?.items) ? operations.items : []; + const statuses = countBy(items, 'status'); + return { + total: items.length, + accepted: statuses.accepted ?? 0, + running: statuses.running ?? 0, + succeeded: statuses.succeeded ?? 0, + failed: statuses.failed ?? 0, + superseded: statuses.superseded ?? 0, + cleanupPending: items.filter(item => item?.cleanupPending === true).length, + recoveryInvalidated: items.filter(item => item?.recoveryInvalidated === true).length, + truncated: operations?.truncated === true, + }; +} + +export function summarizeForwardRecord(record, now = Date.now()) { + if (!isObject(record) || !isObject(record.desired) || !isObject(record.health)) { + return { + state: 'unknown', tone: '', label: '未知 / 无记录', detail: '响应缺少期望或运行证据;不推导成功、零或健康。', + appliedCount: 0, expectedCount: null, operation: null, receivedAt: null, freshUntil: null, reasons: ['missing-evidence'], + }; + } + const desired = record.desired; + const spec = isObject(desired.spec) ? desired.spec : {}; + const protocols = isArray(spec.protocols) ? spec.protocols.filter(value => value === 'tcp' || value === 'udp') : []; + const applied = isArray(record.applied) ? record.applied : []; + const health = record.health; + const operation = isObject(record.lastOperation) ? { + status: typeof record.lastOperation.status === 'string' ? record.lastOperation.status : 'unknown', + cleanupPending: record.lastOperation.cleanupPending === true, + recoveryInvalidated: record.lastOperation.recoveryInvalidated === true, + } : null; + const reasons = []; + if (desired.deleting === true) reasons.push('删除中'); + if (desired.recoverySuspended === true) reasons.push('恢复挂起'); + if (operation?.cleanupPending) reasons.push('旧资源清理中'); + if (operation?.recoveryInvalidated) reasons.push('恢复已失效'); + if (operation && (operation.status === 'failed' || operation.status === 'superseded')) reasons.push('Operation ' + operation.status); + if (!isTime(health.receivedAt)) reasons.push('无报告时间'); + if (isTime(health.freshUntil) && Date.parse(health.freshUntil) <= now) reasons.push('报告已过期'); + + const multiNode = isObject(spec.path) && isArray(spec.path.via); + const expectedCount = multiNode ? null : protocols.length || null; + const expired = isTime(health.freshUntil) && Date.parse(health.freshUntil) <= now; + const reportStale = expired || !isTime(health.receivedAt); + let state = 'unknown'; + let tone = ''; + let label = '未知 / 无新鲜证据'; + if (reportStale) { + label = expired ? '未知 / 报告已过期' : '未知 / 无报告时间'; + } else if (health.condition === 'ready') { + if (expectedCount !== null && applied.length < expectedCount) { + state = 'partial'; tone = 'progress'; label = '部分证据 / 未覆盖全部协议'; + } else { + state = 'ready'; tone = 'good'; label = '已应用 / 报告新鲜'; + } + } else if (health.condition === 'degraded') { + state = 'partial'; tone = 'progress'; label = '部分应用 / 有失败或缺口'; + } else if (health.condition === 'unknown') { + label = '未知 / 无新鲜报告'; + } else { + reasons.push('未知健康条件'); + } + const evidence = expectedCount === null + ? '应用观测 ' + applied.length + ' 条(多节点,不以单节点协议数作分母)' + : '应用观测 ' + applied.length + ' / 期望协议 ' + expectedCount; + return { + state, tone, label, + detail: evidence + (reasons.length ? ' · ' + reasons.join(' · ') : ''), + appliedCount: applied.length, expectedCount, operation, + receivedAt: isTime(health.receivedAt) ? health.receivedAt : null, + freshUntil: isTime(health.freshUntil) ? health.freshUntil : null, + reasons, + }; +} + +export function summarizeDiagnosticRecord(data) { + if (!isObject(data) || !isObject(data.intent)) { + return { + capturedAt: null, mode: 'unknown', partial: true, stale: true, unknown: true, + operations: operationSummary(null), deployment: null, nodes: null, listeners: null, + probe: null, cleanup: null, + note: '诊断响应缺少意图;不推导成功、零或健康。', + }; + } + const mode = typeof data.intent.mode === 'string' ? data.intent.mode : 'unknown'; + const operations = operationSummary(data.operations); + const deployment = isObject(data.deployment) ? { + stage: typeof data.deployment.stage === 'string' ? data.deployment.stage : 'unknown', + matchesIntent: data.deployment.matchesIntent === true, + authorizationUntil: isTime(data.deployment.authorizationUntil) ? data.deployment.authorizationUntil : null, + } : null; + const nodes = isArray(data.nodes) ? data.nodes : []; + const nodeEvidence = nodes.map(node => isObject(node?.evidence) ? node.evidence : null); + const nodeObservations = nodeEvidence.map(evidence => evidence?.observation?.status ?? 'unknown'); + const nodeCounts = countBy(nodeObservations.map(status => ({ status })), 'status'); + const nodeSummary = { + total: nodes.length, + fresh: nodeCounts.fresh ?? 0, + stale: nodeCounts.stale ?? 0, + unknown: nodeCounts.unknown ?? 0, + missing: nodeEvidence.filter(evidence => evidence === null).length, + revoked: nodeEvidence.filter(evidence => evidence?.revoked === true).length, + currentSession: nodeEvidence.filter(evidence => evidence?.sessionCurrent === true).length, + currentSnapshot: nodeEvidence.filter(evidence => evidence?.snapshotCurrent === true).length, + }; + const listeners = isArray(data.listeners?.items) ? data.listeners.items : []; + const listenerCounts = countBy(listeners, 'relation'); + const conditions = countBy(listeners.map(row => ({ condition: row?.observation?.condition ?? 'unknown' })), 'condition'); + const listenerSummary = { + total: listeners.length, + current: listenerCounts['current-intent'] ?? 0, + historical: listenerCounts['historical-intent'] ?? 0, + reportFresh: listeners.filter(row => row?.reportFresh === true).length, + currentReportFresh: listeners.filter(row => row?.relation === 'current-intent' && row?.reportFresh === true).length, + nodeEvidenceMatches: listeners.filter(row => row?.nodeEvidenceMatches === true).length, + ready: conditions.ready ?? 0, + degraded: conditions.degraded ?? 0, + error: conditions.error ?? 0, + unknown: conditions.unknown ?? 0, + truncated: data.listeners?.truncated === true, + }; + const probes = isArray(data.probe?.items) ? data.probe.items : []; + const probeSummary = { + total: probes.length, + withResult: probes.filter(row => row?.result !== null && row?.result !== undefined).length, + windowOpen: probes.filter(row => row?.result?.windowOpen === true).length, + truncated: data.probe?.truncated === true, + historyComplete: data.probe?.historyComplete === true, + }; + const cleanup = isObject(data.cleanup) ? { + complete: data.cleanup.complete === true, + slots: isArray(data.cleanup.slots?.items) ? data.cleanup.slots.items.length : 0, + retirements: isArray(data.cleanup.retirements?.items) ? data.cleanup.retirements.items.length : 0, + leases: isArray(data.cleanup.leases?.items) ? data.cleanup.leases.items.length : 0, + slotsTruncated: data.cleanup.slots?.truncated === true, + retirementsTruncated: data.cleanup.retirements?.truncated === true, + leasesTruncated: data.cleanup.leases?.truncated === true, + } : null; + const partial = operations.truncated || operations.failed > 0 || operations.cleanupPending > 0 || + listenerSummary.truncated || listenerSummary.degraded + listenerSummary.error > 0 || probeSummary.truncated; + const stale = nodeSummary.stale > 0 || listenerSummary.currentReportFresh < listenerSummary.current; + const unknown = nodeSummary.unknown > 0 || nodeSummary.missing > 0 || listenerSummary.unknown > 0 || + (mode !== 'direct' && deployment === null); + return { + capturedAt: isTime(data.capturedAt) ? data.capturedAt : null, + mode, + source: typeof data.source === 'string' ? data.source : 'unknown', + consistency: typeof data.consistency === 'string' ? data.consistency : 'unknown', + health: typeof data.health === 'string' ? data.health : 'unknown', + operations, deployment, nodes: nodeSummary, listeners: listenerSummary, probe: probeSummary, cleanup, + partial, stale, unknown, + note: '计数来自同次只读投影;Operation、Applied 证据、Listener 发布元组和 Probe 结果分别显示,任何一项都不能单独证明端到端健康。', + }; +} diff --git a/apps/web/index.html b/apps/web/index.html index 4f2d50ef..ffd6e758 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -54,6 +54,21 @@
用户希望系统如何转发;Revision 代表配置版本。
Control 是否已经接收、执行或完成部署动作。
Agent 实际应用且仍新鲜的部署观察结果。
Agent 上报的运行状况;不等于独立端到端探测。
从“节点管理”确认 Agent 能力和端口授权,再到“转发管理”创建规则。202 Accepted 仅表示任务已持久接收,不代表执行完成。默认关闭的受限 TCP 探测只检查 Direct 已有目标的 TCP 连接,不发送应用数据,不把 Agent 在线或历史连接成功当作当前业务健康。
数据库不可达或请求失败时,页面显示错误并保留“未知”,不会把缺失记录当作零或健康。支持包在 Forward 分层诊断中按同次读取生成;升级评估与恢复仍由原 ops/Authority/Journal owner 执行,本页不提供写操作。
+精确模板、候选组、持久发布与入口迁移均已存在。本面板只提供导航,不自动发布、不自动切换、不推断物理主机拓扑。
+ +发布以 previewDigest、expectedRevision CAS、逐位置 apply/compensate 和 Operation 为准;不承诺跨 Forward 全局原子、自动回滚、自动故障转移或物理主机验收。
+| 节点 | 结论 | 不可选原因 | 报告 |
|---|
仅基于当前页已加载节点与 Node Policy 能力声明;平台、角色、flag 未单独上报时显示未知。预检不授予执行,提交时服务端独立校验授权、目标、端口和凭据。
+| 入口与端口 | 目标 | 协议 | 期望状态 | 操作状态 | 操作 |
|---|---|---|---|---|---|
| 连接后加载转发 | |||||
| 入口与端口 | 目标 | 协议 | 期望状态 | 运行状态 | 操作状态 | 操作 |
|---|---|---|---|---|---|---|
| 连接后加载转发 | ||||||
指标语义:source 以面板响应为准(原始族为 agent-reported);health 通常为 unknown / executionAuthorized=false。delta 仅在相邻连续样本内计算速率;baseline/gap/counter-reset/historical/unavailable/overflowed/terminal 不推算。coverage 的 returned/available、omitted/truncated 与 tails 淘汰分别表示窗口覆盖和保留边界;historyComplete=false。fresh 仅表示当前会话且接收时间在 30 秒内;stale/historical 不是当前健康。缺测不补零,图表点不是分母,四族指标互不替代。
| 采集 / 接收时间(Asia/Shanghai) | 会话 / 连续性 | 进程 Heap / Runtime / 峰值 RSS | Goroutine / FD | 存量 TCP / UDP / 排空实例 | 区间 CPU / 丢弃样本 |
|---|
只按手动选择的有界时间窗读取,不自动轮询。原始记录按 24 小时窗口显示、每节点最多 8640 点,过期物理清理分批进行;未送达样本不补发,Agent 重启建立新基线,不删除已存历史。截断、缺口和无数据不是零值。CPU 仅在同一采样会话连续区间按进程 CPU 时间差 / uptime 差计算,可超过 100%;不跨缺口或重置推算。峰值 RSS 是进程峰值,不是主机总内存。null 表示平台不支持或未提供。
@@ -576,6 +598,7 @@指标语义:source 以面板响应为准(原始族为 agent-reported);health 通常为 unknown / executionAuthorized=false。delta 仅在相邻连续样本内计算速率;baseline/gap/counter-reset/historical/unavailable/overflowed/terminal 不推算。coverage 的 returned/available、omitted/truncated 与 tails 淘汰分别表示窗口覆盖和保留边界;historyComplete=false。fresh 仅表示当前会话且接收时间在 30 秒内;stale/historical 不是当前健康。缺测不补零,图表点不是分母,四族指标互不替代。
| 完整入口实例身份 | 采集 / 接收时间(Asia/Shanghai + UTC) | 状态 / 机器会话观察 | 实例累计(不是 Forward 合计) | 连续区间 / 精确字节速率 | 导出与覆盖缺口 | 历史发布映射(非当前绑定) |
|---|
手动读取,不自动轮询;接收默认关闭,页面不会启用采集。累计值使用十进制整数,速率仅按连续区间计算并截断至三位小数,不跨缺口/重置/溢出/终态外推。Direct 正数 Write 即使返回错误也计入已写字节;Relay TCP 只计完整成功业务帧,失败部分帧不补算;UDP 完整成功的空包增加次数但不增加字节。没有端到端交付、主机网卡字节或链路健康结论。
@@ -586,6 +609,7 @@独立、默认关闭的 agent-reported 只读观察,scope=agent-visible-os。Agent 所见 OS / 根文件系统 / 网络命名空间不等于已验证的物理宿主,也不是 cgroup CPU/内存限制。它们不代表进程用量、规则 payload、健康或容量承诺;本页面不启动 Probe 或执行命令。
+指标语义:source 以面板响应为准(原始族为 agent-reported);health 通常为 unknown / executionAuthorized=false。delta 仅在相邻连续样本内计算速率;baseline/gap/counter-reset/historical/unavailable/overflowed/terminal 不推算。coverage 的 returned/available、omitted/truncated 与 tails 淘汰分别表示窗口覆盖和保留边界;historyComplete=false。fresh 仅表示当前会话且接收时间在 30 秒内;stale/historical 不是当前健康。缺测不补零,图表点不是分母,四族指标互不替代。
| Agent 网络命名空间接口 | 累计 RX(不是规则 payload) | 累计 TX(不推导速率) |
|---|
独立、默认关闭的 agent-reported 计数观察,不签发、领取或执行 Probe,不修改原 TCP 探测/回执流程。按完整 Direct TCP 发布绑定、Probe 实例和 counter epoch 隔离;最新一项不是 Forward 总计,也不是健康、网络丢包率或每个许可的审计证明。
+指标语义:source 以面板响应为准(原始族为 agent-reported);health 通常为 unknown / executionAuthorized=false。delta 仅在相邻连续样本内计算速率;baseline/gap/counter-reset/historical/unavailable/overflowed/terminal 不推算。coverage 的 returned/available、omitted/truncated 与 tails 淘汰分别表示窗口覆盖和保留边界;historyComplete=false。fresh 仅表示当前会话且接收时间在 30 秒内;stale/historical 不是当前健康。缺测不补零,图表点不是分母,四族指标互不替代。
| 完整绑定 / Probe 实例 | 采集 / 接收(Asia/Shanghai + UTC) | 状态 / 机器会话观察 | 累计与 inFlight gauge | 连续区间(不是全部尝试失败率) | 导出 / 覆盖缺口 | 接收时来源(非当前权限) |
|---|
attempted/succeeded/failed/unknown 是独立累计,inFlight 是 gauge,不能求负差当重置。前期在途任务可在本期完成,因此完成数大于本期 attempted 不代表计数回退。有效结果子集占比仅为 failed/(succeeded+failed),不把 unknown/inFlight 或缺尾视为全部 attempt 的失败率。区间比例仅在连续且前后 inFlight 都为 0、unknown 增量为 0 时显示;仍只属于该实例、该有效结果子集,不推断当前健康。
@@ -617,6 +642,7 @@指标语义:source 以面板响应为准(原始族为 agent-reported);health 通常为 unknown / executionAuthorized=false。delta 仅在相邻连续样本内计算速率;baseline/gap/counter-reset/historical/unavailable/overflowed/terminal 不推算。coverage 的 returned/available、omitted/truncated 与 tails 淘汰分别表示窗口覆盖和保留边界;historyComplete=false。fresh 仅表示当前会话且接收时间在 30 秒内;stale/historical 不是当前健康。缺测不补零,图表点不是分母,四族指标互不替代。
| 精确 Series Key / 完整实例身份 | UTC 桶 / Asia/Shanghai / 接收与采集边界 | Gauge 采样统计(非时间加权) | 原始累计观察(不求和) | 有效连续增量 / 各指标合格时长 | 缺测 / 重置 / 覆盖质量 | 连续区间覆盖与归属 |
|---|
只手动读取,五秒超时,无轮询、无自动重试。新读取按当前 UTC 分钟/小时对齐,包含尚未关闭的当前桶;下一页固定原 from/to/family/resolution/series/limit,但 keyset 不是全库快照,更新与清理可能改变剩余结果。更换筛选、关闭或断开会取消本地等待并清除输出。
diff --git a/apps/web/test/forward-preflight.test.mjs b/apps/web/test/forward-preflight.test.mjs new file mode 100644 index 00000000..302920e2 --- /dev/null +++ b/apps/web/test/forward-preflight.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { explainNodePreflight, summarizePreflight } from '../forward-preflight.js'; + +const now = Date.parse('2026-10-20T01:00:00.000Z'); +const node = (overrides = {}) => ({ + id: 'node-1', revoked: false, credentialUntil: '2026-10-21T00:00:00.000Z', + lastReportAt: '2026-10-20T00:59:30.000Z', + policy: { capabilities: ['adp.3', 'native.direct.v1', 'tcp', 'udp'], allowedListenerAddresses: ['127.0.0.1'], allowedPorts: [8080] }, + ...overrides, +}); + +test('declared capabilities plus fresh report are selectable but still advisory', () => { + const result = explainNodePreflight(node(), { protocol: 'both', now }); + assert.equal(result.status, 'supported'); + assert.equal(result.tone, 'good'); + assert.deepEqual(result.reasons, []); + assert.equal(result.dimensions.platform, 'unknown'); + assert.equal(result.dimensions.roles, 'unknown'); + assert.equal(result.facts.status, 'fresh'); +}); + +test('missing protocol capability is unsupported with an explicit reason', () => { + const value = node(); value.policy = { ...value.policy, capabilities: ['adp.3', 'native.direct.v1', 'tcp'] }; + const result = explainNodePreflight(value, { protocol: 'both', now }); + assert.equal(result.status, 'unsupported'); + assert.ok(result.reasons.some(reason => reason.code === 'CAPABILITY_MISSING' && reason.message.includes('udp'))); +}); + +test('revoked and expired credentials are unsupported before capability checks', () => { + assert.equal(explainNodePreflight(node({ revoked: true }), { protocol: 'tcp', now }).status, 'unsupported'); + const expired = explainNodePreflight(node({ credentialUntil: '2026-10-19T00:00:00.000Z' }), { protocol: 'tcp', now }); + assert.equal(expired.status, 'unsupported'); + assert.ok(expired.reasons.some(reason => reason.code === 'CREDENTIAL_EXPIRED')); +}); + +test('stale or missing report is unknown rather than selectable', () => { + const stale = explainNodePreflight(node({ lastReportAt: '2026-10-20T00:00:00.000Z' }), { protocol: 'tcp', now }); + assert.equal(stale.status, 'unknown'); + assert.equal(stale.facts.status, 'stale'); + assert.ok(stale.reasons.some(reason => reason.code === 'REPORT_STALE')); + const missing = explainNodePreflight(node({ lastReportAt: null }), { protocol: 'tcp', now }); + assert.equal(missing.status, 'unknown'); + assert.ok(missing.reasons.some(reason => reason.code === 'REPORT_MISSING')); +}); + +test('missing policy stays unknown and does not invent capabilities', () => { + const result = explainNodePreflight(node({ policy: null }), { protocol: 'tcp', now }); + assert.equal(result.status, 'unknown'); + assert.deepEqual(result.capabilities, []); + assert.ok(result.reasons.some(reason => reason.code === 'POLICY_MISSING')); +}); + +test('summary counts remain separate', () => { + const rows = [ + explainNodePreflight(node(), { protocol: 'tcp', now }), + explainNodePreflight(node({ id: 'node-2', revoked: true }), { protocol: 'tcp', now }), + explainNodePreflight(node({ id: 'node-3', lastReportAt: null }), { protocol: 'tcp', now }), + ]; + assert.deepEqual(summarizePreflight(rows), { total: 3, supported: 1, unsupported: 1, unknown: 1 }); +}); diff --git a/apps/web/test/forward-status.test.mjs b/apps/web/test/forward-status.test.mjs new file mode 100644 index 00000000..038d4775 --- /dev/null +++ b/apps/web/test/forward-status.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { summarizeForwardRecord, summarizeDiagnosticRecord } from '../forward-status.js'; + +const direct = (overrides = {}) => ({ + desired: { revision: 3, spec: { protocols: ['tcp', 'udp'] }, deleting: false, recoverySuspended: false }, + applied: [{ assignmentId: 'a', nodeId: 'node-1', condition: 'ready' }, { assignmentId: 'b', nodeId: 'node-1', condition: 'ready' }], + health: { condition: 'ready', receivedAt: '2026-10-20T01:00:00.000Z', freshUntil: '2026-10-20T01:01:00.000Z' }, + lastOperation: { id: 'op-1', kind: 'update', status: 'succeeded', cleanupPending: false, recoveryInvalidated: false }, + ...overrides, +}); + +test('forward record without evidence stays unknown', () => { + const summary = summarizeForwardRecord(null); + assert.equal(summary.state, 'unknown'); + assert.equal(summary.tone, ''); + assert.deepEqual(summary.reasons, ['missing-evidence']); +}); + +test('ready direct record reports applied protocol count', () => { + const summary = summarizeForwardRecord(direct(), Date.parse('2026-10-20T01:00:30.000Z')); + assert.equal(summary.state, 'ready'); + assert.equal(summary.tone, 'good'); + assert.equal(summary.appliedCount, 2); + assert.equal(summary.expectedCount, 2); + assert.match(summary.detail, /应用观测 2 \/ 期望协议 2/); +}); + +test('degraded record is partial and keeps cleanup pending visible', () => { + const summary = summarizeForwardRecord(direct({ + health: { condition: 'degraded', receivedAt: '2026-10-20T01:00:00.000Z', freshUntil: '2026-10-20T01:01:00.000Z' }, + lastOperation: { id: 'op-2', kind: 'update', status: 'failed', cleanupPending: true, recoveryInvalidated: true }, + }), Date.parse('2026-10-20T01:00:30.000Z')); + assert.equal(summary.state, 'partial'); + assert.equal(summary.tone, 'progress'); + assert.equal(summary.operation.cleanupPending, true); + assert.match(summary.detail, /旧资源清理中/); + assert.match(summary.detail, /恢复已失效/); +}); + +test('ready condition with missing applied evidence is not reported as fully applied', () => { + const summary = summarizeForwardRecord(direct({ applied: [{ assignmentId: 'a', nodeId: 'node-1' }] }), + Date.parse('2026-10-20T01:00:30.000Z')); + assert.equal(summary.state, 'partial'); + assert.match(summary.detail, /应用观测 1 \/ 期望协议 2/); +}); + +test('expired freshness is surfaced without converting it to success', () => { + const summary = summarizeForwardRecord(direct(), Date.parse('2026-10-20T01:02:00.000Z')); + assert.equal(summary.state, 'unknown'); + assert.match(summary.label, /报告已过期/); + assert.match(summary.detail, /报告已过期/); +}); + +test('multi-node record does not use protocol count as a node denominator', () => { + const summary = summarizeForwardRecord(direct({ + desired: { revision: 4, spec: { protocols: ['tcp'], path: { via: ['node-2'] } }, deleting: false, recoverySuspended: false }, + }), Date.parse('2026-10-20T01:00:30.000Z')); + assert.equal(summary.expectedCount, null); + assert.match(summary.detail, /多节点/); +}); + +test('diagnostic summary keeps operation, listener and probe counts separate', () => { + const summary = summarizeDiagnosticRecord({ + capturedAt: '2026-10-20T01:00:00.000Z', source: 'persisted-control-and-agent-state', + consistency: 'read-committed-not-global-snapshot', health: 'unknown', + intent: { protocols: ['tcp'], path: ['node-1'] }, + operations: { truncated: false, items: [ + { status: 'succeeded', cleanupPending: false, recoveryInvalidated: false }, + { status: 'failed', cleanupPending: true, recoveryInvalidated: false }, + ] }, + deployment: null, + nodes: [ + { evidence: { revoked: false, sessionCurrent: true, snapshotCurrent: true, observation: { status: 'fresh' } } }, + { evidence: null }, + ], + listeners: { truncated: false, items: [ + { relation: 'current-intent', reportFresh: true, nodeEvidenceMatches: true, observation: { condition: 'ready' } }, + { relation: 'historical-intent', reportFresh: false, nodeEvidenceMatches: false, observation: { condition: 'error' } }, + ] }, + probe: { truncated: false, historyComplete: false, items: [{ result: { windowOpen: true } }, { result: null }] }, + cleanup: { complete: false, slots: { truncated: false, items: [{}] }, retirements: { truncated: false, items: [] }, leases: { truncated: false, items: [] } }, + }); + assert.equal(summary.operations.failed, 1); + assert.equal(summary.operations.cleanupPending, 1); + assert.equal(summary.listeners.current, 1); + assert.equal(summary.listeners.historical, 1); + assert.equal(summary.listeners.ready, 1); + assert.equal(summary.listeners.error, 1); + assert.equal(summary.probe.withResult, 1); + assert.equal(summary.probe.windowOpen, 1); + assert.equal(summary.nodes.fresh, 1); + assert.equal(summary.nodes.missing, 1); + assert.equal(summary.partial, true); + assert.equal(summary.stale, false, 'historical listener freshness is not current-intent staleness'); + assert.equal(summary.unknown, true); +}); diff --git a/docs/architecture/browser-native-leaf-inventory-v1.md b/docs/architecture/browser-native-leaf-inventory-v1.md index 676f79d3..3751caaf 100644 --- a/docs/architecture/browser-native-leaf-inventory-v1.md +++ b/docs/architecture/browser-native-leaf-inventory-v1.md @@ -79,6 +79,23 @@ connection-version state. Some leaves also track Scope explicitly; others rely o that original request binding. This document does not invent a uniform Scope, cloning, cancellation, command-key, return-shape or error policy across them. +## Shared Pure Helper + +`apps/web/forward-status.js` is an L2 pure projection helper, not another leaf. +`browser-native/control-console.js` imports it directly; the composition root +passes `summarizeDiagnosticRecord` into `initForwardDiagnostics` as an initializer +input so the diagnostics leaf retains no source import. The helper only validates +the fields it displays, derives bounded Operation/Listener/Probe counts and +unknown/partial flags, and performs no HTTP, DOM, storage, command or health +decision. + +`apps/web/forward-preflight.js` is a second L2 pure helper, consumed only by +`browser-native/control-console.js`. It derives per-node capability/credential/ +report-freshness reasons from the already loaded Node Policy. Platform, role and +flag dimensions remain `unknown`; the result is advisory and does not grant +execution. The service still validates authorization, target, port and credential +on submit. + ## Native Responsibilities And Actual Request Triggers | Leaf | Actual format/view/lifecycle responsibilities and bounded behavior | diff --git a/docs/architecture/foundation-boundaries-v1.json b/docs/architecture/foundation-boundaries-v1.json index ed055dcd..a6de2a59 100644 --- a/docs/architecture/foundation-boundaries-v1.json +++ b/docs/architecture/foundation-boundaries-v1.json @@ -242,7 +242,7 @@ "id": "L2", "a5ClassifiedNativeLeaves": ["agent/internal/platformclock/synchronization_values.go", "agent/internal/agentidentity/records.go", "agent/internal/nativeexecution/credential_wire.go"], "a5NativePublicEntries": ["platformclock.SyncStatus", "platformclock.SynchronizationFacts", "agentidentity.CredentialBundle", "agentidentity.RotationRequest", "agentidentity.RelayResponse", "agentidentity.RelayBundle", "agentidentity.CredentialBundleFacts", "agentidentity.CredentialBundleFromFacts", "agentidentity.RotationRequestFacts", "agentidentity.RotationRequestFromFacts", "agentidentity.RelayResponseFacts", "agentidentity.RelayResponseFromFacts", "agentidentity.RelayBundleFacts", "agentidentity.RelayBundleFromFacts"], - "scopes": ["apps/web/browser-native/public.js", "apps/web/browser-native/rule-console.js", "apps/web/probe.js", "apps/web/probe-runs.js", "apps/web/probe-plans.js", "apps/web/transport-console.js", "apps/web/tunnel-console.js", "apps/web/observation-services.js", "apps/web/path-templates.js", "apps/web/path-groups.js", "apps/web/path-publications.js", "apps/web/ingress-groups.js", "apps/web/ingress-migrations.js", "apps/web/forward-diagnostics.js", "apps/web/audit-console.js", "apps/web/station-alerts.js", "apps/web/notification-endpoints.js", "apps/web/notification-deliveries.js", "apps/web/telemetry-console.js", "apps/web/forward-telemetry-console.js", "apps/web/host-telemetry-console.js", "apps/web/probe-telemetry-console.js", "apps/web/telemetry-aggregate-console.js", "apps/web/browser-native/control-console.js", "apps/web/browser-native/relay-forms.js", "apps/web/browser-native/relay-console.js", "apps/control/src/compatibility/native-control-bootstrap-command.ts", "agent/internal/agentplatform/native.go", "packages/contracts/src/index.ts", "packages/contracts/src/shared.ts", "packages/contracts/src/runtime.ts", "packages/contracts/src/probe.ts", "packages/contracts/src/observability.ts", "packages/contracts/src/experimental-transit.ts", "packages/contracts/src/legacy-adp2.ts", "packages/contracts/src/staged-probe-echo.ts", "packages/contracts/src/types.ts", "packages/contracts/src/validate.ts", "packages/contracts/src/json.ts", "packages/contracts/src/digest.ts", "packages/contracts/src/time.ts", "packages/contracts/src/adp3.ts", "packages/contracts/src/relay.ts", "packages/contracts/src/multihop.ts", "packages/contracts/src/native-drivers.ts", "packages/contracts/src/transit.ts", "packages/contracts/src/adp3-transit.ts", "packages/contracts/src/transit-negotiation.ts", "packages/contracts/src/transit-delivery.ts", "packages/contracts/src/probe-negotiation.ts", "packages/contracts/src/probe-instance.ts", "packages/contracts/src/probe-claim.ts", "packages/contracts/src/probe-delivery.ts", "packages/contracts/src/probe-result.ts", "packages/contracts/src/probe-types.ts", "packages/contracts/src/probe-services.ts", "packages/contracts/src/probe-echo.ts", "packages/contracts/src/telemetry.ts", "packages/contracts/src/host-telemetry.ts", "packages/contracts/src/forward-telemetry.ts", "packages/contracts/src/probe-telemetry.ts", "packages/contracts/data/native-drivers-v1.json", "apps/control/src/infrastructure/mysql-control-bootstrap.ts", "apps/control/src/infrastructure/authority.ts", "apps/control/src/infrastructure/mysql-telemetry-recovery-store.ts", "apps/control/src/infrastructure/mysql-station-alert-recovery-store.ts", "apps/control/src/infrastructure/mysql-notification-recovery-store.ts", "apps/control/src/infrastructure/mysql-path-publication-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-migration-recovery-store.ts", "apps/control/src/infrastructure/mysql-recovery-event-store.ts", + "scopes": ["apps/web/browser-native/public.js", "apps/web/forward-status.js", "apps/web/forward-preflight.js", "apps/web/browser-native/rule-console.js", "apps/web/probe.js", "apps/web/probe-runs.js", "apps/web/probe-plans.js", "apps/web/transport-console.js", "apps/web/tunnel-console.js", "apps/web/observation-services.js", "apps/web/path-templates.js", "apps/web/path-groups.js", "apps/web/path-publications.js", "apps/web/ingress-groups.js", "apps/web/ingress-migrations.js", "apps/web/forward-diagnostics.js", "apps/web/audit-console.js", "apps/web/station-alerts.js", "apps/web/notification-endpoints.js", "apps/web/notification-deliveries.js", "apps/web/telemetry-console.js", "apps/web/forward-telemetry-console.js", "apps/web/host-telemetry-console.js", "apps/web/probe-telemetry-console.js", "apps/web/telemetry-aggregate-console.js", "apps/web/browser-native/control-console.js", "apps/web/browser-native/relay-forms.js", "apps/web/browser-native/relay-console.js", "apps/control/src/compatibility/native-control-bootstrap-command.ts", "agent/internal/agentplatform/native.go", "packages/contracts/src/index.ts", "packages/contracts/src/shared.ts", "packages/contracts/src/runtime.ts", "packages/contracts/src/probe.ts", "packages/contracts/src/observability.ts", "packages/contracts/src/experimental-transit.ts", "packages/contracts/src/legacy-adp2.ts", "packages/contracts/src/staged-probe-echo.ts", "packages/contracts/src/types.ts", "packages/contracts/src/validate.ts", "packages/contracts/src/json.ts", "packages/contracts/src/digest.ts", "packages/contracts/src/time.ts", "packages/contracts/src/adp3.ts", "packages/contracts/src/relay.ts", "packages/contracts/src/multihop.ts", "packages/contracts/src/native-drivers.ts", "packages/contracts/src/transit.ts", "packages/contracts/src/adp3-transit.ts", "packages/contracts/src/transit-negotiation.ts", "packages/contracts/src/transit-delivery.ts", "packages/contracts/src/probe-negotiation.ts", "packages/contracts/src/probe-instance.ts", "packages/contracts/src/probe-claim.ts", "packages/contracts/src/probe-delivery.ts", "packages/contracts/src/probe-result.ts", "packages/contracts/src/probe-types.ts", "packages/contracts/src/probe-services.ts", "packages/contracts/src/probe-echo.ts", "packages/contracts/src/telemetry.ts", "packages/contracts/src/host-telemetry.ts", "packages/contracts/src/forward-telemetry.ts", "packages/contracts/src/probe-telemetry.ts", "packages/contracts/data/native-drivers-v1.json", "apps/control/src/infrastructure/mysql-control-bootstrap.ts", "apps/control/src/infrastructure/authority.ts", "apps/control/src/infrastructure/mysql-telemetry-recovery-store.ts", "apps/control/src/infrastructure/mysql-station-alert-recovery-store.ts", "apps/control/src/infrastructure/mysql-notification-recovery-store.ts", "apps/control/src/infrastructure/mysql-path-publication-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-migration-recovery-store.ts", "apps/control/src/infrastructure/mysql-recovery-event-store.ts", "apps/control/src/infrastructure/multihop-release.ts", "apps/control/src/infrastructure/mysql-migration-statements.ts", "agent/internal/transitdelivery/validate.go", "agent/internal/transitdelivery/schema.json", "agent/internal/transitnegotiation/validate.go", "agent/internal/transitnegotiation/schema.json", @@ -252,7 +252,7 @@ "agent/internal/probenegotiation/validate.go", "agent/internal/probenegotiation/types_generated.go", "agent/internal/probenegotiation/schema.json", "agent/internal/probeinstance/validate.go", "agent/internal/probeinstance/types_generated.go", "agent/internal/probeinstance/schema.json", "agent/internal/probeclaim/validate.go", "agent/internal/probeclaim/types_generated.go", "agent/internal/probeclaim/schema.json", "agent/internal/probedelivery/validate.go", "agent/internal/probedelivery/types_generated.go", "agent/internal/probedelivery/schema.json", "agent/internal/proberesult/validate.go", "agent/internal/proberesult/types_generated.go", "agent/internal/proberesult/schema.json", "agent/internal/probetypes/validate.go", "agent/internal/probetypes/types.go", "agent/internal/probetypes/schema.json", "agent/internal/probeservices/validate.go", "agent/internal/probeservices/types.go", "agent/internal/probeservices/schema.json", "agent/internal/probeecho/validate.go", "agent/internal/probeecho/types.go", "agent/internal/probeecho/frame.go", "agent/internal/probeecho/schema.json", "agent/internal/observationresponder/config.go", "agent/internal/observationresponder/server.go", "agent/internal/observationresponder/tls.go", "agent/internal/observationresponder/wire_budget.go", "agent/internal/observationresponder/key_permissions_linux.go", "agent/internal/observationresponder/key_permissions_other.go", "agent/cmd/time-proof/main.go", "agent/cmd/adp-validate/main.go", "agent/cmd/tunex-observation-responder/main.go", "apps/control/src/infrastructure/mysql.ts", "apps/control/src/infrastructure/mysql-runtime-status-store.ts", "agent/internal/forward/tcp.go", "agent/internal/forward/udp.go", "agent/internal/forward/authorization.go", "agent/internal/forward/budget.go", "agent/internal/forward/payload.go", "agent/internal/forward/probe_ingress.go", "agent/internal/relay/engine.go", "agent/internal/relay/multihop.go", "agent/internal/relay/native_configuration.go", "agent/internal/relay/tls.go", "agent/internal/relay/wire.go", "agent/internal/relay/udp_engine.go", "agent/internal/relay/frame_connection.go", "agent/internal/payloadstats/counters.go", "agent/internal/payloadstats/snapshot.go", "agent/internal/agentcli", "apps/control/src/cli.ts", "agent/internal/observationwire", "agent/internal/transit/engine.go", "agent/internal/transit/tls.go", "agent/internal/transit/wire.go", "agent/internal/adp3transit/validate.go", "agent/internal/adp3transit/schema.json", "agent/internal/transitcontracts/validate.go", "agent/internal/transitcontracts/schema.json", "apps/control/src/infrastructure/mysql-node-observation.ts", "agent/internal/probe/native", "apps/control/src/compatibility", "apps/control/src/infrastructure/notification-secrets.ts", "apps/control/src/infrastructure/notification-https.ts", "apps/control/src/infrastructure/mysql-transit-staging-store.ts", "apps/control/src/infrastructure/mysql-transit-coordination-store.ts", "apps/control/src/infrastructure/mysql-transit-delivery-store.ts", "apps/control/src/infrastructure/mysql-transit-machine.ts", "apps/control/src/infrastructure/mysql-ingress-migration-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-store.ts", "apps/control/src/infrastructure/mysql-ingress-group-store.ts", "apps/control/src/infrastructure/mysql-publication-store.ts", "apps/control/src/infrastructure/mysql-path-group-store.ts", "apps/control/src/infrastructure/mysql-path-binding-store.ts", "apps/control/src/infrastructure/mysql-path-template-store.ts", "apps/control/src/infrastructure/mysql-probe-permit-store.ts", "apps/control/src/infrastructure/mysql-probe-machine.ts", "apps/control/src/infrastructure/mysql-probe-read.ts", "apps/control/src/infrastructure/mysql-probe-permit-intents.ts", "apps/control/src/infrastructure/mysql-probe-instance-store.ts", "apps/control/src/infrastructure/mysql-probe-qualification-store.ts", "apps/control/src/infrastructure/mysql-runtime-host.ts", "apps/control/src/infrastructure/mysql-runtime-resources.ts", "apps/control/src/infrastructure/mysql-runtime-ledger.ts", "apps/control/src/infrastructure/mysql-runtime-relay.ts", "apps/control/src/infrastructure/mysql-node-authority.ts", "apps/control/src/infrastructure/mysql-identity.ts", "apps/control/src/infrastructure/mysql-relay-configuration.ts", "apps/control/src/infrastructure/mysql-intent-store.ts", "apps/control/src/infrastructure/mysql-management-read.ts", "apps/control/src/infrastructure/mysql-read-pagination.ts", "apps/control/src/infrastructure/identity-secrets.ts", "apps/control/src/infrastructure/pki.ts", "agent/internal/platformobservation", "agent/internal/platformclock", "agent/internal/platformstate", "agent/internal/authorization", "agent/internal/state", "agent/internal/agentidentity", "agent/internal/controlchannel", "agent/internal/platformruntime", "agent/internal/executiondriver", "agent/internal/nativeexecution", "agent/internal/agentenrollment", "agent/internal/observationtrust", "agent/internal/egress", "apps/control/src/infrastructure/mysql-notification-endpoint-store.ts", "apps/control/src/infrastructure/mysql-notification-outbox-store.ts", "apps/control/src/infrastructure/mysql-probe-history-store.ts", "apps/control/src/infrastructure/mysql-probe-plan-store.ts", "apps/control/src/infrastructure/mysql-observation-service-store.ts", "apps/control/src/infrastructure/mysql-audit-read-store.ts", "apps/control/src/infrastructure/mysql-forward-diagnostic-read-store.ts", "apps/control/src/infrastructure/mysql-station-alert-storage.ts", "apps/control/src/infrastructure/mysql-station-alert-fact-store.ts", "apps/control/src/infrastructure/station-alert-storage-native.ts", "apps/control/src/infrastructure/mysql-telemetry.ts", "apps/control/src/infrastructure/mysql-host-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry.ts", "apps/control/src/infrastructure/mysql-probe-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-probe-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-telemetry-rollup-storage.ts"], - "public": ["apps/web/browser-native/public.js", "apps/web/probe.js", "apps/web/probe-runs.js", "apps/web/probe-plans.js", "apps/web/transport-console.js", "apps/web/tunnel-console.js", "apps/web/observation-services.js", "apps/web/path-templates.js", "apps/web/path-groups.js", "apps/web/path-publications.js", "apps/web/ingress-groups.js", "apps/web/ingress-migrations.js", "apps/web/forward-diagnostics.js", "apps/web/audit-console.js", "apps/web/station-alerts.js", "apps/web/notification-endpoints.js", "apps/web/notification-deliveries.js", "apps/web/telemetry-console.js", "apps/web/forward-telemetry-console.js", "apps/web/host-telemetry-console.js", "apps/web/probe-telemetry-console.js", "apps/web/telemetry-aggregate-console.js", "github.com/tunex/vnext/agent/internal/agentplatform", "@tunex/contracts", "@tunex/contracts/shared", "@tunex/contracts/runtime", "@tunex/contracts/probe", "@tunex/contracts/observability", "@tunex/contracts/experimental-transit", "@tunex/contracts/legacy-adp2", "@tunex/contracts/staged-probe-echo", "apps/control/src/infrastructure/mysql-control-bootstrap.ts", "apps/control/src/infrastructure/authority.ts", "apps/control/src/infrastructure/mysql-telemetry-recovery-store.ts", "apps/control/src/infrastructure/mysql-station-alert-recovery-store.ts", "apps/control/src/infrastructure/mysql-notification-recovery-store.ts", "apps/control/src/infrastructure/mysql-path-publication-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-migration-recovery-store.ts", "apps/control/src/infrastructure/mysql-recovery-event-store.ts", "github.com/tunex/vnext/agent/internal/contracts", "github.com/tunex/vnext/agent/internal/adp3", "github.com/tunex/vnext/agent/internal/adp3execution", "github.com/tunex/vnext/agent/internal/adp3relay", "github.com/tunex/vnext/agent/internal/relaycontracts", "github.com/tunex/vnext/agent/internal/probenegotiation", "github.com/tunex/vnext/agent/internal/probeinstance", "github.com/tunex/vnext/agent/internal/probeclaim", "github.com/tunex/vnext/agent/internal/probedelivery", "github.com/tunex/vnext/agent/internal/proberesult", "github.com/tunex/vnext/agent/internal/probetypes", "github.com/tunex/vnext/agent/internal/probeservices", "github.com/tunex/vnext/agent/internal/probeecho", "github.com/tunex/vnext/agent/internal/observationresponder", "apps/control/src/infrastructure/mysql.ts", "apps/control/src/infrastructure/mysql-runtime-status-store.ts", "github.com/tunex/vnext/agent/internal/forward", "github.com/tunex/vnext/agent/internal/relay", "github.com/tunex/vnext/agent/internal/payloadstats", "github.com/tunex/vnext/agent/internal/agentcli", "github.com/tunex/vnext/agent/internal/observationwire", "github.com/tunex/vnext/agent/internal/adp3transit", "github.com/tunex/vnext/agent/internal/transitcontracts", "apps/control/src/compatibility/control-http/public.ts", "apps/control/src/infrastructure/mysql-node-observation.ts", "github.com/tunex/vnext/agent/internal/probe/native", "apps/control/src/compatibility/public.ts", "apps/control/src/infrastructure/notification-secrets.ts", "apps/control/src/infrastructure/notification-https.ts", "apps/control/src/infrastructure/mysql-transit-staging-store.ts", "apps/control/src/infrastructure/mysql-transit-coordination-store.ts", "apps/control/src/infrastructure/mysql-transit-delivery-store.ts", "apps/control/src/infrastructure/mysql-transit-machine.ts", "apps/control/src/infrastructure/mysql-ingress-migration-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-store.ts", "apps/control/src/infrastructure/mysql-ingress-group-store.ts", "apps/control/src/infrastructure/mysql-publication-store.ts", "apps/control/src/infrastructure/mysql-path-group-store.ts", "apps/control/src/infrastructure/mysql-path-binding-store.ts", "apps/control/src/infrastructure/mysql-path-template-store.ts", "apps/control/src/infrastructure/mysql-probe-permit-store.ts", "apps/control/src/infrastructure/mysql-probe-machine.ts", "apps/control/src/infrastructure/mysql-probe-read.ts", "apps/control/src/infrastructure/mysql-probe-permit-intents.ts", "apps/control/src/infrastructure/mysql-probe-instance-store.ts", "apps/control/src/infrastructure/mysql-probe-qualification-store.ts", "apps/control/src/infrastructure/mysql-runtime-resources.ts", "apps/control/src/infrastructure/mysql-runtime-ledger.ts", "apps/control/src/infrastructure/mysql-runtime-relay.ts", "apps/control/src/infrastructure/mysql-node-authority.ts", "apps/control/src/infrastructure/mysql-identity.ts", "apps/control/src/infrastructure/mysql-relay-configuration.ts", "apps/control/src/infrastructure/mysql-intent-store.ts", "apps/control/src/infrastructure/mysql-management-read.ts", "apps/control/src/infrastructure/mysql-read-pagination.ts", "apps/control/src/infrastructure/identity-secrets.ts", "apps/control/src/infrastructure/pki.ts", "github.com/tunex/vnext/agent/internal/platformobservation", "github.com/tunex/vnext/agent/internal/platformclock", "github.com/tunex/vnext/agent/internal/platformstate", "github.com/tunex/vnext/agent/internal/authorization", "github.com/tunex/vnext/agent/internal/state", "github.com/tunex/vnext/agent/internal/agentidentity", "github.com/tunex/vnext/agent/internal/controlchannel", "github.com/tunex/vnext/agent/internal/platformruntime", "github.com/tunex/vnext/agent/internal/executiondriver", "github.com/tunex/vnext/agent/internal/nativeexecution", "github.com/tunex/vnext/agent/internal/agentenrollment", "github.com/tunex/vnext/agent/internal/observationtrust", "github.com/tunex/vnext/agent/internal/egress", "apps/control/src/infrastructure/mysql-notification-endpoint-store.ts", "apps/control/src/infrastructure/mysql-notification-outbox-store.ts", "apps/control/src/infrastructure/mysql-probe-history-store.ts", "apps/control/src/infrastructure/mysql-probe-plan-store.ts", "apps/control/src/infrastructure/mysql-observation-service-store.ts", "apps/control/src/infrastructure/mysql-audit-read-store.ts", "apps/control/src/infrastructure/mysql-forward-diagnostic-read-store.ts", "apps/control/src/infrastructure/mysql-station-alert-storage.ts", "apps/control/src/infrastructure/mysql-station-alert-fact-store.ts", "apps/control/src/infrastructure/mysql-telemetry.ts", "apps/control/src/infrastructure/mysql-host-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry.ts", "apps/control/src/infrastructure/mysql-probe-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-probe-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-telemetry-rollup-storage.ts"], + "public": ["apps/web/browser-native/public.js", "apps/web/forward-status.js", "apps/web/forward-preflight.js", "apps/web/probe.js", "apps/web/probe-runs.js", "apps/web/probe-plans.js", "apps/web/transport-console.js", "apps/web/tunnel-console.js", "apps/web/observation-services.js", "apps/web/path-templates.js", "apps/web/path-groups.js", "apps/web/path-publications.js", "apps/web/ingress-groups.js", "apps/web/ingress-migrations.js", "apps/web/forward-diagnostics.js", "apps/web/audit-console.js", "apps/web/station-alerts.js", "apps/web/notification-endpoints.js", "apps/web/notification-deliveries.js", "apps/web/telemetry-console.js", "apps/web/forward-telemetry-console.js", "apps/web/host-telemetry-console.js", "apps/web/probe-telemetry-console.js", "apps/web/telemetry-aggregate-console.js", "github.com/tunex/vnext/agent/internal/agentplatform", "@tunex/contracts", "@tunex/contracts/shared", "@tunex/contracts/runtime", "@tunex/contracts/probe", "@tunex/contracts/observability", "@tunex/contracts/experimental-transit", "@tunex/contracts/legacy-adp2", "@tunex/contracts/staged-probe-echo", "apps/control/src/infrastructure/mysql-control-bootstrap.ts", "apps/control/src/infrastructure/authority.ts", "apps/control/src/infrastructure/mysql-telemetry-recovery-store.ts", "apps/control/src/infrastructure/mysql-station-alert-recovery-store.ts", "apps/control/src/infrastructure/mysql-notification-recovery-store.ts", "apps/control/src/infrastructure/mysql-path-publication-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-recovery-store.ts", "apps/control/src/infrastructure/mysql-ingress-migration-recovery-store.ts", "apps/control/src/infrastructure/mysql-recovery-event-store.ts", "github.com/tunex/vnext/agent/internal/contracts", "github.com/tunex/vnext/agent/internal/adp3", "github.com/tunex/vnext/agent/internal/adp3execution", "github.com/tunex/vnext/agent/internal/adp3relay", "github.com/tunex/vnext/agent/internal/relaycontracts", "github.com/tunex/vnext/agent/internal/probenegotiation", "github.com/tunex/vnext/agent/internal/probeinstance", "github.com/tunex/vnext/agent/internal/probeclaim", "github.com/tunex/vnext/agent/internal/probedelivery", "github.com/tunex/vnext/agent/internal/proberesult", "github.com/tunex/vnext/agent/internal/probetypes", "github.com/tunex/vnext/agent/internal/probeservices", "github.com/tunex/vnext/agent/internal/probeecho", "github.com/tunex/vnext/agent/internal/observationresponder", "apps/control/src/infrastructure/mysql.ts", "apps/control/src/infrastructure/mysql-runtime-status-store.ts", "github.com/tunex/vnext/agent/internal/forward", "github.com/tunex/vnext/agent/internal/relay", "github.com/tunex/vnext/agent/internal/payloadstats", "github.com/tunex/vnext/agent/internal/agentcli", "github.com/tunex/vnext/agent/internal/observationwire", "github.com/tunex/vnext/agent/internal/adp3transit", "github.com/tunex/vnext/agent/internal/transitcontracts", "apps/control/src/compatibility/control-http/public.ts", "apps/control/src/infrastructure/mysql-node-observation.ts", "github.com/tunex/vnext/agent/internal/probe/native", "apps/control/src/compatibility/public.ts", "apps/control/src/infrastructure/notification-secrets.ts", "apps/control/src/infrastructure/notification-https.ts", "apps/control/src/infrastructure/mysql-transit-staging-store.ts", "apps/control/src/infrastructure/mysql-transit-coordination-store.ts", "apps/control/src/infrastructure/mysql-transit-delivery-store.ts", "apps/control/src/infrastructure/mysql-transit-machine.ts", "apps/control/src/infrastructure/mysql-ingress-migration-store.ts", "apps/control/src/infrastructure/mysql-ingress-deployment-store.ts", "apps/control/src/infrastructure/mysql-ingress-group-store.ts", "apps/control/src/infrastructure/mysql-publication-store.ts", "apps/control/src/infrastructure/mysql-path-group-store.ts", "apps/control/src/infrastructure/mysql-path-binding-store.ts", "apps/control/src/infrastructure/mysql-path-template-store.ts", "apps/control/src/infrastructure/mysql-probe-permit-store.ts", "apps/control/src/infrastructure/mysql-probe-machine.ts", "apps/control/src/infrastructure/mysql-probe-read.ts", "apps/control/src/infrastructure/mysql-probe-permit-intents.ts", "apps/control/src/infrastructure/mysql-probe-instance-store.ts", "apps/control/src/infrastructure/mysql-probe-qualification-store.ts", "apps/control/src/infrastructure/mysql-runtime-resources.ts", "apps/control/src/infrastructure/mysql-runtime-ledger.ts", "apps/control/src/infrastructure/mysql-runtime-relay.ts", "apps/control/src/infrastructure/mysql-node-authority.ts", "apps/control/src/infrastructure/mysql-identity.ts", "apps/control/src/infrastructure/mysql-relay-configuration.ts", "apps/control/src/infrastructure/mysql-intent-store.ts", "apps/control/src/infrastructure/mysql-management-read.ts", "apps/control/src/infrastructure/mysql-read-pagination.ts", "apps/control/src/infrastructure/identity-secrets.ts", "apps/control/src/infrastructure/pki.ts", "github.com/tunex/vnext/agent/internal/platformobservation", "github.com/tunex/vnext/agent/internal/platformclock", "github.com/tunex/vnext/agent/internal/platformstate", "github.com/tunex/vnext/agent/internal/authorization", "github.com/tunex/vnext/agent/internal/state", "github.com/tunex/vnext/agent/internal/agentidentity", "github.com/tunex/vnext/agent/internal/controlchannel", "github.com/tunex/vnext/agent/internal/platformruntime", "github.com/tunex/vnext/agent/internal/executiondriver", "github.com/tunex/vnext/agent/internal/nativeexecution", "github.com/tunex/vnext/agent/internal/agentenrollment", "github.com/tunex/vnext/agent/internal/observationtrust", "github.com/tunex/vnext/agent/internal/egress", "apps/control/src/infrastructure/mysql-notification-endpoint-store.ts", "apps/control/src/infrastructure/mysql-notification-outbox-store.ts", "apps/control/src/infrastructure/mysql-probe-history-store.ts", "apps/control/src/infrastructure/mysql-probe-plan-store.ts", "apps/control/src/infrastructure/mysql-observation-service-store.ts", "apps/control/src/infrastructure/mysql-audit-read-store.ts", "apps/control/src/infrastructure/mysql-forward-diagnostic-read-store.ts", "apps/control/src/infrastructure/mysql-station-alert-storage.ts", "apps/control/src/infrastructure/mysql-station-alert-fact-store.ts", "apps/control/src/infrastructure/mysql-telemetry.ts", "apps/control/src/infrastructure/mysql-host-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry.ts", "apps/control/src/infrastructure/mysql-probe-telemetry.ts", "apps/control/src/infrastructure/mysql-forward-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-probe-telemetry-mapping.ts", "apps/control/src/infrastructure/mysql-telemetry-rollup-storage.ts"], "providedPorts": ["RuntimeStatusStorageReader", "TelemetryRecoveryStore", "StationAlertRecoveryStore", "NotificationRecoveryStore", "PathPublicationRecoveryStore", "IngressDeploymentRecoveryStore", "IngressMigrationRecoveryStore", "RecoveryEventStorage", "AuthorizedNodeReportReader", "NotificationSecrets", "NotificationSender", "TransitStagingStorage", "TransitCoordinationStorage", "TransitDeliveryStorage", "MachineSessionLockReader", "IngressMigrationStorage", "IngressDeploymentStorage", "IngressGroupStorage", "PathPublicationStorage", "PathGroupStorage", "PathGroupStorageReader", "PathGroupBindingStorage", "PathGroupBindingStorageReader", "PathBindingStorage", "PathBindingStorageReader", "PathBindingSequenceReader", "PathTemplateStorage", "PathTemplateStorageReader", "ProbePermitStorage", "MachineSessionReader", "PublishedRuntimeReader", "RuntimeForwards", "ProbeInstanceStorage", "ProbeQualificationStorage", "RuntimeCodec", "RuntimeStateStore", "RuntimeExclusiveReservationStore", "RuntimeReservationReleaseStore", "RuntimeResourceStore", "RuntimeLedgerStore", "RuntimeRelayStore", "RuntimeRelayObservations", "RuntimeLedgerCompiler", "RuntimeRelayCompiler", "NodeAuthorityStore", "NodeMachineLookup", "NodeCertificateSigner", "NodeSecrets", "NodeAuthorityCodec", "IdentityAccessStore", "IdentityAccountLookup", "IdentityValueCodec", "TransportProfileStore", "RelayEndpointStore", "RelayConfigurationCodec", "ForwardIntentStore", "IdempotencyStore", "StoredValues", "ManagementReadStore", "ProfileReferenceReadStore", "TunnelMetadataReadStore", "ReadProjectionValues", "ForwardImportAdapter", "executiontime.SynchronizationSources", "executionstate.RuntimeStore", "executionstate.SnapshotInput", "executionstate.SnapshotCodec", "executionidentity.Provider", "executionchannel.Provider", "executionchannel.Auxiliary", "runtimeplatform.Bootstrap", "runtimeplatform.Scheduler", "runtimeplatform.Entropy", "runtimeplatform.MeasurementFiles", "runtimeplatform.LocalLog", "runtimeobservation.Provider", "executionhost.PreparedInstance", "executionhost.Driver", "executionenrollment.Platform", "executionenrollment.Channel", "runtimeplatform.Dialer", "NotificationEndpointStorage", "NotificationOutboxStorage", "ProbeHistoryStorage", "ProbePlanStorage", "ObservationMetadataStorage", "AuditStorage", "ForwardDiagnosticStorage", "StationAlertStorage", "StationFactsStorage", "TelemetryStorage", "TelemetryMappedStorage", "TelemetryPublishedMappingPort", "TelemetryObservedMappingPort", "TelemetryRollupStorage"], "dependsOn": ["L1", "L0"], "forbid": ["product-command-construction", "new-authority", "unknown-protocol-fallback"] diff --git a/docs/development/load-baseline-2026-10-20.md b/docs/development/load-baseline-2026-10-20.md new file mode 100644 index 00000000..827913c1 --- /dev/null +++ b/docs/development/load-baseline-2026-10-20.md @@ -0,0 +1,37 @@ +# 负载基线评估记录(D1) + +日期:2026-10-20(Asia/Shanghai)。 +状态:**planned, not executed**。本轮没有取得获授权的负载、长稳或资源实验证据。 + +## 目的与边界 + +D1 的目标是先取得观察池、周期任务和页面请求的负载基线,再决定是否需要按 key +合并、有界唤醒或慢消费者处理。没有基线时,不新增事件通道、调度平台或常驻 +工作队列。周期对账和原事务/授权 owner 仍是正确性来源;通知不能续租、进程锁 +不能替代跨实例权威。 + +## 待测量项 + +| 类别 | 需要的证据 | +| --- | --- | +| 观察池 | 节点/Forward/Probe 规模、活跃订阅数、每类请求频率、页面可见性变化 | +| 周期任务 | 任务数、执行时长分布、重叠/跳过次数、失败退避、drain 时间 | +| 页面请求 | 列表/详情/诊断/telemetry 的并发数、响应时间、响应字节、取消/超时 | +| 有界资源 | 活跃任务数、等待 key 数、等待字节、过期/取消/合并/公平性、关闭排空 | +| 稳定性 | 饱和、丢通知、重连、事务失败、清理和内存上界的持续时间与故障注入 | + +## 决策规则 + +1. 如果基线没有明确瓶颈,记录“无需新增调度机制”的结论即可,不为 D2 强建平台。 +2. 如果确有瓶颈,再评估按 key 合并、有界唤醒和慢消费者处理;必须分别定义 + 待处理 key 数、字节数、活跃数、过期、取消、合并、关闭和溢出行为。 +3. 事件只能加速唤醒,不能替代周期对账、撤销、执行期限或授权。 +4. 任何实现都不得把同机 namespace、短时压测或历史绿灯写成独立物理主机、 + 长稳或生产容量证明。 + +## 所需授权与证据绑定 + +执行前需要明确获授权的负载实验、准确源码修订、环境/拓扑、持续时间、故障 +场景和证据保存位置。功能/负载实验按项目约定在 GitHub Actions 或独立获授权 +环境执行;本轮只有源码与计划,没有执行结果,因此 **D1 未完成**,也不能据此 +宣布无需优化或已经优化。 diff --git a/docs/development/next-milestones.md b/docs/development/next-milestones.md index 34c72733..5c69f145 100644 --- a/docs/development/next-milestones.md +++ b/docs/development/next-milestones.md @@ -6,15 +6,19 @@ | 顺序 | 工作 | 退出条件 | | --- | --- | --- | | 已完成 | 地基 B 限定范围接受 | 220 项断言及适用执行与准确冻结修订对齐,公共契约/示例可用;PR #112 已合并为 `5a764f7` | -| C1 | 状态解释 | 列表/详情/批次区分 Accepted、Applied、listener ready、Probe、历史/缺测、清理;通过权限与迟到响应验收 | -| C2 | 能力预检 | 展示逐节点平台/角色/flag/时效及拒绝原因;预检后变化不破坏当前实例 | -| C3 | 指标解释 | 明确来源、起点、窗口、coverage/gap/stale、精确 uint64 与重置/重放/拓扑身份 | -| C4 | 运维开发闭环 | 原诊断、支持包、评估/维护/备份/恢复/review 接线完成,数据库故障和保护状态拒绝边界不变 | -| D1 | 负载评估 | 先测基线,确有瓶颈才设计有界调度;允许以无需新机制的结论结束 | -| D2 | 固定拓扑/人工切换 | 固定引用、CAS、部分失败、撤权、补偿、重放及租约边界验收;独立记录物理主机 | +| C1(Actions 通过) | 状态解释 | 列表/详情/批次区分 Accepted、Applied、listener ready、Probe、历史/缺测、清理;通过权限与迟到响应验收 | +| C2(Actions 通过) | 能力预检 | 展示逐节点平台/角色/flag/时效及拒绝原因;预检后变化不破坏当前实例 | +| C3(Actions 通过) | 指标解释 | 明确来源、起点、窗口、coverage/gap/stale、精确 uint64 与重置/重放/拓扑身份 | +| C4(Actions 通过) | 运维开发闭环 | 原诊断、支持包、评估/维护/备份/恢复/review 接线完成,数据库故障和保护状态拒绝边界不变 | +| D1(未执行) | 负载评估 | 先测基线,确有瓶颈才设计有界调度;允许以无需新机制的结论结束 | +| D2(Actions 通过) | 固定拓扑/人工切换 | 固定引用、CAS、部分失败、撤权、补偿、重放及租约边界验收;独立记录物理主机 | | 另立项 | 自动策略/外部兼容 | 明确目标、版本、授权与环境,按公共接口与 FR 治理实施 | -C–E 尚未按这些结果包完成;下一包为 C1,本轮没有开始。B 的接口进入门禁仅对 +C–E 尚未按这些结果包完成跨环境验收。2026-10-20 已写入 C1–C4/D2 的只读 Web 源码 +切片,执行本地单测、类型、边界、契约、Go 构建和 UDP smoke(均通过),并提交为分支 +`verify/c1-d2-20261020`(修订 `8172791d`)通过配置内 Actions/容器验收(11 作业全绿, +草稿 PR #113 未合并)。独立物理主机和长稳/负载实验未执行。D1 只记录了 +[负载基线评估方案](load-baseline-2026-10-20.md),未执行测量。B 的接口进入门禁仅对 验证记录所列冻结修订/范围开放;后续任务授权、模块契约和集成仍需分别满足。 可按依赖先补最小公共 DTO,不能同时扩展协议、执行授权和自动策略。 diff --git a/docs/development/progress.md b/docs/development/progress.md index 0315e5e6..58a0d48f 100644 --- a/docs/development/progress.md +++ b/docs/development/progress.md @@ -30,13 +30,91 @@ Probe/Relay Chromium 和 3/4/8 节点 namespace 故障恢复,以及容器构 逐项结果、条件跳过与实际运行号以验证记录为准,不能拼接旧修订绿灯。 本地仅执行获授权的静态检查,未运行功能、MySQL、网络或浏览器实验。 +## 2026-10-20 C1–D2 源码批次 + +当前未提交工作树已写入 C1–C4/D2 的只读 Web 切片: + +- C1 状态解释:Forward 列表增加运行状态摘要;分层诊断增加 Operation/Listener/ + Probe/清理计数与 partial/stale/unknown 说明。摘要来自既有 `GET forwards/:id` + 与诊断响应,不新增权威或健康结论。列表当前按可见行逐条读取(最多 25 条), + 未新增批量契约;D1 需测量该读取模式,若成为瓶颈再加批量读模型。 +- C2 能力预检:转发表单按已加载 Node Policy 展示逐节点可选/不可选/未知原因; + 平台、角色、flag 未单独上报时保持 unknown,提交仍由服务端独立校验。 +- C3 指标解释:四族 telemetry 与聚合面板增加来源、delta、coverage、freshness + 和缺测语义说明;不新增采集、聚合或健康判断。 +- C4 运维闭环:概览增加当前页失败 Operation、报告过期/撤销节点、审计/告警/ + 通知/执行记录入口与数据库失败不补零说明。 +- D2 固定拓扑入口:概览增加路径模板、候选组/手动切换、持久发布、入口迁移的 + 统一导航;不改变 Path/Ingress 命令、CAS、补偿或 Operation 语义。 + +D1 仅记录[负载基线评估方案](load-baseline-2026-10-20.md),未执行负载、长稳或 +资源实验。本批行为参考 ForwardX `0f8d096`(2.3.286,AGPL-3.0-only)的状态计数、 +预检和指标语义,但为 TuneX L2 独立实现,未复制源码;临时参考克隆已清理。 +本轮源码完成后,已按 2026-10-20 验收授权执行本地单测、类型、边界、契约、Go +构建/UDP smoke(结果见下),并提交为分支 `verify/c1-d2-20261020` 通过配置内 +Actions/容器验收(见下节)。独立物理主机和长稳/负载实验未执行。D1 仍未执行; +不得据此宣称正式发行或完整 D2/跨环境验收。 + +计划验收覆盖(完整验收仍需相应环境执行):C1 覆盖 Direct ready/partial/unknown、过期/缺失报告、 +多节点计数、truncated、迟到响应不覆盖新结果;C2 覆盖撤销、凭据过期、报告过期、 +缺能力、未知 Policy 及服务端拒绝;C3 覆盖 baseline/gap/counter-reset/historical/ +unavailable/overflowed/terminal、coverage 截断与缺测不补零;C4 覆盖失败 Operation、 +过期节点、权限裁剪、数据库/请求失败和只读导航;D2 只验证现有 Path/Ingress 入口、 +CAS/补偿/Operation 语义未变及人工切换导航。 + +## 2026-10-20 C1–D2 本地验收 + +测试树为 `84d1a35` 加本批未提交变更(dirty tree,无独立提交)。结果: + +| 命令 | 结果 | +| --- | --- | +| `npm run test:web` | 166/166 通过,0 失败 | +| `npm test` | 1936 通过,153 跳过,0 失败 | +| `npm run typecheck` | 通过 | +| `npm run test:boundaries` | 952 个源文件检查通过;18/18 测试通过 | +| `npm run contracts:check` | 通过 | +| `npm run test:go` | 全包通过 | +| `npm run build:go` | 通过 | +| `npm run test:udp-smoke` | 2/2 通过 | +| `node scripts/verify-offline-plan.mjs` | 通过 | + +新增测试合计 13/13 通过(`forward-status.test.mjs` 7 项、`forward-preflight.test.mjs` +6 项);`console-module-assets` 验证新增 `forward-status.js`/`forward-preflight.js` +的固定路由、安全头和字节一致性。`npm test` 的 153 个跳过仍是数据库条件项,不是数据库 +通过;本地未执行 MySQL/mTLS、浏览器、Actions、容器、独立物理主机或长稳/负载 +实验,D1 仍未执行。该证据绑定 dirty tree,不能替代准确提交修订的 Actions 验收, +也不代表正式发行。 + +## 2026-10-20 C1–D2 Actions 验收 + +C1–D2 源码已提交为分支 `verify/c1-d2-20261020`,草稿 PR #113,通过配置内 +GitHub Actions 在**确切修订**上完成功能验收: + +| 身份 | 准确值 | +| --- | --- | +| 被测源码修订 | `8172791d31a0f8f92d7bd76a26523c5a67dcda95` | +| CI run | 39374960189,`success` / `completed` | +| 容器 run | 39374960192,`success` / `completed` | +| PR #113 | 草稿,`MERGEABLE`;未合并、未发行 | + +11 个作业全部通过、无失败无重试:`contracts`(4m30s)、`deployment-tools`(10s)、 +`windows-agent`(1m20s)、`unsupported-platform`(1m5s)、`probe-workflow`(3m53s)、 +`relay-isolation`(8m29s)、`multihop (3)`(8m6s)、`multihop (4)`(10m41s)、 +`multihop (8)`(30m19s)、`phase1`(20m23s)、`containers`(1m32s)。覆盖 8 节点 +namespace 故障恢复、Windows 原生执行、macOS 明确不支持分支、MySQL/mTLS/回滚、 +Probe/Relay Chromium 与容器构建。发布类 workflow(images.yml / candidate-images.yml) +未被 PR 触发;未发布镜像、未合并 PR、未打标签或正式发行。独立物理主机、长稳/负载 +(D1)仍未执行,`multihop`/namespace 证据共享同一 Linux runner,不构成独立平台或 +容量证明。 + ## 后续进入条件 后续模块可消费上述冻结公共接口或经过独立验证的后继修订;任务仍需明确授权, 采用独立分支/worktree、模块目录、公共入口、契约测试和集成说明。当前 main 已 包含 PR #112(`5a764f7`);`373c291` 只是此前的 A5 合并点,不要从旧 main 推定 B 接口已集成。 -C1 状态解释是计划中的下一包,本轮没有开始实现;后续顺序见 +C1–C4/D2 的源码切片已在分支 `verify/c1-d2-20261020`(修订 `8172791d`)通过配置内 +Actions 验收,草稿 PR #113 尚未合并;D1 负载与独立物理主机证据仍未执行。后续顺序见 [开发方案](development-plan.md)与[下一阶段目标](next-milestones.md)。 独立物理多主机、长稳/负载、独立 Responder 构建发布、正式发行与生产就绪