1/**
2 * Flow Dashboard
3 *
4 * The network operations center for your land.
5 * Three sections: Pulse Strip, Tree Map, Network Layer.
6 * All data from existing APIs. Polls every 10 seconds.
7 */
8
9import express from "express";
10import authenticateLite from "../../html-rendering/authenticateLite.js";
11import { page } from "../../html-rendering/html/layout.js";
12
13const router = express.Router();
14
15router.get("/dashboard/flow", authenticateLite, async (req, res) => {
16 if (!req.userId) return res.redirect("/login");
17 // Inject userId into the page so client JS can fetch the right endpoints
18 const injectedJs = `const USER_ID = "${req.userId}";\n` + JS;
19 res.send(page({
20 title: "Flow Dashboard -- TreeOS",
21 bare: true,
22 css: CSS,
23 body: BODY,
24 js: injectedJs,
25 }));
26});
27
28export default router;
29
30// ── CSS ──
31
32const CSS = `
33*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
34
35body {
36 background: #0a0a0a;
37 color: #e5e5e5;
38 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
39 -webkit-font-smoothing: antialiased;
40 overflow-x: hidden;
41}
42
43:root {
44 --signal-success: #22c55e;
45 --signal-pending: #eab308;
46 --signal-failed: #ef4444;
47 --signal-canopy: #3b82f6;
48 --signal-mycelium: #a855f7;
49 --node-active: rgba(34, 197, 94, 0.15);
50 --node-dormant: rgba(255, 255, 255, 0.03);
51 --peer-healthy: #22c55e;
52 --peer-degraded: #eab308;
53 --peer-dead: #ef4444;
54 --glass: rgba(255, 255, 255, 0.04);
55 --glass-border: rgba(255, 255, 255, 0.08);
56 --text-dim: rgba(255, 255, 255, 0.4);
57 --text-mid: rgba(255, 255, 255, 0.6);
58 --text-bright: rgba(255, 255, 255, 0.9);
59}
60
61.flow-dashboard {
62 max-width: 1400px;
63 margin: 0 auto;
64 padding: 20px;
65 display: flex;
66 flex-direction: column;
67 gap: 20px;
68 min-height: 100vh;
69}
70
71/* ── Header ── */
72.fd-header {
73 display: flex;
74 align-items: center;
75 justify-content: space-between;
76 padding: 16px 0;
77}
78.fd-title {
79 font-size: 20px;
80 font-weight: 600;
81 color: var(--text-bright);
82 letter-spacing: -0.3px;
83}
84.fd-subtitle {
85 font-size: 13px;
86 color: var(--text-dim);
87 margin-top: 2px;
88}
89.fd-status {
90 display: flex;
91 align-items: center;
92 gap: 8px;
93 font-size: 12px;
94 color: var(--text-dim);
95}
96.fd-breath-dot {
97 width: 8px;
98 height: 8px;
99 border-radius: 50%;
100 background: var(--signal-success);
101 animation: breathPulse 2s ease-in-out infinite;
102}
103.fd-breath-dot.dormant { background: #333; animation: none; }
104@keyframes breathPulse {
105 0%, 100% { opacity: 0.3; transform: scale(0.8); }
106 50% { opacity: 1; transform: scale(1.2); }
107}
108
109/* ── Section container ── */
110.fd-section {
111 background: var(--glass);
112 border: 1px solid var(--glass-border);
113 border-radius: 16px;
114 overflow: hidden;
115}
116.fd-section-header {
117 padding: 14px 20px;
118 border-bottom: 1px solid var(--glass-border);
119 font-size: 13px;
120 font-weight: 600;
121 color: var(--text-mid);
122 text-transform: uppercase;
123 letter-spacing: 0.5px;
124}
125
126/* ── Pulse Strip ── */
127.pulse-strip {
128 position: relative;
129 height: 80px;
130 overflow: hidden;
131 padding: 0 20px;
132}
133.pulse-timeline {
134 position: relative;
135 height: 100%;
136 width: 100%;
137}
138.pulse-dot {
139 position: absolute;
140 width: 6px;
141 height: 6px;
142 border-radius: 50%;
143 top: 50%;
144 transform: translateY(-50%);
145 opacity: 0.8;
146 transition: opacity 0.3s;
147}
148.pulse-dot:hover { opacity: 1; transform: translateY(-50%) scale(2); }
149.pulse-dot.succeeded { background: var(--signal-success); }
150.pulse-dot.failed, .pulse-dot.rejected { background: var(--signal-failed); }
151.pulse-dot.queued, .pulse-dot.partial, .pulse-dot.awaiting { background: var(--signal-pending); }
152.pulse-dot.canopy { background: var(--signal-canopy); }
153.pulse-dot.mycelium { background: var(--signal-mycelium); }
154.pulse-hour-label {
155 position: absolute;
156 bottom: 4px;
157 font-size: 10px;
158 color: var(--text-dim);
159 transform: translateX(-50%);
160}
161.pulse-empty {
162 display: flex;
163 align-items: center;
164 justify-content: center;
165 height: 100%;
166 color: var(--text-dim);
167 font-size: 13px;
168}
169.pulse-tooltip {
170 position: fixed;
171 background: rgba(0,0,0,0.9);
172 border: 1px solid var(--glass-border);
173 border-radius: 8px;
174 padding: 8px 12px;
175 font-size: 12px;
176 color: var(--text-bright);
177 pointer-events: none;
178 z-index: 100;
179 max-width: 250px;
180 display: none;
181}
182
183/* ── Tree Map ── */
184.tree-map {
185 padding: 16px 20px;
186 max-height: 500px;
187 overflow-y: auto;
188}
189.tm-tree { margin-bottom: 12px; }
190.tm-tree-name {
191 font-size: 14px;
192 font-weight: 600;
193 color: var(--text-bright);
194 cursor: pointer;
195 padding: 6px 0;
196 display: flex;
197 align-items: center;
198 gap: 8px;
199}
200.tm-tree-name:hover { color: #fff; }
201.tm-chevron {
202 font-size: 10px;
203 transition: transform 0.2s;
204 color: var(--text-dim);
205}
206.tm-chevron.open { transform: rotate(90deg); }
207.tm-children { padding-left: 20px; border-left: 1px solid rgba(255,255,255,0.05); }
208.tm-node {
209 padding: 4px 8px;
210 margin: 2px 0;
211 border-radius: 6px;
212 font-size: 13px;
213 color: var(--text-mid);
214 cursor: pointer;
215 display: flex;
216 align-items: center;
217 gap: 8px;
218 transition: background 0.3s;
219}
220.tm-node:hover { background: rgba(255,255,255,0.06); }
221.tm-node.active { background: var(--node-active); color: var(--text-bright); }
222.tm-node.pulsing { animation: nodePulse 1.5s ease-in-out infinite; }
223.tm-node.dormant { color: rgba(255,255,255,0.2); }
224.tm-signal-count {
225 font-size: 10px;
226 color: var(--text-dim);
227 margin-left: auto;
228}
229.tm-channel-badge {
230 font-size: 9px;
231 padding: 1px 6px;
232 border-radius: 10px;
233 background: rgba(168, 85, 247, 0.15);
234 color: var(--signal-mycelium);
235 border: 1px solid rgba(168, 85, 247, 0.2);
236}
237@keyframes nodePulse {
238 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
239 50% { box-shadow: 0 0 8px 2px rgba(34, 197, 94, 0.2); }
240}
241.tm-collapsed-summary {
242 font-size: 11px;
243 color: var(--text-dim);
244 padding: 4px 8px;
245}
246.tm-empty {
247 padding: 20px;
248 text-align: center;
249 color: var(--text-dim);
250 font-size: 13px;
251}
252
253/* ── Node Panel ── */
254.node-panel {
255 position: fixed;
256 right: 0;
257 top: 0;
258 width: 360px;
259 height: 100vh;
260 background: #111;
261 border-left: 1px solid var(--glass-border);
262 padding: 20px;
263 overflow-y: auto;
264 transform: translateX(100%);
265 transition: transform 0.25s ease;
266 z-index: 50;
267}
268.node-panel.open { transform: translateX(0); }
269.np-close {
270 position: absolute;
271 top: 12px;
272 right: 12px;
273 background: none;
274 border: none;
275 color: var(--text-dim);
276 font-size: 18px;
277 cursor: pointer;
278}
279.np-path {
280 font-size: 12px;
281 color: var(--signal-canopy);
282 font-family: monospace;
283 margin-bottom: 16px;
284 word-break: break-all;
285}
286.np-stat {
287 display: flex;
288 justify-content: space-between;
289 padding: 6px 0;
290 font-size: 13px;
291 color: var(--text-mid);
292 border-bottom: 1px solid rgba(255,255,255,0.04);
293}
294.np-stat-label { color: var(--text-dim); }
295.np-signal-list { margin-top: 16px; }
296.np-signal-title {
297 font-size: 12px;
298 font-weight: 600;
299 color: var(--text-dim);
300 text-transform: uppercase;
301 letter-spacing: 0.5px;
302 margin-bottom: 8px;
303}
304.np-signal {
305 padding: 6px 0;
306 font-size: 12px;
307 color: var(--text-mid);
308 border-bottom: 1px solid rgba(255,255,255,0.03);
309 display: flex;
310 gap: 8px;
311}
312.np-signal-dir { color: var(--signal-success); }
313.np-signal-time { color: var(--text-dim); margin-left: auto; font-size: 11px; }
314
315/* ── Network Layer ── */
316.network-layer { padding: 20px; min-height: 120px; }
317.nl-graph {
318 display: flex;
319 align-items: center;
320 justify-content: center;
321 gap: 40px;
322 flex-wrap: wrap;
323 padding: 20px 0;
324}
325.nl-peer {
326 display: flex;
327 flex-direction: column;
328 align-items: center;
329 gap: 6px;
330 cursor: pointer;
331 padding: 12px;
332 border-radius: 12px;
333 transition: background 0.2s;
334}
335.nl-peer:hover { background: rgba(255,255,255,0.04); }
336.nl-peer-dot {
337 width: 12px;
338 height: 12px;
339 border-radius: 50%;
340}
341.nl-peer-name {
342 font-size: 12px;
343 color: var(--text-mid);
344 max-width: 100px;
345 overflow: hidden;
346 text-overflow: ellipsis;
347 white-space: nowrap;
348 text-align: center;
349}
350.nl-peer-status {
351 font-size: 10px;
352 color: var(--text-dim);
353}
354.nl-center {
355 display: flex;
356 flex-direction: column;
357 align-items: center;
358 gap: 4px;
359}
360.nl-center-dot {
361 width: 20px;
362 height: 20px;
363 border-radius: 50%;
364 background: var(--signal-canopy);
365 border: 2px solid rgba(59, 130, 246, 0.3);
366}
367.nl-center-label {
368 font-size: 11px;
369 font-weight: 600;
370 color: var(--text-bright);
371}
372.nl-empty {
373 text-align: center;
374 color: var(--text-dim);
375 font-size: 13px;
376 padding: 20px;
377}
378.nl-hidden { display: none; }
379
380/* ── Responsive ── */
381@media (max-width: 768px) {
382 .flow-dashboard { padding: 12px; gap: 12px; }
383 .pulse-strip { height: 50px; }
384 .node-panel { width: 100%; }
385 .tree-map { max-height: 400px; }
386 .nl-graph { gap: 20px; }
387}
388`;
389
390// ── Body HTML ──
391
392const BODY = `
393<div class="flow-dashboard">
394 <div class="fd-header">
395 <div>
396 <div class="fd-title">Flow Dashboard</div>
397 <div class="fd-subtitle">Signal activity across your land</div>
398 </div>
399 <div style="display:flex;align-items:center;gap:16px;">
400 <div class="fd-status">
401 <div class="fd-breath-dot dormant" id="breathDot"></div>
402 <span id="breathLabel">loading...</span>
403 </div>
404 <a href="/dashboard" style="color:var(--text-dim);text-decoration:none;font-size:0.85rem;padding:6px 14px;border:1px solid var(--glass-border);border-radius:8px;">Dashboard</a>
405 </div>
406 </div>
407
408 <div class="fd-section">
409 <div class="fd-section-header">Pulse Strip (last 24h)</div>
410 <div class="pulse-strip" id="pulseStrip">
411 <div class="pulse-timeline" id="pulseTimeline"></div>
412 </div>
413 </div>
414
415 <div class="fd-section">
416 <div class="fd-section-header">Tree Map</div>
417 <div class="tree-map" id="treeMap">
418 <div class="tm-empty">Loading trees...</div>
419 </div>
420 </div>
421
422 <div class="fd-section" id="networkSection">
423 <div class="fd-section-header">Network</div>
424 <div class="network-layer" id="networkLayer">
425 <div class="nl-empty">Loading peers...</div>
426 </div>
427 </div>
428
429 <div class="node-panel" id="nodePanel">
430 <button class="np-close" onclick="closePanel()">×</button>
431 <div id="panelContent"></div>
432 </div>
433
434 <div class="pulse-tooltip" id="tooltip"></div>
435</div>
436`;
437
438// ── Client-side JavaScript ──
439
440const JS = `
441const API = "/api/v1";
442const POLL_INTERVAL = 10000;
443const MAX_DOTS = 500;
444const HOUR_MS = 3600000;
445const DAY_MS = 24 * HOUR_MS;
446
447let flowData = [];
448let roots = []; // [{ id, name }] (id always a string)
449let signalCounts = {}; // rootId -> signal count
450let expandedTrees = {}; // rootId -> { tree, signalMap }
451let selectedNode = null;
452
453// ── Fetch helpers ──
454
455async function fetchJson(url) {
456 try {
457 const r = await fetch(url, { credentials: "include" });
458 if (!r.ok) return null;
459 const j = await r.json();
460 return j.data != null ? j.data : j;
461 } catch { return null; }
462}
463
464// id() safely extracts a string ID from MongoDB _id (could be ObjectId or string)
465function id(obj) {
466 if (!obj) return "";
467 if (typeof obj === "string") return obj;
468 if (obj._id) return String(obj._id);
469 if (obj.id) return String(obj.id);
470 return String(obj);
471}
472
473// ── Load roots ──
474// GET /api/v1/user/:userId -> { roots: [{ _id, name, visibility }] }
475
476async function loadRoots() {
477 const userData = await fetchJson(API + "/user/" + USER_ID);
478 // Normalize: always { id: string, name: string }
479 roots = (userData?.roots || []).map(r => ({
480 id: id(r),
481 name: r.name || id(r).slice(0, 8),
482 }));
483 return roots;
484}
485
486// ── Pulse Strip ──
487// Fetch flow data from first few trees in parallel (just flow, not tree structure)
488
489async function loadPulseStrip() {
490 if (!roots.length) return;
491
492 // Fetch all cascade results globally, then filter by rootId per tree
493 const globalFlow = await fetchJson(API + "/flow?limit=200");
494 // Results come as { signalId: [results...] }. Flatten into a single array.
495 const resultMap = globalFlow?.results || {};
496 const allResults = [];
497 for (const [signalId, signalResults] of Object.entries(resultMap)) {
498 if (Array.isArray(signalResults)) {
499 for (const r of signalResults) allResults.push({ ...r, signalId });
500 }
501 }
502
503 const allSignals = [];
504 const now = Date.now();
505 const cutoff = now - DAY_MS;
506
507 // Reset signal counts for tree cards
508 signalCounts = {};
509
510 for (const r of allResults) {
511 const ts = new Date(r.timestamp || r.createdAt).getTime();
512 if (ts > cutoff) {
513 allSignals.push({
514 timestamp: ts,
515 status: r.status || "succeeded",
516 source: r.source,
517 extName: r.extName,
518 });
519 // Count per source node's root (if available)
520 const srcRoot = r.rootId || r.source || "unknown";
521 signalCounts[srcRoot] = (signalCounts[srcRoot] || 0) + 1;
522 }
523 }
524
525 allSignals.sort((a, b) => a.timestamp - b.timestamp);
526 flowData = allSignals.slice(-MAX_DOTS);
527 renderPulseStrip();
528 renderTreeCards(); // update signal badges on tree cards
529}
530
531function renderPulseStrip() {
532 const timeline = document.getElementById("pulseTimeline");
533 if (!flowData.length) {
534 timeline.innerHTML = '<div class="pulse-empty">No signals in the last 24 hours</div>';
535 return;
536 }
537
538 const now = Date.now();
539 const start = now - DAY_MS;
540 let html = "";
541
542 for (let h = 0; h < 24; h += 4) {
543 const t = start + h * HOUR_MS;
544 const pct = ((t - start) / DAY_MS) * 100;
545 const label = new Date(t).toLocaleTimeString([], { hour: "numeric" });
546 html += '<div class="pulse-hour-label" style="left:' + pct + '%">' + label + '</div>';
547 }
548
549 for (const signal of flowData) {
550 const pct = ((signal.timestamp - start) / DAY_MS) * 100;
551 if (pct < 0 || pct > 100) continue;
552 const y = 20 + Math.random() * 30;
553 html += '<div class="pulse-dot ' + signal.status + '" '
554 + 'style="left:' + pct + '%;top:' + y + 'px" '
555 + 'data-ts="' + signal.timestamp + '" '
556 + 'data-status="' + signal.status + '" '
557 + 'data-ext="' + (signal.extName || "") + '"'
558 + '></div>';
559 }
560
561 timeline.innerHTML = html;
562
563 const tooltip = document.getElementById("tooltip");
564 timeline.querySelectorAll(".pulse-dot").forEach(dot => {
565 dot.addEventListener("mouseenter", (e) => {
566 const ts = new Date(parseInt(dot.dataset.ts));
567 const time = ts.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
568 const ext = dot.dataset.ext ? " (" + dot.dataset.ext + ")" : "";
569 tooltip.textContent = dot.dataset.status + ext + " at " + time;
570 tooltip.style.display = "block";
571 tooltip.style.left = e.clientX + 12 + "px";
572 tooltip.style.top = e.clientY - 30 + "px";
573 });
574 dot.addEventListener("mouseleave", () => { tooltip.style.display = "none"; });
575 });
576}
577
578// ── Tree Map (lazy load) ──
579// Page load: collapsed cards. Click: fetch that tree. One call per expand.
580
581function renderTreeCards() {
582 const container = document.getElementById("treeMap");
583 if (!roots.length) {
584 container.innerHTML = '<div class="tm-empty">No trees</div>';
585 return;
586 }
587
588 let html = "";
589 for (const root of roots) {
590 const sig = signalCounts[root.id] || 0;
591 const isExpanded = !!expandedTrees[root.id];
592
593 html += '<div class="tm-tree" id="tcard-' + root.id + '">';
594 html += '<div class="tm-tree-name" data-rid="' + root.id + '">';
595 html += '<span class="tm-chevron' + (isExpanded ? " open" : "") + '">></span> ';
596 html += esc(root.name);
597 if (sig > 0) html += ' <span class="tm-signal-count">' + sig + '</span>';
598 html += '</div>';
599 html += '<div class="tm-children" id="tbody-' + root.id + '"';
600 if (!isExpanded) html += ' style="display:none"';
601 html += '>';
602
603 if (isExpanded) {
604 html += buildTreeHtml(expandedTrees[root.id], root.id);
605 }
606
607 html += '</div></div>';
608 }
609
610 container.innerHTML = html;
611}
612
613function buildTreeHtml(td, rootId) {
614 if (!td || !td.tree) return '<div class="tm-collapsed-summary">Failed to load</div>';
615 const kids = (td.tree.children || []).filter(function(n) { return n.name && n.name[0] !== "."; });
616 if (!kids.length) return '<div class="tm-collapsed-summary">Empty tree</div>';
617 return renderNodes(kids, td.signalMap, Date.now(), rootId, 0);
618}
619
620async function toggleTreeLazy(rootId) {
621 var body = document.getElementById("tbody-" + rootId);
622 var card = document.getElementById("tcard-" + rootId);
623 var chevron = card ? card.querySelector(".tm-chevron") : null;
624
625 if (expandedTrees[rootId]) {
626 delete expandedTrees[rootId];
627 if (body) body.style.display = "none";
628 if (chevron) chevron.classList.remove("open");
629 return;
630 }
631
632 // Show loading
633 if (body) { body.style.display = ""; body.innerHTML = '<div class="tm-collapsed-summary">Loading...</div>'; }
634 if (chevron) chevron.classList.add("open");
635
636 try {
637 // GET /root/:rootId -> { _id, name, children: [{_id, name, children, ...}], ... }
638 // GET /flow -> { results: { signalId: [...] } }
639 var results = await Promise.all([
640 fetchJson(API + "/root/" + rootId),
641 fetchJson(API + "/flow?limit=50")
642 ]);
643 var tree = results[0];
644 var flow = results[1];
645
646 var signalMap = {};
647 // Flatten signal map into array
648 var flowResultMap = (flow && flow.results) ? flow.results : {};
649 var flowResults = [];
650 for (var [sid, sResults] of Object.entries(flowResultMap)) {
651 if (Array.isArray(sResults)) {
652 for (var sr of sResults) flowResults.push(sr);
653 }
654 }
655 for (var i = 0; i < flowResults.length; i++) {
656 var r = flowResults[i];
657 var ts = new Date(r.timestamp || r.createdAt).getTime();
658 var src = r.source ? String(r.source) : null;
659 var tgt = r.nodeId ? String(r.nodeId) : null;
660 if (src) {
661 if (!signalMap[src]) signalMap[src] = { sent: 0, received: 0, lastSignal: 0 };
662 signalMap[src].sent++;
663 if (ts > signalMap[src].lastSignal) signalMap[src].lastSignal = ts;
664 }
665 if (tgt) {
666 if (!signalMap[tgt]) signalMap[tgt] = { sent: 0, received: 0, lastSignal: 0 };
667 signalMap[tgt].received++;
668 if (ts > signalMap[tgt].lastSignal) signalMap[tgt].lastSignal = ts;
669 }
670 }
671
672 expandedTrees[rootId] = { tree: tree, signalMap: signalMap };
673 if (body) body.innerHTML = buildTreeHtml(expandedTrees[rootId], rootId);
674 } catch (err) {
675 if (body) body.innerHTML = '<div class="tm-collapsed-summary">Error: ' + err.message + '</div>';
676 }
677}
678
679function renderNodes(nodes, signalMap, now, rootId, depth) {
680 var visible = [];
681 for (var i = 0; i < nodes.length; i++) {
682 if (nodes[i].name && nodes[i].name[0] !== ".") visible.push(nodes[i]);
683 }
684 if (!visible.length) return "";
685 if (depth > 2) {
686 return '<div class="tm-collapsed-summary">' + countAll(visible) + ' deeper nodes</div>';
687 }
688 var html = "";
689 for (var i = 0; i < visible.length; i++) {
690 var node = visible[i];
691 var nid = String(node._id || "");
692 var sig = signalMap[nid];
693 var cls = "tm-node";
694 if (sig && sig.sent > 0 && (now - sig.lastSignal < HOUR_MS)) cls += " pulsing";
695 else if (sig && (now - sig.lastSignal < HOUR_MS)) cls += " active";
696 var badge = sig ? ' <span class="tm-signal-count">' + (sig.sent + sig.received) + '</span>' : "";
697 html += '<div class="' + cls + '" data-nodeid="' + nid + '" data-rootid="' + rootId + '">' + esc(node.name) + badge + '</div>';
698 var kids = node.children;
699 if (kids && kids.length) {
700 html += '<div class="tm-children">' + renderNodes(kids, signalMap, now, rootId, depth + 1) + '</div>';
701 }
702 }
703 return html;
704}
705
706function countAll(nodes) {
707 var c = 0;
708 for (var i = 0; i < nodes.length; i++) {
709 c++;
710 if (nodes[i].children) c += countAll(nodes[i].children);
711 }
712 return c;
713}
714
715// ── Node Panel ──
716
717async function selectNode(nodeId, rootId) {
718 selectedNode = nodeId;
719 const panel = document.getElementById("nodePanel");
720 const content = document.getElementById("panelContent");
721
722 const node = await fetchJson(API + "/node/" + nodeId);
723 const flowRaw = await fetchJson(API + "/flow?limit=50");
724 const flowMap = flowRaw?.results || {};
725 const flowSignals = [];
726 for (const [, sResults] of Object.entries(flowMap)) {
727 if (Array.isArray(sResults)) {
728 for (const sr of sResults) flowSignals.push(sr);
729 }
730 }
731 // Filter to signals involving this node
732 const signals = flowSignals.filter(s => s.source === nodeId || s.nodeId === nodeId);
733
734 let channels = null;
735 try { channels = await fetchJson(API + "/node/" + nodeId + "/channels"); } catch {}
736
737 let html = '<div class="np-path">' + esc(node?.path || node?.name || nodeId) + '</div>';
738 const sent = signals.filter(s => s.source === nodeId).length;
739 const received = signals.length - sent;
740
741 html += '<div class="np-stat"><span class="np-stat-label">Signals sent</span><span>' + sent + '</span></div>';
742 html += '<div class="np-stat"><span class="np-stat-label">Signals received</span><span>' + received + '</span></div>';
743
744 if (channels?.subscriptions?.length) {
745 html += '<div class="np-stat"><span class="np-stat-label">Channels</span><span>'
746 + channels.subscriptions.map(c => c.channelName).join(", ") + '</span></div>';
747 }
748
749 if (node?.metadata?.cascade?.enabled) {
750 html += '<div class="np-stat"><span class="np-stat-label">Cascade</span><span>enabled</span></div>';
751 }
752
753 // Recent signals
754 if (signals.length > 0) {
755 html += '<div class="np-signal-list"><div class="np-signal-title">Recent Signals</div>';
756 for (const sig of signals.slice(0, 15)) {
757 const dir = sig.source === nodeId ? ">" : "<";
758 const dirCls = sig.source === nodeId ? "sent" : "received";
759 const ago = timeAgo(new Date(sig.timestamp || sig.createdAt));
760 const desc = sig.extName || sig.status || "";
761 html += '<div class="np-signal">'
762 + '<span class="np-signal-dir">' + dir + '</span>'
763 + '<span>' + esc(desc) + '</span>'
764 + '<span class="np-signal-time">' + ago + '</span>'
765 + '</div>';
766 }
767 html += '</div>';
768 }
769
770 content.innerHTML = html;
771 panel.classList.add("open");
772}
773
774function closePanel() {
775 document.getElementById("nodePanel").classList.remove("open");
776 selectedNode = null;
777}
778
779// ── Network Layer ──
780
781async function loadNetwork() {
782 const section = document.getElementById("networkSection");
783 const container = document.getElementById("networkLayer");
784
785 let peers;
786 try {
787 peers = await fetchJson(API + "/canopy/admin/peers");
788 } catch {}
789 if (!peers?.length) {
790 section.classList.add("nl-hidden");
791 return;
792 }
793
794 section.classList.remove("nl-hidden");
795 peerData = peers;
796
797 let html = '<div class="nl-graph">';
798
799 // Peers on the left
800 const leftPeers = peers.slice(0, Math.ceil(peers.length / 2));
801 const rightPeers = peers.slice(Math.ceil(peers.length / 2));
802
803 for (const p of leftPeers) html += renderPeer(p);
804
805 // Center: this land
806 html += '<div class="nl-center"><div class="nl-center-dot"></div><div class="nl-center-label">This Land</div></div>';
807
808 for (const p of rightPeers) html += renderPeer(p);
809
810 html += '</div>';
811 container.innerHTML = html;
812}
813
814function renderPeer(peer) {
815 const status = peer.status || "unknown";
816 let color = "var(--peer-dead)";
817 let label = "inactive";
818 if (status === "active" || status === "healthy") { color = "var(--peer-healthy)"; label = "healthy"; }
819 else if (status === "degraded") { color = "var(--peer-degraded)"; label = "degraded"; }
820 else if (status === "unreachable") { color = "var(--peer-dead)"; label = "unreachable"; }
821
822 const name = peer.name || peer.landUrl || peer.peerId?.slice(0, 12) || "unknown";
823 return '<div class="nl-peer">'
824 + '<div class="nl-peer-dot" style="background:' + color + '"></div>'
825 + '<div class="nl-peer-name">' + esc(name) + '</div>'
826 + '<div class="nl-peer-status">' + label + '</div>'
827 + '</div>';
828}
829
830// ── Breath Status ──
831// Breath has no HTTP endpoint. Infer activity from flow data and tree count.
832
833async function loadBreath() {
834 const dot = document.getElementById("breathDot");
835 const label = document.getElementById("breathLabel");
836 if (!dot || !label) return;
837 const treeCount = roots?.length || 0;
838
839 if (treeCount === 0) {
840 dot.className = "fd-breath-dot dormant";
841 label.textContent = "no trees";
842 return;
843 }
844
845 // Check recent signal activity as a proxy for breath
846 const recentSignals = flowData.filter(s => Date.now() - s.timestamp < HOUR_MS).length;
847 if (recentSignals > 0) {
848 dot.classList.remove("dormant");
849 // Faster pulse when more active
850 const rate = recentSignals > 20 ? 0.5 : recentSignals > 5 ? 1 : 2;
851 dot.style.animationDuration = rate + "s";
852 label.textContent = treeCount + " tree" + (treeCount > 1 ? "s" : "") + ", " + recentSignals + " signals/hr";
853 } else {
854 dot.classList.add("dormant");
855 label.textContent = treeCount + " tree" + (treeCount > 1 ? "s" : "") + ", quiet";
856 }
857}
858
859// ── Helpers ──
860
861function esc(s) {
862 if (!s) return "";
863 const div = document.createElement("div");
864 div.textContent = s;
865 return div.innerHTML;
866}
867
868function timeAgo(date) {
869 const sec = Math.floor((Date.now() - date.getTime()) / 1000);
870 if (sec < 60) return sec + "s ago";
871 if (sec < 3600) return Math.floor(sec / 60) + "m ago";
872 if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
873 return Math.floor(sec / 86400) + "d ago";
874}
875
876// ── Event delegation for tree map clicks ──
877
878document.getElementById("treeMap").addEventListener("click", function(e) {
879 // Tree name click (expand/collapse)
880 var treeName = e.target.closest(".tm-tree-name");
881 if (treeName && treeName.dataset.rid) {
882 toggleTreeLazy(treeName.dataset.rid);
883 return;
884 }
885 // Node click (open panel)
886 var nodeEl = e.target.closest(".tm-node");
887 if (nodeEl && nodeEl.dataset.nodeid) {
888 selectNode(nodeEl.dataset.nodeid, nodeEl.dataset.rootid);
889 }
890});
891
892// ── Init and polling ──
893
894async function init() {
895 // One call: get root list
896 await loadRoots();
897
898 // Render tree cards immediately (collapsed, no fetches)
899 renderTreeCards();
900
901 // Load flow data and network in parallel (flow is independent of tree structure)
902 await Promise.allSettled([loadPulseStrip(), loadNetwork()]);
903 loadBreath().catch(() => {});
904
905 // Always update breath label even if loadBreath fails
906 const label = document.getElementById("breathLabel");
907 if (label && label.textContent === "loading...") {
908 label.textContent = roots.length + " tree" + (roots.length !== 1 ? "s" : "");
909 }
910
911 // Poll flow data every 10s (updates pulse strip + signal badges on cards)
912 setInterval(async () => {
913 await loadPulseStrip();
914 loadBreath();
915 }, POLL_INTERVAL);
916
917 // Refresh root list every 60s (picks up new trees)
918 setInterval(async () => {
919 await loadRoots();
920 renderTreeCards();
921 }, 60000);
922}
923
924init();
925`;
926
1// TreeOS Extension: flow
2// Scoped cascade flow queries. Land, tree, or node level.
3
4import Node from "../../seed/models/node.js";
5import { SYSTEM_ROLE } from "../../seed/protocol.js";
6import { getDescendantIds } from "../../seed/tree/treeFetch.js";
7import { getAllCascadeResults } from "../../seed/tree/cascade.js";
8
9/**
10 * Load all .flow partition nodes, sorted newest first.
11 */
12async function getFlowPartitions() {
13 const flowNode = await Node.findOne({ systemRole: SYSTEM_ROLE.FLOW }).select("_id").lean();
14 if (!flowNode) return [];
15 return Node.find({ parent: flowNode._id })
16 .select("name metadata")
17 .sort({ name: -1 })
18 .lean();
19}
20
21/**
22 * Filter cascade results to only include entries where source is in the given set.
23 * Returns { [signalId]: resultEntry[] } sorted newest first, capped at limit.
24 */
25function filterResultsBySource(partitions, sourceIds, limit) {
26 const sourceSet = new Set(sourceIds.map(String));
27 const filtered = {};
28 let count = 0;
29
30 for (const partition of partitions) {
31 if (count >= limit) break;
32 const results = partition.metadata instanceof Map
33 ? partition.metadata.get("results") || {}
34 : partition.metadata?.results || {};
35
36 const entries = Object.entries(results).sort((a, b) => {
37 const aTime = a[1][a[1].length - 1]?.timestamp || 0;
38 const bTime = b[1][b[1].length - 1]?.timestamp || 0;
39 return new Date(bTime) - new Date(aTime);
40 });
41
42 for (const [signalId, signalResults] of entries) {
43 if (count >= limit) break;
44 const matching = signalResults.filter(r => sourceSet.has(String(r.source)));
45 if (matching.length > 0) {
46 filtered[signalId] = matching;
47 count++;
48 }
49 }
50 }
51
52 return filtered;
53}
54
55/**
56 * Get cascade flow results scoped to the caller's position.
57 *
58 * - Land root node: all flow results (land level view)
59 * - Tree root node (rootOwner set): results for every node in that tree
60 * - Regular node: results where that node is the source
61 *
62 * @param {string} nodeId
63 * @param {number} limit max signal groups to return
64 * @returns {{ scope: "land"|"tree"|"node", nodeId: string, results: object }}
65 */
66export async function getFlowForPosition(nodeId, limit = 50) {
67 const node = await Node.findById(nodeId).select("systemRole rootOwner name").lean();
68 if (!node) return { scope: "node", nodeId, results: {} };
69
70 // Land root: return everything
71 if (node.systemRole === SYSTEM_ROLE.LAND_ROOT) {
72 const results = await getAllCascadeResults(limit);
73 return { scope: "land", nodeId, results };
74 }
75
76 const partitions = await getFlowPartitions();
77 if (partitions.length === 0) {
78 return { scope: node.rootOwner ? "tree" : "node", nodeId, results: {} };
79 }
80
81 // Tree root: collect all descendant IDs and filter
82 if (node.rootOwner) {
83 const descendantIds = await getDescendantIds(nodeId);
84 const results = filterResultsBySource(partitions, descendantIds, limit);
85 return { scope: "tree", nodeId, results };
86 }
87
88 // Regular node: filter by this single node
89 const results = filterResultsBySource(partitions, [nodeId], limit);
90 return { scope: "node", nodeId, results };
91}
92
1import { getFlowForPosition } from "./core.js";
2
3export async function init(core) {
4 const { default: router } = await import("./routes.js");
5
6 // Mount flow dashboard page if html-rendering is available
7 try {
8 const { getExtension } = await import("../loader.js");
9 const htmlExt = getExtension("html-rendering");
10 if (htmlExt?.pageRouter) {
11 const { default: flowDashboardRouter } = await import("./app/flowDashboard.js");
12 htmlExt.pageRouter.use("/", flowDashboardRouter);
13 }
14 } catch (err) {
15 const log = (await import("../../seed/log.js")).default;
16 log.warn("Flow", `Dashboard page not mounted: ${err.message}`);
17 }
18
19 return {
20 router,
21 exports: { getFlowForPosition },
22 };
23}
24
1export default {
2 name: "flow",
3 version: "1.0.1",
4 builtFor: "treeos-cascade",
5 description:
6 "Cascade is the kernel's nervous system. When content is written at a cascade-enabled " +
7 "node, the kernel fires onCascade and stores the results in daily partition nodes under " +
8 "the .flow system node. Those results accumulate quickly. The flow extension provides " +
9 "the query layer that makes them useful by scoping results to the caller's current " +
10 "position in the tree." +
11 "\n\n" +
12 "Position determines scope. At the land root, you see every cascade result across all " +
13 "trees: the global view. At a tree root, you see results for every node in that tree, " +
14 "gathered by walking all descendant IDs and filtering partitions to matching sources. " +
15 "At any regular node, you see only results where that specific node was the cascade " +
16 "source. This three-tier scoping means the same endpoint serves land operators reviewing " +
17 "system-wide activity, tree owners reviewing their tree's cascade health, and users " +
18 "inspecting why a particular node triggered or received a signal." +
19 "\n\n" +
20 "The stats endpoint exposes the internal partition structure: how many daily partitions " +
21 "exist, the oldest and newest dates, today's signal count versus the daily cap, and the " +
22 "configured result TTL. This gives operators visibility into cascade storage growth and " +
23 "retention without touching the database directly. The CLI surfaces both endpoints: " +
24 "\"flow\" shows scoped results for the current position, \"flow signal\" drills into a " +
25 "single signal ID, and \"flow stats\" shows partition health. The core getFlowForPosition " +
26 "function is exported so other extensions can query cascade results programmatically " +
27 "without going through HTTP.",
28
29 needs: {
30 services: [],
31 models: ["Node"],
32 },
33
34 optional: {
35 extensions: ["html-rendering"],
36 },
37
38 provides: {
39 models: {},
40 routes: "./routes.js",
41 tools: false,
42 jobs: false,
43 orchestrator: false,
44 energyActions: {},
45 sessionTypes: {},
46 env: [],
47
48 cli: [
49 {
50 command: "flow [action] [args...]", scope: ["tree"],
51 description: "Cascade flow scoped to current position. Actions: signal, stats.",
52 method: "GET",
53 endpoint: "/node/:nodeId/flow",
54 subcommands: {
55 "signal": { method: "GET", endpoint: "/flow/:signalId", args: ["signalId"], description: "Drill into one signal" },
56 "stats": { method: "GET", endpoint: "/flow/stats", description: "Partition sizes, cap status" },
57 },
58 },
59 ],
60
61 hooks: {
62 fires: [],
63 listens: [],
64 },
65 },
66};
67
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import log from "../../seed/log.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import { getFlowForPosition } from "./core.js";
6
7const router = express.Router();
8
9// GET /node/:nodeId/flow - Cascade flow scoped to position
10// Land root: all flow. Tree root: tree-wide flow. Node: that node's flow.
11router.get("/node/:nodeId/flow", authenticate, async (req, res) => {
12 try {
13 const { nodeId } = req.params;
14 const limit = Math.min(Math.max(parseInt(req.query.limit || "50", 10), 1), 500);
15 const data = await getFlowForPosition(nodeId, limit);
16 sendOk(res, data);
17 } catch (err) {
18 log.error("Flow", "Error reading flow:", err.message);
19 sendError(res, 500, ERR.INTERNAL, err.message);
20 }
21});
22
23// GET /flow/stats - partition sizes, today's count, retention
24router.get("/flow/stats", authenticate, async (req, res) => {
25 try {
26 const Node = (await import("../../seed/models/node.js")).default;
27 const { SYSTEM_ROLE } = await import("../../seed/protocol.js");
28 const { getLandConfigValue } = await import("../../seed/landConfig.js");
29
30 const flowNode = await Node.findOne({ systemRole: SYSTEM_ROLE.FLOW }).select("_id children").lean();
31 if (!flowNode) return sendOk(res, { message: "No .flow node found" });
32
33 const partitions = await Node.find({ parent: flowNode._id })
34 .select("name metadata")
35 .sort({ name: -1 })
36 .lean();
37
38 const today = new Date().toISOString().slice(0, 10);
39 const ttl = parseInt(getLandConfigValue("resultTTL") || "604800", 10);
40 const maxPerDay = parseInt(getLandConfigValue("flowMaxResultsPerDay") || "10000", 10);
41
42 const partitionStats = partitions.map((p) => {
43 const results = p.metadata instanceof Map
44 ? p.metadata.get("results") || {}
45 : p.metadata?.results || {};
46 return { date: p.name, signalCount: Object.keys(results).length };
47 });
48
49 const todayPartition = partitionStats.find((p) => p.date === today);
50
51 sendOk(res, {
52 partitionCount: partitions.length,
53 oldestPartition: partitions.length > 0 ? partitions[partitions.length - 1].name : null,
54 newestPartition: partitions.length > 0 ? partitions[0].name : null,
55 todaySignals: todayPartition?.signalCount || 0,
56 todayCap: maxPerDay,
57 resultTTLDays: Math.round(ttl / 86400),
58 partitions: partitionStats,
59 });
60 } catch (err) {
61 log.error("Flow", "Stats error:", err.message);
62 sendError(res, 500, ERR.INTERNAL, err.message);
63 }
64});
65
66export default router;
67
Loading comments...