feat(data-only): dashboard web + carte monde

Pipeline web data-only complet :

Cote feeds, OSC target :57124 ajoute au profil data-only.toml
pour que le bridge.py diffuse vers : SC (57121) + oF (57123)
+ Web (57124).

Cote web (data_only_viz/web/) :
- server.js : Express :3211 + WS broadcast + osc UDP :57124
  parse les paths /data/<feed>/<sub> en JSON et diffuse
- public/dashboard.html : grille de cards live avec sparklines
  SVG vanilla (USGS, SWPC Kp/wind/Bz/X-ray, Blitz, OpenSky,
  Bluesky, meteo, air, ISS, volcans, social_buzz, grid)
- public/map.html : Leaflet dark fullscreen avec markers
  ephemeres (60s TTL) pour seismes/foudre/avions/volcans +
  marker ISS persistant qui suit la position

Cote launcher :
- ProcessManager : startDataWeb / stopDataWeb + openDataDashboard
  / openDataMap, port :3211 hardcode, script attendu a
  metalVizDir/web/server.js
- MenuBarContent : nouvelle row 'Dashboard data-only' en mode
  .dataOnly et .bodyMesh + boutons Dashboard/Carte monde quand
  le serveur tourne
- stopAll inclut dataWebProc
This commit is contained in:
L'électron rare
2026-05-13 16:50:54 +02:00
parent 39d8739f4c
commit 0eebcb3aec
11 changed files with 1985 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+1273
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "av-live-data-only-web",
"version": "0.1.0",
"description": "Dashboard live + carte mondiale pour le mode data-only d'AV-Live.",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2",
"osc": "^2.4.4",
"ws": "^8.18.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
* { box-sizing: border-box; }
body {
margin: 0;
background: #0a0a14;
color: #d7d7e0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", system-ui,
sans-serif;
overflow: hidden;
}
header {
padding: 16px 24px;
background: linear-gradient(180deg, #15152a 0%, #0e0e1f 100%);
border-bottom: 1px solid #2a2a4a;
display: flex;
align-items: center;
gap: 16px;
}
header h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.04em;
color: #fff;
}
header .pill {
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 60, 130, 0.18);
color: #ff5e95;
border: 1px solid rgba(255, 60, 130, 0.4);
}
header nav { margin-left: auto; }
header nav a {
color: #aaa;
text-decoration: none;
margin-left: 16px;
font-size: 13px;
}
header nav a:hover { color: #fff; }
main {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
height: calc(100vh - 65px);
overflow: auto;
}
.card {
background: #14142a;
border: 1px solid #25254a;
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 120px;
}
.card h2 {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #8a8aa8;
}
.card .value {
font-size: 28px;
font-weight: 700;
color: #fff;
font-variant-numeric: tabular-nums;
}
.card .sub {
font-size: 12px;
color: #aaa;
font-variant-numeric: tabular-nums;
}
.card.alert { border-color: #ff5e95; }
.card.alert .value { color: #ff5e95; }
.card.warn .value { color: #ffd84a; }
.card.green .value { color: #6fe9b3; }
.card .spark {
flex: 1;
width: 100%;
min-height: 30px;
}
.card.wide { grid-column: span 2; }
.card.tall { grid-row: span 2; }
.feed-pill {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: #1e1e3a;
color: #8a8aa8;
margin-right: 4px;
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 6px 16px;
background: rgba(10, 10, 20, 0.9);
border-top: 1px solid #25254a;
font-size: 11px;
color: #6a6a8a;
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>AV-Live · Data-only Dashboard</title>
<link rel="stylesheet" href="css/dashboard.css" />
</head>
<body>
<header>
<h1>AV-Live · Data-only</h1>
<span class="pill" id="conn">connexion…</span>
<span class="pill" id="rate" style="background:rgba(110,233,179,.15);color:#6fe9b3;border-color:rgba(110,233,179,.4);">0 msg/s</span>
<nav>
<a href="/dashboard.html">Dashboard</a>
<a href="/map.html">Carte monde</a>
</nav>
</header>
<main id="grid"></main>
<footer id="log">en attente du bridge OSC :57124…</footer>
<script type="module" src="js/dashboard.js"></script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
// Dashboard live : OSC -> WS -> cards + sparklines.
// Aucune dependance externe. Tous les graphes en SVG vanilla.
const grid = document.getElementById("grid");
const connPill = document.getElementById("conn");
const ratePill = document.getElementById("rate");
const logEl = document.getElementById("log");
// ------------------- WebSocket connection -------------------
let ws = null;
let msgCount = 0;
function connect() {
const url = (location.protocol === "https:" ? "wss://" : "ws://")
+ location.host;
ws = new WebSocket(url);
ws.onopen = () => {
connPill.textContent = "connecte";
connPill.style.background = "rgba(110,233,179,.18)";
connPill.style.color = "#6fe9b3";
connPill.style.borderColor = "rgba(110,233,179,.4)";
};
ws.onclose = () => {
connPill.textContent = "deconnecte";
connPill.style.background = "rgba(255,94,149,.18)";
connPill.style.color = "#ff5e95";
setTimeout(connect, 2000);
};
ws.onmessage = (ev) => {
msgCount++;
try { handleMessage(JSON.parse(ev.data)); }
catch (e) { console.warn("parse error", e); }
};
}
connect();
// Rate counter
setInterval(() => {
ratePill.textContent = `${msgCount} msg/s`;
msgCount = 0;
}, 1000);
// ------------------- Card registry --------------------------
const cards = new Map(); // id -> {el, valueEl, subEl, sparkEl, history}
function ensureCard(id, opts = {}) {
let c = cards.get(id);
if (c) return c;
const el = document.createElement("div");
el.className = "card";
if (opts.wide) el.classList.add("wide");
if (opts.alert) el.classList.add("alert");
if (opts.warn) el.classList.add("warn");
if (opts.green) el.classList.add("green");
const title = document.createElement("h2");
title.textContent = opts.title || id;
const valueEl = document.createElement("div");
valueEl.className = "value";
valueEl.textContent = "—";
const subEl = document.createElement("div");
subEl.className = "sub";
const sparkEl = document.createElementNS(
"http://www.w3.org/2000/svg", "svg");
sparkEl.setAttribute("class", "spark");
sparkEl.setAttribute("preserveAspectRatio", "none");
sparkEl.setAttribute("viewBox", "0 0 100 30");
el.append(title, valueEl, subEl, sparkEl);
grid.appendChild(el);
c = { el, valueEl, subEl, sparkEl, history: [], order: opts.order ?? 99 };
cards.set(id, c);
// Sort children by order
[...grid.children]
.sort((a, b) => Number(a.dataset.order || 99) - Number(b.dataset.order || 99));
el.dataset.order = c.order;
return c;
}
function pushHistory(card, v, maxLen = 64) {
card.history.push(v);
if (card.history.length > maxLen) card.history.shift();
}
function drawSpark(card, color = "#ff5e95") {
const hist = card.history;
if (hist.length < 2) return;
const min = Math.min(...hist);
const max = Math.max(...hist);
const range = (max - min) || 1;
const stepX = 100 / (hist.length - 1);
const points = hist.map((v, i) => {
const x = i * stepX;
const y = 28 - ((v - min) / range) * 26;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
card.sparkEl.innerHTML = `<polyline fill="none" stroke="${color}" stroke-width="1.5" points="${points}" />`;
}
function fmt(v, digits = 2) {
return (typeof v === "number" ? v.toFixed(digits) : "—");
}
// ------------------- Message handlers -----------------------
const handlers = {
usgs: (m) => {
if (m.sub === "event") {
const [, , mag] = m.args;
const c = ensureCard("usgs", { title: "USGS · seisme", order: 1, alert: mag >= 5 });
c.valueEl.textContent = `M${fmt(mag, 1)}`;
c.subEl.textContent = `dernier event • ${new Date(m.t).toLocaleTimeString()}`;
pushHistory(c, Number(mag) || 0);
drawSpark(c);
}
},
swpc: (m) => {
if (m.sub === "kp") {
const c = ensureCard("kp", { title: "SWPC · Kp index", order: 5,
alert: m.args[0] >= 6, warn: m.args[0] >= 4 });
c.valueEl.textContent = fmt(m.args[0], 1);
c.subEl.textContent = "geomagnetique 0..9";
pushHistory(c, m.args[0]);
drawSpark(c);
} else if (m.sub === "wind") {
const c = ensureCard("wind", { title: "SWPC · vent solaire", order: 6 });
c.valueEl.textContent = `${fmt(m.args[0], 0)} km/s`;
pushHistory(c, m.args[0]);
drawSpark(c, "#6fe9b3");
} else if (m.sub === "bz") {
const c = ensureCard("bz", { title: "SWPC · Bz IMF", order: 7,
alert: m.args[0] <= -10 });
c.valueEl.textContent = `${fmt(m.args[0], 1)} nT`;
pushHistory(c, m.args[0]);
drawSpark(c);
} else if (m.sub === "xray") {
const c = ensureCard("xray", { title: "SWPC · X-ray flux", order: 8 });
c.valueEl.textContent = m.args[0].toExponential(2);
pushHistory(c, Math.log10(Math.max(m.args[0], 1e-10)));
drawSpark(c, "#ffd84a");
}
},
blitzortung: (m) => {
if (m.sub === "strike") {
const c = ensureCard("blitz", { title: "Foudre · global", order: 2 });
c.valueEl.textContent = `${(c.history.length + 1)}`;
c.subEl.textContent = `dernier • ${new Date(m.t).toLocaleTimeString()}`;
pushHistory(c, c.history.length + 1, 128);
drawSpark(c, "#ffd84a");
}
},
opensky: (m) => {
if (m.sub === "count") {
const c = ensureCard("opensky", { title: "OpenSky · avions", order: 9 });
c.valueEl.textContent = String(m.args[0] | 0);
pushHistory(c, m.args[0]);
drawSpark(c, "#6fe9b3");
}
},
bluesky: (m) => {
if (m.sub === "post") {
const c = ensureCard("bsky", { title: "Bluesky · firehose", order: 10 });
pushHistory(c, c.history.length + 1, 80);
c.valueEl.textContent = `${c.history.length}`;
c.subEl.textContent = "posts (window)";
drawSpark(c, "#6fa6ff");
}
},
openmeteo: (m) => {
if (m.sub === "now") {
const [t, hum, wspd, , press, rain] = m.args;
const c = ensureCard("meteo", { title: "Meteo locale", order: 11, wide: true });
c.valueEl.textContent = `${fmt(t, 1)}°C / ${fmt(hum, 0)}% HR`;
c.subEl.textContent = `vent ${fmt(wspd, 1)} m/s · ${fmt(press, 0)} hPa · pluie ${fmt(rain, 1)} mm/h`;
pushHistory(c, t);
drawSpark(c, "#6fa6ff");
}
},
openaq: (m) => {
if (m.sub === "now") {
const [pm25, pm10, no2, o3] = m.args;
const c = ensureCard("air", { title: "Qualite air (μg/m³)", order: 12,
wide: true, alert: pm25 > 35, warn: pm25 > 15 });
c.valueEl.textContent = `PM2.5 ${fmt(pm25, 0)}`;
c.subEl.textContent = `PM10 ${fmt(pm10, 0)} · NO₂ ${fmt(no2, 0)} · O₃ ${fmt(o3, 0)}`;
pushHistory(c, pm25);
drawSpark(c);
}
},
iss: (m) => {
if (m.sub === "pos") {
const [lat, lon, alt, vel] = m.args;
const c = ensureCard("iss", { title: "ISS · position", order: 13, wide: true });
c.valueEl.textContent = `${fmt(lat, 1)}°, ${fmt(lon, 1)}°`;
c.subEl.textContent = `alt ${fmt(alt, 0)} km · vel ${fmt(vel, 0)} km/h`;
} else if (m.sub === "pass") {
const c = ensureCard("iss", { title: "ISS · passage !", order: 13, wide: true, green: true });
c.subEl.textContent = `dans le radius • dist ${fmt(m.args[1], 0)} km`;
}
},
volcano: (m) => {
if (m.sub === "active") {
const c = ensureCard("volcano", { title: "Volcans actifs", order: 14,
alert: m.args[0] >= 20 });
c.valueEl.textContent = String(m.args[0] | 0);
c.subEl.textContent = "eruptions 7 derniers jours";
pushHistory(c, m.args[0]);
drawSpark(c);
} else if (m.sub === "eruption") {
const c = ensureCard("volcano", { title: "Volcans actifs", order: 14 });
c.subEl.textContent = `nouvelle eruption • ${m.args[3] || "region inconnue"}`;
}
},
social_buzz: (m) => {
if (m.sub === "pulse") {
const c = ensureCard("social", { title: "Pulse social", order: 15 });
c.valueEl.textContent = fmt(m.args[0] * 100, 0) + " %";
pushHistory(c, m.args[0]);
drawSpark(c, "#ff5e95");
} else if (m.sub === "reddit") {
const c = ensureCard("reddit", { title: "Reddit /r/all hot", order: 16 });
c.valueEl.textContent = fmt(m.args[0], 0);
c.subEl.textContent = `score moyen · ${fmt(m.args[1], 0)} comments`;
pushHistory(c, m.args[0]);
drawSpark(c, "#ff8838");
} else if (m.sub === "hn") {
const c = ensureCard("hn", { title: "HackerNews top", order: 17 });
c.valueEl.textContent = fmt(m.args[0], 0);
c.subEl.textContent = `score moyen · ${fmt(m.args[1], 0)} comments`;
pushHistory(c, m.args[0]);
drawSpark(c, "#ff8838");
}
},
netzfrequenz: (m) => {
if (m.sub === "freq") {
const c = ensureCard("grid", { title: "Reseau EU · 50 Hz", order: 18 });
c.valueEl.textContent = `${fmt(m.args[0], 3)} Hz`;
c.subEl.textContent = `delta ${fmt((m.args[0] - 50) * 1000, 1)} mHz`;
pushHistory(c, m.args[0]);
drawSpark(c);
}
},
};
function handleMessage(msg) {
logEl.textContent = `${new Date(msg.t).toLocaleTimeString()} /data/${msg.feed}/${msg.sub} ${JSON.stringify(msg.args)}`;
const h = handlers[msg.feed];
if (h) h(msg);
}
+95
View File
@@ -0,0 +1,95 @@
// Carte mondiale fullscreen : seismes, foudre, avions, ISS, volcans.
// Markers ephemeres (fade out apres TTL_MS) + ISS persistant.
const map = L.map("map", { zoomControl: false, attributionControl: false })
.setView([20, 0], 2);
L.tileLayer(
"https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png",
{ maxZoom: 12 }
).addTo(map);
const connEl = document.getElementById("conn");
const countsEl = document.getElementById("counts");
let total = 0;
// ------------------- WebSocket --------------------------------
function connect() {
const url = (location.protocol === "https:" ? "wss://" : "ws://")
+ location.host;
const ws = new WebSocket(url);
ws.onopen = () => connEl.textContent = "connecte";
ws.onclose = () => {
connEl.textContent = "deconnecte";
setTimeout(connect, 2000);
};
ws.onmessage = (ev) => {
try { handleMessage(JSON.parse(ev.data)); }
catch (e) { console.warn(e); }
};
}
connect();
// ------------------- Marker helpers ---------------------------
const TTL_MS = 60_000; // markers visibles 60s
function ephemeralMarker(lat, lon, kind, label) {
const icon = L.divIcon({
className: "",
iconSize: [10, 10],
html: `<div class="${kind}" style="width:10px;height:10px"></div>`,
});
const m = L.marker([lat, lon], { icon }).addTo(map);
if (label) m.bindTooltip(label, { direction: "top" });
setTimeout(() => map.removeLayer(m), TTL_MS);
total += 1;
countsEl.textContent = `${total} events`;
return m;
}
// ISS : un seul marker persistant qui bouge
let issMarker = null;
function updateIss(lat, lon) {
const icon = L.divIcon({
className: "",
iconSize: [16, 16],
html: `<div class="iss" style="width:16px;height:16px"></div>`,
});
if (issMarker) issMarker.setLatLng([lat, lon]);
else issMarker = L.marker([lat, lon], { icon })
.addTo(map)
.bindTooltip("ISS", { permanent: true, direction: "top",
offset: [0, -8] });
}
// ------------------- Handlers ---------------------------------
const handlers = {
usgs: (m) => {
if (m.sub !== "event") return;
const [lat, lon, mag] = m.args;
ephemeralMarker(lat, lon, "quake", `M${mag.toFixed(1)} seisme`);
},
blitzortung: (m) => {
if (m.sub !== "strike") return;
const [lat, lon] = m.args;
ephemeralMarker(lat, lon, "strike", "foudre");
},
opensky: (m) => {
if (m.sub !== "state") return;
const [, , lat, lon] = m.args;
if (lat && lon) ephemeralMarker(lat, lon, "plane", "vol");
},
iss: (m) => {
if (m.sub === "pos") updateIss(m.args[0], m.args[1]);
},
volcano: (m) => {
if (m.sub !== "eruption") return;
const [lat, lon, vei, region] = m.args;
ephemeralMarker(lat, lon, "volcano",
`volcan ${region} (VEI ${vei})`);
},
};
function handleMessage(msg) {
const h = handlers[msg.feed];
if (h) h(msg);
}
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>AV-Live · Carte monde</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
html, body { margin: 0; height: 100%; background: #000; }
#map { width: 100vw; height: 100vh; background: #050516; }
.hud {
position: fixed; top: 14px; left: 14px;
background: rgba(0,0,0,0.6);
color: #fff;
font-family: -apple-system, system-ui, sans-serif;
font-size: 12px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15);
z-index: 999;
}
.hud b { color: #ff5e95; }
.hud a { color: #aaa; margin-left: 12px; text-decoration: none; }
.leaflet-container { background: #050516 !important; }
/* Custom marker styles */
.quake { background: #ff5e95; border-radius: 50%; }
.strike { background: #ffd84a; border-radius: 50%; }
.plane { background: #6fe9b3; border-radius: 50%; }
.iss { background: #6fa6ff; border-radius: 50%; border: 2px solid #fff; }
.volcano { background: #ff8838; border-radius: 50%; }
</style>
</head>
<body>
<div id="map"></div>
<div class="hud">
<b id="conn">connexion…</b>
· <span id="counts">0 events</span>
<a href="/dashboard.html">Dashboard</a>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="js/map.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
// =====================================================================
// data_only_viz / web / server.js
//
// Bridge OSC -> WebSocket pour le mode data-only d'AV-Live.
//
// data_feeds bridge.py
// | OSC UDP :57124
// v
// server.js (Express :3211 + WS)
// | JSON via WebSocket
// v
// browser : dashboard.html / map.html
//
// Conversion : chaque message OSC /data/<feed>/<sub> args... devient
// { feed, sub, args, t }
// diffuse a tous les clients WS connectes.
// =====================================================================
import express from "express";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import osc from "osc";
const __dirname = dirname(fileURLToPath(import.meta.url));
const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3211", 10);
const OSC_PORT_IN = parseInt(process.env.OSC_PORT_IN ?? "57124", 10);
const app = express();
app.use(express.static(join(__dirname, "public")));
app.get("/", (_req, res) => res.redirect("/dashboard.html"));
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
// Ring buffer for late-joiners : ~500 last messages
const HISTORY_MAX = 500;
const history = [];
function record(msg) {
history.push(msg);
if (history.length > HISTORY_MAX) history.shift();
}
wss.on("connection", (ws) => {
// Replay recent history so a new dashboard sees the latest values
for (const msg of history.slice(-100)) {
try { ws.send(JSON.stringify(msg)); } catch {}
}
});
function broadcast(msg) {
const payload = JSON.stringify(msg);
for (const c of wss.clients) {
if (c.readyState === 1) {
try { c.send(payload); } catch {}
}
}
}
// OSC UDP listener
const udp = new osc.UDPPort({
localAddress: "127.0.0.1",
localPort: OSC_PORT_IN,
metadata: false,
});
udp.on("message", (m) => {
// Path format : /data/<feed>/<sub>
const parts = (m.address || "").split("/").filter(Boolean);
if (parts.length < 2 || parts[0] !== "data") return;
const feed = parts[1];
const sub = parts.slice(2).join("/") || "msg";
const msg = {
t: Date.now(),
feed,
sub,
args: Array.isArray(m.args) ? m.args : [],
};
record(msg);
broadcast(msg);
});
udp.on("error", (e) => console.error("OSC error:", e));
udp.open();
console.log(`OSC listening on udp :${OSC_PORT_IN}`);
httpServer.listen(HTTP_PORT, () => {
console.log(`Data-only web on http://127.0.0.1:${HTTP_PORT}/`);
console.log(` Dashboard : http://127.0.0.1:${HTTP_PORT}/dashboard.html`);
console.log(` Map : http://127.0.0.1:${HTTP_PORT}/map.html`);
});
@@ -118,6 +118,31 @@ struct MenuBarContent: View {
)
}
// Dashboard web data-only (Express :3211, dashboard + carte
// monde live OSC). Visible en dataOnly + bodyMesh.
if processManager.mode == .dataOnly
|| processManager.mode == .bodyMesh {
ProcessRow(
title: "Dashboard data-only",
subtitle: "Express :\(processManager.dataWebPort) — dashboard + carte monde",
isRunning: processManager.dataWebRunning,
start: processManager.startDataWeb,
stop: processManager.stopDataWeb
)
if processManager.dataWebRunning {
HStack {
Button(action: processManager.openDataDashboard) {
Label("Dashboard", systemImage: "gauge")
}
Button(action: processManager.openDataMap) {
Label("Carte monde", systemImage: "globe")
}
Spacer()
}
.padding(.horizontal, 4)
}
}
if processManager.mode == .full {
HStack {
Button(action: processManager.openBrowser) {
@@ -291,6 +291,73 @@ final class ProcessManager: ObservableObject {
private var webWantsRestart = false
// ----------------- Web data-only dashboard ------------------------
private var dataWebProc: Process?
@Published var dataWebRunning = false
let dataWebPort: Int = 3211
/// Lance le dashboard web data-only (Express + WS bridge sur :3211,
/// ecoute OSC UDP :57124). Necessite node + le dossier
/// `data_only_viz/web/`.
func startDataWeb() {
guard dataWebProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
append(source: "launcher", text: "node not found at \(nodePath)")
return
}
let script = URL(fileURLWithPath: metalVizDir)
.appendingPathComponent("web/server.js").path
guard FileManager.default.fileExists(atPath: script) else {
append(source: "launcher",
text: "data-only web server.js manquant a \(script)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: nodePath)
p.arguments = [script]
p.currentDirectoryURL = URL(fileURLWithPath: script)
.deletingLastPathComponent()
var env = ProcessInfo.processInfo.environment
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
+ ":/usr/local/bin:/opt/homebrew/bin"
env["HTTP_PORT"] = String(dataWebPort)
p.environment = env
attach(process: p, label: "dataweb")
do {
try p.run()
dataWebProc = p
DispatchQueue.main.async { self.dataWebRunning = true }
p.terminationHandler = { [weak self] _ in
DispatchQueue.main.async {
self?.dataWebProc = nil
self?.dataWebRunning = false
}
}
append(source: "launcher",
text: "started data-only web on :\(dataWebPort)")
} catch {
append(source: "launcher",
text: "data-only web start failed: \(error)")
}
}
func stopDataWeb() {
dataWebProc?.terminate()
}
func openDataDashboard() {
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/dashboard.html") {
NSWorkspace.shared.open(url)
}
}
func openDataMap() {
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/map.html") {
NSWorkspace.shared.open(url)
}
}
func restartWeb() {
guard webProc != nil else { startWeb(); return }
append(source: "launcher", text: "restarting web server…")
@@ -737,6 +804,7 @@ final class ProcessManager: ObservableObject {
dataFeedsProc?.terminate()
metalVizProc?.terminate()
bodyAppProc?.terminate()
dataWebProc?.terminate()
}
func clearLogs() {