You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1901 lines
83 KiB
1901 lines
83 KiB
/**
|
|
* 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 `<span class="dash-glyph dash-glyph--${tier || 'na'}"></span>`;
|
|
}
|
|
|
|
/**
|
|
* 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 => `
|
|
<div class="dash-kpi">
|
|
<span class="dash-kpi__label">${escapeHtml(kpi.label)}</span>
|
|
<span class="dash-kpi__value">${escapeHtml(String(kpi.value))}</span>
|
|
</div>`).join('');
|
|
|
|
return `
|
|
<div class="dash-card dash-panel--wide dash-overview dash-overview--${summary.tier}">
|
|
<div class="dash-overview__status">
|
|
${glyph(summary.tier)}
|
|
<div class="dash-overview__copy">
|
|
<div class="dash-overview__eyebrow">Overall health</div>
|
|
<div class="dash-overview__title">${escapeHtml(summary.title)}</div>
|
|
<div class="dash-overview__detail">${escapeHtml(summary.detail)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="dash-overview__meta">
|
|
<span>${escapeHtml(summary.equipment)}</span>
|
|
<span>${escapeHtml(statusWord(summary.tier))}</span>
|
|
</div>
|
|
<div class="dash-overview__kpis">${kpis}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 => `
|
|
<div class="dash-mode__item">
|
|
<span class="dash-mode__label">${escapeHtml(item.label)}</span>
|
|
<span class="dash-mode__value">${escapeHtml(item.value)}</span>
|
|
<span class="dash-mode__hint">${escapeHtml(item.hint)}</span>
|
|
</div>`).join('');
|
|
|
|
return `
|
|
<div class="dash-card dash-panel--wide dash-mode">
|
|
<h3 class="dash-card__title">${icon('activity', {size:16})} Operating Mode</h3>
|
|
<div class="dash-card__sub">Equipment telemetry role currently inferred from dpworldapp configuration.</div>
|
|
<div class="dash-mode__grid">${items}</div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="dash-path__node dash-path__node--${tier}">
|
|
<div class="dash-path__state">${glyph(tier)}<span>${statusWord(tier)}</span></div>
|
|
<div class="dash-path__label">${escapeHtml(node.label)}</div>
|
|
<div class="dash-path__value">${escapeHtml(safeValue)}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="dash-card dash-panel--wide">
|
|
<h3 class="dash-card__title">${icon('radio-tower', {size:16})} Data Path</h3>
|
|
<div class="dash-path">${nodes}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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)
|
|
? `<button type="button" class="dash-issue__action dash-issue__action--link"
|
|
data-goto="${escapeHtml(issue.targetPage)}" data-no-dirty>
|
|
${actionText} ${icon('chevron-right', {size:12})}
|
|
</button>`
|
|
: (actionText ? `<div class="dash-issue__action">${actionText}</div>` : '');
|
|
return `
|
|
<div class="dash-issue dash-issue--${tier}">
|
|
${glyph(tier)}
|
|
<div class="dash-issue__body">
|
|
<div class="dash-issue__text">${escapeHtml(issue.text)}</div>
|
|
<div class="dash-issue__detail">${escapeHtml(issue.detail || '')}</div>
|
|
${actionHtml}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="dash-card dash-panel--wide dash-issues">
|
|
<h3 class="dash-card__title">${icon('alert-triangle', {size:16})} Current Issues</h3>
|
|
<div class="dash-issues__list">${rows}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ─── Card renderers ───────────────────────────────────────────
|
|
|
|
function renderCoreApp(c) {
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title">${icon('puzzle', {size:16})} Core App</h3>
|
|
<div class="dash-row">${glyph(c.tier)}<span class="dash-row__lbl">dpworldapp</span>
|
|
<span class="dash-row__val">${c.running ? 'Running' : 'Stopped'}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Version</span>
|
|
<span class="dash-row__val">${escapeHtml(c.version || '—')}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">PID / uptime</span>
|
|
<span class="dash-row__val">${c.pid || '—'} · ${fmtUptime(c.uptime_seconds)}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Last log</span>
|
|
<span class="dash-row__val">${fmtAge(c.last_log_age_seconds)}</span></div>
|
|
<div class="dash-row"><span class="dash-row__val dash-row__val--dim">${escapeHtml(c.last_log_line || '')}</span></div>
|
|
</div>`;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="dash-row dash-row--header">
|
|
${glyph(i.tier)}<span class="dash-row__lbl">${escapeHtml(i.name)}</span>
|
|
<span class="dash-row__val">${i.up ? 'Up' : 'Down'}</span></div>
|
|
<div class="dash-row dash-row--sub">
|
|
<span class="dash-row__lbl dash-row__lbl--sub">IP</span>
|
|
<span class="dash-row__val">${escapeHtml(i.ip || '—')}${ipDrift}</span></div>
|
|
<div class="dash-row dash-row--sub">
|
|
<span class="dash-row__lbl dash-row__lbl--sub">MAC</span>
|
|
<span class="dash-row__val">${escapeHtml(i.mac || '—')}${macDrift}</span></div>`;
|
|
}).join('');
|
|
const wifiRow = n.wifi.iface ? `
|
|
<div class="dash-row">${glyph(n.wifi.tier)}<span class="dash-row__lbl">WiFi SSID</span>
|
|
<span class="dash-row__val">${escapeHtml(n.wifi.ssid || 'Off')}</span>
|
|
<span class="dash-row__val--dim">${n.wifi.signal_dbm != null ? n.wifi.signal_dbm + ' dBm' : ''}</span></div>` : '';
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title">${icon('globe', {size:16})} Network</h3>
|
|
${ifRows}
|
|
${wifiRow}
|
|
<div class="dash-row">${glyph(n.gateway.tier)}<span class="dash-row__lbl">Gateway</span>
|
|
<span class="dash-row__val">${escapeHtml(n.gateway.ip || '—')}</span></div>
|
|
<div class="dash-row">${glyph(n.dns.tier)}<span class="dash-row__lbl">DNS</span>
|
|
<span class="dash-row__val">${escapeHtml((n.dns.servers || []).join(', ') || '—')}</span></div>
|
|
${networkApplyRows(status && status.network_apply)}
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="dash-disk">
|
|
<div class="dash-disk__head"><span>${label}</span>
|
|
<span class="dash-row__val--dim">${d.percent}% · ${fmtBytes(d.free)} free</span></div>
|
|
<div class="dash-disk__bar"><div class="dash-disk__fill dash-disk__fill--${tier}" style="width:${d.percent}%;"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
|
|
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
|
|
? ` <span class="dash-row__val--dim">cached ${(rt.cache_age_s ?? 0).toFixed(0)}s</span>`
|
|
: '';
|
|
dpFwRow = `
|
|
<div class="dash-row">${glyph('ok')}<span class="dash-row__lbl">dpworldapp FW</span>
|
|
<span class="dash-row__val">${escapeHtml(rt.firmware || '—')}${cacheNote}</span></div>`;
|
|
} else {
|
|
// 8989 telemetry unavailable — keep a dim row so operators can see the missing source.
|
|
dpFwRow = `
|
|
<div class="dash-row">${glyph('na')}<span class="dash-row__lbl">dpworldapp FW</span>
|
|
<span class="dash-row__val dash-row__val--dim">telemetry unavailable (8989)</span></div>`;
|
|
}
|
|
const gnssDrift = rtAvailable
|
|
? driftBadge(gnss.fw_version, rt.gnss_firmware, 'amss.bin', 'dpworldapp')
|
|
: '';
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title">${icon('wrench', {size:16})} Hardware Modules</h3>
|
|
${dpFwRow}
|
|
<div class="dash-row">${glyph(gnss.tier)}<span class="dash-row__lbl">GNSS FW</span>
|
|
<span class="dash-row__val">${escapeHtml(gnss.fw_version || '—')}${gnssDrift}</span></div>
|
|
<div class="dash-row">${glyph(wifi.tier)}<span class="dash-row__lbl">WiFi FW</span>
|
|
<span class="dash-row__val"${wifiFullAttr}>${escapeHtml(wifi.fw_version_short || '—')}</span></div>
|
|
</div>`;
|
|
}
|
|
|
|
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 => `
|
|
<div class="dash-row">${glyph(p.tier)}<span class="dash-row__lbl">${escapeHtml(p.label)}</span>
|
|
<span class="dash-row__val">${p.duration_s != null ? p.duration_s.toFixed(1) + 's' : '—'}</span></div>`).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'
|
|
? '<div class="dash-row"><span class="dash-row__val dash-row__val--dim">Startup detail unavailable (log rotated)</span></div>'
|
|
: '';
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title">${icon('activity', {size:16})} dpworldapp Status</h3>
|
|
<div class="dash-row"><span class="dash-row__lbl">Started</span>
|
|
<span class="dash-row__val">${escapeHtml(s.started_at || '—')}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Result</span>
|
|
<span class="dash-row__val">${resultIcon} ${escapeHtml(s.result_label || s.result || '—')}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">PID / uptime</span>
|
|
<span class="dash-row__val">${s.pid || '—'} · ${fmtUptime(s.uptime_seconds)}</span></div>
|
|
<div class="dash-row dash-row--section">— Startup Phases —</div>
|
|
${rotatedNote}
|
|
${phaseRows}
|
|
<div class="dash-row dash-row--section">— Active Configuration —</div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Equipment</span>
|
|
<span class="dash-row__val">${eq}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Protocol</span>
|
|
<span class="dash-row__val">${proto}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">CAN bus</span>
|
|
<span class="dash-row__val">${canDesc}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Speed src</span>
|
|
<span class="dash-row__val">${escapeHtml(c.speed_data || '—')}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Odo speed</span>
|
|
<span class="dash-row__val">${escapeHtml(c.odo_speed || '—')}</span></div>
|
|
<div class="dash-row"><span class="dash-row__lbl">Direction</span>
|
|
<span class="dash-row__val">${escapeHtml(c.odo_dir || '—')}</span></div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 ` <span class="drift-warn" title="${escapeHtml(tip)}">${icon('alert-triangle', {size:12})}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 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]) =>
|
|
`<span class="net-badge net-badge--${tier === 'ok' ? 'ok' : 'fail'}">${escapeHtml(n)}</span>`).join(' ');
|
|
const drift = na.drift?.dirty
|
|
? `<span class="net-badge net-badge--warn">drift ${(na.drift && na.drift.fields ? na.drift.fields.length : 0)}</span>` : '';
|
|
const wd = na.watchdog?.critical
|
|
? '<span class="net-badge net-badge--fail">watchdog CRITICAL</span>'
|
|
: (na.watchdog?.enabled ? '' : '<span class="net-badge net-badge--na">watchdog off</span>');
|
|
const country = na.country_pending
|
|
? '<span class="net-badge net-badge--warn">country: reboot required</span>' : '';
|
|
return `<div class="dash-row"><span class="dash-row__lbl">Network</span>
|
|
<span class="dash-row__val">${ifaceBadges} ${drift} ${wd} ${country}</span></div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="dash-card dash-panel--wide dash-stale" data-dash-stale="true" role="alert">
|
|
${icon('alert-triangle', {size:16})}
|
|
<div class="dash-stale__copy">
|
|
<div class="dash-stale__title">Live status unavailable</div>
|
|
<div class="dash-stale__detail">Showing the last known values — ${reason}. Do not treat the cards below as current.</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ─── 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 = "<level> <dBm>".
|
|
|
|
function _srOnly(text) {
|
|
return `<span class="sr-only">${escapeHtml(text)}</span>`;
|
|
}
|
|
|
|
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 `<span class="dash-header__icon${tierClass}">${icon(name, { size: 14 })}${sr}</span>`;
|
|
}
|
|
|
|
function _statusHero(iconName, tier, label, value, detail = '') {
|
|
const clean = cleanTier(tier);
|
|
const detailHtml = detail
|
|
? `<span class="dash-status-hero__detail">${escapeHtml(detail)}</span>`
|
|
: '';
|
|
return `
|
|
<div class="dash-status-hero dash-status-hero--${clean}">
|
|
<span class="dash-status-hero__icon dash-status-hero__icon--${clean}">
|
|
${icon(iconName, { size: 18 })}
|
|
${_srOnly(statusWord(clean))}
|
|
</span>
|
|
<span class="dash-status-hero__copy">
|
|
<span class="dash-status-hero__eyebrow">${escapeHtml(label)}</span>
|
|
<span class="dash-status-hero__value">${escapeHtml(value || 'Unknown')}</span>
|
|
${detailHtml}
|
|
</span>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<rect class="${cls}" x="${x}" y="${y}" width="${barWidth}" height="${h}"/>`;
|
|
}).join('');
|
|
return `<svg class="rssi-bars" width="${totalW}" height="${maxH}" aria-label="${escapeHtml(ariaLabel)}" role="img">${barEls}</svg>`;
|
|
}
|
|
|
|
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'
|
|
? `<span class="net-port__signal">${_rssiBarsSvg(wifiSig.bars, wifiSig.tier, barsAriaLabel)}</span>`
|
|
: '';
|
|
return `<div class="net-port net-port--${s.tier} ${roleClass}">
|
|
<span class="net-port__icon">${slotIcon}</span>
|
|
<span class="net-port__name">${escapeHtml(s.name)}</span>
|
|
<span class="net-port__label">${escapeHtml(s.label)}</span>
|
|
${signal}
|
|
</div>`;
|
|
}).join('');
|
|
const slotsRow = `<div class="net-port-panel"><div class="net-ports">${slotEls}</div></div>`;
|
|
|
|
// ── Per-interface compact rows ──────────────────────────────
|
|
const ifRows = '<div class="net-iface-list">' + 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 ? `
|
|
<span class="net-iface-row__item">
|
|
<span class="net-iface-row__label">MAC</span>
|
|
<span class="net-iface-row__value">${escapeHtml(iface.mac || '-')} ${macDrift}</span>
|
|
</span>` : '';
|
|
return `
|
|
<div class="net-iface-row net-iface-row--${advanced ? 'advanced' : 'user'} net-iface-row--${upTier}">
|
|
<span class="net-iface-row__icon net-iface-row__icon--${upTier}">
|
|
${icon(ifaceIcon, { size: 16 })}
|
|
</span>
|
|
<span class="net-iface-row__state">
|
|
${_glyphWithSr(upTier)}
|
|
<span class="net-iface-row__name">${escapeHtml(iface.name)}</span>
|
|
<span class="net-iface-row__link">${escapeHtml(iface.up ? 'Up' : 'Down')}</span>
|
|
</span>
|
|
<span class="net-iface-row__item">
|
|
<span class="net-iface-row__label">IP</span>
|
|
<span class="net-iface-row__value">${escapeHtml(iface.ip || '-')} ${ipDrift}</span>
|
|
</span>
|
|
${macItem}
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
|
|
// ── Wi-Fi SSID + RSSI bars ──────────────────────────────────
|
|
const wifiRow = wifi.ssid != null ? `
|
|
<div class="dash-row">
|
|
${_glyphWithSr(wifiSig.tier)}
|
|
<span class="dash-row__lbl">Wi-Fi SSID</span>
|
|
<span class="dash-row__val">${escapeHtml(wifi.ssid)}</span>
|
|
<span class="dash-row__val--dim">${escapeHtml(dBmStr)}</span>
|
|
${_rssiBarsSvg(wifiSig.bars, wifiSig.tier, barsAriaLabel)}
|
|
</div>` : '';
|
|
|
|
// ── Gateway ─────────────────────────────────────────────────
|
|
const gwTier = gateway.ip ? 'ok' : 'na';
|
|
const gatewayRow = `
|
|
<div class="dash-row">
|
|
${_glyphWithSr(gwTier)}
|
|
<span class="dash-row__lbl">Gateway</span>
|
|
<span class="dash-row__val">${escapeHtml(gateway.ip || '—')}</span>
|
|
</div>`;
|
|
|
|
// ── Advanced-only: DNS row ───────────────────────────────────
|
|
const dnsRow = advanced ? `
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">DNS</span>
|
|
<span class="dash-row__val">${escapeHtml((dns.servers || []).join(', ') || '—')}</span>
|
|
</div>` : '';
|
|
|
|
// ── 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 = `<div class="dash-row">${_glyphWithSr(overallTier)}<span class="dash-row__lbl">Network</span>
|
|
<span class="dash-row__val">${escapeHtml(overallWord)}</span></div>`;
|
|
const applyRows = advanced ? networkApplyRows(na) : '';
|
|
|
|
return `
|
|
<div class="dash-card dash-card--network">
|
|
<h3 class="dash-card__title">${icon('globe', { size: 16 })} Network</h3>
|
|
${slotsRow}
|
|
${ifRows}
|
|
${wifiRow}
|
|
${gatewayRow}
|
|
${dnsRow}
|
|
${summaryRow}
|
|
${applyRows}
|
|
</div>`;
|
|
}
|
|
|
|
// ─── 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 `<span class="dash-status-chip dash-status-chip--warn">${icon('alert-triangle', { size: 13 })} Status may be stale</span>`;
|
|
if (!critical && !warning) return `<span class="dash-status-chip dash-status-chip--ok">${icon('check-circle-2', { size: 13 })} All systems operational</span>`;
|
|
const tier = critical ? 'error' : 'warn';
|
|
const parts = []; if (critical) parts.push(`${critical} critical`); if (warning) parts.push(`${warning} warning`);
|
|
return `<span class="dash-status-chip dash-status-chip--${tier}">${icon('alert-triangle', { size: 13 })} Attention needed · ${parts.join(', ')}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `<span class="dash-header__part dash-header__equipment">${_headerIcon('factory')}<span class="dash-header__label">Equipment</span><span class="dash-header__value">${escapeHtml(h.equipment)}</span></span>`;
|
|
const ifacePart = `<span class="dash-header__part dash-header__iface dash-header__part--${ifaceTier}">${_headerIcon('cable', ifaceTier)}<span class="dash-header__label">I/F</span><span class="dash-header__value">${escapeHtml(iface.label || 'Interface unknown')}</span></span>`;
|
|
const uplinkPart = `<span class="dash-header__part dash-header__uplink dash-header__part--${uplinkTier}">${_headerIcon('server', uplinkTier)}<span class="dash-header__label">Server</span><span class="dash-header__value">${escapeHtml(statusWord(uplinkTier))} ${escapeHtml(uplink.value || 'Uplink unknown')}</span></span>`;
|
|
|
|
return `<div class="dash-header-summary">${equipPart}${overallStatusChip(status, _stale)}${ifacePart}${uplinkPart}</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
? `<div class="dash-row"><span class="dash-row__val dash-row__val--dim">not under service supervision</span></div>`
|
|
: '';
|
|
|
|
// User-mode rows: icon-first status hero + firmware evidence
|
|
const baseRows = `
|
|
${_statusHero('activity', runTier, 'dpworldapp', runWord)}
|
|
${supervisionNote}
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">FW Version</span>
|
|
<span class="dash-row__val">${escapeHtml(d.version || '—')}</span>
|
|
</div>`;
|
|
|
|
// 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 += `
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">PID</span>
|
|
<span class="dash-row__val">${pid != null ? escapeHtml(String(pid)) : '—'} · ${fmtUptime(uptime)}</span>
|
|
</div>`;
|
|
|
|
// Last log line
|
|
const lastLogAge = app.last_log_age_seconds;
|
|
const lastLogLine = app.last_log_line || '';
|
|
advancedRows += `
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">Last log</span>
|
|
<span class="dash-row__val">${fmtAge(lastLogAge)}</span>
|
|
</div>`;
|
|
if (lastLogLine) {
|
|
advancedRows += `
|
|
<div class="dash-row">
|
|
<span class="dash-row__val dash-row__val--dim">${escapeHtml(lastLogLine)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 => `
|
|
<div class="dash-row">${_glyphWithSr(cleanTier(p.tier))}
|
|
<span class="dash-row__lbl">${escapeHtml(p.label)}</span>
|
|
<span class="dash-row__val">${p.duration_s != null ? p.duration_s.toFixed(1) + 's' : '—'}</span>
|
|
</div>`).join('');
|
|
const rotatedNote = s.detail_unavailable_reason === 'log_rotated'
|
|
? '<div class="dash-row"><span class="dash-row__val dash-row__val--dim">Startup detail unavailable (log rotated)</span></div>'
|
|
: '';
|
|
advancedRows += `
|
|
<div class="dash-row dash-row--section">— Startup Phases —</div>
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">Result</span>
|
|
<span class="dash-row__val">${resultIcon} ${escapeHtml(s.result_label || s.result || '—')}</span>
|
|
</div>
|
|
${rotatedNote}
|
|
${phaseRows}`;
|
|
}
|
|
}
|
|
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title dash-card__title--sr">${icon('activity', { size: 16 })} dpworldapp</h3>
|
|
${baseRows}
|
|
${advancedRows}
|
|
<div class="dash-card__action">
|
|
<button type="button" class="btn btn--danger btn--sm btn--full" data-action="restart-dpworldapp" data-no-dirty>${icon('refresh-cw', { size: 14 })} Restart dpworldapp</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title">${icon('cpu', { size: 16 })} Hardware Modules</h3>
|
|
<div class="hw-module">
|
|
<div class="hw-module__head">${_glyphWithSr(tier)}${icon('radio-tower', { size: 14 })}<span class="hw-module__name">GNSS</span></div>
|
|
${_statusHero('radio-tower', tier, 'GNSS', g.moduleLabel)}
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">Mode</span>
|
|
<span class="dash-row__val">${escapeHtml(mode)}</span>
|
|
</div>
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">GNSS FW</span>
|
|
<span class="dash-row__val">${escapeHtml(g.fw || '—')}${gnssDrift}</span>
|
|
</div>
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">RTK/fix</span>
|
|
<span class="dash-row__val">${escapeHtml(g.fixLabel)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="hw-module">
|
|
<div class="hw-module__head">${_glyphWithSr(wifiTier)}${icon('wifi', { size: 14 })}<span class="hw-module__name">Wi-Fi FW</span></div>
|
|
<div class="dash-row dash-row--sub"><span class="dash-row__lbl dash-row__lbl--sub">FW</span><span class="dash-row__val"${wifiFullAttr}>${escapeHtml(wifi.fw_version_short || '—')}</span></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title dash-card__title--sr">${icon('cable', { size: 16 })} Equipment I/F</h3>
|
|
${_statusHero('cable', tier, 'Equipment I/F', eq.label || 'Unknown')}
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">Config</span>
|
|
<span class="dash-row__val dash-row__val--dim">${escapeHtml(safeValue)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ─── 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
|
|
? `<div class="dash-row">
|
|
<span class="dash-row__lbl">Endpoint</span>
|
|
<span class="dash-row__val">${escapeHtml(String(endpoint))}</span>
|
|
</div>`
|
|
: `<div class="dash-row">
|
|
${_glyphWithSr('warn')}
|
|
<span class="dash-row__lbl">Endpoint</span>
|
|
<span class="dash-row__val dash-row__val--dim">Not configured</span>
|
|
</div>`;
|
|
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title dash-card__title--sr">${icon('upload', { size: 16 })} Server Uplink</h3>
|
|
${_statusHero('server', tier, 'Server', u.mode)}
|
|
${endpointRow}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
? `<div class="dash-row">
|
|
<span class="dash-row__lbl">Disk /</span>
|
|
<span class="dash-row__val">${percent}% · ${fmtBytes(disk.free)} free</span>
|
|
</div>`
|
|
: `<div class="dash-row">
|
|
<span class="dash-row__lbl">Disk /</span>
|
|
<span class="dash-row__val dash-row__val--dim">Unknown</span>
|
|
</div>`;
|
|
|
|
// Device uptime from system.uptime_seconds (NOT core_app.uptime_seconds)
|
|
const uptimeSeconds = sys.uptime_seconds != null ? sys.uptime_seconds : null;
|
|
const uptimeRow = `
|
|
<div class="dash-row">
|
|
<span class="dash-row__lbl">Device uptime</span>
|
|
<span class="dash-row__val">${uptimeSeconds != null ? fmtUptime(uptimeSeconds) : '—'}</span>
|
|
</div>`;
|
|
|
|
return `
|
|
<div class="dash-card">
|
|
<h3 class="dash-card__title dash-card__title--sr">${icon('bar-chart-3', { size: 16 })} System</h3>
|
|
${_statusHero('gauge', tier, 'System', percent != null ? `Disk ${percent}%` : 'Disk unknown')}
|
|
${diskHtml}
|
|
${uptimeRow}
|
|
</div>`;
|
|
}
|
|
|
|
// ─── 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 = `
|
|
<div class="page-header">
|
|
<h1 class="page-header__title">${icon('home', {size:28})} Device Status</h1>
|
|
<p class="page-header__desc" id="dash-updated">Loading status...</p>
|
|
</div>
|
|
<div class="dash-grid" id="dash-grid"></div>
|
|
`;
|
|
},
|
|
|
|
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;
|
|
|