/** * home.js — Landing Dashboard * * Read-only device health page: Core App, Communication, Network, System * cards plus an action panel. Polls /api/system-status every 10 s. */ import { getSystemStatus, restartDpworldapp } from '../api.js'; import { showToast } from '../toast.js'; import { escapeHtml } from '../utils.js'; import { DASHBOARD_REFRESH_MS, CAN_BAUDRATE_LABELS, DASHBOARD_ISSUE_PAGES } from '../constants.js'; import { icon } from '../icons.js'; // v1.8.0 Task 6: User 모드는 watchdog/drift/critical 상세를 숨기고 "네트워크: 정상/주의" 1줄 요약만. // view-mode 는 leaf 모듈 — 순환 import 없음. import { isAdvanced } from '../view-mode.js'; import { confirmModal } from '../confirm-modal.js'; let pollTimer = null; let lastStatus = null; // v1.7.1 Task 2: injectable navigation for issue deep-links. Default clicks the // matching sidebar nav-item so the production nav-guard path is honored; app.js // overrides it with showPage. Settable to null in tests to restore the default. let _navigate = null; // v1.7.1 Task 1: freshness truth + zombie guard. // _stale — last poll failed; the rendered cards are no longer trustworthy. // _lastError — message from the failed poll (for the stamp). let _stale = false; let _lastError = ''; let _lastReceivedAt = null; let _gridEl = null; // v1.7.1 Task 2: stable grid element for delegated deep-link clicks // v1.11.9 (review LOW concurrency): in-flight guard for the dpworldapp restart. poll() // re-renders the grid (innerHTML) producing a fresh ENABLED restart button while a // restart is still running — without this flag a second gesture re-enters handleRestart. let _restartInFlight = false; const FRESHNESS_CLOCK_SKEW_MS = 10 * 60 * 1000; // ─── Formatting helpers ─────────────────────────────────────── function fmtBytes(n) { if (!n) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, v = n; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`; } function fmtUptime(sec) { if (sec == null) return '—'; const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); return d > 0 ? `${d}d ${h}h ${m}m` : `${h}h ${m}m`; } function fmtAge(sec) { if (sec == null) return 'no log'; if (sec < 60) return `${Math.round(sec)}s ago`; if (sec < 3600) return `${Math.round(sec / 60)}m ago`; return `${Math.round(sec / 3600)}h ago`; } function glyph(tier) { return ``; } /** * v1.7.1 Task 1: relative-time freshness from the backend `generated_at`. * Accepts an ISO-8601 string or epoch (s or ms). Returns "Updated Ns ago" / * "Updated Nm ago" etc. — never a hardcoded "just now". Returns null when the * timestamp is missing or unparseable (caller falls back to a neutral label). */ function fmtFreshness(generatedAt) { if (generatedAt == null || generatedAt === '') return null; let ms; if (typeof generatedAt === 'number') { ms = generatedAt < 1e12 ? generatedAt * 1000 : generatedAt; // epoch s vs ms } else { ms = Date.parse(String(generatedAt)); } if (!Number.isFinite(ms)) return null; let sec = Math.round((Date.now() - ms) / 1000); if (sec < 0) sec = 0; // clock skew — clamp instead of "in the future" if (sec < 5) return 'Updated just now'; if (sec < 60) return `Updated ${sec}s ago`; if (sec < 3600) return `Updated ${Math.round(sec / 60)}m ago`; return `Updated ${Math.round(sec / 3600)}h ago`; } function freshnessTimestampMs(value) { if (value == null || value === '') return null; let ms; if (typeof value === 'number') { ms = value < 1e12 ? value * 1000 : value; } else { ms = Date.parse(String(value)); } return Number.isFinite(ms) ? ms : null; } function fmtReceivedFreshness(receivedAt) { const ms = freshnessTimestampMs(receivedAt); if (ms == null) return null; let sec = Math.round((Date.now() - ms) / 1000); if (sec < 0) sec = 0; if (sec < 5) return 'Received just now'; if (sec < 60) return `Received ${sec}s ago`; if (sec < 3600) return `Received ${Math.round(sec / 60)}m ago`; return `Received ${Math.round(sec / 3600)}h ago`; } function dashboardFreshnessLabel(status) { if (!status) return null; const generatedMs = freshnessTimestampMs(status.generated_at); const receivedMs = freshnessTimestampMs(_lastReceivedAt); if (generatedMs != null && receivedMs != null) { const skewMs = Math.abs(receivedMs - generatedMs); if (skewMs > FRESHNESS_CLOCK_SKEW_MS) { return `${fmtReceivedFreshness(receivedMs)} - device clock differs`; } } return fmtFreshness(status.generated_at) || fmtReceivedFreshness(receivedMs); } function staleFreshnessSuffix(label) { if (!label) return ''; if (label.startsWith('Updated ')) return ` (${label.replace(/^Updated /, 'last update ')})`; if (label.startsWith('Received ')) return ` (${label.replace(/^Received /, 'last received ')})`; return ` (${label})`; } function cleanTier(tier) { return ['ok', 'warn', 'error', 'na'].includes(tier) ? tier : 'na'; } function statusWord(tier) { return { ok: 'OK', warn: 'Warning', error: 'Fault', na: 'Unknown', }[cleanTier(tier)]; } function tierWeight(tier) { return { error: 3, warn: 2, na: 1, ok: 0, }[cleanTier(tier)]; } function dominantIssueTier(issues) { return (issues || []).reduce((worst, issue) => ( tierWeight(issue.tier) > tierWeight(worst) ? cleanTier(issue.tier) : worst ), 'ok'); } function issueCounts(issues) { return (issues || []).reduce((counts, issue) => { const tier = cleanTier(issue.tier); counts[tier] += 1; return counts; }, { error: 0, warn: 0, na: 0, ok: 0 }); } function configFromStatus(status) { return (status && status.dpworldapp_status && status.dpworldapp_status.config) || {}; } function normalizeProtocol(protocol) { const p = String(protocol || 'NONE').trim().toUpperCase().replace(/[-\s]+/g, '_'); if (p === 'CANBUS') return 'CAN_BUS'; if (p === 'CAN_BUS' || p === 'CAN') return 'CAN_BUS'; if (p === 'OPCUA' || p === 'OPC_UA') return 'OPC_UA'; if (p === 'MODBUS') return 'MODBUS'; return p || 'NONE'; } function protocolLabel(protocol) { return { CAN_BUS: 'CAN-BUS', OPC_UA: 'OPC-UA', MODBUS: 'Modbus', NONE: 'No active protocol', }[normalizeProtocol(protocol)] || String(protocol || 'Unknown'); } function hasConfiguredValue(value) { if (value == null) return false; const s = String(value).trim().toUpperCase(); return s !== '' && s !== 'NONE' && s !== 'OFF' && s !== 'N/A'; } function equipmentLabel(status) { const c = configFromStatus(status); if (hasConfiguredValue(c.equipment) && hasConfiguredValue(c.equipment_id)) { return `${c.equipment}-${c.equipment_id}`; } if (hasConfiguredValue(c.equipment)) return String(c.equipment); return 'Unassigned equipment'; } function positioningMode(status) { const c = configFromStatus(status); const speedSource = String(c.speed_data || '').toUpperCase(); const hasDeadReckoning = speedSource.includes('ODO') || hasConfiguredValue(c.odo_speed) || hasConfiguredValue(c.odo_dir); return hasDeadReckoning ? 'ZED-F9R RTK(DR)' : 'ZED-F9R RTK'; } function isPortOnlyEndpoint(value) { const s = String(value || '').trim(); return /^:?\d+$/.test(s); } function interfaceValueFromConfig(c, fallback) { const configured = c.protocol_endpoint; if (hasConfiguredValue(fallback) && (!hasConfiguredValue(configured) || isPortOnlyEndpoint(configured))) { return String(fallback); } if (hasConfiguredValue(configured)) return String(configured); return fallback || 'Configured'; } function activeInterfaceStatus(status) { const c = configFromStatus(status); const comm = (status && status.communication) || {}; const protocol = normalizeProtocol(c.protocol || comm.protocol); if (protocol === 'CAN_BUS') { const can = comm.can || {}; const speed = c.can_speed != null ? (CAN_BAUDRATE_LABELS[c.can_speed] || String(c.can_speed)) : ''; const configured = [c.can_type, speed].filter(hasConfiguredValue).join(' '); return { label: 'CAN-BUS', tier: cleanTier(can.tier), value: can.label || configured || 'Configured CAN input', }; } if (protocol === 'OPC_UA') { const opc = comm.opc_ua || {}; return { label: 'OPC-UA', tier: cleanTier(opc.tier), value: interfaceValueFromConfig(c, opc.endpoint || opc.label), }; } if (protocol === 'MODBUS') { const modbus = comm.modbus || {}; return { label: 'Modbus', tier: cleanTier(modbus.tier), value: interfaceValueFromConfig(c, modbus.endpoint || modbus.label), }; } return { label: protocolLabel(protocol), tier: protocol === 'NONE' ? 'na' : 'warn', value: protocol === 'NONE' ? 'No active protocol' : 'Protocol configured without a health adapter', }; } function runtimeTier(status) { const s = (status && status.dpworldapp_status) || {}; if (s.result === 'failed') return 'error'; if (s.result === 'hung' || s.result === 'starting') return 'warn'; if (s.result === 'healthy') return 'ok'; const app = (status && status.core_app) || {}; if (app.running === false) return 'error'; return cleanTier(app.tier || (app.running ? 'ok' : 'na')); } function runtimeLabel(status) { const s = (status && status.dpworldapp_status) || {}; if (s.result_label) return s.result_label; if (s.result) return s.result; const app = (status && status.core_app) || {}; return app.running ? 'Running' : 'Stopped'; } function positioningTier(status) { const hw = (status && status.hardware_modules) || {}; if (hw.gnss && hw.gnss.tier) return cleanTier(hw.gnss.tier); const rt = (status && status.dpworldapp_runtime) || {}; if (rt.available && rt.gnss_firmware) return 'ok'; if (rt.available) return 'warn'; return 'na'; } function serverUplinkStatus(status) { const srv = (status && status.communication && status.communication.protocol_srv) || {}; return { tier: cleanTier(srv.tier), value: srv.endpoint || srv.label || 'Protocol server', }; } function hasDrift(ours, theirs, normalize) { if (ours == null || theirs == null) return false; const a = String(ours).trim(); const b = String(theirs).trim(); if (!a || !b) return false; const compareA = normalize ? normalize(a) : a; const compareB = normalize ? normalize(b) : b; return compareA !== compareB; } function pathDominantTier(status) { const active = activeInterfaceStatus(status); const uplink = serverUplinkStatus(status); return dominantIssueTier([ { tier: positioningTier(status) }, { tier: active.tier }, { tier: runtimeTier(status) }, { tier: uplink.tier }, ]); } function dashboardHealthSummary(status) { const issues = collectDashboardIssues(status); const counts = issueCounts(issues); const issueTier = dominantIssueTier(issues); const pathTier = pathDominantTier(status); const tier = tierWeight(issueTier) >= tierWeight(pathTier) ? issueTier : pathTier; const copy = { ok: { title: 'Ready to send data', detail: 'RTK position, equipment interface, runtime, and HTTP uplink look available.', }, warn: { title: 'Attention required', detail: 'The data path is partly available, but one or more signals need operator review.', }, error: { title: 'Critical fault', detail: 'One or more required telemetry stages are failing.', }, na: { title: 'Unknown state', detail: 'Required telemetry evidence is missing or not configured.', }, }[cleanTier(tier)]; return { tier: cleanTier(tier), title: copy.title, detail: copy.detail, counts, equipment: equipmentLabel(status), positioning: positioningMode(status), interfaceLabel: activeInterfaceStatus(status).label, }; } function collectDashboardIssues(status) { const issues = []; const app = (status && status.core_app) || {}; const rt = (status && status.dpworldapp_runtime) || {}; const hw = (status && status.hardware_modules) || {}; const comm = (status && status.communication) || {}; const sys = (status && status.system) || {}; const dp = (status && status.dpworldapp_status) || {}; const network = (status && status.network) || {}; // ── dpworldapp stopped / startup result ───────────────────── if (app.running === false) { issues.push({ tier: 'error', type: 'runtime', text: 'dpworldapp stopped', detail: 'Runtime cannot collect or forward equipment data.', action: 'Restart dpworldapp or inspect service logs.', }); } if (dp.result === 'failed') { issues.push({ tier: 'error', type: 'runtime', text: 'Startup failed', detail: dp.result_label || 'Check dpworldapp startup phases.', action: 'Open dpworldapp Status startup phases.', }); } else if (dp.result === 'hung') { issues.push({ tier: 'warn', type: 'runtime', text: 'Startup appears hung', detail: dp.result_label || 'One or more startup phases did not complete.', action: 'Open dpworldapp Status startup phases.', }); } // ── Active equipment interface ────────────────────────────── const cfg = configFromStatus(status); const activeProtocol = normalizeProtocol(cfg.protocol || comm.protocol); const activeIface = activeInterfaceStatus(status); const activeTier = cleanTier(activeIface.tier); if (activeProtocol !== 'NONE' && (activeTier === 'error' || activeTier === 'warn' || activeTier === 'na')) { const ifaceWord = activeTier === 'error' ? 'Unreachable' : (activeTier === 'warn' ? 'Needs attention' : 'Status unavailable'); issues.push({ tier: activeTier, type: interfaceIssueType(activeProtocol), text: `${activeIface.label} ${ifaceWord}`, detail: activeIface.value || 'Active equipment interface has no live health evidence.', action: `Open ${activeIface.label} settings.`, }); } // ── Server uplink ──────────────────────────────────────────── const srv = comm.protocol_srv || {}; const srvTier = cleanTier(srv.tier); if (srvTier === 'error') { issues.push({ tier: 'error', type: 'server', text: 'Protocol server unreachable', detail: srv.endpoint || srv.label, action: 'Verify configured server endpoint', }); } else if (srvTier === 'warn') { issues.push({ tier: 'warn', type: 'server', text: 'Protocol server degraded', detail: srv.endpoint || srv.label, action: 'Verify configured server endpoint', }); } else if (srvTier === 'na') { issues.push({ tier: 'na', type: 'server', text: 'Server uplink status unavailable', detail: srv.endpoint || srv.label || 'Protocol server health has not reported yet.', action: 'Configure or test protocol server endpoint.', }); } // ── D7: Wi-Fi signal poor (User-facing, clickable) ────────── // Emit only when Wi-Fi signal is Poor (<= -80 dBm) and Wi-Fi is the sole // active uplink (no eth0/eth1 up-with-ip). Per D3 / status-rules §Wi-Fi RSSI. const wifi = network.wifi || {}; const wifiSignalDbm = wifi.signal_dbm != null ? wifi.signal_dbm : null; if (wifiSignalDbm !== null && wifiSignalDbm <= -80) { const ifaces = network.interfaces || []; const ethUpWithIp = ifaces.filter(i => (i.name === 'eth0' || i.name === 'eth1') && i.up && i.ip ); const wifiIsSoleUplink = ethUpWithIp.length === 0; if (wifiIsSoleUplink) { issues.push({ tier: 'error', type: 'wifi-signal', text: 'Wi-Fi signal poor', detail: `Signal ${wifiSignalDbm} dBm — below reliable threshold.`, action: 'Check Wi-Fi antenna placement and channel.', }); } } // ── Disk ──────────────────────────────────────────────────── const rootDisk = sys.disk_root || {}; // D9: use frontend diskTier (80/90 thresholds) instead of backend tier const rdPercent = rootDisk.percent != null ? rootDisk.percent : null; // Note: diskTier is defined later in this file as a named export (function declaration, // so it is hoisted); diskTierHelper is a const alias pointing to the same function. const rdTier = diskTierHelper(rdPercent); if (rdTier === 'error' || rdTier === 'warn') { issues.push({ tier: rdTier, type: 'disk', text: `Root disk ${rdPercent ?? '?'}% used`, detail: `${fmtBytes(rootDisk.free)} free on /`, action: 'Free disk space or rotate logs.', }); } // ── Network apply faults (non-Advanced: watchdog critical + country pending) ── const na = (status && status.network_apply) || null; if (na && na.error) { // v1.11.9 (review HIGH honesty): a network-apply subsystem INIT FAILURE // (na.error set) previously fell through the `!na.error` guard with NO issue, // so the header chip + Current Issues showed green during an active failure. // Surface it as a warn issue so the dashboard is never falsely green. issues.push({ tier: 'warn', type: 'net-apply', text: 'Network apply subsystem error', detail: (typeof na.error === 'string' ? na.error : 'Network apply subsystem failed to initialise.'), action: 'Open Apply & Status.', }); } else if (na) { if (na.watchdog && na.watchdog.critical) { issues.push({ tier: 'error', type: 'net-apply', text: 'Network apply watchdog critical', detail: 'A network apply did not converge; the live config may differ from intended.', action: 'Open Apply & Status.', }); } else if (na.country_pending) { issues.push({ tier: 'warn', type: 'net-apply', text: 'Country change pending reboot', detail: 'The regulatory domain change applies after a reboot.', action: 'Reboot to apply the country change.', }); } } // ── GNSS firmware drift — Advanced only ───────────────────── if (isAdvanced() && rt.available && hw.gnss && hasDrift(hw.gnss.fw_version, rt.gnss_firmware)) { issues.push({ tier: 'warn', type: 'gnss-drift', text: 'GNSS firmware drift', detail: 'amss.bin version differs from dpworldapp runtime.', action: 'Align GNSS firmware source with dpworldapp.', }); } // ── Network/runtime drift — Advanced only ──────────────────── // v1.8.1 (review I-3): drift is an apply-machine concept that User mode hides — its // deep-link targets the Advanced-only Apply & Status page (showPage redirects User // back to home → dead-end). Default Network state is based on live interface/IP/ // gateway evidence; apply/runtime drift remains an Advanced diagnostic. (network.interfaces || []).forEach((iface) => { const dpView = _dpWorldappViewForIface(iface.name || '', rt); const ipDrift = hasDrift(iface.ip, dpView.ip); const macDrift = hasDrift(iface.mac, dpView.mac, _normalizeMac); if ((ipDrift || macDrift) && isAdvanced()) { issues.push({ tier: 'warn', type: 'net-drift', text: `${iface.name} runtime drift`, detail: 'OS network state differs from dpworldapp telemetry.', action: 'Compare OS interface state with runtime telemetry.', }); } }); // v1.7.1 Task 2: resolve each issue's deep-link target from its type. return issues.map(issue => ({ ...issue, targetPage: issue.type ? DASHBOARD_ISSUE_PAGES[issue.type] : undefined, })); } /** * v1.7.1 Task 2: protocol → interface issue type, so a degraded interface deep-links * to the matching protocol page (or the protocol selector when none is active). */ function interfaceIssueType(protocol) { if (protocol === 'CAN_BUS') return 'interface-can'; if (protocol === 'OPC_UA') return 'interface-opcua'; if (protocol === 'MODBUS') return 'interface-modbus'; return 'interface'; } function renderHealthOverview(status) { const summary = dashboardHealthSummary(status); const kpis = [ { label: 'Critical', value: summary.counts.error }, { label: 'Warnings', value: summary.counts.warn }, { label: 'Unknown', value: summary.counts.na }, { label: 'Runtime', value: statusWord(runtimeTier(status)) }, ].map(kpi => `
${escapeHtml(kpi.label)} ${escapeHtml(String(kpi.value))}
`).join(''); return `
${glyph(summary.tier)}
Overall health
${escapeHtml(summary.title)}
${escapeHtml(summary.detail)}
${escapeHtml(summary.equipment)} ${escapeHtml(statusWord(summary.tier))}
${kpis}
`; } function renderOperatingMode(status) { const active = activeInterfaceStatus(status); const uplink = serverUplinkStatus(status); const items = [ { label: 'Equipment', value: equipmentLabel(status), hint: 'Configured target' }, { label: 'Positioning', value: positioningMode(status), hint: 'ZED-F9R GNSS source' }, { label: 'Equipment Interface', value: active.label, hint: active.value }, // v1.5.5.9 D-1: 측정하지 않는 주기(rate) 단정 제거. 전송 방식(HTTP)만 표기하고 // 실제 endpoint 는 hint 로. (Data Path 노드는 reachability tier 로 건강도 표시.) { label: 'Server Uplink', value: 'HTTP', hint: uplink.value }, ].map(item => `
${escapeHtml(item.label)} ${escapeHtml(item.value)} ${escapeHtml(item.hint)}
`).join(''); return `

${icon('activity', {size:16})} Operating Mode

Equipment telemetry role currently inferred from dpworldapp configuration.
${items}
`; } /** * renderDataPathHealth(status) → HTML string | '' * * D4 (spec §2): Data Path pipeline is Advanced-only. Returns empty string in User mode. * Node labels must be configured/running/reachable — NEVER 'receiving'/'flowing'. */ export function renderDataPathHealth(status) { // D4: Advanced-only — pipeline arrow-graph implies data-flow which we cannot prove if (!isAdvanced()) return ''; const active = activeInterfaceStatus(status); const uplink = serverUplinkStatus(status); const nodes = [ { label: 'Positioning', tier: positioningTier(status), value: positioningMode(status) }, { label: 'Equipment Interface', tier: active.tier, value: active.label }, { label: 'Runtime', tier: runtimeTier(status), value: runtimeLabel(status) }, // v1.5.5.9 D-1: hardcoded uplink label 대신 실제 server endpoint 표시. // 건강도는 uplink.tier glyph + statusWord 로 이미 전달됨. { label: 'Server Uplink', tier: uplink.tier, value: uplink.value }, ].map(node => { const tier = cleanTier(node.tier); // Sanitize labels: replace any forbidden flow-claim words const safeValue = String(node.value || '').replace(/\bReceiving\b/gi, 'Enabled') .replace(/\bFlowing\b/gi, 'Active'); return `
${glyph(tier)}${statusWord(tier)}
${escapeHtml(node.label)}
${escapeHtml(safeValue)}
`; }).join(''); return `

${icon('radio-tower', {size:16})} Data Path

${nodes}
`; } export function renderCurrentIssues(status) { const issues = collectDashboardIssues(status); const rows = (issues.length ? issues : [{ tier: 'ok', text: 'No current issues', detail: 'Dashboard signals are within expected range.', action: '', }]).map(issue => { const tier = cleanTier(issue.tier); // v1.7.1 Task 2: render the action as a clickable deep-link when we know // which page resolves it. data-goto + delegated handler (no inline onclick). const actionText = escapeHtml(issue.action || ''); const actionHtml = (issue.action && issue.targetPage) ? `` : (actionText ? `
${actionText}
` : ''); return `
${glyph(tier)}
${escapeHtml(issue.text)}
${escapeHtml(issue.detail || '')}
${actionHtml}
`; }).join(''); return `

${icon('alert-triangle', {size:16})} Current Issues

${rows}
`; } // ─── Card renderers ─────────────────────────────────────────── function renderCoreApp(c) { return `

${icon('puzzle', {size:16})} Core App

${glyph(c.tier)}dpworldapp ${c.running ? 'Running' : 'Stopped'}
Version ${escapeHtml(c.version || '—')}
PID / uptime ${c.pid || '—'} · ${fmtUptime(c.uptime_seconds)}
Last log ${fmtAge(c.last_log_age_seconds)}
${escapeHtml(c.last_log_line || '')}
`; } /** * v1.5.5.2: dpworldapp 측 view → OS interface 이름 매핑 (drift 비교용). * - wlan0/wlan1 ... → WIFI_* * - eth0 → ETH0_* * - eth1 → ETH1_* * 매핑되는 dpworldapp 키가 없으면 빈 객체 → drift 비교 silent skip. */ function _dpWorldappViewForIface(name, rt) { if (!rt || !rt.available) return {}; if (name.startsWith('wlan')) return { ip: rt.wifi_ip, mac: rt.wifi_mac }; if (name === 'eth0') return { ip: rt.eth0_ip, mac: rt.eth0_mac }; if (name === 'eth1') return { ip: rt.eth1_ip, mac: rt.eth1_mac }; return {}; } function renderNetwork(n, rt, status) { // v1.5.5.2: 각 인터페이스 — Up/Down + IP + MAC + dpworldapp drift indicator const ifRows = n.interfaces.map(i => { const dp = _dpWorldappViewForIface(i.name, rt); const ipDrift = driftBadge(i.ip, dp.ip, 'OS', 'dpworldapp'); // v1.5.5.3 fix: MAC 정규화 후 비교 (OS sysfs '92:d4:..' vs dpworldapp '92d4..'). const macDrift = driftBadge(i.mac, dp.mac, 'OS', 'dpworldapp', _normalizeMac); return `
${glyph(i.tier)}${escapeHtml(i.name)} ${i.up ? 'Up' : 'Down'}
IP ${escapeHtml(i.ip || '—')}${ipDrift}
MAC ${escapeHtml(i.mac || '—')}${macDrift}
`; }).join(''); const wifiRow = n.wifi.iface ? `
${glyph(n.wifi.tier)}WiFi SSID ${escapeHtml(n.wifi.ssid || 'Off')} ${n.wifi.signal_dbm != null ? n.wifi.signal_dbm + ' dBm' : ''}
` : ''; return `

${icon('globe', {size:16})} Network

${ifRows} ${wifiRow}
${glyph(n.gateway.tier)}Gateway ${escapeHtml(n.gateway.ip || '—')}
${glyph(n.dns.tier)}DNS ${escapeHtml((n.dns.servers || []).join(', ') || '—')}
${networkApplyRows(status && status.network_apply)}
`; } function diskBar(d, label) { // v1.5.4.3 U4 fix: tier별 색상을 CSS 클래스로 — 인라인 hex 하드코딩 제거. // 라이트/다크 테마 토큰(--success/--warning/--error)이 자동 적용된다. const tier = d.tier === 'error' ? 'error' : (d.tier === 'warn' ? 'warn' : 'ok'); return `
${label} ${d.percent}% · ${fmtBytes(d.free)} free
`; } function renderHardwareModules(hw, rt) { const gnss = hw && hw.gnss ? hw.gnss : { tier: 'na', fw_version: null }; const wifi = hw && hw.wifi ? hw.wifi : { tier: 'na', fw_version_short: null, fw_version_full: null }; const wifiFullAttr = wifi.fw_version_full ? ` title="${escapeHtml(wifi.fw_version_full)}"` : ''; // v1.5.5.2: dpworldapp FW + GNSS drift indicator 통합 // v1.5.5.3 fix: telemetry unavailable / cache age 표시 복원 (v1.5.5.1 패턴 회귀 fix). const rtAvailable = rt && rt.available; let dpFwRow; if (rtAvailable) { const cacheNote = rt.cached ? ` cached ${(rt.cache_age_s ?? 0).toFixed(0)}s` : ''; dpFwRow = `
${glyph('ok')}dpworldapp FW ${escapeHtml(rt.firmware || '—')}${cacheNote}
`; } else { // 8989 telemetry unavailable — keep a dim row so operators can see the missing source. dpFwRow = `
${glyph('na')}dpworldapp FW telemetry unavailable (8989)
`; } const gnssDrift = rtAvailable ? driftBadge(gnss.fw_version, rt.gnss_firmware, 'amss.bin', 'dpworldapp') : ''; return `

${icon('wrench', {size:16})} Hardware Modules

${dpFwRow}
${glyph(gnss.tier)}GNSS FW ${escapeHtml(gnss.fw_version || '—')}${gnssDrift}
${glyph(wifi.tier)}WiFi FW ${escapeHtml(wifi.fw_version_short || '—')}
`; } function renderDpworldappStatus(s) { // v1.5.5.2: Firmware Runtime section 제거 — 정보를 Network + Hardware Modules // 카드로 분산 (사용자 요청). 본 카드는 다시 dpworldapp 운영 status 중심으로. if (!s) return ''; const resultIcon = { healthy: icon('check-circle-2', {size:14}), starting: icon('hourglass', {size:14}), hung: icon('alert-triangle', {size:14}), failed: icon('x-circle', {size:14}), unknown: '—', }[s.result] || '—'; const phaseRows = (s.phases || []).map(p => `
${glyph(p.tier)}${escapeHtml(p.label)} ${p.duration_s != null ? p.duration_s.toFixed(1) + 's' : '—'}
`).join(''); const c = s.config || {}; const eq = c.equipment ? `${escapeHtml(c.equipment)}-${escapeHtml(c.equipment_id || '?')}` : '—'; const proto = c.protocol && c.protocol !== 'NONE' ? `${escapeHtml(c.protocol)}${c.protocol_endpoint ? ' · ' + escapeHtml(c.protocol_endpoint) : ''}` : 'NONE'; const canSpeedLabel = c.can_speed != null ? (CAN_BAUDRATE_LABELS[c.can_speed] || String(c.can_speed)) : '?'; const canDesc = c.can_input === 'on' ? `${escapeHtml(c.can_type || '?')} · ${canSpeedLabel} (on)` : 'off'; const rotatedNote = s.detail_unavailable_reason === 'log_rotated' ? '
Startup detail unavailable (log rotated)
' : ''; return `

${icon('activity', {size:16})} dpworldapp Status

Started ${escapeHtml(s.started_at || '—')}
Result ${resultIcon} ${escapeHtml(s.result_label || s.result || '—')}
PID / uptime ${s.pid || '—'} · ${fmtUptime(s.uptime_seconds)}
— Startup Phases —
${rotatedNote} ${phaseRows}
— Active Configuration —
Equipment ${eq}
Protocol ${proto}
CAN bus ${canDesc}
Speed src ${escapeHtml(c.speed_data || '—')}
Odo speed ${escapeHtml(c.odo_speed || '—')}
Direction ${escapeHtml(c.odo_dir || '—')}
`; } /** * v1.5.5.1: drift badge — 우리 측 view (board_config / amss.bin) 와 dpworldapp 측 실측값 * 의 mismatch 발견 시 warning icon + tooltip. * * 빈 값은 silent (비교 불가, drift 아님). 둘 다 있을 때만 비교. * v1.5.5.3 fix: 선택적 normalize 함수 지원 — MAC 같이 format 다른 비교 시 적용. * Tooltip 은 정규화 안 한 원본 값으로 표시 (사용자 가독성). */ function driftBadge(ours, theirs, ourLabel = 'configurator', theirLabel = 'dpworldapp', normalize) { if (ours == null || theirs == null) return ''; const a = String(ours).trim(); const b = String(theirs).trim(); if (!a || !b) return ''; const compareA = normalize ? normalize(a) : a; const compareB = normalize ? normalize(b) : b; if (compareA === compareB) return ''; const tip = `${ourLabel}: ${a} · ${theirLabel}: ${b}`; return ` ${icon('alert-triangle', {size:12})}`; } /** * v1.5.5.3: MAC 비교용 정규화 — lowercase + 모든 구분자 제거. * OS sysfs: "92:d4:78:cd:59:7b" / "92-d4-78-cd-59-7b" / "92.d4.78.cd.59.7b" 등 * dpworldapp 8989: "92d478cd597b" (구분자 없음) * → 두 형식을 같은 12자 소문자 hex 로 정규화 후 비교. */ function _normalizeMac(s) { return String(s).toLowerCase().replace(/[^0-9a-f]/g, ''); } // v1.6.0 §9: Network apply/watchdog 요약 row — system_status.network_apply (provider 미주입/구버전이면 null) // v1.10.1: Advanced-only — User mode fault signals are surfaced via userApplyFaultTier in the summary. function networkApplyRows(na) { if (!na) return ''; if (na.error) return ''; const ifaceBadges = Object.entries(na.interfaces || {}).map(([n, tier]) => `${escapeHtml(n)}`).join(' '); const drift = na.drift?.dirty ? `drift ${(na.drift && na.drift.fields ? na.drift.fields.length : 0)}` : ''; const wd = na.watchdog?.critical ? 'watchdog CRITICAL' : (na.watchdog?.enabled ? '' : 'watchdog off'); const country = na.country_pending ? 'country: reboot required' : ''; return `
Network ${ifaceBadges} ${drift} ${wd} ${country}
`; } /** * v1.7.1 Task 1: single place that reflects freshness + stale state into the DOM. * Sets the stamp text and a machine-detectable stale marker on the grid so the * cards are not misread as "healthy" when the last poll failed (zombie guard). */ function updateFreshness() { const stamp = document.getElementById('dash-updated'); const grid = document.getElementById('dash-grid'); const lastFresh = dashboardFreshnessLabel(lastStatus); if (_stale) { // Server unreachable — the rendered cards reflect the last good poll, but // we must say so loudly and not let the operator trust them. const suffix = staleFreshnessSuffix(lastFresh); if (stamp) stamp.textContent = `Connection lost — status may be stale${suffix}`; if (grid) grid.setAttribute('data-dash-stale', 'true'); } else { if (stamp) stamp.textContent = lastFresh || 'Updated'; if (grid) grid.removeAttribute('data-dash-stale'); } } function renderCards() { const grid = document.getElementById('dash-grid'); if (!grid || !lastStatus) return; grid.innerHTML = (_stale ? renderStaleBanner() : '') + renderHeaderSummary(lastStatus) + renderNetworkCard(lastStatus) + renderDpworldappCard(lastStatus) + renderGnssCard(lastStatus) + renderEquipmentCard(lastStatus) + renderServerUplinkCard(lastStatus) + renderCurrentIssues(lastStatus) + (isAdvanced() ? renderDataPathHealth(lastStatus) : ''); updateFreshness(); } /** * v1.7.1 Task 1: stale banner shown above the cards when polling has failed so the * last-good render cannot be mistaken for live, healthy state. */ function renderStaleBanner() { const reason = _lastError ? escapeHtml(_lastError) : 'the status service is unreachable'; return ` `; } // ─── Polling ────────────────────────────────────────────────── async function poll() { try { lastStatus = await getSystemStatus(); _lastReceivedAt = Date.now(); _stale = false; _lastError = ''; renderCards(); } catch (err) { // v1.7.1 Task 1: zombie guard — never silently keep the last "healthy" // render. Mark stale and re-render so the banner + freshness reflect reality. _stale = true; _lastError = err && err.message ? err.message : 'connection error'; if (lastStatus) { renderCards(); } else { // No prior good render — show stale state in the stamp directly. updateFreshness(); const stamp = document.getElementById('dash-updated'); if (stamp) stamp.textContent = 'Status unavailable — ' + _lastError; } } } // ─── Navigation (issue deep-link) ───────────────────────────── /** * v1.7.1 Task 2: navigate to a page. Uses the injected handler when present * (app.js → showPage), otherwise clicks the matching sidebar nav-item so the * production nav-guard path is honored. */ function navigateTo(pageId) { if (!pageId) return; if (_navigate) { _navigate(pageId); return; } const navItem = document.querySelector(`.nav-item[data-page="${pageId}"]`); if (navItem) navItem.click(); } /** Delegated click handler for issue deep-links and in-card actions (bound once per mount). */ function onGridClick(e) { const restartBtn = e.target.closest && e.target.closest('[data-action="restart-dpworldapp"]'); if (restartBtn) { e.preventDefault(); if (!_restartInFlight) handleRestart(); return; } const link = e.target.closest && e.target.closest('[data-goto]'); if (!link) return; e.preventDefault(); navigateTo(link.getAttribute('data-goto')); } /** Allow app.js (and tests) to inject the navigation function. */ export function setNavigate(fn) { _navigate = typeof fn === 'function' ? fn : null; } // ─── Dashboard V2 — pure network helpers (Task 1) ───────────── // // These are NAMED exports so jsdom unit-tests can import them directly. // No DOM access — pure data → data transforms. /** * networkInterfaceSlots(status) * Maps status.network.interfaces[] → fixed primary slots * [{name, role, up, ip, mac, tier, label}]. * Primary equipment dashboard slots are eth0, eth1, wlan0. We intentionally do * not synthesize a WAN slot because this hardware has no observed WAN interface. * Per-port rules (status-rules §Per-Port State): * up && ip → tier 'ok', label 'Up' * up && !ip → tier 'warn', label 'Up, no IP' * !up → tier 'na', label 'Down' */ export function networkInterfaceSlots(status) { const ifaces = (status && status.network && status.network.interfaces) || []; const wifi = (status && status.network && status.network.wifi) || {}; const byName = new Map(ifaces.map(iface => [iface.name, iface])); return ['eth0', 'eth1', 'wlan0'].map(name => { const present = byName.has(name); const iface = byName.get(name) || { name, up: false, ip: '', mac: '' }; const { up, ip, mac } = iface; const role = name === 'wlan0' ? 'wifi' : 'wired'; let tier, label; if (!present) { // No live evidence about this primary interface — honesty invariant: // absence of data is 'Unknown', not the definite-negative 'Down'. tier = 'na'; label = 'Unknown'; } else if (up && ip) { tier = 'ok'; label = 'Up'; if (role === 'wifi' && wifi.signal_dbm != null) { const sig = wifiSignalLevel(wifi.signal_dbm); if (sig.tier === 'error') { tier = 'error'; label = 'Up, poor signal'; } else if (sig.tier === 'warn') { tier = 'warn'; label = 'Up, weak signal'; } } } else if (up && !ip) { tier = 'warn'; label = 'Up, no IP'; } else { tier = 'na'; label = 'Down'; } return { name, role, up: !!up, ip: ip || '', mac: mac || '', tier, label }; }); } /** * wifiSignalLevel(signalDbm) * Maps RSSI integer (or null/undefined) → {label, tier, bars}. * Boundaries per status-rules §Wi-Fi RSSI Display: * >= -60 Strong / ok / 4 * -61 .. -69 Good / ok / 3 * -70 .. -79 Fair / warn / 2 * <= -80 Poor / error / 1 * null/undefined Unknown / warn / 0 ← MUST NOT collapse to Strong */ export function wifiSignalLevel(signalDbm) { if (signalDbm == null) { return { label: 'Unknown', tier: 'warn', bars: 0 }; } if (signalDbm >= -60) { return { label: 'Strong', tier: 'ok', bars: 4 }; } if (signalDbm >= -69) { return { label: 'Good', tier: 'ok', bars: 3 }; } if (signalDbm >= -79) { return { label: 'Fair', tier: 'warn', bars: 2 }; } // <= -80 return { label: 'Poor', tier: 'error', bars: 1 }; } /** * networkOverallTier(network) * Returns 'ok' | 'warn' | 'error' | 'na' per status-rules §Overall Network State. * * Rules: * 1. At least one interface up-with-ip AND gateway.ip present → 'ok' * 2. At least one interface up-with-ip AND gateway.ip absent → 'warn' * 3. No interface up-with-ip → 'error' * * D3 — Wi-Fi Poor single-uplink: * wifi.signal_dbm <= -80 is 'error' ONLY when wifi is the sole uplink * (no eth0/eth1 is up-with-ip); otherwise degrade to 'warn'. * eth being down must NOT alone make it 'error'. */ export function networkOverallTier(network) { if (!network) return 'na'; const ifaces = network.interfaces || []; const gateway = (network.gateway && network.gateway.ip) || ''; const wifi = network.wifi || {}; // v1.11.10 (review #19): an EMPTY interfaces array is "no evidence", not a // definite fault — return neutral 'na' instead of falsely claiming 'error'. if (ifaces.length === 0) return 'na'; // Determine which interfaces are up-with-ip const upWithIp = ifaces.filter(i => i.up && i.ip); if (upWithIp.length === 0) { return 'error'; } // Check if wifi signal is Poor (<= -80) const signalDbm = wifi.signal_dbm != null ? wifi.signal_dbm : null; const wifiPoor = signalDbm != null && signalDbm <= -80; if (wifiPoor) { // D3: is wifi the sole uplink? const ethUpWithIp = upWithIp.filter(i => i.name === 'eth0' || i.name === 'eth1'); if (ethUpWithIp.length === 0) { // wifi is the only uplink and it's poor → fault return 'error'; } // eth is usable — downgrade Poor wifi to warn (not error) return 'warn'; } // Normal path: at least one up-with-ip exists if (gateway) { return 'ok'; } return 'warn'; } /** * v1.10.1 (Codex review CRITICAL): User-safe apply FAULT tier. * A hard apply fault (watchdog CRITICAL / country reboot pending) is NOT an * Advanced-only diagnostic like drift/DNS — it must surface in the default * (User) Network summary. Returns 'error' on watchdog critical, 'warn' on * country reboot pending, else 'ok'. Drift is intentionally EXCLUDED (Advanced). */ export function userApplyFaultTier(na) { // v1.11.5 (review bug #8): a network-subsystem INIT FAILURE is surfaced by the backend // fault provider as na.error → 'warn' so the Network summary is never green on a failed // subsystem. A bare null/missing na just means "status not loaded yet" (server fix #2 // guarantees network_apply is present once /api/system-status returns), so it falls // through to 'ok' — avoids a false "needs attention" flash on every healthy page load. if (na && na.error) return 'warn'; if (na && na.watchdog && na.watchdog.critical) return 'error'; if (na && na.country_pending) return 'warn'; return 'ok'; } /** * Returns the worse of two tier strings (higher tierWeight wins). * Used to combine live network tier with apply fault tier. */ function worstTier(a, b) { return tierWeight(a) >= tierWeight(b) ? cleanTier(a) : cleanTier(b); } // ─── Dashboard V2 — Task 3: Network card DOM renderer ───────── // // Named export so jsdom tests can call renderNetworkCard(status) directly. // Called from renderCards() (V2 minimal layout — the legacy renderNetwork is no longer wired). // // Two-face design: // User (default): port slots + per-iface IP + Wi-Fi SSID+RSSI bars + // Gateway + worstTier(networkOverallTier, userApplyFaultTier) summary line. // Advanced: adds MAC, DNS row, per-iface driftBadge, networkApplyRows full badges. // // a11y (D8): every status glyph emits a visually-hidden statusWord span; // RSSI bars element gets aria-label = " ". function _srOnly(text) { return `${escapeHtml(text)}`; } function _glyphWithSr(tier) { return `${glyph(tier)}${_srOnly(statusWord(tier))}`; } function _headerIcon(name, tier = null) { const clean = tier == null ? '' : cleanTier(tier); const tierClass = clean ? ` dash-header__icon--${clean}` : ''; const sr = clean ? _srOnly(statusWord(clean)) : ''; return `${icon(name, { size: 14 })}${sr}`; } function _statusHero(iconName, tier, label, value, detail = '') { const clean = cleanTier(tier); const detailHtml = detail ? `${escapeHtml(detail)}` : ''; return `
${icon(iconName, { size: 18 })} ${_srOnly(statusWord(clean))} ${escapeHtml(label)} ${escapeHtml(value || 'Unknown')} ${detailHtml}
`; } function _rssiBarsSvg(bars, tier, ariaLabel) { // 4-bar SVG: each bar is progressively taller. Filled bars use tier colour class. const heights = [4, 7, 10, 13]; const barWidth = 3; const gap = 2; const totalW = 4 * barWidth + 3 * gap; // 18 const maxH = 14; const barEls = heights.map((h, i) => { const filled = i < bars; const cls = filled ? `rssi-bar rssi-bar--${tier}` : 'rssi-bar rssi-bar--empty'; const y = maxH - h; const x = i * (barWidth + gap); return ``; }).join(''); return `${barEls}`; } export function renderNetworkCard(status) { const n = (status && status.network) || {}; const rt = (status && status.dpworldapp_runtime) || {}; const na = (status && status.network_apply) || null; const advanced = isAdvanced(); const wifi = n.wifi || {}; const gateway = n.gateway || {}; const dns = n.dns || {}; const wifiSig = wifiSignalLevel(wifi.signal_dbm != null ? wifi.signal_dbm : null); const dBmStr = wifi.signal_dbm != null ? `${wifi.signal_dbm} dBm` : 'Unknown'; const barsAriaLabel = `${wifiSig.label} ${dBmStr}`; // ── Port slots row ────────────────────────────────────────── const slots = networkInterfaceSlots(status); const slotEls = slots.map(s => { const roleClass = s.role === 'wifi' ? 'net-port--wifi' : 'net-port--wired'; const slotIcon = s.role === 'wifi' ? icon('wifi', { size: 24, class: 'net-port__svg' }) : icon('ethernet-port', { size: 26, class: 'net-port__svg' }); const signal = s.role === 'wifi' ? `${_rssiBarsSvg(wifiSig.bars, wifiSig.tier, barsAriaLabel)}` : ''; return `
${slotIcon} ${escapeHtml(s.name)} ${escapeHtml(s.label)} ${signal}
`; }).join(''); const slotsRow = `
${slotEls}
`; // ── Per-interface compact rows ────────────────────────────── const ifRows = '
' + slots.map(iface => { const dp = _dpWorldappViewForIface(iface.name, rt); const ipDrift = advanced ? driftBadge(iface.ip, dp.ip, 'OS', 'dpworldapp') : ''; const macDrift = advanced ? driftBadge(iface.mac, dp.mac, 'OS', 'dpworldapp', _normalizeMac) : ''; const upTier = iface.up ? (iface.ip ? 'ok' : 'warn') : 'na'; const ifaceIcon = iface.role === 'wifi' ? 'wifi' : 'ethernet-port'; const macItem = advanced ? ` MAC ${escapeHtml(iface.mac || '-')} ${macDrift} ` : ''; return `
${icon(ifaceIcon, { size: 16 })} ${_glyphWithSr(upTier)} ${escapeHtml(iface.name)} ${escapeHtml(iface.up ? 'Up' : 'Down')} IP ${escapeHtml(iface.ip || '-')} ${ipDrift} ${macItem}
`; }).join('') + '
'; // ── Wi-Fi SSID + RSSI bars ────────────────────────────────── const wifiRow = wifi.ssid != null ? `
${_glyphWithSr(wifiSig.tier)} Wi-Fi SSID ${escapeHtml(wifi.ssid)} ${escapeHtml(dBmStr)} ${_rssiBarsSvg(wifiSig.bars, wifiSig.tier, barsAriaLabel)}
` : ''; // ── Gateway ───────────────────────────────────────────────── const gwTier = gateway.ip ? 'ok' : 'na'; const gatewayRow = `
${_glyphWithSr(gwTier)} Gateway ${escapeHtml(gateway.ip || '—')}
`; // ── Advanced-only: DNS row ─────────────────────────────────── const dnsRow = advanced ? `
DNS ${escapeHtml((dns.servers || []).join(', ') || '—')}
` : ''; // ── Overall summary ───────────────────────────────────────── // v1.10.1 (FIX A): combine live network tier with hard apply faults (watchdog // critical / country reboot pending). These are NOT Advanced-only diagnostics — // they are real operator-visible faults. Drift remains Advanced-only. const overallTier = worstTier(networkOverallTier(n), userApplyFaultTier(na)); const overallWord = { ok: 'Network usable', warn: 'Needs attention', error: 'Needs attention', na: 'Unknown' }[overallTier] || 'Unknown'; const summaryRow = `
${_glyphWithSr(overallTier)}Network ${escapeHtml(overallWord)}
`; const applyRows = advanced ? networkApplyRows(na) : ''; return `

${icon('globe', { size: 16 })} Network

${slotsRow} ${ifRows} ${wifiRow} ${gatewayRow} ${dnsRow} ${summaryRow} ${applyRows}
`; } // ─── Dashboard V2 — card state helpers (Task 2) ─────────────── // // Pure named exports: dpworldappMinimal / gnssMinimal / // equipmentInterfaceMinimal / serverUplinkMinimal / dashboardHeaderSummary. // No DOM access. All green states require runtime evidence. /** * dpworldappMinimal(status) → { running, version, tier } * status-rules §dpworldapp State Rules: * running && version present → ok * running && version missing → warn * running=false → error * core_app missing → na * * version priority: dpworldapp_runtime.firmware ?? core_app.version */ export function dpworldappMinimal(status) { const app = (status && status.core_app) || null; if (!app) return { running: null, version: null, tier: 'na' }; const running = app.running; if (running === false) return { running: false, version: null, tier: 'error' }; const rt = (status && status.dpworldapp_runtime) || {}; const version = rt.firmware != null ? rt.firmware : (app.version != null ? app.version : null); // unit_managed: pass through from core_app (undefined when backend pre-v1.10.4) const unit_managed = app.unit_managed; let tier; if (running && version) { // v1.11.5: defer to backend tier when it signals a problem (e.g. stale log) // rather than overriding to 'ok' purely from running+version presence. tier = (app.tier === 'warn' || app.tier === 'error') ? app.tier : 'ok'; } else if (running && !version) { tier = 'warn'; } else { // running is null/undefined (core_app exists but running not set) tier = 'na'; } return { running: !!running, version, tier, unit_managed }; } /** * gnssMinimal(status) → { moduleLabel, fw, fixLabel, tier } * status-rules §GNSS Card State Rules. * * ★ HONEST (D4): * - fixLabel is ALWAYS 'RTK unknown' — no fix-quality data exists. * - Green (tier='ok') requires runtime evidence (fw or gnss.tier from hw/runtime), * never config-only inference. * * fw priority: hardware_modules.gnss.fw_version ?? dpworldapp_runtime.gnss_firmware * tier: reuses positioningTier() logic (module-evidence tier), but overrides * to 'na' when dpworldapp is stopped (module evidence is unobservable). */ export function gnssMinimal(status) { const FIX_LABEL = 'RTK unknown'; const app = (status && status.core_app) || {}; // If dpworldapp is stopped, module evidence is unobservable → na if (app.running === false) { return { moduleLabel: 'Module unknown', fw: null, fixLabel: FIX_LABEL, tier: 'na' }; } const hw = (status && status.hardware_modules) || {}; const gnssHw = hw.gnss || {}; const rt = (status && status.dpworldapp_runtime) || {}; // Firmware: prefer hw fw_version, fallback to runtime gnss_firmware const fw = gnssHw.fw_version != null ? gnssHw.fw_version : (rt.gnss_firmware != null ? rt.gnss_firmware : null); // Module evidence tier via positioningTier (checks hw.gnss.tier + runtime gnss_firmware). // positioningTier returns 'na' when rt.available is falsy; however, status-rules says // "dpworldapp running but no GNSS evidence → Warning". Promote 'na' to 'warn' when // dpworldapp is running (we know app.running !== false by this point in the function). const rawTier = positioningTier(status); const tier = rawTier === 'na' ? 'warn' : rawTier; const moduleLabel = (tier === 'ok') ? 'Module seen' : 'Module unknown'; return { moduleLabel, fw, fixLabel: FIX_LABEL, tier }; } /** * equipmentInterfaceMinimal(status) → { label, tier, value } * Reuses activeInterfaceStatus(status). * D5: protocol 'NONE' → tier 'na' (already returned by activeInterfaceStatus — confirmed). * CAN label is 'Enabled'/'Configured', never 'Receiving'. */ export function equipmentInterfaceMinimal(status) { return activeInterfaceStatus(status); } /** * serverUplinkMinimal(status) → { tier, value, mode } * Reuses serverUplinkStatus(status) for tier + value. * D9: endpoint null/empty → tier 'warn' (override). * mode is always static 'HTTP telemetry upload'. * Never claims upload rate ('1 Hz'/'upload OK'). */ export function serverUplinkMinimal(status) { const base = serverUplinkStatus(status); const srv = (status && status.communication && status.communication.protocol_srv) || {}; const endpoint = srv.endpoint; // D9: endpoint missing or null → warn const tier = (endpoint != null && String(endpoint).trim() !== '') ? base.tier : 'warn'; return { tier, value: base.value, mode: 'HTTP telemetry upload', }; } /** * dashboardHeaderSummary(status) → { equipment, activeIface, posMode, uplink } * Composes existing helpers. * ★ posMode is the configured-mode label (e.g. 'ZED-F9R RTK(DR)') — NOT a health claim. * It is distinct from gnssMinimal's 'RTK unknown'. It reads as configuration. */ export function dashboardHeaderSummary(status) { return { equipment: equipmentLabel(status), activeIface: activeInterfaceStatus(status), posMode: positioningMode(status), uplink: serverUplinkStatus(status), }; } // ─── Dashboard V2 — Task 4: Right-column DOM renderers ──────── // // Named exports: renderHeaderSummary / renderDpworldappCard / // renderGnssCard / renderEquipmentCard // // Mirror Task 3's renderNetworkCard pattern: // - User (default): minimal safe info only // - Advanced (isAdvanced()): extra diagnostic rows appended // - a11y (D8): _glyphWithSr() for all status glyphs; escapeHtml on dynamic values /** * overallStatusChip(status, stale) → HTML string * Summarizes collectDashboardIssues into a compact status chip for the header bar. * HONESTY: never green for config-inferred-only; only reflects real issue data. * v1.11.10 (review #20): when the last poll failed (stale=true) the cards reflect a * stale snapshot — never show the green "All systems operational" chip; force WARN. */ function overallStatusChip(status, stale) { const issues = collectDashboardIssues(status) || []; let critical = 0, warning = 0; for (const it of issues) { const t = cleanTier(it.tier); if (t === 'error') critical++; else if (t === 'warn') warning++; } if (stale && !critical) return `${icon('alert-triangle', { size: 13 })} Status may be stale`; if (!critical && !warning) return `${icon('check-circle-2', { size: 13 })} All systems operational`; const tier = critical ? 'error' : 'warn'; const parts = []; if (critical) parts.push(`${critical} critical`); if (warning) parts.push(`${warning} warning`); return `${icon('alert-triangle', { size: 13 })} Attention needed · ${parts.join(', ')}`; } /** * renderHeaderSummary(status) → HTML string * * One-line header bar: equipment | overall status chip | active interface | uplink. * posMode is labeled as configured mode ("Mode: ZED-F9R RTK(DR)") — NOT a health/fix claim. * Composed from dashboardHeaderSummary helpers. */ export function renderHeaderSummary(status) { const h = dashboardHeaderSummary(status); const iface = h.activeIface || {}; const uplink = h.uplink || {}; const ifaceTier = cleanTier(iface.tier); const uplinkTier = cleanTier(uplink.tier); const equipPart = `${_headerIcon('factory')}Equipment${escapeHtml(h.equipment)}`; const ifacePart = `${_headerIcon('cable', ifaceTier)}I/F${escapeHtml(iface.label || 'Interface unknown')}`; const uplinkPart = `${_headerIcon('server', uplinkTier)}Server${escapeHtml(statusWord(uplinkTier))} ${escapeHtml(uplink.value || 'Uplink unknown')}`; return `
${equipPart}${overallStatusChip(status, _stale)}${ifacePart}${uplinkPart}
`; } /** * renderDpworldappCard(status) → HTML string * * User mode: Running/Stopped + firmware version ONLY. * No PID, no uptime, no last-log-line, no startup phases, no cache-age. * Advanced mode: also appends startup-phase table (reuses renderDpworldappStatus * phase rows content) + PID/uptime + last-log. */ export function renderDpworldappCard(status) { const d = dpworldappMinimal(status); const runTier = cleanTier(d.tier); const runWord = d.running ? 'Running' : (d.running === false ? 'Stopped' : 'Unknown'); // When running outside the systemd unit, surface a muted note for honesty. const supervisionNote = (d.running && d.unit_managed === false) ? `
not under service supervision
` : ''; // User-mode rows: icon-first status hero + firmware evidence const baseRows = ` ${_statusHero('activity', runTier, 'dpworldapp', runWord)} ${supervisionNote}
FW Version ${escapeHtml(d.version || '—')}
`; // Advanced-only extra rows: PID/uptime, last-log, startup phase table let advancedRows = ''; if (isAdvanced()) { const s = (status && status.dpworldapp_status) || null; const app = (status && status.core_app) || {}; // PID / uptime const pid = (s && s.pid) || app.pid; const uptime = (s && s.uptime_seconds != null) ? s.uptime_seconds : app.uptime_seconds; advancedRows += `
PID ${pid != null ? escapeHtml(String(pid)) : '—'} · ${fmtUptime(uptime)}
`; // Last log line const lastLogAge = app.last_log_age_seconds; const lastLogLine = app.last_log_line || ''; advancedRows += `
Last log ${fmtAge(lastLogAge)}
`; if (lastLogLine) { advancedRows += `
${escapeHtml(lastLogLine)}
`; } // Startup phase table (from dpworldapp_status) if (s) { const resultIcon = { healthy: icon('check-circle-2', { size: 14 }), starting: icon('hourglass', { size: 14 }), hung: icon('alert-triangle', { size: 14 }), failed: icon('x-circle', { size: 14 }), unknown: '—', }[s.result] || '—'; const phaseRows = (s.phases || []).map(p => `
${_glyphWithSr(cleanTier(p.tier))} ${escapeHtml(p.label)} ${p.duration_s != null ? p.duration_s.toFixed(1) + 's' : '—'}
`).join(''); const rotatedNote = s.detail_unavailable_reason === 'log_rotated' ? '
Startup detail unavailable (log rotated)
' : ''; advancedRows += `
— Startup Phases —
Result ${resultIcon} ${escapeHtml(s.result_label || s.result || '—')}
${rotatedNote} ${phaseRows}`; } } return `

${icon('activity', { size: 16 })} dpworldapp

${baseRows} ${advancedRows}
`; } /** * renderGnssCard(status) → HTML string * * v1.11.0: Renamed to "Hardware Modules" — combines GNSS + Wi-Fi FW evidence. * User mode: GNSS 'Module seen' + firmware + 'RTK unknown' + Wi-Fi FW row. * NEVER 'Positioning OK'. Wi-Fi tier is firmware-presence only (NOT link health). * Advanced mode: also appends GNSS fw drift badge (amss.bin vs dpworldapp runtime). */ export function renderGnssCard(status) { const g = gnssMinimal(status); const tier = cleanTier(g.tier); const mode = positioningMode(status); // GNSS fw drift badge — only in Advanced mode const hw = (status && status.hardware_modules) || {}; const gnssHw = hw.gnss || {}; const rt = (status && status.dpworldapp_runtime) || {}; const gnssDrift = (isAdvanced() && rt.available) ? driftBadge(gnssHw.fw_version, rt.gnss_firmware, 'amss.bin', 'dpworldapp') : ''; // Wi-Fi firmware block — tier is firmware-presence, NEVER link health const wifi = hw.wifi || { tier: 'na', fw_version_short: null, fw_version_full: null }; const wifiTier = cleanTier(wifi.tier); const wifiFullAttr = wifi.fw_version_full ? ` title="${escapeHtml(wifi.fw_version_full)}"` : ''; return `

${icon('cpu', { size: 16 })} Hardware Modules

${_glyphWithSr(tier)}${icon('radio-tower', { size: 14 })}GNSS
${_statusHero('radio-tower', tier, 'GNSS', g.moduleLabel)}
Mode ${escapeHtml(mode)}
GNSS FW ${escapeHtml(g.fw || '—')}${gnssDrift}
RTK/fix ${escapeHtml(g.fixLabel)}
${_glyphWithSr(wifiTier)}${icon('wifi', { size: 14 })}Wi-Fi FW
FW${escapeHtml(wifi.fw_version_short || '—')}
`; } /** * renderEquipmentCard(status) → HTML string * * Active protocol + short config summary (CAN type/baudrate or endpoint). * NEVER 'Receiving' (CAN = Enabled/Configured). * NONE → Unknown. * a11y (D8): _glyphWithSr on status glyph; escapeHtml on all dynamic values. */ export function renderEquipmentCard(status) { const eq = equipmentInterfaceMinimal(status); const tier = cleanTier(eq.tier); // Short config summary line — prefer value from helper, guard against 'Receiving' const rawValue = String(eq.value || ''); const safeValue = rawValue.replace(/\bReceiving\b/gi, 'Enabled'); return `

${icon('cable', { size: 16 })} Equipment I/F

${_statusHero('cable', tier, 'Equipment I/F', eq.label || 'Unknown')}
Config ${escapeHtml(safeValue)}
`; } // ─── Dashboard V2 — Task 5: Bottom-row renderers ────────────── // // Named exports: diskTier / renderServerUplinkCard / renderSystemCard // renderCurrentIssues is also named-exported (see above, Task 5). // renderDataPathHealth is now named-exported and Advanced-only. /** * diskTier(percent) → 'ok' | 'warn' | 'error' | 'na' * * D9 spec §2: frontend computes disk tier from raw percent using 80/90 thresholds, * because the backend uses different thresholds (60/80). * * Boundaries (status-rules §System Card): * < 80 → ok * 80 .. < 90 → warn * >= 90 → error * null/undef → na */ export function diskTier(percent) { if (percent == null || typeof percent !== 'number') return 'na'; if (percent < 80) return 'ok'; if (percent < 90) return 'warn'; return 'error'; } // Internal alias so collectDashboardIssues can call it without name collision // with the outer `diskTier` export (hoisting is fine for function declarations, // but to be safe with arrow expressions we alias here). const diskTierHelper = diskTier; /** * renderServerUplinkCard(status) → HTML string * * Task 5 — Server Uplink card (bottom row, minimal). * Uses serverUplinkMinimal() for tier + endpoint value. * Mode label is always static 'HTTP telemetry upload'. * NEVER claims upload rate ('1 Hz') or 'upload OK'. * endpoint missing → warn tier (D9). * * a11y (D8): _glyphWithSr() on status glyph; escapeHtml on dynamic values. */ export function renderServerUplinkCard(status) { const u = serverUplinkMinimal(status); const tier = cleanTier(u.tier); const srv = (status && status.communication && status.communication.protocol_srv) || {}; const endpoint = srv.endpoint; const endpointRow = endpoint ? `
Endpoint ${escapeHtml(String(endpoint))}
` : `
${_glyphWithSr('warn')} Endpoint Not configured
`; return `

${icon('upload', { size: 16 })} Server Uplink

${_statusHero('server', tier, 'Server', u.mode)} ${endpointRow}
`; } /** * renderSystemCard(status) → HTML string * * Task 5 — System card (bottom row, minimal). * Shows: root disk usage (tier from diskTier(percent) — D9 80/90 thresholds) * + device uptime (from status.system.uptime_seconds, NOT core_app.uptime_seconds). * Does NOT show: event count, /opt disk, raw percent details. * * a11y (D8): _glyphWithSr() on disk tier glyph; escapeHtml on dynamic values. */ export function renderSystemCard(status) { const sys = (status && status.system) || {}; const disk = sys.disk_root || {}; const percent = disk.percent != null ? disk.percent : null; const tier = diskTier(percent); // Disk summary uses the frontend 80/90 tier rule from diskTier(). const diskHtml = percent != null ? `
Disk / ${percent}% · ${fmtBytes(disk.free)} free
` : `
Disk / Unknown
`; // Device uptime from system.uptime_seconds (NOT core_app.uptime_seconds) const uptimeSeconds = sys.uptime_seconds != null ? sys.uptime_seconds : null; const uptimeRow = `
Device uptime ${uptimeSeconds != null ? fmtUptime(uptimeSeconds) : '—'}
`; return `

${icon('bar-chart-3', { size: 16 })} System

${_statusHero('gauge', tier, 'System', percent != null ? `Disk ${percent}%` : 'Disk unknown')} ${diskHtml} ${uptimeRow}
`; } // ─── Actions ────────────────────────────────────────────────── async function handleRestart() { // v1.11.9 (review LOW concurrency): poll() re-renders the grid mid-restart, yielding a // fresh ENABLED button — guard re-entry with a module-level in-flight flag (the disabled // button alone is insufficient). Reset in finally so a failed restart can be retried. if (_restartInFlight) return; const ok = await confirmModal({ title: 'Restart dpworldapp', message: 'The telemetry runtime stops for a few seconds while it restarts. Equipment data is not collected or forwarded during the restart.', confirmLabel: 'Restart', cancelLabel: 'Cancel', danger: true, }); if (!ok) return; if (_restartInFlight) return; // re-check after the await on the confirm modal _restartInFlight = true; const btn = document.querySelector('[data-action="restart-dpworldapp"]'); if (btn) { btn.disabled = true; } try { const r = await restartDpworldapp(); if (r && r.restarted && r.running) showToast(`dpworldapp restarted${r.pid ? ' (PID ' + r.pid + ')' : ''}.`, 'success'); else showToast('Restart issued but app is not active — ' + ((r && r.error) || ''), 'warning'); // v1.11.10 (review #23): re-poll INSIDE the try so the in-flight guard (cleared in // finally) stays set until the post-restart render resolves — otherwise the poll() // re-render yields a fresh ENABLED button while still rendering, allowing a 2nd restart. await poll(); } catch (err) { showToast((err && err.message) || 'Restart failed.', 'error'); } finally { _restartInFlight = false; } } // ─── Page interface ─────────────────────────────────────────── const homePage = { render(container) { container.classList.add('page-container--dashboard'); container.innerHTML = `
`; }, mount(container) { // v1.7.1 Task 2: single delegated listener on the stable grid element — // survives poll re-renders (grid.innerHTML only) and is removed on destroy, // so revisits never accumulate listeners. _gridEl = container.querySelector('#dash-grid'); if (_gridEl) _gridEl.addEventListener('click', onGridClick); poll(); pollTimer = setInterval(poll, DASHBOARD_REFRESH_MS); }, destroy() { document.getElementById('page-container')?.classList.remove('page-container--dashboard'); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (_gridEl) { _gridEl.removeEventListener('click', onGridClick); _gridEl = null; } lastStatus = null; _stale = false; _lastError = ''; _lastReceivedAt = null; _restartInFlight = false; }, }; export function renderHomePage(container) { homePage.render(container); homePage.mount(container); } export default homePage;