/**
* 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} ${icon('chevron-right', {size:12})}
`
: (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 `
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 `
${icon('alert-triangle', {size:16})}
Live status unavailable
Showing the last known values — ${reason}. Do not treat the cards below as current.
`;
}
// ─── 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 ``;
}
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 ``;
}
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 = ``;
// ── 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 = ``;
const ifacePart = ``;
const uplinkPart = ``;
return ``;
}
/**
* 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}
${icon('refresh-cw', { size: 14 })} Restart dpworldapp
`;
}
/**
* 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;