"use strict"; // Basic Van Router dashboard for Cockpit. // All read-only status is gathered in ONE cockpit.spawn per refresh: the Python // bridge only frees spawn-pipe fds at GC time, so a dozen spawns every 5s marched // it into its 1024-fd limit (EMFILE on admin escalation). Mutating actions use // { superuser: "require" } which triggers Cockpit's admin (polkit) escalation. // AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit. // Interface names/vendor ID below are templated from deploy.conf at deploy // time (see deploy.sh's render()) — edit deploy.conf, not the values here. const APS = [ { iface: "@WIFI_5G_IFACE@", unit: "hostapd", band: "5GHz" }, { iface: "@WIFI_2G_IFACE@", unit: "hostapd-2g", band: "2.4GHz" }, ]; const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN // Wired LAN bridge ports: clients behind them are found via the bridge FDB. const LAN_PORTS = { "eth0": "LAN (eth0)", "@LAN_USB_IFACE@": "LAN (USB)" }; // Starlink dish: gRPC status API on the fixed management IP (reached via the /32 // link route the netplan profile installs on the RTL8153 uplink). const STARLINK = { iface: "@STARLINK_IFACE@", dish: "192.168.100.1:9200" }; // Cellular modem (Quectel EC25-AF): used to find the USB device for a hard restart. const MODEM_USB_VENDOR = "@MODEM_USB_VENDOR@"; function run(args, opts) { return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {})); } function sh(cmd, opts) { return run(["sh", "-c", cmd], opts); } function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """ }[c])); } // Single-quote a string for safe use inside `sh -c`. function esc_sh(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } /* ---------- one-spawn status collection ---------- */ // Each section's output is preceded by a @@vr:@@ marker line. const STATUS_SCRIPT = (() => { const parts = []; const add = (name, cmd) => parts.push(`printf '\\n@@vr:%s@@\\n' ${esc_sh(name)}; { ${cmd}; } 2>/dev/null || true`); APS.forEach((a, i) => { add(`active${i}`, `systemctl is-active ${a.unit}`); add(`info${i}`, `iw dev ${a.iface} info`); add(`stations${i}`, `iw dev ${a.iface} station dump`); }); add("deploywarn", "cat /var/lib/vanlink/deploy-warnings.json"); add("neigh", "ip -j neigh show dev br0"); add("fdb", "bridge -j fdb show br br0"); add("leases", "cat /var/lib/misc/dnsmasq.leases"); add("thermal", "cat /run/van-thermal/state.json"); // Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the // three failure modes apart; anything starting with '{' is dish status JSON. add("starlink", `if ! ip link show ${STARLINK.iface} >/dev/null 2>&1; then echo ABSENT; ` + `elif ! command -v grpcurl >/dev/null 2>&1; then echo NOGRPCURL; ` + `else timeout 4 grpcurl -plaintext -max-time 3 -d '{"get_status":{}}' ` + `${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`); add("battery", "cat /run/van-battery/state.json"); add("failover", "cat /run/van-failover/state.json"); add("modem", "mmcli -m any -K"); add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status"); add("routes", "ip -j route show default"); add("addrs", "ip -j -4 addr"); return parts.join("\n"); })(); function parseSections(out) { const secs = {}; let cur = null; out.split("\n").forEach(line => { const m = line.match(/^@@vr:(\w+)@@$/); if (m) { cur = m[1]; secs[cur] = []; } else if (cur !== null) secs[cur].push(line); }); Object.keys(secs).forEach(k => { secs[k] = secs[k].join("\n"); }); return secs; } function parseJSON(text, fallback) { try { return JSON.parse(text); } catch (e) { return fallback; } } /* ---------- Deploy warnings (deploy.sh's collected warn() calls) ---------- */ function renderDeployWarnings(dw) { const card = document.getElementById("deploywarn-card"); const warnings = (dw && dw.warnings) || []; if (!warnings.length) { card.style.display = "none"; return; } card.style.display = ""; let html = ``; html += `

From last deploy (${esc(dw.deployed || "—")}) — ` + `re-run sudo ./deploy.sh after fixing to clear.

`; document.getElementById("deploywarn").innerHTML = html; } /* ---------- Access Point ---------- */ function parseAP(a, i, secs) { const active = (secs[`active${i}`] || "").trim() || "inactive"; const info = secs[`info${i}`] || ""; const ssid = (info.match(/\bssid (.+)/) || [])[1]; const chan = (info.match(/\bchannel \d+[^\n]*/) || [])[0]; const width = (info.match(/\bwidth: ([^\n,]+)/) || [])[1]; const stations = (secs[`stations${i}`] || "").split(/Station /).slice(1).map(b => ({ mac: b.split(" ")[0], sig: (b.match(/signal:\s*([\-\d]+)/) || [])[1], tx: (b.match(/tx bitrate:\s*([\d.]+ MBit\/s)/) || [])[1] })); return { band: a.band, unit: a.unit, active, ssid, chan, width, stations }; } function parseClientDir(secs) { // MAC -> { ip, host } for AP clients. Kernel neighbor table first (covers // static-IP clients), then dnsmasq leases on top (authoritative + hostname). const dir = {}; parseJSON(secs.neigh, []).forEach(n => { if (n.lladdr && n.dst && !n.dst.includes(":")) // IPv4 only dir[n.lladdr.toLowerCase()] = { ip: n.dst, host: null }; }); // lease line: (secs.leases || "").trim().split("\n").forEach(l => { const f = l.split(" "); if (f.length >= 4) dir[f[1].toLowerCase()] = { ip: f[2], host: f[3] === "*" ? null : f[3] }; }); return dir; } function renderAPs(aps) { const el = document.getElementById("ap"); el.innerHTML = ""; aps.forEach(ap => { const beaconing = !!ap.ssid; const div = document.createElement("div"); div.className = "ap-band"; let html = `

${esc(ap.band)}   ${esc(ap.unit)}: ` + `${esc(ap.active)}`; if (beaconing) html += `   SSID ${esc(ap.ssid)}   ${esc(ap.chan || "")}   ${esc(ap.width || "")}`; else html += `   not beaconing`; html += `   Clients: ${ap.stations.length}` + `

`; div.innerHTML = html; div.querySelector(".ap-restart").onclick = () => restartAP(ap.unit); el.appendChild(div); }); } /* ---------- Clients (Wi-Fi stations + wired bridge-port FDB) ---------- */ function collectClients(aps, secs, dir) { // Wi-Fi clients come from the hostapd station dumps (with signal/rate); // wired ones from learned bridge-FDB entries on the LAN ports. const rows = []; const seen = new Set(); aps.forEach(ap => ap.stations.forEach(s => { const c = dir[s.mac.toLowerCase()] || {}; seen.add(s.mac.toLowerCase()); rows.push({ conn: ap.band, mac: s.mac, ip: c.ip, host: c.host, sig: s.sig ? s.sig + " dBm" : "?", tx: s.tx || "?" }); })); parseJSON(secs.fdb, []).forEach(e => { const conn = LAN_PORTS[e.ifname]; const mac = (e.mac || "").toLowerCase(); // learned entries only: "permanent" = the port's own MAC, and FDB // duplicates entries per vlan — hence the seen-dedupe. if (!conn || e.master !== "br0" || e.state === "permanent" || seen.has(mac)) return; seen.add(mac); const c = dir[mac] || {}; rows.push({ conn, mac: e.mac, ip: c.ip, host: c.host, sig: "—", tx: "—" }); }); rows.sort((a, b) => a.conn.localeCompare(b.conn) || (a.ip || "").localeCompare(b.ip || "")); return rows; } function renderClients(rows) { const el = document.getElementById("clients"); if (!rows.length) { el.innerHTML = `

No clients connected.

`; return; } let html = `` + ``; rows.forEach(r => { html += `` + `` + ``; }); html += `
HostnameIPMACConnectionSignalTX rate
${esc(r.host || "—")}${esc(r.ip || "—")}${esc(r.mac)}${esc(r.conn)}${esc(r.sig)}${esc(r.tx)}
`; el.innerHTML = html; } /* ---------- Temperatures (van-thermal daemon state) ---------- */ function renderThermal(th) { const el = document.getElementById("thermal"); if (!th || !th.sensors) { el.innerHTML = `

van-thermal daemon not running (no state file).

`; return; } const pillClass = { ok: "ok", warn: "warn", crit: "bad" }; let html = ``; Object.keys(th.sensors).forEach(name => { const s = th.sensors[name]; let value, limits; if (s.kind === "fan") { value = s.rpm == null ? "—" : `${esc(s.rpm)} RPM`; limits = s.pwm == null ? "—" : `pwm ${esc(s.pwm)}/255`; } else if (s.kind === "undervolt") { value = s.now == null ? "—" : s.now ? "undervoltage" : s.since_boot ? "dip since boot" : "ok"; limits = "—"; } else { value = s.temp == null ? "—" : `${esc(s.temp)} °C`; limits = `${esc(s.warn)} / ${esc(s.crit)} °C`; } const lvl = s.level || "ok"; html += `` + `` + ``; }); html += `
SensorValueStatusLimits
${esc(name.toUpperCase())}${value}${esc(lvl)}${limits}
`; html += `

History: /var/log/van-thermal.csv · alerts: journalctl -u van-thermal

`; el.innerHTML = html; } /* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */ function fmtMbps(bps) { return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps"; } function fmtUptime(s) { s = parseInt(s, 10); if (isNaN(s)) return "—"; const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return (d ? `${d}d ` : "") + (d || h ? `${h}h ` : "") + `${m}m`; } function renderStarlink(raw) { const el = document.getElementById("starlink"); const t = (raw || "").trim(); if (!t || t === "ABSENT") { el.innerHTML = `

Starlink adapter (${esc(STARLINK.iface)}) not plugged in.

`; return; } if (t === "NOGRPCURL") { el.innerHTML = `

grpcurl not installed — the dish status API is gRPC. ` + `arm64 binary: github.com/fullstorydev/grpcurl/releases

`; return; } if (t === "UNREACHABLE" || t[0] !== "{") { el.innerHTML = `

dish unreachable ` + `adapter present but 192.168.100.1 not answering ` + `(dish booting / unpowered / route missing?)

`; return; } const st = (parseJSON(t, {}) || {}).dishGetStatus || {}; const obs = st.obstructionStats || {}; const alerts = Object.keys(st.alerts || {}).filter(k => st.alerts[k]); let state; if (st.outage) state = `${esc(st.outage.cause || "OUTAGE")}`; else if (obs.currentlyObstructed) state = `obstructed`; else state = `online`; let html = `

${state}` + (alerts.length ? ` alerts: ${esc(alerts.join(", "))}` : "") + `   uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` + ` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}

`; html += ``; html += `` + `` + `` + ``; html += `
Latency (PoP)DownUpObstructed
${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"}${esc(fmtMbps(st.downlinkThroughputBps))}${esc(fmtMbps(st.uplinkThroughputBps))}${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"}
`; html += `

Dish web UI: http://192.168.100.1 (from the van LAN)

`; el.innerHTML = html; } /* ---------- Battery / power source (van-battery daemon state) ---------- */ function renderBattery(b) { const el = document.getElementById("battery"); if (!b || b.capacity == null) { el.innerHTML = `

van-battery daemon not running (no state file).

`; return; } const cap = b.capacity; const shut = b.shutdown_level != null ? b.shutdown_level : 10; const warns = b.warn_levels || []; const lowest = warns.length ? Math.min.apply(null, warns) : 25; // Charge severity only bites while on battery; on mains it's just charging/full. let lvl = "ok"; if (b.on_battery) lvl = cap <= shut ? "crit" : cap <= lowest ? "warn" : "ok"; const pillClass = { ok: "ok", warn: "warn", crit: "bad" }[lvl]; const src = b.on_battery ? `on battery` : `on mains`; let html = `

Power: ${src}   Charge: ${esc(cap)}%

`; html += `
`; if (b.on_battery && b.alerted_below != null) html += `

Low-battery alert sent at ${esc(b.alerted_below)}%.

`; html += `

On battery: alerts at ${esc(warns.join("/"))}%, auto-shutdown at ${esc(shut)}%.` + ` Alerts: journalctl -u van-battery

`; el.innerHTML = html; } /* ---------- WAN failover (van-failover daemon state) ---------- */ function renderFailover(fo) { const el = document.getElementById("failover"); if (!fo || !fo.wans) { el.innerHTML = `

van-failover daemon not running (no state file).

`; return; } let html = `

Active egress: ${esc(fo.active_device || "—")}` + ` · updated ${esc(fo.updated || "")}

`; html += ``; fo.wans.forEach(w => { const status = !w.present ? `absent` : w.up ? `up` : `down`; const prio = w.preferred ? `${esc(w.priority)} (preferred)` : esc(w.priority); html += `` + `` + ``; }); html += `
WANPriorityDeviceStatusMetric
${w.active ? "★ " : ""}${esc(w.name)}${prio}${esc(w.device || "—")}${status}${w.route_metric != null ? esc(w.route_metric) : "—"}
`; el.innerHTML = html; } /* ---------- Wi-Fi network selector (wlan0 WAN) ---------- */ // The onboard radio used for the WiFi WAN — unlike the AP dongles it's never // templated, this host only ever has the one. const WIFI_IFACE = "wlan0"; // SSIDs the van's own AP radios are currently beaconing (kept live from refresh()'s // `iw dev ... info` parse, not hardcoded) — wlan0 "connecting" to its own AP would be // a nonsensical loop, so those rows get their Connect button disabled below. let ownAPSSIDs = new Set(); // nmcli -t escapes ':' inside field values as '\:' — split on unescaped ':' only. // SSIDs are environment-controlled strings (the van parks near arbitrary APs), so // worth handling properly rather than a naive split(":"). function splitNmcli(line) { const parts = []; let cur = ""; for (let i = 0; i < line.length; i++) { if (line[i] === "\\" && line[i + 1] === ":") { cur += ":"; i++; } else if (line[i] === ":") { parts.push(cur); cur = ""; } else cur += line[i]; } parts.push(cur); return parts; } function renderWifiList(rows) { const el = document.getElementById("wifi"); if (!rows.length) { el.innerHTML = `

No networks found. Try Scan.

`; return; } let html = ``; rows.forEach(r => { const ownAP = ownAPSSIDs.has(r.ssid); const label = r.inUse ? "Connected" : ownAP ? "Own AP" : "Connect"; html += `` + `` + ``; }); html += `
SSIDSignalSecurity
${r.inUse ? "★ " : ""}${esc(r.ssid)}${esc(r.signal)}%${esc(r.security || "open")}
`; el.innerHTML = html; el.querySelectorAll(".wifi-connect").forEach((btn, i) => { const r = rows[i]; btn.disabled = r.inUse || ownAPSSIDs.has(r.ssid); btn.onclick = () => connectWifi(r); }); } async function scanWifi() { const el = document.getElementById("wifi"); el.innerHTML = `

Scanning…

`; try { // Actually triggering a rescan (not just listing NM's cache) needs the // org.freedesktop.NetworkManager.wifi.scan polkit action, which a plain // Cockpit session doesn't have — without escalation this silently returns // only the cached/connected AP instead of erroring. const out = await run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "ifname", WIFI_IFACE, "--rescan", "yes"], { superuser: "require" }); const seen = new Set(); const rows = out.trim().split("\n").filter(Boolean).map(splitNmcli).map(f => ({ inUse: f[0].indexOf("*") !== -1, ssid: f[1], signal: f[2], security: f[3], })).filter(r => { if (!r.ssid || seen.has(r.ssid)) return false; seen.add(r.ssid); return true; }).sort((a, b) => (b.inUse - a.inUse) || (parseInt(b.signal, 10) - parseInt(a.signal, 10))); renderWifiList(rows); } catch (e) { el.innerHTML = `

Scan failed: ${esc(e.message)}

`; } } async function connectWifi(r) { if (ownAPSSIDs.has(r.ssid)) return; // belt-and-suspenders; button is disabled too // Try without a password first — reuses a saved profile's stored secret (or // just works for an open network); only prompt if NM actually needs one. try { await run(["nmcli", "device", "wifi", "connect", r.ssid, "ifname", WIFI_IFACE], { superuser: "require" }); scanWifi(); return; } catch (e) { if (!/secret|password|psk/i.test(e.message || "")) { window.alert("Connect failed: " + e.message); return; } } const pwd = window.prompt(`Password for "${r.ssid}":`); if (!pwd) return; try { await run(["nmcli", "device", "wifi", "connect", r.ssid, "password", pwd, "ifname", WIFI_IFACE], { superuser: "require" }); } catch (e) { window.alert("Connect failed: " + e.message); } scanWifi(); } document.getElementById("wifi-scan").onclick = scanWifi; /* ---------- WAN / uplinks ---------- */ // mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem). // NM's gsm device is the control port (cdc-wdm0); IP/routes live on the wwan netdev. function parseModem(secs) { const kv = {}; (secs.modem || "").trim().split("\n").forEach(l => { const i = l.indexOf(":"); if (i > 0) kv[l.slice(0, i).trim()] = l.slice(i + 1).trim(); }); if (!kv["modem.generic.state"]) return null; let netdev = null; const techs = []; Object.keys(kv).forEach(k => { if (k.startsWith("modem.generic.ports.value")) { const m = kv[k].match(/^(\S+) \(net\)$/); if (m) netdev = m[1]; } if (k.startsWith("modem.generic.access-technologies.value")) techs.push(kv[k]); }); const op = kv["modem.3gpp.operator-name"]; return { netdev, signal: kv["modem.generic.signal-quality.value"], tech: techs.join("/"), operator: op === "--" ? null : op, }; } function parseWAN(secs) { const modem = parseModem(secs); const devs = (secs.devices || "").trim().split("\n").filter(Boolean).map(line => { const [device, type, state, ...rest] = line.split(":"); return { device, type, state, connection: rest.join(":"), nmdev: device }; }).filter(d => (d.type === "ethernet" || d.type === "wifi" || d.type === "gsm") && d.state !== "unmanaged" && // networkd-owned LAN ports (eth0, enx* dongle) !APS.some(a => a.iface === d.device)); const routes = parseJSON(secs.routes, []); const metricByDev = {}; routes.forEach(r => { if (r.dev) metricByDev[r.dev] = r.metric; }); const activeDev = routes.length ? routes.slice().sort((a, b) => (a.metric || 0) - (b.metric || 0))[0].dev : null; const ipByDev = {}; parseJSON(secs.addrs, []).forEach(a => { const info = (a.addr_info || []).find(x => x.family === "inet"); if (info) ipByDev[a.ifname] = info.local + "/" + info.prefixlen; }); devs.forEach(d => { if (d.type === "gsm" && modem) { // show the routed netdev; keep nmdev (cdc-wdm0) for nmcli actions if (modem.netdev) d.device = modem.netdev; if (modem.signal != null) d.signal = `${esc(modem.signal)}%` + (modem.tech || modem.operator ? ` ${esc([modem.tech, modem.operator].filter(Boolean).join(" · "))}` : ""); } d.metric = metricByDev[d.device]; d.ip = ipByDev[d.device]; d.active = d.device === activeDev; }); return devs; } // NM device states that count as "ready to serve traffic" vs. transient vs. // broken/unusable — drives the state-column pill color. const WAN_STATE_PILL = { connected: "ok", connecting: "warn" }; function renderWAN(devs) { const tb = document.getElementById("wan"); tb.innerHTML = ""; devs.forEach(d => { const isConnected = d.state === "connected"; // No carrier: nmcli can't connect a device with nothing on the other // end of the wire, so the button would just fail — disable it rather // than let it produce a silent "Toggle failed" alert. const noCarrier = d.state === "unavailable"; const tr = document.createElement("tr"); tr.innerHTML = `${d.active ? "★ " : ""}${esc(d.device)}` + `${esc(d.type)}` + `${esc(d.state)}` + `${esc(d.ip || "—")}` + `${d.signal || "—"}` + `${d.metric != null ? esc(d.metric) : "—"}` + ``; const acts = tr.querySelector(".acts"); if (d.connection) { const pref = document.createElement("button"); pref.className = "btn"; pref.textContent = "Prefer"; pref.disabled = d.active; pref.onclick = () => preferWAN(d); acts.appendChild(pref); } const tog = document.createElement("button"); tog.className = "btn"; tog.textContent = isConnected ? "Disconnect" : "Connect"; tog.disabled = !isConnected && noCarrier; tog.onclick = () => toggleWAN(d, isConnected); acts.appendChild(tog); if (d.type === "gsm") { const rst = document.createElement("button"); rst.className = "btn"; rst.textContent = "Restart"; rst.onclick = () => restartModem(); acts.appendChild(rst); } tb.appendChild(tr); }); } async function preferWAN(chosen) { // Don't touch metrics directly — van-failover owns them and would revert us within // one probe cycle (and `nmcli device reapply` flaps the r8152 USB carrier). Instead // record the preference; the daemon gives this device the lowest base metric. try { await sh(`mkdir -p ${esc_sh(PREFER_FILE.replace(/\/[^/]*$/, ""))} && ` + `printf %s ${esc_sh(chosen.device)} > ${esc_sh(PREFER_FILE)}`, { superuser: "require" }); } catch (e) { window.alert("Prefer failed: " + e.message); } setTimeout(refresh, 4500); // daemon enforces on its next probe loop } async function toggleWAN(d, isConnected) { try { const verb = isConnected ? "disconnect" : "connect"; await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" }); } catch (e) { window.alert("Toggle failed: " + e.message); } refresh(); } async function restartModem() { // Radio bounce via ModemManager (the EC25 MBIM plugin doesn't support --reset); // the autoconnect Koodo profile reconnects on enable (verified). If MM can't // talk to the modem at all, fall back to a USB unbind/bind (found by Quectel // vendor id) so the whole driver stack + MM re-probe the device. const script = `if ! { mmcli -m any --disable && mmcli -m any --enable; }; then ` + `dev=""; for f in /sys/bus/usb/devices/*/idVendor; do ` + `[ "$(cat "$f")" = ${esc_sh(MODEM_USB_VENDOR)} ] && { dev=$(basename "$(dirname "$f")"); break; }; done; ` + `[ -n "$dev" ] || { echo "no modem USB device found" >&2; exit 1; }; ` + `echo "$dev" > /sys/bus/usb/drivers/usb/unbind; sleep 3; ` + `echo "$dev" > /sys/bus/usb/drivers/usb/bind; fi`; try { await sh(script, { superuser: "require" }); } catch (e) { window.alert("Modem restart failed: " + e.message); } setTimeout(refresh, 8000); } async function restartAP(unit) { try { await run(["systemctl", "restart", unit], { superuser: "require" }); } catch (e) { window.alert("Restart failed: " + e.message); } setTimeout(refresh, 2500); } /* ---------- loop ---------- */ async function refresh() { try { const secs = parseSections(await sh(STATUS_SCRIPT)); renderDeployWarnings(parseJSON(secs.deploywarn, null)); const aps = APS.map((a, i) => parseAP(a, i, secs)); ownAPSSIDs = new Set(aps.map(a => a.ssid).filter(Boolean)); renderAPs(aps); renderClients(collectClients(aps, secs, parseClientDir(secs))); renderThermal(parseJSON(secs.thermal, null)); renderStarlink(secs.starlink); renderBattery(parseJSON(secs.battery, null)); renderFailover(parseJSON(secs.failover, null)); renderWAN(parseWAN(secs)); document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString(); } catch (e) { document.getElementById("updated").textContent = "error: " + (e.message || e); } } refresh(); setInterval(refresh, 5000);