Wiring view: every machine and what connects it (Companion, MIDI, Dante, watchdog, web, Art-Net), hide types or single links, scripts per computer
RESI LIVE NOT REC follows the Resi auto-record service (custom autorec_status, not during its REC hold; stale heartbeat = no verdict); INGEST watches AUTO REC (running, heartbeat, Resi reachable), hold restarts it. Probes list each computer's scripts by file name only. HyperDeck addresses read from bonjourHost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+54
-8
@@ -27,7 +27,7 @@ MAC_PROBE = (
|
||||
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
|
||||
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
|
||||
'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; '
|
||||
'echo SP_NOW; date +%s; echo SP_END'
|
||||
'echo SP_NOW; date +%s; echo SP_SCRIPTS; ps -axo pid=,args= 2>/dev/null | grep -E \'[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt)( |$)\' | grep -v SP_SCRIPTS; echo SP_END'
|
||||
)
|
||||
WIN_PROBE = (
|
||||
'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & '
|
||||
@@ -35,8 +35,15 @@ WIN_PROBE = (
|
||||
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
|
||||
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
|
||||
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
|
||||
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END'
|
||||
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_SCRIPTS & powershell -NoProfile -Command '
|
||||
'"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match \'[.](ps1|py|js|bat|cmd|vbs)\' } | '
|
||||
'ForEach-Object { [string]$_.ProcessId + \' \' + $_.CommandLine }" 2>nul & echo SP_END'
|
||||
)
|
||||
# A script: a file run by an interpreter (its name only is kept, never the rest of the command line).
|
||||
SCRIPT_RE = r"[^ /\\]+[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt|ps1|bat|cmd|vbs)( |\"|$)"
|
||||
# Not the user's scripts: programs' own insides (Companion's modules, app bundles, the system).
|
||||
NOT_SCRIPTS = (".app/Contents/", "/companion/modules/", "/System/", "/usr/libexec/", "/Library/Apple/", "\\Windows\\",
|
||||
"/node_modules/", "Program Files")
|
||||
|
||||
|
||||
def shell_path(path):
|
||||
@@ -113,6 +120,38 @@ def parse_sc(lines):
|
||||
return services
|
||||
|
||||
|
||||
def parse_scripts(lines, agents=None, tasks=None):
|
||||
"""The scripts a computer runs: [{"name", "pid", "by", "running"}]. From `ps` (pid, command line) lines,
|
||||
our own launchd jobs (org.waterschurch.*, linked to their script by pid) and the \\StatusPanel\\ tasks
|
||||
({path: state}). Only a script's file name is kept, never the rest of its command line."""
|
||||
agents, out, seen_pids = agents or {}, [], set()
|
||||
by_pid = {pid: label for label, pid in agents.items() if pid}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
m = re.match(r"^(\d+)\s+(.*)$", line)
|
||||
if not m or "SP_SCRIPTS" in line:
|
||||
continue
|
||||
found = re.search(SCRIPT_RE, m.group(2))
|
||||
if not found:
|
||||
continue
|
||||
# The script's own path (from the last " /", ' "' or " C:\" before its name): the interpreter may sit
|
||||
# inside an app (Apple's python3 is in Python.app) without the script being a program's insides.
|
||||
head = m.group(2)[:found.end()]
|
||||
cut = max([head.rfind(" /"), head.rfind(' "')] + [x.start() for x in re.finditer(r" [A-Za-z]:\\", head)])
|
||||
if any(x in head[cut + 1:] for x in NOT_SCRIPTS):
|
||||
continue
|
||||
name = re.split(r"[\\/]", found.group(0).strip().strip('"'))[-1]
|
||||
pid = int(m.group(1))
|
||||
out.append({"name": name, "pid": pid, "by": by_pid.get(pid, ""), "running": True})
|
||||
seen_pids.add(pid)
|
||||
for label, pid in sorted(agents.items()):
|
||||
if label.startswith("org.waterschurch.") and pid not in seen_pids:
|
||||
out.append({"name": label[len("org.waterschurch."):], "pid": pid, "by": label, "running": pid is not None})
|
||||
for path, st in sorted((tasks or {}).items()):
|
||||
out.append({"name": path.split("\\")[-1], "pid": None, "by": path, "running": st.lower() == "running", "state": st})
|
||||
return sorted(out, key=lambda x: (not x["running"], x["name"].lower()))
|
||||
|
||||
|
||||
def light(ok, reason=""):
|
||||
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
|
||||
|
||||
@@ -192,7 +231,7 @@ def mac_items(host, parsed, cfg):
|
||||
item.update({"light": "unknown", "reason": ""})
|
||||
items.append(item)
|
||||
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
|
||||
"processes": sorted(proc_names)[:200]}
|
||||
"processes": sorted(proc_names)[:200], "scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), agents)}
|
||||
|
||||
|
||||
def windows_items(host, parsed):
|
||||
@@ -228,8 +267,13 @@ def windows_items(host, parsed):
|
||||
item.update({"light": "unknown", "reason": ""})
|
||||
items.append(item)
|
||||
setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow}
|
||||
return items, {"tasks": sorted(t for t in tasks if "StatusPanel" in t), "services": sorted(k for k, v in services.items()
|
||||
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup
|
||||
# A task that starts a program is "Ready" once it did: it counts as running while that program runs.
|
||||
up = {"\\StatusPanel\\" + run["name"] for run, item in zip(host.get("runs", []), items)
|
||||
if run.get("kind") == "task" and run.get("exe") and item.get("light") == "ok"}
|
||||
ours = {t: ("Running" if t in up else row[2] if len(row) > 2 else "") for t, row in tasks.items() if "StatusPanel" in t}
|
||||
return items, {"tasks": sorted(ours), "services": sorted(k for k, v in services.items()
|
||||
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60],
|
||||
"scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), tasks=ours)}, setup
|
||||
|
||||
|
||||
def probe_host(cfg, alias, host):
|
||||
@@ -245,7 +289,7 @@ def probe_host(cfg, alias, host):
|
||||
rec["error"] = err.strip() or "no answer"
|
||||
return rec
|
||||
rec["reachable"] = True
|
||||
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END"))
|
||||
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_SCRIPTS", "SP_END"))
|
||||
rec["computer_name"] = values(out).get("NAME", "").strip()
|
||||
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
|
||||
rec["setup"]["key_ok"] = True
|
||||
@@ -257,7 +301,7 @@ def probe_host(cfg, alias, host):
|
||||
return rec
|
||||
rec["reachable"] = True
|
||||
v = values(out)
|
||||
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END"))
|
||||
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_SCRIPTS", "SP_END"))
|
||||
rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "")
|
||||
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
||||
rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
|
||||
@@ -301,6 +345,7 @@ def probe_self(cfg, alias, host):
|
||||
try:
|
||||
agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
|
||||
procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout
|
||||
args_out = subprocess.run(["/bin/ps", "-axo", "pid=,args="], capture_output=True, text=True, timeout=5).stdout
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
rec["error"] = str(e)
|
||||
return rec
|
||||
@@ -308,7 +353,8 @@ def probe_self(cfg, alias, host):
|
||||
helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])],
|
||||
capture_output=True, text=True, timeout=6).stdout
|
||||
parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [],
|
||||
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))]}
|
||||
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))],
|
||||
"SP_SCRIPTS": [l for l in args_out.splitlines() if re.search(SCRIPT_RE, l)]}
|
||||
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
||||
for item, run in zip(rec["items"], host.get("runs", [])):
|
||||
if run.get("kind") == "folder":
|
||||
|
||||
@@ -522,6 +522,134 @@ class Site:
|
||||
"the DVS helper and its sudo rule", "the Dante and switch-management ports are up", "Google Drive is signed in (saved switcher files)"],
|
||||
}
|
||||
|
||||
# The Wiring screen: every computer and device the panel knows, and what connects them (2026-10-27:
|
||||
# "like a wiring diagram ... hide different connections ... how many and what scripts are running on each machine").
|
||||
WIRE_TYPES = [
|
||||
{"id": "companion", "label": "Companion control", "color": "#4f8cff", "hint": "Companion's connections: what the deck's buttons drive"},
|
||||
{"id": "midi", "label": "Network MIDI", "color": "#b36bff", "hint": "rtpMIDI / Audio MIDI Setup sessions and their peers"},
|
||||
{"id": "watch", "label": "Watchdog", "color": "#8a94a6", "hint": "What the status panel logs in to or pings"},
|
||||
{"id": "web", "label": "Web checks", "color": "#2bb3a3", "hint": "Web pages the panel asks (apps' own pages, Resi's cloud)"},
|
||||
{"id": "dante", "label": "Dante", "color": "#f0a020", "hint": "Dante devices and the switch port each is plugged into"},
|
||||
{"id": "artnet", "label": "Art-Net", "color": "#e8618c", "hint": "Lighting data the panel hears"},
|
||||
]
|
||||
|
||||
def wiring_view(self):
|
||||
cfg = self.load()
|
||||
snap = self.read_json("snapshot.json", {}) or {}
|
||||
live = self.read_json("machines-live.json", {}) or {}
|
||||
hosts = cfg.get("hosts", {})
|
||||
me = next((a for a, h in hosts.items() if h.get("self")), None)
|
||||
lines = {l["alias"]: l for l in self.computer_lines(snap)}
|
||||
word_light = {"OK": "ok", "Problem": "problem", "Off": "not_needed"}
|
||||
nodes, edges, owner = {}, {}, {"127.0.0.1": me, "localhost": me}
|
||||
|
||||
def norm(x):
|
||||
return re.sub(r"[^a-z0-9]", "", str(x).lower())
|
||||
for alias, h in hosts.items():
|
||||
probe = live.get(alias) or {}
|
||||
ip = (h.get("ssh") or "").split("@")[-1]
|
||||
for a in [ip] + [n.get("ip") for n in h.get("networks", [])] + [i.get("ip") for i in (probe.get("found") or {}).get("interfaces", [])]:
|
||||
if a:
|
||||
owner[a] = alias
|
||||
line = lines.get(alias, {})
|
||||
found = probe.get("found") or {}
|
||||
nodes["h:" + alias] = {"id": "h:" + alias, "label": h.get("name", alias), "kind": "computer", "os": h.get("os", "mac"),
|
||||
"self": bool(h.get("self")), "address": ip or ("this Mac" if h.get("self") else ""),
|
||||
"role": h.get("role", ""), "light": word_light.get(line.get("word"), "unknown"),
|
||||
"reason": line.get("reason", ""), "scripts": found.get("scripts"), "looked": probe.get("time")}
|
||||
for m in (self.read_json("fleet.json", {}) or {}).values():
|
||||
alias = next((a for a, h in hosts.items() if norm(h.get("name")) == norm(m.get("name"))), None)
|
||||
for a in m.get("ips") or [] if alias else []:
|
||||
owner.setdefault(a, alias)
|
||||
|
||||
def target(address, label, kind="device"):
|
||||
"""The node at an address: a computer when it is one of them, else a device (one per address)."""
|
||||
host = (address or "").split("//")[-1].split("/")[0].split(":")[0] if "://" in (address or "") else (address or "").split(":")[0]
|
||||
if host in owner and owner[host]:
|
||||
return "h:" + owner[host]
|
||||
nid = "d:" + (host or norm(label))
|
||||
node = nodes.setdefault(nid, {"id": nid, "label": label, "kind": kind, "address": host, "light": "unknown", "names": []})
|
||||
if label not in node["names"]:
|
||||
node["names"].append(label)
|
||||
return nid
|
||||
|
||||
def edge(kind, a, b, label, light):
|
||||
if not a or not b or a == b:
|
||||
if a and a == b and a in nodes:
|
||||
nodes[a].setdefault("local", []).append({"type": kind, "label": label, "light": light})
|
||||
return
|
||||
key = "|".join([kind] + sorted([a, b]) + [label])
|
||||
edges[key] = {"id": key, "type": kind, "from": a, "to": b, "label": label, "light": light}
|
||||
conns, conn_hosts = snap.get("conns") or {}, snap.get("conn_hosts") or {}
|
||||
gear = cfg.get("gear") or {}
|
||||
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless", "psm": "in-ears", "atem": "switcher"}
|
||||
conn_kind = {c["conn"]: kinds.get(c["type"], "device") for t in cfg["tiles"] for c in t.get("checks", []) if c.get("conn")}
|
||||
me_id = "h:" + me if me else None
|
||||
for label, st in sorted(conns.items()):
|
||||
address = conn_hosts.get(label) or (gear.get(label) or {}).get("address")
|
||||
light = "ok" if st.get("category") == "good" else ("not_needed" if not st.get("enabled") else "problem")
|
||||
if label == "audinate-dantecontroller":
|
||||
continue # the Dante type draws the Dante network itself
|
||||
node = target(address, label, conn_kind.get(label, "device")) if address else target(None, label, "service")
|
||||
if node.startswith("d:"):
|
||||
nodes[node]["light"] = light if nodes[node]["light"] in ("unknown", "ok") else nodes[node]["light"]
|
||||
edge("companion", me_id, node, label, light)
|
||||
sessions = {me: snap.get("midi") or {}} if me else {}
|
||||
sessions.update(snap.get("remote_midi") or {})
|
||||
for alias, sess in sessions.items():
|
||||
for name, s in (sess or {}).items():
|
||||
for peer in s.get("peers", []):
|
||||
other = target(peer.get("address"), peer.get("name") or peer.get("address"), "MIDI")
|
||||
edge("midi", "h:" + alias, other, "%s <-> %s" % (name, peer.get("name")),
|
||||
"ok" if peer.get("state") in ("running", "connected") else "problem")
|
||||
pings = snap.get("ping") or {}
|
||||
for alias, h in hosts.items():
|
||||
if h.get("self") or h.get("disabled"):
|
||||
continue
|
||||
probe = live.get(alias) or {}
|
||||
ip = (h.get("ssh") or "").split("@")[-1]
|
||||
ok = probe.get("reachable") if probe else (pings.get(ip) == 0 if ip in pings else None)
|
||||
edge("watch", me_id, "h:" + alias, "logs in (SSH)", "unknown" if ok is None else ("ok" if ok else "problem"))
|
||||
http = snap.get("http") or {}
|
||||
for t in cfg["tiles"]:
|
||||
for c in t.get("checks", []):
|
||||
if c["type"] == "ping" and c.get("host"):
|
||||
node = target(c["host"], c.get("name") or c["host"])
|
||||
fails = pings.get(c["host"])
|
||||
edge("watch", me_id, node, "ping", "unknown" if fails is None else ("ok" if fails == 0 else "problem"))
|
||||
elif c["type"] == "http" and c.get("url"):
|
||||
ok = http.get(c["url"])
|
||||
node = target(c["url"], c.get("name") or c.get("msg") or c["url"].split("//")[-1].split("/")[0],
|
||||
"device" if re.search(r"//(10|192\.168|172|127)\.", c["url"]) else "cloud")
|
||||
edge("web", me_id, node, c.get("name") or c.get("msg") or "web page", "unknown" if ok is None else ("ok" if ok else "problem"))
|
||||
for dev, d in sorted((snap.get("dante_map") or {}).items()):
|
||||
sw = "sw:" + str(d.get("switch"))
|
||||
nodes.setdefault(sw, {"id": sw, "label": "Dante switch %s" % d.get("switch"), "kind": "switch", "address": d.get("switch"),
|
||||
"light": "ok", "names": []})
|
||||
nd = norm(dev)
|
||||
# A computer's Dante name ends with its own (NA-Tracks: tracks; NA-ProPresenter-Mac: ProPresenter Mac);
|
||||
# STUDIO-B-MON is a monitor, not the Studio B Mac. A device already drawn joins up by name (SuperRack-LiveBox).
|
||||
alias = next((a for a, h in hosts.items() if nd.endswith(norm(a)) or nd.endswith(norm(h.get("name", a)))), None)
|
||||
same = next((n["id"] for n in nodes.values() if n["kind"] != "computer" and n["id"] != sw
|
||||
and any(len(norm(x)) >= 6 and nd.endswith(norm(x)) for x in n.get("names", []))), None)
|
||||
node = "h:" + alias if alias else same or target(None, dev, "Dante")
|
||||
if node.startswith("d:") and dev not in nodes[node]["names"]:
|
||||
nodes[node]["names"].append(dev)
|
||||
fresh = time.time() - (d.get("seen") or 0) < 3600
|
||||
edge("dante", node, sw, "port %s%s" % (d.get("port"), " (PoE)" if d.get("poe") else ""), "ok" if fresh else "unknown")
|
||||
for src, age in (snap.get("artnet") or {}).items():
|
||||
edge("artnet", target(src, "Art-Net from %s" % src), me_id, "Art-Net", "ok" if age < 10 else "problem")
|
||||
rank = {"problem": 3, "ok": 1, "not_needed": 0, "unknown": -1}
|
||||
for e in edges.values(): # a device is as good as what reaches it
|
||||
for end in (e["from"], e["to"]):
|
||||
n = nodes.get(end)
|
||||
if n and n["kind"] != "computer" and rank.get(e["light"], -1) > rank.get(n["light"], -1):
|
||||
n["light"] = e["light"]
|
||||
used = {e["from"] for e in edges.values()} | {e["to"] for e in edges.values()}
|
||||
out = [n for n in nodes.values() if n["kind"] == "computer" or n["id"] in used]
|
||||
return {"nodes": out, "edges": list(edges.values()), "types": self.WIRE_TYPES, "me": me_id, "time": snap.get("time"),
|
||||
"probing": bool(self.probing and self.probing.is_alive())}
|
||||
|
||||
def gear_view(self):
|
||||
cfg = self.load()
|
||||
snap = self.read_json("snapshot.json", {}) or {}
|
||||
@@ -769,6 +897,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, site.companion_page(int(n)))
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/wiring":
|
||||
return self.send(200, site.wiring_view())
|
||||
if path == "/api/machines":
|
||||
return self.send(200, site.machines_view())
|
||||
if path == "/api/gear":
|
||||
|
||||
+233
-2
@@ -233,7 +233,7 @@
|
||||
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
||||
|
||||
// ---- routing ---------------------------------------------------------------
|
||||
const screens = { overview, keys, computers, settings, logs, history, help, review };
|
||||
const screens = { overview, keys, computers, wiring, settings, logs, history, help, review };
|
||||
let timer = null;
|
||||
async function route() {
|
||||
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
||||
@@ -247,7 +247,7 @@
|
||||
await fn(rest);
|
||||
wireInputs(main);
|
||||
wireOnOff(main);
|
||||
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
|
||||
const refresh = name === "overview" || name === "wiring" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
|
||||
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
||||
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
||||
}
|
||||
@@ -630,6 +630,237 @@
|
||||
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>` : ""}</div>`;
|
||||
}
|
||||
|
||||
// ---- Wiring ------------------------------------------------------------------
|
||||
// Every computer and device the panel knows and what connects them, like a wiring diagram (2026-10-27). Drag a
|
||||
// machine to arrange it; click one to see its scripts and connections; hide connection types or single
|
||||
// connections. Positions and what is hidden are kept in this browser only.
|
||||
const W_W = 2000, W_H = 1300;
|
||||
const wire = { data: null, pos: null, hidden: null, types: null, sel: null, focus: false, problems: false, drag: null, view: null };
|
||||
const wireRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
|
||||
const wireSave = () => { store.set("sp-wire-pos", JSON.stringify(wire.pos)); store.set("sp-wire-hidden", JSON.stringify([...wire.hidden])); store.set("sp-wire-types", JSON.stringify([...wire.types])); };
|
||||
const wireType = (id) => (wire.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
|
||||
const wireNode = (id) => wire.data.nodes.find((n) => n.id === id);
|
||||
const wireName = (id) => (wireNode(id) || { label: id }).label;
|
||||
async function wiring(rest, quiet) {
|
||||
if (quiet && (wire.drag || document.getElementById("dialog").hidden === false)) return;
|
||||
wire.data = await api("/api/wiring");
|
||||
if (!wire.pos) { wire.pos = wireRead("sp-wire-pos", {}); wire.hidden = new Set(wireRead("sp-wire-hidden", [])); wire.types = new Set(wireRead("sp-wire-types", ["watch"])); } // the watchdog's own lines: hidden at first
|
||||
if (wire.sel && !(wire.sel.edge ? wire.data.edges.some((e) => e.id === wire.sel.id) : wireNode(wire.sel.id))) wire.sel = null;
|
||||
wireLayout();
|
||||
if (!(quiet && document.getElementById("wiresvg"))) {
|
||||
main.innerHTML = `<h1>Wiring${hint("What is connected to what. Drag a machine to move it, click it for its scripts and connections. Colours: the kind of connection; red = that connection has a problem; dashed = can't see it from here.")}</h1>
|
||||
<div class="tabs" id="wiretools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wzin" aria-label="zoom in">+</button><button class="small" id="wzout" aria-label="zoom out">−</button><button class="small" id="wfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wiresvg" class="canvas" viewBox="0 0 ${W_W} ${W_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wireside"></aside></div>`;
|
||||
wireEvents();
|
||||
}
|
||||
wireTools(); wireDraw(); wireSide();
|
||||
}
|
||||
|
||||
function wireLayout(all) {
|
||||
const d = wire.data, cx = W_W / 2, cy = W_H / 2;
|
||||
const fresh = d.nodes.filter((n) => all || !wire.pos[n.id]).map((n) => n.id);
|
||||
if (!fresh.length) return;
|
||||
const nb = {};
|
||||
for (const e of d.edges) if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
|
||||
const move = new Set(fresh), hubs = d.nodes.filter((n) => (n.kind === "computer" || n.kind === "switch") && n.id !== d.me);
|
||||
hubs.sort((a, b) => (a.kind === "switch") - (b.kind === "switch") || a.label.localeCompare(b.label));
|
||||
if (d.me && move.has(d.me)) wire.pos[d.me] = { x: cx, y: cy };
|
||||
const angle = {};
|
||||
hubs.forEach((n, i) => { angle[n.id] = 2 * Math.PI * i / hubs.length - Math.PI / 2; if (move.has(n.id)) wire.pos[n.id] = { x: cx + W_W * 0.24 * Math.cos(angle[n.id]), y: cy + W_H * 0.26 * Math.sin(angle[n.id]) }; });
|
||||
// Each device beside the machine it belongs to: its switch or computer with the most links, else Companion's Mac.
|
||||
const anchor = {}, groups = {};
|
||||
for (const n of d.nodes) {
|
||||
if (n.kind === "computer" || n.kind === "switch") continue;
|
||||
const count = {};
|
||||
for (const o of nb[n.id] || []) if (angle[o] !== undefined) count[o] = (count[o] || 0) + 1;
|
||||
const best = Object.keys(count).sort((a, b) => count[b] - count[a])[0] || d.me;
|
||||
anchor[n.id] = best; (groups[best] = groups[best] || []).push(n);
|
||||
}
|
||||
for (const [hub, list] of Object.entries(groups)) {
|
||||
list.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
|
||||
list.forEach((n, i) => {
|
||||
if (!move.has(n.id)) return;
|
||||
if (hub === d.me || angle[hub] === undefined) { // the outer ring, kinds together
|
||||
const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2;
|
||||
wire.pos[n.id] = { x: cx + W_W * 0.43 * Math.cos(a), y: cy + W_H * 0.44 * Math.sin(a) };
|
||||
} else { // a fan outward from its machine
|
||||
const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.0 + 0.16 * (1 + (i % 2));
|
||||
wire.pos[n.id] = { x: cx + W_W * 0.24 * r * Math.cos(a), y: cy + W_H * 0.26 * r * Math.sin(a) };
|
||||
}
|
||||
});
|
||||
}
|
||||
// Push apart whatever overlaps (boxes, with a margin), each device held near its machine.
|
||||
const ids = d.nodes.map((n) => n.id), size = {};
|
||||
for (const n of d.nodes) size[n.id] = nodeSize(n);
|
||||
for (let it = 0; it < 260; it++) {
|
||||
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
|
||||
const a = ids[i], b = ids[j];
|
||||
if (!move.has(a) && !move.has(b)) continue;
|
||||
const pa = wire.pos[a], pb = wire.pos[b];
|
||||
const ox = (size[a].w + size[b].w) / 2 + 14 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 12 - Math.abs(pa.y - pb.y);
|
||||
if (ox <= 0 || oy <= 0) continue;
|
||||
const ma = move.has(a) ? (move.has(b) ? 0.5 : 1) : 0, mb = 1 - ma;
|
||||
if (ox < oy) { const sx = (pa.x < pb.x ? -1 : 1) * ox; pa.x += sx * ma; pb.x -= sx * mb; }
|
||||
else { const sy = (pa.y < pb.y ? -1 : 1) * oy; pa.y += sy * ma; pb.y -= sy * mb; }
|
||||
}
|
||||
for (const id of move) {
|
||||
const h = anchor[id], p = wire.pos[id];
|
||||
if (h && h !== d.me && wire.pos[h]) { const q = wire.pos[h], dx = q.x - p.x, dy = q.y - p.y, dd = Math.hypot(dx, dy) || 1; if (dd > 230) { p.x += dx * 0.02; p.y += dy * 0.02; } }
|
||||
p.x = Math.max(size[id].w / 2 + 6, Math.min(W_W - size[id].w / 2 - 6, p.x)); p.y = Math.max(20, Math.min(W_H - 20, p.y));
|
||||
}
|
||||
}
|
||||
wireSave();
|
||||
}
|
||||
|
||||
function wireFit() {
|
||||
const { nodes } = wireShown();
|
||||
if (!nodes.length) { wire.view = { x: 0, y: 0, w: W_W, h: W_H }; return; }
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const n of nodes) { const p = wire.pos[n.id], { w, h } = nodeSize(n); x0 = Math.min(x0, p.x - w / 2); x1 = Math.max(x1, p.x + w / 2); y0 = Math.min(y0, p.y - h / 2); y1 = Math.max(y1, p.y + h / 2); }
|
||||
const svg = document.getElementById("wiresvg"), box = svg && svg.getBoundingClientRect();
|
||||
const pad = 30, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : W_W / W_H;
|
||||
const vw = Math.max(w, h * ratio), vh = vw / ratio;
|
||||
wire.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
|
||||
}
|
||||
function wireZoom(f, px, py) {
|
||||
const v = wire.view, nw = Math.max(250, Math.min(W_W * 2.5, v.w * f)), k = nw / v.w;
|
||||
px = px == null ? v.x + v.w / 2 : px; py = py == null ? v.y + v.h / 2 : py;
|
||||
wire.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
|
||||
const svg = document.getElementById("wiresvg"); if (svg) svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
|
||||
}
|
||||
|
||||
function wireShown() {
|
||||
const d = wire.data, sel = wire.sel && !wire.sel.edge ? wire.sel.id : null;
|
||||
const edges = d.edges.filter((e) => !wire.types.has(e.type) && !wire.hidden.has(e.id) && (!wire.problems || e.light === "problem")
|
||||
&& (!wire.focus || !sel || e.from === sel || e.to === sel));
|
||||
const ends = new Set(); for (const e of edges) { ends.add(e.from); ends.add(e.to); }
|
||||
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wire.focus && !wire.problems) || n.id === sel);
|
||||
return { edges, nodes };
|
||||
}
|
||||
const nodeSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
|
||||
|
||||
function wireDraw() {
|
||||
const svg = document.getElementById("wiresvg");
|
||||
if (!svg) return;
|
||||
if (!wire.view) wireFit();
|
||||
svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
|
||||
const { edges, nodes } = wireShown(), sel = wire.sel;
|
||||
const near = new Set();
|
||||
if (sel && !sel.edge) { near.add(sel.id); for (const e of edges) if (e.from === sel.id || e.to === sel.id) { near.add(e.from); near.add(e.to); } }
|
||||
if (sel && sel.edge) { const e = wire.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
|
||||
const pairs = {};
|
||||
for (const e of edges) { const k = [e.from, e.to].sort().join("|"); (pairs[k] = pairs[k] || []).push(e); }
|
||||
let out = "";
|
||||
for (const list of Object.values(pairs)) list.forEach((e, i) => {
|
||||
const a = wire.pos[e.from], b = wire.pos[e.to];
|
||||
if (!a || !b) return;
|
||||
const off = (i - (list.length - 1) / 2) * 16, dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
|
||||
const mx = (a.x + b.x) / 2 - dy / len * off, my = (a.y + b.y) / 2 + dx / len * off;
|
||||
const t = wireType(e.type), on = sel ? (sel.edge ? sel.id === e.id : e.from === sel.id || e.to === sel.id) : true;
|
||||
out += `<path class="wedge ${esc(e.light)}${on ? "" : " dim"}${sel && sel.edge && sel.id === e.id ? " sel" : ""}" data-edge="${esc(e.id)}" d="M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}" style="stroke:${e.light === "problem" ? "var(--problem)" : t.color}"><title>${esc(t.label)}: ${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`;
|
||||
});
|
||||
for (const n of nodes) {
|
||||
const p = wire.pos[n.id], { w, h } = nodeSize(n), dim = sel && !near.has(n.id);
|
||||
const scripts = n.scripts ? n.scripts.filter((x) => x.running).length : null;
|
||||
const shape = n.kind === "computer" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9"/>`
|
||||
: n.kind === "switch" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="2"/>`
|
||||
: `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>`;
|
||||
const name = String(n.label).length > 26 ? String(n.label).slice(0, 25) + "…" : n.label;
|
||||
out += `<g class="wnode ${esc(n.kind)} ${esc(n.light)}${dim ? " dim" : ""}${sel && !sel.edge && sel.id === n.id ? " sel" : ""}" data-node="${esc(n.id)}" transform="translate(${p.x},${p.y})"><title>${esc(n.label)}${n.address ? " · " + esc(n.address) : ""}${n.reason ? " · " + esc(n.reason) : ""}</title>${shape}
|
||||
<circle class="wdot" cx="${-w / 2 + 12}" cy="0" r="5"/><text x="${-w / 2 + 22}" y="${n.kind === "computer" ? -3 : 4}">${esc(name)}</text>
|
||||
${n.kind === "computer" ? `<text class="wsub" x="${-w / 2 + 22}" y="13">${scripts == null ? "scripts: not looked yet" : scripts + " script" + (scripts === 1 ? "" : "s") + " running"}${n.self ? " · this Mac" : ""}</text>` : ""}</g>`;
|
||||
}
|
||||
svg.innerHTML = out;
|
||||
}
|
||||
|
||||
function wireTools() {
|
||||
const el = document.getElementById("wiretools");
|
||||
if (!el) return;
|
||||
const d = wire.data, count = {};
|
||||
for (const e of d.edges) count[e.type] = (count[e.type] || 0) + 1;
|
||||
const bad = d.edges.filter((e) => e.light === "problem").length;
|
||||
el.innerHTML = d.types.filter((t) => count[t.id]).map((t) => `<button class="small wtype${wire.types.has(t.id) ? " off" : " on"}" data-wtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wire.types.has(t.id) ? "show" : "hide"}"><i style="background:${t.color}"></i>${esc(t.label)} <span class="muted">${count[t.id]}</span></button>`).join("")
|
||||
+ `<button class="small${wire.problems ? " on" : ""}" id="wprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
|
||||
+ `<button class="small" id="wall" data-hint="Show every connection again">Show all</button>`
|
||||
+ `<button class="small" id="wre" data-hint="Forget where machines were dragged and lay them out again">Re-arrange</button>`
|
||||
+ `<button class="small" id="wprobe" style="margin-left:auto" ${d.probing ? "disabled" : ""} data-hint="Log in to every computer now and list its scripts (read-only)">${d.probing ? "Looking…" : "Check scripts now"}</button>`;
|
||||
for (const b of el.querySelectorAll("[data-wtype]")) b.onclick = () => { const t = b.dataset.wtype; wire.types.has(t) ? wire.types.delete(t) : wire.types.add(t); wireSave(); wireTools(); wireDraw(); wireSide(); };
|
||||
el.querySelector("#wprob").onclick = () => { wire.problems = !wire.problems; wireTools(); wireDraw(); wireSide(); };
|
||||
el.querySelector("#wall").onclick = () => { wire.types.clear(); wire.hidden.clear(); wire.focus = false; wire.problems = false; wireSave(); wireTools(); wireDraw(); wireSide(); };
|
||||
el.querySelector("#wre").onclick = () => { wireLayout(true); wireFit(); wireDraw(); };
|
||||
el.querySelector("#wprobe").onclick = async (ev) => { ev.target.disabled = true; ev.target.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => wiring([], true), 6000); };
|
||||
}
|
||||
|
||||
function wireSide() {
|
||||
const el = document.getElementById("wireside");
|
||||
if (!el) return;
|
||||
const d = wire.data, sel = wire.sel;
|
||||
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}" ${wire.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wireName(other))}</b> <span class="muted small">${esc(e.label)}</span></span></label></li>`;
|
||||
let html;
|
||||
if (sel && sel.edge) {
|
||||
const e = d.edges.find((x) => x.id === sel.id), t = wireType(e.type);
|
||||
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>
|
||||
<p><button class="small" data-whide-one="${esc(e.id)}">${wire.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wsel="${esc(e.from)}">${esc(wireName(e.from))}</button> <button class="small" data-wsel="${esc(e.to)}">${esc(wireName(e.to))}</button></p>`;
|
||||
} else if (sel) {
|
||||
const n = wireNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id);
|
||||
const byType = {};
|
||||
for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e);
|
||||
const scripts = n.kind !== "computer" ? "" : n.scripts == null
|
||||
? `<h3>Scripts</h3><p class="small muted">Not looked yet: press Check scripts now.</p>`
|
||||
: `<h3>Scripts <span class="muted small">${n.scripts.filter((x) => x.running).length} running${n.scripts.some((x) => !x.running) ? `, ${n.scripts.filter((x) => !x.running).length} stopped` : ""}</span></h3>${n.scripts.length ? `<ul class="wlist">${n.scripts.map((x) => `<li>${lightDot(x.running ? "ok" : "not_needed", x.running ? "running" : (x.state || "not running"))}<span class="mono small">${esc(x.name)}</span>${x.by && x.by !== x.name ? ` <span class="muted small">${esc(x.by.replace(/^org\.waterschurch\./, ""))}</span>` : ""}</li>`).join("")}</ul>` : `<p class="small muted">None.</p>`}${n.looked ? `<p class="small muted">looked ${esc(ago(Date.now() / 1000 - n.looked))}</p>` : ""}`;
|
||||
html = `<h2>${lightDot(n.light, lightWord[n.light])} ${esc(n.label)}</h2><p class="small muted">${esc(n.kind === "computer" ? (n.os === "windows" ? "Windows PC" : "Mac") + (n.self ? " · this Mac (the panel)" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</p>
|
||||
${n.reason ? `<p class="small">${esc(n.reason)}</p>` : ""}${n.names && n.names.length > 1 ? `<p class="small muted">also called ${esc(n.names.filter((x) => x !== n.label).join(", "))}</p>` : ""}
|
||||
<p><button class="small${wire.focus ? " on" : ""}" id="wfocus">${wire.focus ? "Show everything" : "Only this one's connections"}</button></p>${scripts}
|
||||
<h3>Connections <span class="muted small">${mine.length}</span></h3>${Object.entries(byType).map(([t, list]) => `<p class="small wtyp"><i class="wsw" style="background:${wireType(t).color}"></i>${esc(wireType(t).label)}</p><ul class="wlist">${list.map((e) => row(e, e.from === n.id ? e.to : e.from)).join("")}</ul>`).join("") || `<p class="small muted">None.</p>`}
|
||||
${(n.local || []).length ? `<p class="small muted">On this machine itself: ${esc(n.local.map((x) => x.label).join(", "))}</p>` : ""}`;
|
||||
} else {
|
||||
const comps = d.nodes.filter((n) => n.kind === "computer");
|
||||
const bad = d.edges.filter((e) => e.light === "problem");
|
||||
html = `<h2>${comps.length} computers, ${d.nodes.length - comps.length} devices</h2><p class="small">${d.edges.length} connections. Click a machine for its scripts and connections; untick a connection to hide it.</p>
|
||||
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wsel="${esc(n.id)}">${esc(n.label)}</a> <span class="muted small">${n.scripts == null ? "not looked yet" : n.scripts.filter((x) => x.running).length + " running"}</span></li>`).join("")}</ul>
|
||||
${bad.length ? `<h3>Problems <span class="muted small">${bad.length}</span></h3><ul class="wlist">${bad.map((e) => `<li><a href="#" data-wedge="${esc(e.id)}">${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
|
||||
${wire.hidden.size ? `<h3>Hidden <span class="muted small">${wire.hidden.size}</span></h3><ul class="wlist">${[...wire.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}"><span>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} <span class="muted small">${esc(e.label)}</span></span></label></li>`).join("")}</ul>` : ""}`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
for (const c of el.querySelectorAll("[data-whide]")) c.onchange = () => { c.checked ? wire.hidden.delete(c.dataset.whide) : wire.hidden.add(c.dataset.whide); wireSave(); wireDraw(); wireSide(); };
|
||||
for (const b of el.querySelectorAll("[data-whide-one]")) b.onclick = () => { const id = b.dataset.whideOne; wire.hidden.has(id) ? wire.hidden.delete(id) : wire.hidden.add(id); wireSave(); wireDraw(); wireSide(); };
|
||||
for (const a of el.querySelectorAll("[data-wsel]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wsel }; wireDraw(); wireSide(); };
|
||||
for (const a of el.querySelectorAll("[data-wedge]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wedge, edge: true }; wireDraw(); wireSide(); };
|
||||
const f = el.querySelector("#wfocus"); if (f) f.onclick = () => { wire.focus = !wire.focus; wireDraw(); wireSide(); };
|
||||
}
|
||||
|
||||
function wireEvents() {
|
||||
const svg = document.getElementById("wiresvg");
|
||||
const at = (ev) => { const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); };
|
||||
document.getElementById("wzin").onclick = () => wireZoom(0.8);
|
||||
document.getElementById("wzout").onclick = () => wireZoom(1.25);
|
||||
document.getElementById("wfit").onclick = () => { wireFit(); wireDraw(); };
|
||||
svg.addEventListener("wheel", (ev) => { ev.preventDefault(); const p = at(ev); wireZoom(ev.deltaY > 0 ? 1.12 : 1 / 1.12, p.x, p.y); }, { passive: false });
|
||||
svg.addEventListener("pointerdown", (ev) => {
|
||||
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]");
|
||||
const p = at(ev);
|
||||
if (g) { const id = g.dataset.node; wire.drag = { id, dx: wire.pos[id].x - p.x, dy: wire.pos[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
|
||||
else if (path) { wire.sel = { id: path.dataset.edge, edge: true }; wireDraw(); wireSide(); return; }
|
||||
else wire.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wire.view.x, vy: wire.view.y, k: wire.view.w / svg.getBoundingClientRect().width, moved: false };
|
||||
svg.setPointerCapture(ev.pointerId); ev.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointermove", (ev) => {
|
||||
const dr = wire.drag; if (!dr) return;
|
||||
if (!dr.moved && Math.hypot(ev.clientX - dr.x0, ev.clientY - dr.y0) < 4) return;
|
||||
dr.moved = true;
|
||||
if (dr.pan) { wire.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wire.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`); return; }
|
||||
const p = at(ev);
|
||||
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
|
||||
wireDraw();
|
||||
});
|
||||
const end = () => {
|
||||
const dr = wire.drag; wire.drag = null; if (!dr) return;
|
||||
if (dr.pan) { if (!dr.moved) { wire.sel = null; wire.focus = false; wireDraw(); wireSide(); } return; }
|
||||
if (dr.moved) wireSave(); else { wire.sel = wire.sel && !wire.sel.edge && wire.sel.id === dr.id ? null : { id: dr.id }; if (!wire.sel) wire.focus = false; }
|
||||
wireDraw(); wireSide();
|
||||
};
|
||||
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
|
||||
}
|
||||
|
||||
// ---- Computers ---------------------------------------------------------------
|
||||
let compTab = "map";
|
||||
async function computers(rest, quiet) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#overview" data-hint="Is the room OK"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg><span>Overview</span></a>
|
||||
<a href="#keys" data-hint="The deck: what each key watches and does"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h2M10 10h2M14 10h2M18 10h0M6 14h12"/></svg><span>Keys</span></a>
|
||||
<a href="#computers" data-hint="What runs where"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><span>Computers</span></a>
|
||||
<a href="#wiring" data-hint="What is connected to what, and the scripts on each computer"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="7" height="5" rx="1"/><rect x="15" y="3" width="7" height="5" rx="1"/><rect x="8.5" y="16" width="7" height="5" rx="1"/><path d="M5.5 8v3.5h13V8M12 11.5V16"/></svg><span>Wiring</span></a>
|
||||
<a href="#settings" data-hint="Everything you can change"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Settings</span></a>
|
||||
<a href="#logs" data-hint="What the watchdog did and saw"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span>Logs</span></a>
|
||||
<a href="#history" data-hint="Every version ever applied"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></a>
|
||||
|
||||
@@ -153,3 +153,35 @@ details.script textarea { width: 100%; font-size: .8rem; }
|
||||
.onoff.on span { background: #1f8f4e; color: #fff; }
|
||||
.onoff input:disabled + span { opacity: .75; cursor: default; }
|
||||
tr.off td, .trig.off, .key.off { opacity: .55; }
|
||||
|
||||
/* Wiring: the diagram and the panel beside it (one column on a phone) */
|
||||
.wire { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
|
||||
@media (max-width: 900px) { .wire { grid-template-columns: minmax(0, 1fr); } }
|
||||
.wirecanvas { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; touch-action: none; }
|
||||
.wzoom { position: absolute; top: 8px; left: 8px; display: flex; gap: 4px; z-index: 1; } .wzoom button { min-width: 30px; justify-content: center; }
|
||||
.wirecanvas svg.canvas { cursor: move; }
|
||||
.wirecanvas svg.canvas { width: 100%; height: calc(100vh - 175px); min-height: 420px; display: block; fill: none; stroke: none; stroke-width: 1; user-select: none; -webkit-user-select: none; }
|
||||
.wedge { fill: none; stroke-width: 2.2; opacity: .85; cursor: pointer; }
|
||||
.wedge:hover, .wedge.sel { stroke-width: 4.5; opacity: 1; }
|
||||
.wedge.unknown { stroke-dasharray: 5 5; } .wedge.not_needed { opacity: .3; }
|
||||
.wedge.problem { stroke-width: 3.4; stroke-dasharray: 9 5; animation: wflow 1s linear infinite; }
|
||||
@keyframes wflow { to { stroke-dashoffset: -14; } }
|
||||
.wedge.dim, .wnode.dim { opacity: .12; }
|
||||
.wnode { cursor: grab; } .wnode:active { cursor: grabbing; }
|
||||
.wnode rect { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
|
||||
.wnode.computer rect { stroke: var(--fg); stroke-width: 1.8; }
|
||||
.wnode.switch rect { fill: var(--bg); }
|
||||
.wnode.problem rect { stroke: var(--problem); stroke-width: 2.5; }
|
||||
.wnode.sel rect { stroke: var(--accent); stroke-width: 3.5; }
|
||||
.wnode text { fill: var(--fg); font: 600 13px var(--font); stroke: none; }
|
||||
.wnode:not(.computer) text { font-weight: 500; font-size: 12px; }
|
||||
.wnode text.wsub { fill: var(--muted); font: 11px var(--font); }
|
||||
.wnode .wdot { fill: var(--unknown); stroke: none; }
|
||||
.wnode.ok .wdot { fill: var(--ok); } .wnode.problem .wdot { fill: var(--problem); } .wnode.not_needed .wdot { fill: var(--none); }
|
||||
.wtype i, .wsw { display: inline-block; width: 12px; height: 4px; border-radius: 2px; margin-right: 2px; vertical-align: middle; }
|
||||
.wtype.off { opacity: .45; text-decoration: line-through; } .wtype.on { border-color: var(--line); }
|
||||
.wireside { position: sticky; top: 70px; max-height: calc(100vh - 90px); overflow: auto; margin: 0; }
|
||||
.wireside h2 { margin: 4px 0 6px; font-size: 1.1rem; } .wireside h3 { margin: 14px 0 4px; font-size: .95rem; }
|
||||
.wlist { list-style: none; margin: 0; padding: 0; } .wlist li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.wrow { display: flex; gap: 6px; align-items: center; cursor: pointer; } .wtyp { margin: 8px 0 2px; }
|
||||
main:has(.wire) { max-width: none; }
|
||||
|
||||
Reference in New Issue
Block a user