New "Clients" card lists everything on the van LAN in one table — hostname, IP, MAC, connection (5GHz / 2.4GHz / LAN eth0 / LAN USB), signal, TX rate. Wi-Fi rows come from the hostapd station dumps as before; wired rows from learned bridge-FDB entries on the LAN ports (the port's own MAC is "permanent", and FDB duplicates entries per vlan — both filtered), enriched with IP/hostname from dnsmasq leases + neighbor table. The per-band station tables move out of the Access Points card (which keeps its status line + client count), and the WAN/Uplinks table now hides unmanaged devices so the networkd-owned LAN ports don't show up as ghost WAN rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
444 lines
19 KiB
JavaScript
444 lines
19 KiB
JavaScript
"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.
|
|
const APS = [
|
|
{ iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU
|
|
{ iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU
|
|
];
|
|
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)", "enx00e04c331140": "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: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" };
|
|
|
|
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:<name>@@ 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("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("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; }
|
|
}
|
|
|
|
/* ---------- 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: <expiry-epoch> <mac> <ip> <hostname|*> <client-id>
|
|
(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 = `<p><b>${esc(ap.band)}</b> ${esc(ap.unit)}: ` +
|
|
`<span class="pill ${ap.active === "active" ? "ok" : "bad"}">${esc(ap.active)}</span>`;
|
|
if (beaconing)
|
|
html += ` SSID <b>${esc(ap.ssid)}</b> ${esc(ap.chan || "")} ${esc(ap.width || "")}`;
|
|
else
|
|
html += ` <span class="pill bad">not beaconing</span>`;
|
|
html += ` Clients: <b>${ap.stations.length}</b>` +
|
|
` <button class="btn ap-restart">Restart</button></p>`;
|
|
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 = `<p class="muted">No clients connected.</p>`;
|
|
return;
|
|
}
|
|
let html = `<table><thead><tr><th>Hostname</th><th>IP</th><th>MAC</th>` +
|
|
`<th>Connection</th><th>Signal</th><th>TX rate</th></tr></thead><tbody>`;
|
|
rows.forEach(r => {
|
|
html += `<tr><td>${esc(r.host || "—")}</td><td>${esc(r.ip || "—")}</td>` +
|
|
`<td>${esc(r.mac)}</td><td>${esc(r.conn)}</td>` +
|
|
`<td>${esc(r.sig)}</td><td>${esc(r.tx)}</td></tr>`;
|
|
});
|
|
html += `</tbody></table>`;
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
/* ---------- Temperatures (van-thermal daemon state) ---------- */
|
|
|
|
function renderThermal(th) {
|
|
const el = document.getElementById("thermal");
|
|
if (!th || !th.sensors) {
|
|
el.innerHTML = `<p class="muted">van-thermal daemon not running (no state file).</p>`;
|
|
return;
|
|
}
|
|
const pillClass = { ok: "ok", warn: "warn", crit: "bad" };
|
|
let html = `<table><thead><tr><th>Sensor</th><th>Value</th><th>Status</th><th>Limits</th></tr></thead><tbody>`;
|
|
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 += `<tr><td>${esc(name.toUpperCase())}</td><td>${value}</td>` +
|
|
`<td><span class="pill ${pillClass[lvl] || ""}">${esc(lvl)}</span></td>` +
|
|
`<td class="muted">${limits}</td></tr>`;
|
|
});
|
|
html += `</tbody></table>`;
|
|
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
|
|
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 = `<p class="muted">Starlink adapter (<code>${esc(STARLINK.iface)}</code>) not plugged in.</p>`;
|
|
return;
|
|
}
|
|
if (t === "NOGRPCURL") {
|
|
el.innerHTML = `<p class="muted">grpcurl not installed — the dish status API is gRPC. ` +
|
|
`arm64 binary: <code>github.com/fullstorydev/grpcurl/releases</code></p>`;
|
|
return;
|
|
}
|
|
if (t === "UNREACHABLE" || t[0] !== "{") {
|
|
el.innerHTML = `<p><span class="pill bad">dish unreachable</span> ` +
|
|
`<span class="muted">adapter present but 192.168.100.1 not answering ` +
|
|
`(dish booting / unpowered / route missing?)</span></p>`;
|
|
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 = `<span class="pill bad">${esc(st.outage.cause || "OUTAGE")}</span>`;
|
|
else if (obs.currentlyObstructed)
|
|
state = `<span class="pill warn">obstructed</span>`;
|
|
else
|
|
state = `<span class="pill ok">online</span>`;
|
|
|
|
let html = `<p>${state}` +
|
|
(alerts.length ? ` <span class="pill warn">alerts: ${esc(alerts.join(", "))}</span>` : "") +
|
|
` <span class="muted">uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` +
|
|
` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}</span></p>`;
|
|
html += `<table><thead><tr><th>Latency (PoP)</th><th>Down</th><th>Up</th><th>Obstructed</th></tr></thead><tbody>`;
|
|
html += `<tr><td>${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"}</td>` +
|
|
`<td>${esc(fmtMbps(st.downlinkThroughputBps))}</td>` +
|
|
`<td>${esc(fmtMbps(st.uplinkThroughputBps))}</td>` +
|
|
`<td>${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"}</td></tr>`;
|
|
html += `</tbody></table>`;
|
|
html += `<p class="muted">Dish web UI: <code>http://192.168.100.1</code> (from the van LAN)</p>`;
|
|
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 = `<p class="muted">van-battery daemon not running (no state file).</p>`;
|
|
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
|
|
? `<span class="pill warn">on battery</span>`
|
|
: `<span class="pill ok">on mains</span>`;
|
|
|
|
let html = `<p>Power: ${src} Charge: <span class="pill ${pillClass}">${esc(cap)}%</span></p>`;
|
|
html += `<div class="bar"><div class="bar-fill ${lvl}" style="width:${Math.max(0, Math.min(100, cap))}%"></div></div>`;
|
|
if (b.on_battery && b.alerted_below != null)
|
|
html += `<p class="muted">Low-battery alert sent at ${esc(b.alerted_below)}%.</p>`;
|
|
html += `<p class="muted">On battery: alerts at ${esc(warns.join("/"))}%, auto-shutdown at ${esc(shut)}%.` +
|
|
` Alerts: <code>journalctl -u van-battery</code></p>`;
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
/* ---------- WAN failover (van-failover daemon state) ---------- */
|
|
|
|
function renderFailover(fo) {
|
|
const el = document.getElementById("failover");
|
|
if (!fo || !fo.wans) {
|
|
el.innerHTML = `<p class="muted">van-failover daemon not running (no state file).</p>`;
|
|
return;
|
|
}
|
|
let html = `<p>Active egress: <b>${esc(fo.active_device || "—")}</b>` +
|
|
` <span class="muted">· updated ${esc(fo.updated || "")}</span></p>`;
|
|
html += `<table><thead><tr><th>WAN</th><th>Priority</th><th>Device</th><th>Status</th><th>Metric</th></tr></thead><tbody>`;
|
|
fo.wans.forEach(w => {
|
|
const status = !w.present ? `<span class="pill">absent</span>`
|
|
: w.up ? `<span class="pill ok">up</span>`
|
|
: `<span class="pill bad">down</span>`;
|
|
const prio = w.preferred ? `${esc(w.priority)} <span class="muted">(preferred)</span>` : esc(w.priority);
|
|
html += `<tr><td class="${w.active ? "active-wan" : ""}">${w.active ? "★ " : ""}${esc(w.name)}</td>` +
|
|
`<td>${prio}</td><td>${esc(w.device || "—")}</td>` +
|
|
`<td>${status}</td><td>${w.route_metric != null ? esc(w.route_metric) : "—"}</td></tr>`;
|
|
});
|
|
html += `</tbody></table>`;
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
/* ---------- WAN / uplinks ---------- */
|
|
|
|
function parseWAN(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(":") };
|
|
}).filter(d => (d.type === "ethernet" || d.type === "wifi") &&
|
|
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 => {
|
|
d.metric = metricByDev[d.device];
|
|
d.ip = ipByDev[d.device];
|
|
d.active = d.device === activeDev;
|
|
});
|
|
return devs;
|
|
}
|
|
|
|
function renderWAN(devs) {
|
|
const tb = document.getElementById("wan");
|
|
tb.innerHTML = "";
|
|
devs.forEach(d => {
|
|
const isUp = d.state === "connected";
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML =
|
|
`<td class="${d.active ? "active-wan" : ""}">${d.active ? "★ " : ""}${esc(d.device)}</td>` +
|
|
`<td>${esc(d.type)}</td>` +
|
|
`<td>${esc(d.state)}</td>` +
|
|
`<td>${esc(d.ip || "—")}</td>` +
|
|
`<td>${d.metric != null ? esc(d.metric) : "—"}</td>` +
|
|
`<td class="acts"></td>`;
|
|
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 = isUp ? "Down" : "Up";
|
|
tog.onclick = () => toggleWAN(d, isUp);
|
|
acts.appendChild(tog);
|
|
|
|
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, isUp) {
|
|
try {
|
|
const verb = isUp ? "disconnect" : "connect";
|
|
await run(["nmcli", "device", verb, d.device], { superuser: "require" });
|
|
} catch (e) { window.alert("Toggle failed: " + e.message); }
|
|
refresh();
|
|
}
|
|
|
|
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));
|
|
const aps = APS.map((a, i) => parseAP(a, i, secs));
|
|
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);
|