@ra3orblade/swarm 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +171 -0
- package/dist/swarm-hook.js +52 -0
- package/dist/swarm-mcp.js +19880 -0
- package/dist/swarm.js +830 -0
- package/dist/swarmd.js +4554 -0
- package/package.json +43 -0
- package/web/app.js +1103 -0
- package/web/fm.css +456 -0
- package/web/icons.js +4 -0
- package/web/index.html +444 -0
- package/web/menus.js +14 -0
- package/web/table.js +262 -0
- package/web/viz.js +272 -0
package/web/table.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Reusable data-grid for the dashboard tables: sortable, resizable, reorderable, filterable
|
|
2
|
+
// columns with a column-visibility menu — all state persisted per-table in localStorage.
|
|
3
|
+
//
|
|
4
|
+
// Usage (app.js):
|
|
5
|
+
// dataTable({ id, columns, rows, leading?, trailing?, rerender }) -> HTML string
|
|
6
|
+
// Columns: { key, label, width, min?, num?, sortable?(=true), filterable?(=true),
|
|
7
|
+
// get(row)->sortable/filterable value, cell(row)->html }
|
|
8
|
+
// leading/trailing: fixed (non-configurable) edge columns { width, cell(row)->html }.
|
|
9
|
+
// `rerender` is called after any state change so the caller can re-render its view.
|
|
10
|
+
|
|
11
|
+
(() => {
|
|
12
|
+
const LS = (id) => `swarm.table.${id}`;
|
|
13
|
+
// Persisted state per grid: localStorage is read once per id, then written through on every mutation.
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
const load = (id) => {
|
|
16
|
+
let st = cache.get(id);
|
|
17
|
+
if (!st) {
|
|
18
|
+
try {
|
|
19
|
+
st = JSON.parse(localStorage.getItem(LS(id)) || "{}");
|
|
20
|
+
} catch {
|
|
21
|
+
st = {};
|
|
22
|
+
}
|
|
23
|
+
cache.set(id, st);
|
|
24
|
+
}
|
|
25
|
+
return st;
|
|
26
|
+
};
|
|
27
|
+
const save = (id, st) => {
|
|
28
|
+
cache.set(id, st);
|
|
29
|
+
localStorage.setItem(LS(id), JSON.stringify(st));
|
|
30
|
+
};
|
|
31
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
32
|
+
const registry = new Map(); // id -> { columns, leading, trailing, rerender }
|
|
33
|
+
|
|
34
|
+
// Merge persisted state with the column defaults, dropping keys that no longer exist.
|
|
35
|
+
function resolve(id, columns) {
|
|
36
|
+
const st = load(id);
|
|
37
|
+
const keys = columns.map((c) => c.key);
|
|
38
|
+
const order = (st.order || keys).filter((k) => keys.includes(k));
|
|
39
|
+
for (const k of keys) if (!order.includes(k)) order.push(k);
|
|
40
|
+
return {
|
|
41
|
+
sort: st.sort && keys.includes(st.sort.key) ? st.sort : null,
|
|
42
|
+
widths: st.widths || {},
|
|
43
|
+
hidden: (st.hidden || []).filter((k) => keys.includes(k)),
|
|
44
|
+
order,
|
|
45
|
+
filters: st.filters || {},
|
|
46
|
+
showFilters: !!st.showFilters,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function dataTable({ id, columns, rows, leading, trailing, rerender, rowAttrs }) {
|
|
51
|
+
registry.set(id, { columns, leading, trailing, rerender });
|
|
52
|
+
const st = resolve(id, columns);
|
|
53
|
+
const byKey = Object.fromEntries(columns.map((c) => [c.key, c]));
|
|
54
|
+
const visible = st.order.map((k) => byKey[k]).filter((c) => c && !st.hidden.includes(c.key));
|
|
55
|
+
|
|
56
|
+
// ---- sort + filter the rows
|
|
57
|
+
let view = rows;
|
|
58
|
+
const needles = Object.entries(st.filters)
|
|
59
|
+
.map(([k, raw]) => [byKey[k], String(raw || "").trim().toLowerCase()])
|
|
60
|
+
.filter(([col, q]) => col && q);
|
|
61
|
+
if (needles.length) view = view.filter((r) => needles.every(([col, q]) => String(col.get?.(r) ?? "").toLowerCase().includes(q)));
|
|
62
|
+
const col = st.sort ? byKey[st.sort.key] : null;
|
|
63
|
+
if (col) {
|
|
64
|
+
// keys computed once per row (get() may format), then sort indices
|
|
65
|
+
const dir = st.sort.dir === "desc" ? -1 : 1;
|
|
66
|
+
const keys = view.map((r) => col.get?.(r));
|
|
67
|
+
const idx = keys.map((_, i) => i);
|
|
68
|
+
idx.sort((i, j) => {
|
|
69
|
+
const x = keys[i],
|
|
70
|
+
y = keys[j];
|
|
71
|
+
if (x == null && y == null) return 0;
|
|
72
|
+
if (x == null) return 1;
|
|
73
|
+
if (y == null) return -1;
|
|
74
|
+
const c = typeof x === "number" && typeof y === "number" ? x - y : collator.compare(String(x), String(y));
|
|
75
|
+
return c * dir;
|
|
76
|
+
});
|
|
77
|
+
view = idx.map((i) => view[i]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- header
|
|
81
|
+
// A column marked flex gets no width (table-layout:fixed lets it absorb the remainder)
|
|
82
|
+
// unless the user has explicitly resized it.
|
|
83
|
+
const w = (c) => {
|
|
84
|
+
const px = st.widths[c.key] ?? (c.flex ? null : (c.width ?? 100));
|
|
85
|
+
return px ? `style="width:${px}px"` : "";
|
|
86
|
+
};
|
|
87
|
+
const arrow = (c) =>
|
|
88
|
+
st.sort?.key === c.key ? `<i class="sort ${st.sort.dir}"></i>` : "";
|
|
89
|
+
const th = (c) =>
|
|
90
|
+
`<th ${c.num ? 'class="num" ' : ""}${w(c)} draggable="true" data-col="${c.key}" data-tid="${id}"` +
|
|
91
|
+
`${c.sortable === false ? "" : ' data-sort="1"'} title="${esc(c.label)} — click to sort, drag to reorder, drag the edge to resize">` +
|
|
92
|
+
`<span class="th-l">${esc(c.label)}${arrow(c)}</span>` +
|
|
93
|
+
`<span class="th-grip" data-resize="${c.key}"></span></th>`;
|
|
94
|
+
const lead = leading ? `<th style="width:${leading.width}px"></th>` : "";
|
|
95
|
+
const trail = trailing
|
|
96
|
+
? `<th style="width:${trailing.width}px" class="th-tools"><span class="th-cols" data-cols="${id}" title="Columns">${window.icon ? window.icon("sliders", 14) : ""}</span></th>`
|
|
97
|
+
: "";
|
|
98
|
+
const head = `<tr>${lead}${visible.map(th).join("")}${trail}</tr>`;
|
|
99
|
+
|
|
100
|
+
// ---- optional filter row
|
|
101
|
+
const frow = st.showFilters
|
|
102
|
+
? `<tr class="filters">${leading ? "<th></th>" : ""}${visible
|
|
103
|
+
.map((c) =>
|
|
104
|
+
c.filterable === false
|
|
105
|
+
? "<th></th>"
|
|
106
|
+
: `<th><input data-filter="${c.key}" data-tid="${id}" value="${esc(st.filters[c.key] || "")}" placeholder="${esc(c.label)}"></th>`,
|
|
107
|
+
)
|
|
108
|
+
.join("")}${trailing ? "<th></th>" : ""}</tr>`
|
|
109
|
+
: "";
|
|
110
|
+
|
|
111
|
+
// ---- body
|
|
112
|
+
const body = view
|
|
113
|
+
.map((r) => {
|
|
114
|
+
const attrs = rowAttrs ? rowAttrs(r) : "";
|
|
115
|
+
const lc = leading ? `<td>${leading.cell(r)}</td>` : "";
|
|
116
|
+
const tc = trailing ? `<td class="td-tools">${trailing.cell(r)}</td>` : "";
|
|
117
|
+
return `<tr ${attrs}>${lc}${visible.map((c) => `<td ${c.num ? 'class="num"' : ""}>${c.cell(r)}</td>`).join("")}${tc}</tr>`;
|
|
118
|
+
})
|
|
119
|
+
.join("");
|
|
120
|
+
|
|
121
|
+
const ncols = visible.length + (leading ? 1 : 0) + (trailing ? 1 : 0);
|
|
122
|
+
const empty = view.length ? "" : `<tr class="tbl-empty"><td colspan="${ncols}">No rows${Object.keys(st.filters).some((k) => st.filters[k]) ? " match the filters" : ""}.</td></tr>`;
|
|
123
|
+
return `<div class="card grid" data-tid="${id}"><table class="dt"><thead>${head}${frow}</thead><tbody>${body}${empty}</tbody></table></div>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
127
|
+
const mutate = (id, fn) => {
|
|
128
|
+
const st = load(id);
|
|
129
|
+
fn(st);
|
|
130
|
+
save(id, st);
|
|
131
|
+
registry.get(id)?.rerender?.();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ---------- delegated interactions (attached once) ----------
|
|
135
|
+
// sort
|
|
136
|
+
document.addEventListener("click", (e) => {
|
|
137
|
+
const cols = e.target.closest?.("[data-cols]");
|
|
138
|
+
if (cols) return openColumnsMenu(cols);
|
|
139
|
+
const th = e.target.closest?.("th[data-sort]");
|
|
140
|
+
if (!th || e.target.closest(".th-grip")) return;
|
|
141
|
+
const id = th.dataset.tid,
|
|
142
|
+
key = th.dataset.col;
|
|
143
|
+
mutate(id, (st) => {
|
|
144
|
+
const cur = st.sort;
|
|
145
|
+
if (!cur || cur.key !== key) st.sort = { key, dir: "asc" };
|
|
146
|
+
else if (cur.dir === "asc") st.sort = { key, dir: "desc" };
|
|
147
|
+
else st.sort = null; // third click clears
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// filter input (debounced-ish via input event)
|
|
152
|
+
document.addEventListener("input", (e) => {
|
|
153
|
+
const inp = e.target.closest?.("input[data-filter]");
|
|
154
|
+
if (!inp) return;
|
|
155
|
+
const id = inp.dataset.tid,
|
|
156
|
+
key = inp.dataset.filter,
|
|
157
|
+
val = inp.value;
|
|
158
|
+
const st = load(id);
|
|
159
|
+
st.filters = st.filters || {};
|
|
160
|
+
if (val) st.filters[key] = val;
|
|
161
|
+
else delete st.filters[key];
|
|
162
|
+
save(id, st);
|
|
163
|
+
// re-render but keep focus + caret on the same filter input
|
|
164
|
+
registry.get(id)?.rerender?.();
|
|
165
|
+
const again = document.querySelector(`input[data-filter="${key}"][data-tid="${id}"]`);
|
|
166
|
+
if (again) {
|
|
167
|
+
again.focus();
|
|
168
|
+
again.setSelectionRange(again.value.length, again.value.length);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// resize + reorder (mousedown starts a drag; both tracked on document)
|
|
173
|
+
let drag = null;
|
|
174
|
+
document.addEventListener("mousedown", (e) => {
|
|
175
|
+
const grip = e.target.closest?.(".th-grip");
|
|
176
|
+
if (!grip) return;
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
const th = grip.closest("th");
|
|
180
|
+
drag = { type: "resize", id: th.dataset.tid, key: grip.dataset.resize, startX: e.clientX, startW: th.offsetWidth, th };
|
|
181
|
+
document.body.classList.add("col-resizing");
|
|
182
|
+
});
|
|
183
|
+
document.addEventListener("mousemove", (e) => {
|
|
184
|
+
if (drag?.type !== "resize") return;
|
|
185
|
+
const min = 40;
|
|
186
|
+
const wpx = Math.max(min, drag.startW + (e.clientX - drag.startX));
|
|
187
|
+
drag.w = wpx;
|
|
188
|
+
if (drag.th) drag.th.style.width = `${wpx}px`; // live feedback without a full re-render
|
|
189
|
+
});
|
|
190
|
+
document.addEventListener("mouseup", () => {
|
|
191
|
+
if (drag?.type === "resize" && drag.w) {
|
|
192
|
+
const { id, key, w } = drag;
|
|
193
|
+
mutate(id, (st) => {
|
|
194
|
+
st.widths = st.widths || {};
|
|
195
|
+
st.widths[key] = w;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
drag = null;
|
|
199
|
+
document.body.classList.remove("col-resizing");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// reorder via native HTML5 drag on the <th>
|
|
203
|
+
let dragCol = null;
|
|
204
|
+
document.addEventListener("dragstart", (e) => {
|
|
205
|
+
const th = e.target.closest?.("th[data-col]");
|
|
206
|
+
if (!th || e.target.closest(".th-grip")) return;
|
|
207
|
+
dragCol = { id: th.dataset.tid, key: th.dataset.col };
|
|
208
|
+
e.dataTransfer.effectAllowed = "move";
|
|
209
|
+
th.classList.add("dragging");
|
|
210
|
+
});
|
|
211
|
+
document.addEventListener("dragover", (e) => {
|
|
212
|
+
const th = e.target.closest?.("th[data-col]");
|
|
213
|
+
if (dragCol && th && th.dataset.tid === dragCol.id) e.preventDefault();
|
|
214
|
+
});
|
|
215
|
+
document.addEventListener("drop", (e) => {
|
|
216
|
+
const th = e.target.closest?.("th[data-col]");
|
|
217
|
+
if (!dragCol || !th || th.dataset.tid !== dragCol.id) return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
const target = th.dataset.col;
|
|
220
|
+
if (target === dragCol.key) return;
|
|
221
|
+
mutate(dragCol.id, (st) => {
|
|
222
|
+
const reg = registry.get(dragCol.id);
|
|
223
|
+
const keys = reg.columns.map((c) => c.key);
|
|
224
|
+
const order = (st.order || keys).filter((k) => keys.includes(k));
|
|
225
|
+
for (const k of keys) if (!order.includes(k)) order.push(k);
|
|
226
|
+
order.splice(order.indexOf(dragCol.key), 1);
|
|
227
|
+
order.splice(order.indexOf(target), 0, dragCol.key);
|
|
228
|
+
st.order = order;
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
document.addEventListener("dragend", () => {
|
|
232
|
+
for (const el of document.querySelectorAll("th.dragging")) el.classList.remove("dragging");
|
|
233
|
+
dragCol = null;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// columns visibility + filter toggle menu
|
|
237
|
+
function openColumnsMenu(anchor) {
|
|
238
|
+
const id = anchor.dataset.cols;
|
|
239
|
+
const reg = registry.get(id);
|
|
240
|
+
if (!reg || !window.menus) return;
|
|
241
|
+
const st = resolve(id, reg.columns);
|
|
242
|
+
const items = [
|
|
243
|
+
{ label: st.showFilters ? "Hide filters" : "Show filters", icon: "magnifying-glass", run: () => mutate(id, (s) => (s.showFilters = !s.showFilters)) },
|
|
244
|
+
{ label: "Reset layout", icon: "arrows-clockwise", run: () => mutate(id, (s) => { s.order = undefined; s.widths = {}; s.hidden = []; s.sort = null; s.filters = {}; s.showFilters = false; }) },
|
|
245
|
+
{ section: "Columns" },
|
|
246
|
+
...reg.columns.map((c) => ({
|
|
247
|
+
label: c.label.charAt(0).toUpperCase() + c.label.slice(1),
|
|
248
|
+
icon: st.hidden.includes(c.key) ? undefined : "check",
|
|
249
|
+
run: () =>
|
|
250
|
+
mutate(id, (s) => {
|
|
251
|
+
s.hidden = s.hidden || [];
|
|
252
|
+
const i = s.hidden.indexOf(c.key);
|
|
253
|
+
if (i >= 0) s.hidden.splice(i, 1);
|
|
254
|
+
else s.hidden.push(c.key);
|
|
255
|
+
}),
|
|
256
|
+
})),
|
|
257
|
+
];
|
|
258
|
+
window.menus.open(anchor, { items });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
window.dataTable = dataTable;
|
|
262
|
+
})();
|
package/web/viz.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Inline-SVG chart helpers. No dependencies, no build step.
|
|
2
|
+
// Colour rules: agents get a fixed categorical slot (never cycled); part-to-whole of one thing
|
|
3
|
+
// (token composition) uses one hue stepped light→dark; heatmap is one hue by opacity.
|
|
4
|
+
|
|
5
|
+
const AGENT_SLOT = { "claude-code": 1, codex: 2, gemini: 3, grok: 4, aider: 5, cline: 6, opencode: 7 };
|
|
6
|
+
const agentColor = (a) => `var(--c${AGENT_SLOT[a] ?? 0})`;
|
|
7
|
+
const agentName = (a) => ({ "claude-code": "Claude", codex: "Codex", gemini: "Gemini", grok: "Grok", aider: "Aider", cline: "Cline", opencode: "opencode" }[a] ?? a);
|
|
8
|
+
const AGENT_ORDER = Object.keys(AGENT_SLOT);
|
|
9
|
+
const agentSort = (a, b) => (AGENT_SLOT[a] ?? 99) - (AGENT_SLOT[b] ?? 99);
|
|
10
|
+
|
|
11
|
+
const fmtUsd = (n) => (n == null ? "—" : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
|
|
12
|
+
const fmtTok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
|
|
13
|
+
const p2 = (n) => (n < 10 ? `0${n}` : String(n));
|
|
14
|
+
const hm = (ts) => { const d = new Date(ts); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; }; // cheap "HH:MM" (toTimeString is slow)
|
|
15
|
+
const attr = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
16
|
+
|
|
17
|
+
/** Legend: swatch + label per series, in fixed order. */
|
|
18
|
+
function legend(keys, name = agentName, color = agentColor) {
|
|
19
|
+
return `<div class="legend">${keys.map((k) => `<span><i style="background:${color(k)}"></i>${attr(name(k))}</span>`).join("")}</div>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stacked column chart. days: ["2026-08-07", …]; series: { key: number[] } aligned to days.
|
|
24
|
+
* Thin columns, 2px surface gaps between segments, baseline, hover tooltip per column.
|
|
25
|
+
*/
|
|
26
|
+
function stackedColumns(days, series, { height = 150, fmt = fmtUsd, color = agentColor, name = agentName, sort = agentSort, label = (d) => d.slice(5) } = {}) {
|
|
27
|
+
const keys = Object.keys(series).sort(sort);
|
|
28
|
+
const totals = days.map((_, i) => keys.reduce((a, k) => a + (series[k][i] ?? 0), 0));
|
|
29
|
+
const max = Math.max(1e-9, ...totals);
|
|
30
|
+
const W = 1000, H = height, padB = 18, padT = 6, plotH = H - padB - padT;
|
|
31
|
+
const n = days.length, slot = W / n, bw = Math.min(26, slot * 0.6);
|
|
32
|
+
const y = (v) => padT + plotH - (v / max) * plotH;
|
|
33
|
+
const ticks = niceTicks(max, 3);
|
|
34
|
+
const grid = ticks.map((t) => `<line x1="0" x2="${W}" y1="${y(t)}" y2="${y(t)}" class="grid"/><text x="0" y="${y(t) - 3}" class="ax">${fmt(t)}</text>`).join("");
|
|
35
|
+
const cols = days.map((d, i) => {
|
|
36
|
+
let acc = 0;
|
|
37
|
+
const x = i * slot + (slot - bw) / 2;
|
|
38
|
+
const segs = keys
|
|
39
|
+
.map((k) => {
|
|
40
|
+
const v = series[k][i] ?? 0;
|
|
41
|
+
if (!v) return "";
|
|
42
|
+
const y1 = y(acc + v), y0 = y(acc);
|
|
43
|
+
acc += v;
|
|
44
|
+
const h = Math.max(0, y0 - y1 - (acc - v > 0 ? 2 : 0));
|
|
45
|
+
const top = acc === totals[i] ? 3 : 0; // round only the topmost data-end
|
|
46
|
+
return `<path d="${roundTop(x, y1, bw, h, top)}" fill="${color(k)}"/>`;
|
|
47
|
+
})
|
|
48
|
+
.join("");
|
|
49
|
+
const tip = `<b>${d}</b><br>${keys.filter((k) => series[k][i]).map((k) => `<i style="background:${color(k)}"></i>${name(k)} ${fmt(series[k][i])}`).join("<br>")}${keys.length > 1 ? `<br><span>total ${fmt(totals[i])}</span>` : ""}`;
|
|
50
|
+
const lbl = n <= 16 || i % Math.ceil(n / 16) === 0 ? `<text x="${x + bw / 2}" y="${H - 4}" class="ax mid">${label(d)}</text>` : "";
|
|
51
|
+
return `<g class="col" data-tip="${attr(tip)}"><rect x="${i * slot}" y="0" width="${slot}" height="${H}" fill="transparent"/>${segs}${lbl}</g>`;
|
|
52
|
+
});
|
|
53
|
+
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart" style="height:${H}px">${grid}<line x1="0" x2="${W}" y1="${y(0)}" y2="${y(0)}" class="base"/>${cols.join("")}</svg>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Weekday × hour heat grid, one hue by opacity. cells: [{dow, hour, v}] */
|
|
57
|
+
function heatmap(cells, { fmt = fmtUsd, label = "cost" } = {}) {
|
|
58
|
+
const grid = Array.from({ length: 7 }, () => Array(24).fill(0));
|
|
59
|
+
for (const c of cells) grid[c.dow][c.hour] += c.v ?? 0;
|
|
60
|
+
const max = Math.max(1e-9, ...grid.flat());
|
|
61
|
+
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
62
|
+
const order = [1, 2, 3, 4, 5, 6, 0];
|
|
63
|
+
const cw = 100 / 24;
|
|
64
|
+
const rows = order.map(
|
|
65
|
+
(d, r) =>
|
|
66
|
+
`<div class="hm-lbl">${days[d]}</div><div class="hm-row">${grid[d]
|
|
67
|
+
.map((v, h) => `<i data-tip="<b>${days[d]} ${String(h).padStart(2, "0")}:00</b><br>${label} ${fmt(v)}" style="opacity:${v ? 0.15 + 0.85 * Math.sqrt(v / max) : 0}"></i>`)
|
|
68
|
+
.join("")}</div>`,
|
|
69
|
+
);
|
|
70
|
+
const hours = Array.from({ length: 24 }, (_, h) => (h % 3 === 0 ? `<span style="left:${h * cw}%">${String(h).padStart(2, "0")}</span>` : "")).join("");
|
|
71
|
+
return `<div class="hm">${rows.join("")}<div></div><div class="hm-hours">${hours}</div></div>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Tiny line for a table cell. pts: number[]; 88×20. */
|
|
75
|
+
function sparkline(pts, color = "var(--acc)") {
|
|
76
|
+
if (!pts || pts.length < 2) return `<svg viewBox="0 0 88 20" class="spark"></svg>`;
|
|
77
|
+
const max = Math.max(1e-9, ...pts);
|
|
78
|
+
const step = 88 / (pts.length - 1);
|
|
79
|
+
const d = pts.map((v, i) => `${i ? "L" : "M"}${(i * step).toFixed(1)},${(18 - (v / max) * 16).toFixed(1)}`).join("");
|
|
80
|
+
const last = pts[pts.length - 1];
|
|
81
|
+
return `<svg viewBox="0 0 88 20" class="spark" preserveAspectRatio="none"><path d="${d}" stroke="${color}"/><circle cx="88" cy="${(18 - (last / max) * 16).toFixed(1)}" r="2" fill="${color}"/></svg>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Horizontal part-to-whole bar, one hue stepped. parts: [{label, v}] in light→dark order. */
|
|
85
|
+
function compositionBar(parts, { fmt = fmtTok } = {}) {
|
|
86
|
+
const total = parts.reduce((a, p) => a + p.v, 0) || 1;
|
|
87
|
+
const steps = ["var(--acc-1)", "var(--acc-2)", "var(--acc-3)", "var(--acc-4)", "var(--acc-5)"];
|
|
88
|
+
const segs = parts
|
|
89
|
+
.map((p, i) => (p.v ? `<i data-tip="<b>${attr(p.label)}</b><br>${fmt(p.v)} · ${((100 * p.v) / total).toFixed(1)}%" style="flex:${p.v};background:${steps[i] ?? steps.at(-1)}"></i>` : ""))
|
|
90
|
+
.join("");
|
|
91
|
+
const lg = parts.filter((p) => p.v).map((p, i) => `<span><i style="background:${steps[parts.indexOf(p)] ?? steps.at(-1)}"></i>${attr(p.label)} <em>${(100 * p.v) / total < 1 ? "<1" : ((100 * p.v) / total).toFixed(0)}%</em></span>`).join("");
|
|
92
|
+
return `<div class="comp">${segs}</div><div class="legend small">${lg}</div>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Horizontal bars for a ranked list (tool mix). rows: [[label, n]] */
|
|
96
|
+
function hbars(rows, color = "var(--acc)") {
|
|
97
|
+
const max = Math.max(1, ...rows.map((r) => r[1]));
|
|
98
|
+
return `<div class="hbars">${rows
|
|
99
|
+
.map(([k, v]) => `<div class="hb"><span class="k">${attr(k)}</span><span class="t"><i style="width:${(100 * v) / max}%;background:${color}"></i></span><span class="n">${v}</span></div>`)
|
|
100
|
+
.join("")}</div>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Per-turn column strip for a session: cost per turn over time. turns: [{ts, costUsd, output, model}] */
|
|
104
|
+
// Memoised on (count, last turn id, height): the session view re-renders on every event, the turns rarely change.
|
|
105
|
+
let stripMemo = { key: "", svg: "" };
|
|
106
|
+
function turnStrip(turns, { height = 64 } = {}) {
|
|
107
|
+
const key = `${turns.length}|${turns[turns.length - 1]?.id ?? ""}|${turns[turns.length - 1]?.costUsd ?? ""}|${height}`;
|
|
108
|
+
if (stripMemo.key === key) return stripMemo.svg;
|
|
109
|
+
const svg = turnStripRender(turns, height);
|
|
110
|
+
stripMemo = { key, svg };
|
|
111
|
+
return svg;
|
|
112
|
+
}
|
|
113
|
+
function turnStripRender(turns, height) {
|
|
114
|
+
const pts = turns.filter((t) => !t.sidechain && !t.agentId);
|
|
115
|
+
if (!pts.length) return "";
|
|
116
|
+
const vals = pts.map((t) => t.costUsd ?? 0);
|
|
117
|
+
const max = Math.max(1e-9, ...vals);
|
|
118
|
+
const W = 1000, H = height, n = pts.length, slot = W / n, bw = Math.max(1.5, Math.min(10, slot * 0.7));
|
|
119
|
+
const bars = pts
|
|
120
|
+
.map((t, i) => {
|
|
121
|
+
const h = ((t.costUsd ?? 0) / max) * (H - 8);
|
|
122
|
+
const x = i * slot + (slot - bw) / 2;
|
|
123
|
+
const tip = `<b>turn ${i + 1}</b> · ${hm(t.ts)}<br>${fmtUsd(t.costUsd)} · ${fmtTok(t.output)} out${t.thinking ? ` · ${fmtTok(t.thinking)} thinking` : ""}<br><span>${attr(t.model ?? "")}</span>`;
|
|
124
|
+
return `<g data-tip="${attr(tip)}"><rect x="${i * slot}" y="0" width="${slot}" height="${H}" fill="transparent"/><path d="${roundTop(x, H - 2 - h, bw, h, 2)}" fill="var(--acc)"/></g>`;
|
|
125
|
+
})
|
|
126
|
+
.join("");
|
|
127
|
+
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart" style="height:${H}px"><line x1="0" x2="${W}" y1="${H - 2}" y2="${H - 2}" class="base"/>${bars}</svg>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Session lanes over a time window. sessions: [{id,title,agent,projectId,startedAt,lastSeenAt,state,costUsd}]
|
|
132
|
+
* One row per session, grouped by project; bar from start to last-seen, coloured by agent.
|
|
133
|
+
*/
|
|
134
|
+
function timeline(sessions, { from, to, projName, now = Date.now() } = {}) {
|
|
135
|
+
const span = Math.max(1, to - from);
|
|
136
|
+
const x = (t) => Math.min(100, Math.max(0, (100 * (t - from)) / span));
|
|
137
|
+
const byProj = new Map();
|
|
138
|
+
for (const s of sessions) (byProj.get(s.projectId) ?? byProj.set(s.projectId, []).get(s.projectId)).push(s);
|
|
139
|
+
const hours = [];
|
|
140
|
+
for (let t = Math.ceil(from / 3.6e6) * 3.6e6; t <= to; t += 3.6e6) hours.push(t);
|
|
141
|
+
const stepEvery = Math.max(1, Math.round(hours.length / 12));
|
|
142
|
+
const axis = `<div class="tl-axis">${hours.map((t, i) => (i % stepEvery ? "" : `<span style="left:${x(t)}%">${new Date(t).getHours().toString().padStart(2, "0")}:00</span>`)).join("")}</div>`;
|
|
143
|
+
const gridLines = hours.map((t) => `<i style="left:${x(t)}%"></i>`).join("");
|
|
144
|
+
const nowLine = now >= from && now <= to ? `<b class="tl-now" style="left:${x(now)}%"></b>` : "";
|
|
145
|
+
const groups = [...byProj.entries()].map(([pid, list]) => {
|
|
146
|
+
const rows = list
|
|
147
|
+
.sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1))
|
|
148
|
+
.map((s) => {
|
|
149
|
+
const a = Math.max(from, new Date(s.startedAt).getTime());
|
|
150
|
+
const b = Math.min(to, new Date(s.lastSeenAt).getTime());
|
|
151
|
+
const live = s.state === "active" || s.state === "waiting";
|
|
152
|
+
const tip = `<b>${attr(s.title ?? s.id.slice(0, 8))}</b><br><i style="background:${agentColor(s.agent)}"></i>${agentName(s.agent)} · ${s.state}<br>${hm(s.startedAt)} → ${hm(s.lastSeenAt)} · ${fmtUsd(s.costUsd)} · ${s.turns} turns`;
|
|
153
|
+
return `<div class="tl-row" data-s="${s.id}"><span class="tl-name">${attr(s.title ?? s.id.slice(0, 8))}</span><span class="tl-track">${gridLines}<i data-tip="${attr(tip)}" class="${live ? "live" : ""}" style="left:${x(a)}%;width:${Math.max(0.4, x(b) - x(a))}%;background:${agentColor(s.agent)};color:${agentColor(s.agent)}"></i></span></div>`;
|
|
154
|
+
})
|
|
155
|
+
.join("");
|
|
156
|
+
return `<div class="tl-group"><div class="tl-proj">${attr(projName(pid))}<small>${list.length}</small></div>${rows}</div>`;
|
|
157
|
+
});
|
|
158
|
+
return `<div class="tl"><div class="tl-row head"><span class="tl-name"></span><span class="tl-track">${axis}${nowLine}</span></div>${groups.join("")}</div>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- helpers
|
|
162
|
+
/**
|
|
163
|
+
* Single-series line with area fill (cumulative spend). days: string[]; values: number[] aligned.
|
|
164
|
+
* Hover column per point, tooltip shows the value and its delta from the previous point.
|
|
165
|
+
*/
|
|
166
|
+
function line(days, values, { height = 150, fmt = fmtUsd, color = "var(--acc)", label = (d) => d.slice(5) } = {}) {
|
|
167
|
+
const n = days.length;
|
|
168
|
+
if (!n) return "";
|
|
169
|
+
const max = Math.max(1e-9, ...values);
|
|
170
|
+
const W = 1000, H = height, padB = 18, padT = 6, plotH = H - padB - padT;
|
|
171
|
+
const slot = W / n;
|
|
172
|
+
const x = (i) => i * slot + slot / 2, y = (v) => padT + plotH - (v / max) * plotH;
|
|
173
|
+
const ticks = niceTicks(max, 3);
|
|
174
|
+
const grid = ticks.map((t) => `<line x1="0" x2="${W}" y1="${y(t)}" y2="${y(t)}" class="grid"/><text x="0" y="${y(t) - 3}" class="ax">${fmt(t)}</text>`).join("");
|
|
175
|
+
const d = values.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join("");
|
|
176
|
+
const area = `${d}L${x(n - 1).toFixed(1)},${y(0)}L${x(0).toFixed(1)},${y(0)}Z`;
|
|
177
|
+
const hover = days
|
|
178
|
+
.map((day, i) => {
|
|
179
|
+
const delta = i ? values[i] - values[i - 1] : values[0];
|
|
180
|
+
const tip = `<b>${day}</b><br>${fmt(values[i])}<br><span>${delta >= 0 ? "+" : ""}${fmt(delta)} that day</span>`;
|
|
181
|
+
const lbl = n <= 16 || i % Math.ceil(n / 16) === 0 ? `<text x="${x(i)}" y="${H - 4}" class="ax mid">${label(day)}</text>` : "";
|
|
182
|
+
return `<g class="pt" data-tip="${attr(tip)}"><rect x="${i * slot}" y="0" width="${slot}" height="${H}" fill="transparent"/><circle cx="${x(i)}" cy="${y(values[i])}" r="3" fill="${color}"/>${lbl}</g>`;
|
|
183
|
+
})
|
|
184
|
+
.join("");
|
|
185
|
+
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart line" style="height:${H}px">${grid}<line x1="0" x2="${W}" y1="${y(0)}" y2="${y(0)}" class="base"/><path d="${area}" fill="${color}" opacity=".12"/><path d="${d}" fill="none" stroke="${color}" stroke-width="2" vector-effect="non-scaling-stroke"/>${hover}</svg>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* GitHub-style activity calendar: the last `weeks` weeks, one cell per day, one hue by opacity.
|
|
190
|
+
* byDay: { "2026-08-20": number }. Returns markup plus the streak numbers it computed.
|
|
191
|
+
*/
|
|
192
|
+
function calendar(byDay, { weeks = 52, fmt = fmtUsd, label = "cost", today = new Date() } = {}) {
|
|
193
|
+
const end = new Date(today); end.setHours(0, 0, 0, 0);
|
|
194
|
+
const start = new Date(end); start.setDate(end.getDate() - (weeks * 7 - 1) - end.getDay());
|
|
195
|
+
const iso = localDay;
|
|
196
|
+
const vals = Object.values(byDay).filter((v) => v > 0);
|
|
197
|
+
const max = Math.max(1e-9, ...vals);
|
|
198
|
+
const cols = [], months = [];
|
|
199
|
+
let lastMonth = -1;
|
|
200
|
+
for (let c = 0, d = new Date(start); d <= end; c++) {
|
|
201
|
+
const cells = [];
|
|
202
|
+
for (let r = 0; r < 7 && d <= end; r++, d.setDate(d.getDate() + 1)) {
|
|
203
|
+
if (r === 0 && d.getMonth() !== lastMonth) { lastMonth = d.getMonth(); months.push([c, d.toLocaleString(undefined, { month: "short" })]); }
|
|
204
|
+
const k = iso(d), v = byDay[k] ?? 0;
|
|
205
|
+
cells.push(`<i data-tip="<b>${k}</b><br>${v ? `${label} ${fmt(v)}` : "no activity"}" style="opacity:${v ? 0.18 + 0.82 * Math.sqrt(v / max) : 0}"></i>`);
|
|
206
|
+
}
|
|
207
|
+
cols.push(`<div class="cal-col">${cells.join("")}</div>`);
|
|
208
|
+
}
|
|
209
|
+
const mk = months.filter(([c], i) => i === 0 ? c < cols.length - 2 : true).map(([c, m]) => `<span style="left:${(100 * c) / cols.length}%">${m}</span>`).join("");
|
|
210
|
+
return `<div class="cal"><div class="cal-months">${mk}</div><div class="cal-grid" style="grid-template-columns:repeat(${cols.length},1fr)">${cols.join("")}</div></div>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** "YYYY-MM-DD" of a Date in the viewer's local zone (the daemon buckets days in localtime too). */
|
|
214
|
+
const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
215
|
+
|
|
216
|
+
/** Current + longest run of consecutive active days. days: sorted "YYYY-MM-DD" strings with activity. */
|
|
217
|
+
function streaks(days, today = new Date()) {
|
|
218
|
+
const set = new Set(days);
|
|
219
|
+
const iso = localDay;
|
|
220
|
+
let longest = 0, run = 0, prev = null;
|
|
221
|
+
for (const d of [...set].sort()) {
|
|
222
|
+
const t = Date.parse(`${d}T00:00:00Z`); // UTC: exact 24h steps, immune to DST
|
|
223
|
+
run = prev != null && t - prev === 86400e3 ? run + 1 : 1;
|
|
224
|
+
prev = t;
|
|
225
|
+
longest = Math.max(longest, run);
|
|
226
|
+
}
|
|
227
|
+
let current = 0;
|
|
228
|
+
const d = new Date(today); d.setHours(0, 0, 0, 0);
|
|
229
|
+
if (!set.has(iso(d))) d.setDate(d.getDate() - 1); // today may not have started yet
|
|
230
|
+
while (set.has(iso(d))) { current++; d.setDate(d.getDate() - 1); }
|
|
231
|
+
return { current, longest };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function roundTop(x, y, w, h, r) {
|
|
235
|
+
if (h <= 0) return "";
|
|
236
|
+
r = Math.min(r, w / 2, h);
|
|
237
|
+
return `M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z`;
|
|
238
|
+
}
|
|
239
|
+
function niceTicks(max, n) {
|
|
240
|
+
const raw = max / n, mag = 10 ** Math.floor(Math.log10(raw));
|
|
241
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? mag * 10;
|
|
242
|
+
const out = [];
|
|
243
|
+
for (let v = step; v <= max; v += step) out.push(v);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- shared tooltip
|
|
248
|
+
(() => {
|
|
249
|
+
const tip = document.createElement("div");
|
|
250
|
+
tip.id = "tip";
|
|
251
|
+
document.body.appendChild(tip);
|
|
252
|
+
let cur = null, rect = null, raf = 0, last = null;
|
|
253
|
+
const place = () => {
|
|
254
|
+
raf = 0;
|
|
255
|
+
if (!cur || !last) return;
|
|
256
|
+
rect ??= tip.getBoundingClientRect(); // measured once per tooltip content, not per mouse move
|
|
257
|
+
const left = Math.min(window.innerWidth - rect.width - 8, last.x + 14);
|
|
258
|
+
const top = last.y + 16 + rect.height > window.innerHeight ? last.y - rect.height - 8 : last.y + 16;
|
|
259
|
+
tip.style.transform = `translate(${left}px,${top}px)`;
|
|
260
|
+
};
|
|
261
|
+
document.addEventListener("mousemove", (e) => {
|
|
262
|
+
const el = e.target.closest?.("[data-tip]");
|
|
263
|
+
if (el !== cur) {
|
|
264
|
+
cur = el;
|
|
265
|
+
rect = null;
|
|
266
|
+
if (el) { tip.innerHTML = el.dataset.tip; tip.style.display = "block"; } else tip.style.display = "none";
|
|
267
|
+
}
|
|
268
|
+
if (el) { last = { x: e.clientX, y: e.clientY }; if (!raf) raf = requestAnimationFrame(place); }
|
|
269
|
+
});
|
|
270
|
+
})();
|
|
271
|
+
|
|
272
|
+
window.viz = { stackedColumns, line, calendar, streaks, localDay, heatmap, sparkline, compositionBar, hbars, turnStrip, timeline, legend, agentColor, agentName, AGENT_ORDER, agentSort };
|