EXTENSION for treeos-cascade
flow
Cascade is the kernel's nervous system. When content is written at a cascade-enabled node, the kernel fires onCascade and stores the results in daily partition nodes under the .flow system node. Those results accumulate quickly. The flow extension provides the query layer that makes them useful by scoping results to the caller's current position in the tree. Position determines scope. At the land root, you see every cascade result across all trees: the global view. At a tree root, you see results for every node in that tree, gathered by walking all descendant IDs and filtering partitions to matching sources. At any regular node, you see only results where that specific node was the cascade source. This three-tier scoping means the same endpoint serves land operators reviewing system-wide activity, tree owners reviewing their tree's cascade health, and users inspecting why a particular node triggered or received a signal. The stats endpoint exposes the internal partition structure: how many daily partitions exist, the oldest and newest dates, today's signal count versus the daily cap, and the configured result TTL. This gives operators visibility into cascade storage growth and retention without touching the database directly. The CLI surfaces both endpoints: "flow" shows scoped results for the current position, "flow signal" drills into a single signal ID, and "flow stats" shows partition health. The core getFlowForPosition function is exported so other extensions can query cascade results programmatically without going through HTTP.
v1.0.1 by TreeOS Site 0 downloads 5 files 1,176 lines 35.0 KB published 38d ago
treeos ext install flow
View changelog

Manifest

Provides

  • routes
  • 1 CLI commands

Requires

  • models: Node

Optional

  • extensions: html-rendering
SHA256: 8c275abf299c372cc5bb2704e13be152a33370cb17d07ef768587622c97861a7

Dependents

1 package depend on this

PackageTypeRelationship
treeos-cascade v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
flowGETCascade flow scoped to current position. Actions: signal, stats.
flow signalGETDrill into one signal
flow statsGETPartition sizes, cap status

Source Code

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()">&times;</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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star flow

Comments

Loading comments...

Post comments from the CLI: treeos ext comment flow "your comment"
Max 3 comments per extension. One star and one flag per user.