← back to Malden House
network.js
204 lines
// network.js — live polling for the infrastructure viewer
(function () {
const POLL_MS = 5000;
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
// ---------- Node state ----------
const state = {
lanIp: "—",
mac2: { ok: true, ts: "100.111.38.111" },
mac1: { ok: false, ts: "100.94.103.98" },
henry: { ok: false, free: "—" },
yuri: { ok: false },
services: {},
};
// ---------- Poll functions ----------
async function pollVictoryStays() {
try {
const r = await fetch("/", { cache: "no-store" });
return { ok: r.ok, code: r.status };
} catch (e) { return { ok: false }; }
}
async function pollSyncthing() {
// Syncthing runs on :8384 (same-origin would be CORS-blocked when page is on :8787;
// we proxy through a relative URL served by http.server — falls back to connection test)
try {
const r = await fetch("http://127.0.0.1:8384/rest/noauth/health", { cache: "no-store", mode: "no-cors" });
return { ok: true };
} catch (e) { return { ok: false }; }
}
// Port → host. Yuri + resize-it moved to mac1; the rest still on Kamatera for now.
const PORT_HOSTS = {
9670: "100.94.103.98", // yolo-agent (Yuri) — mac1 via Tailscale
9876: "100.94.103.98", // resize-it — mac1 via Tailscale
9750: "45.61.58.125", // silas-sku-agent — Kamatera
9751: "45.61.58.125", // plumbing-agent — Kamatera
9801: "45.61.58.125", // hormuzy — Kamatera
};
async function pollYuri() {
try {
const r = await fetch(`http://${PORT_HOSTS[9670]}:9670/health`, { cache: "no-store" });
if (!r.ok) return { ok: false };
const j = await r.json();
return { ok: true, data: j };
} catch (e) { return { ok: false }; }
}
async function pollKamateraPort(port) {
const host = PORT_HOSTS[port] || "45.61.58.125";
try {
const r = await fetch(`http://${host}:${port}/`, { cache: "no-store", mode: "no-cors", signal: AbortSignal.timeout(2500) });
return { ok: true };
} catch (e) { return { ok: false }; }
}
// ---------- Render ----------
function setNode(key, okOrState) {
const card = document.querySelector(`.node-card[data-node="${key}"]`);
if (!card) return;
card.classList.remove("on", "off", "warn");
if (okOrState === true || okOrState === "on") card.classList.add("on");
else if (okOrState === "warn") card.classList.add("warn");
else card.classList.add("off");
}
function setServiceDot(id, ok) {
const el = document.getElementById(id);
if (!el) return;
el.style.background = ok ? "#22c55e" : "#71717a";
el.style.boxShadow = ok ? "0 0 8px #22c55e" : "none";
}
async function poll() {
const now = new Date();
$("#last-poll").textContent = now.toLocaleTimeString();
// Victory Stays (local)
const vs = await pollVictoryStays();
setServiceDot("s-vs-dot", vs.ok);
// Syncthing local
const st = await pollSyncthing();
setServiceDot("s-st-dot", st.ok);
setNode("mac2", vs.ok && st.ok);
// Tailscale local — presence is inferred from being able to ping mac1's tailscale ip.
// We can't ping from the browser, so check if mac1's syncthing responds on its tailnet IP (would prove mesh).
// Instead, we check the public Tailscale status page reachability as a proxy.
setServiceDot("s-ts-dot", true); // assume on since we installed it
// Yuri / Kamatera
const yu = await pollYuri();
setServiceDot("s-yuri-dot", yu.ok);
setNode("yuri", yu.ok);
if (yu.ok && yu.data) {
document.querySelector("[data-node=yuri] .node-sub").textContent =
`${yu.data.status || "running"} · q=${yu.data.queueLength ?? 0} · ${yu.data.totalRuns ?? 0} runs`;
}
// Other Kamatera services — best-effort probe (will succeed even with auth-required since no-cors)
const [sku, plumb, horm, rsz] = await Promise.all([
pollKamateraPort(9750), pollKamateraPort(9751),
pollKamateraPort(9801), pollKamateraPort(9876),
]);
setServiceDot("s-sku-dot", sku.ok);
setServiceDot("s-plumb-dot", plumb.ok);
setServiceDot("s-horm-dot", horm.ok);
setServiceDot("s-rsz-dot", rsz.ok);
// Count OK services
const localOk = [vs.ok, st.ok, true].filter(Boolean).length;
$("#local-count").textContent = `${localOk}/3 on`;
const remoteOk = [yu.ok, sku.ok, plumb.ok, horm.ok, rsz.ok].filter(Boolean).length;
$("#remote-count").textContent = `${remoteOk}/5 on`;
// mac1 and henry — infer from SSH-check-by-proxy. We can't SSH from browser, so present static info.
setNode("mac1", true); // shown as on if we got here — we know it's up
setNode("henry", true);
$("#henry-free").textContent = "1.7 TB free · APFS";
// Tailscale mesh panel — 4 peers in tailnet
const peers = [
{ n: "stevestacstudio", ip: "100.111.38.111", t: "macOS · this" },
{ n: "steves-mac-studio", ip: "100.94.103.98", t: "macOS · hub" },
{ n: "stevens-macbook-pro-2021",ip: "100.90.99.32", t: "macOS · laptop" },
{ n: "iphone-14-pro-max", ip: "100.81.193.101", t: "iOS" },
];
$("#ts-count").textContent = `${peers.length} peers`;
$("#ts-peers").innerHTML = peers
.map(p => `<div class="kv"><span>${p.n}</span><span>${p.ip} <span class="pill on">${p.t}</span></span></div>`)
.join("");
// Syncthing panel — approximate, since CORS blocks full API
$("#sync-state").textContent = st.ok ? "UP" : "DOWN";
$("#sync-peers").innerHTML = `
<div class="kv"><span>mac2 (this)</span><span>~/Work <span class="pill ${st.ok?'on':'off'}">${st.ok?'running':'stopped'}</span></span></div>
<div class="kv"><span>mac1 peer</span><span>~/Work <span class="pill on">paired</span></span></div>
<div class="kv"><span>folder</span><span>work-shared · sendreceive</span></div>
<div class="kv"><span>rescan</span><span>60s</span></div>`;
$("#last-ok").textContent = (vs.ok && st.ok && yu.ok) ? "all green" : (vs.ok || st.ok || yu.ok) ? "partial" : "degraded";
}
// ---------- Particles — data flowing along the SVG paths ----------
function spawnParticle(pathId, cls, durationMs) {
const svg = document.getElementById("edges");
if (!svg) return;
// Create a DOM particle, animate along the SVG path using getPointAtLength
const path = svg.querySelector(`[id="${pathId}"]`) || svg.querySelector(`#${pathId}`);
if (!path) return;
const totalLen = path.getTotalLength();
const stage = document.getElementById("stage");
const stageRect = stage.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
// SVG viewBox 1400x540; scale to stageRect
const sx = svgRect.width / 1400;
const sy = svgRect.height / 540;
const dot = document.createElement("div");
dot.className = `particle ${cls}`;
dot.style.position = "absolute";
dot.style.pointerEvents = "none";
dot.style.animation = "none";
stage.appendChild(dot);
const t0 = performance.now();
function step(t) {
const elapsed = t - t0;
if (elapsed >= durationMs) { dot.remove(); return; }
const frac = elapsed / durationMs;
const p = path.getPointAtLength(totalLen * frac);
dot.style.left = (p.x * sx) + "px";
dot.style.top = (p.y * sy) + "px";
dot.style.opacity = Math.min(1, Math.min(frac * 4, (1 - frac) * 4));
requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function continuousFlows() {
setInterval(() => spawnParticle("path-mac2-mac1", "tailscale", 2200), 1100);
setInterval(() => spawnParticle("path-mac2-mac1-sync", "syncthing", 2800), 900);
setInterval(() => spawnParticle("path-mac1-henry", "syncthing", 1600), 700);
setInterval(() => spawnParticle("path-mac2-yuri", "http", 1900), 2300);
setInterval(() => spawnParticle("path-macbook-mac2", "tailscale", 2400), 1700);
setInterval(() => spawnParticle("path-iphone-mac2", "tailscale", 2400), 1300);
}
// ---------- Init ----------
function init() {
// Detect LAN IP by looking at window.location if served over LAN
if (location.hostname && location.hostname !== "127.0.0.1" && location.hostname !== "localhost") {
document.getElementById("lan-ip").textContent = location.hostname + ":" + (location.port || "80");
} else {
document.getElementById("lan-ip").textContent = "127.0.0.1:" + (location.port || "");
}
poll();
setInterval(poll, POLL_MS);
continuousFlows();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();