← 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();
})();