mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Merge pull request #14 from runbear-io/feat/insights-dashboard
feat: layered Insights dashboard — treemap, hot path, agent coverage matrix
This commit is contained in:
@@ -319,11 +319,18 @@ Hubs also track **read heat**: viewer opens and downloads count as human
|
||||
reads, share-link hits as share reads, and agent tool reads (reported by
|
||||
the sync hooks via `bdrive read-log`) as agent reads — sync replication
|
||||
never counts. Folder listings show heat dots and 30-day read counts to
|
||||
every member, and admins / org owners get an **Insights** view (⋯ menu)
|
||||
plotting each file by reads × days since last change: the hot-but-stale
|
||||
quadrant is the knowledge people rely on that nobody maintains. The API
|
||||
every member, and admins / org owners get an **Insights** dashboard
|
||||
(⋯ menu), four sections with an all/human/agent lens: a **treemap** of
|
||||
every file (cell size = reads, color = staleness, ⚠ on hot+stale — click
|
||||
through to any file), the **reads × freshness** scatter whose hot-but-stale
|
||||
quadrant is the knowledge people rely on that nobody maintains, the
|
||||
**hot path** (top files by reads, agent/human split — effectively the
|
||||
team's agent context window), and an **agent coverage matrix** (which
|
||||
agent devices read which folders). The API
|
||||
(`GET /api/p/<id>/heat?prefix=&days=`) exposes only aggregate counts,
|
||||
distinct-reader counts, and last-read times — never who read what.
|
||||
distinct-reader counts, and last-read times — never who read what;
|
||||
`?by=device` adds the agent-only per-device folder breakdown (device
|
||||
identity is already public via history; human emails never appear).
|
||||
|
||||
### Authentication
|
||||
|
||||
|
||||
@@ -235,3 +235,40 @@ Phase 3 is the point of the feature, not tail work: human view counts are a
|
||||
commodity (every Confluence app has them); *agent* read visibility is the
|
||||
part nobody else can build. Phases are ordered by dependency, not value —
|
||||
ship 1 and 3 before polishing 2 if time is short.
|
||||
|
||||
## Addendum (2026-07-12): layered Insights dashboard
|
||||
|
||||
Chart research against 500-file synthetic data (CodeScene hotspots,
|
||||
Obsidian heatmap plugins, disk-usage treemaps) reshaped the Insights view
|
||||
into four stacked sections, all admin/org-owner gated as before, all driven
|
||||
by ONE heat fetch plus the tree the client already holds, with the
|
||||
all/human/agent lens applied to every section:
|
||||
|
||||
1. **Treemap** (the new landing view, CodeScene-hotspot style): every file
|
||||
at once, top-level folder groups labeled; cell area = reads in the
|
||||
window, cell color = days since last write (fresh→stale), ⚠ on
|
||||
hot+stale cells. Click a file cell → open the file; click a group
|
||||
label → open the folder. Squarified treemap implemented in vanilla JS
|
||||
(the frontend's no-dependency rule stands); labels only on cells that
|
||||
fit them; a single SVG with one delegated click handler so 5,000 files
|
||||
stay cheap.
|
||||
2. **Quadrant scatter**, demoted to the drill-down: unchanged semantics,
|
||||
density-handled (translucent dots, radius = agent share of reads).
|
||||
3. **Agent hot-path**: top-20 files by reads as horizontal stacked bars
|
||||
(agent = accent, human = blue), count at the bar end, click to open,
|
||||
⚠ marker on danger-zone rows. Replaces the plain danger list.
|
||||
4. **Coverage matrix**: agent devices × top-level folders, cell intensity
|
||||
= reads. Needs the one API addition below.
|
||||
|
||||
**API**: `GET /api/p/<id>/heat?by=device&days=N` returns the agent-kind
|
||||
breakdown — per device (id + registry-joined name/OS), reads per top-level
|
||||
folder. Privacy line, unmoved: agent *device* identity is already public
|
||||
via history, so exposing it here is consistent; **human actor identities
|
||||
(emails) still never appear in any response** — the breakdown is computed
|
||||
from agent-kind buckets only, and the handler test asserts no email
|
||||
leaks.
|
||||
|
||||
**Future work, deliberately not built**: calendar heatmap (human vs agent
|
||||
reads/day) and folder read-share streamgraph. Both need a group-by-day
|
||||
variant of the heat query; the daily buckets already exist server-side, so
|
||||
that is an aggregation parameter, not a schema change.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -218,6 +219,47 @@ func (l *ReadLedger) Heat(project, prefix string, since time.Time) map[string]He
|
||||
return out
|
||||
}
|
||||
|
||||
// AgentHeat aggregates agent reads per device per top-level folder ("" for
|
||||
// root files) — the coverage-matrix data. Agent buckets only, by design:
|
||||
// agent actors are device ids, which history already exposes; human actors
|
||||
// (emails) must never leave the server, so human/share buckets are not
|
||||
// consulted at all.
|
||||
func (l *ReadLedger) AgentHeat(project string, since time.Time) map[string]map[string]int64 {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
sinceDay := ""
|
||||
if !since.IsZero() {
|
||||
sinceDay = since.UTC().Format("2006-01-02")
|
||||
}
|
||||
out := map[string]map[string]int64{}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for key, st := range l.byKey {
|
||||
if key.Project != project || key.Kind != ReadKindAgent {
|
||||
continue
|
||||
}
|
||||
if key.Day == "" {
|
||||
if sinceDay != "" {
|
||||
continue
|
||||
}
|
||||
} else if key.Day < sinceDay {
|
||||
continue
|
||||
}
|
||||
folder := ""
|
||||
if i := strings.IndexByte(key.Path, '/'); i >= 0 {
|
||||
folder = key.Path[:i]
|
||||
}
|
||||
m := out[key.Actor]
|
||||
if m == nil {
|
||||
m = map[string]int64{}
|
||||
out[key.Actor] = m
|
||||
}
|
||||
m[folder] += st.Count
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close flushes any pending buckets.
|
||||
func (l *ReadLedger) Close() error {
|
||||
if l == nil {
|
||||
@@ -337,8 +379,11 @@ func (s *Server) recordRead(r *http.Request, path string) {
|
||||
}
|
||||
|
||||
// handleHeat serves per-path read aggregates: ?prefix= bounds to a folder,
|
||||
// ?days= bounds the window (default 30, 0 = all time). Counts only — actor
|
||||
// identities never leave the server.
|
||||
// ?days= bounds the window (default 30, 0 = all time). With ?by=device it
|
||||
// returns the agent-kind breakdown instead: per device (registry-joined),
|
||||
// reads per top-level folder. In both shapes, counts only — human actor
|
||||
// identities never leave the server (agent devices are already public via
|
||||
// history, so naming them here is consistent).
|
||||
func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
|
||||
if s.Reads == nil {
|
||||
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
|
||||
@@ -358,6 +403,15 @@ func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
|
||||
if days > 0 {
|
||||
since = time.Now().UTC().AddDate(0, 0, -days)
|
||||
}
|
||||
switch q.Get("by") {
|
||||
case "":
|
||||
case "device":
|
||||
s.heatByDevice(w, projectID(r), since)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "invalid by (use device)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
entries := s.Reads.Heat(projectID(r), q.Get("prefix"), since)
|
||||
out := map[string]any{"entries": entries}
|
||||
if !since.IsZero() {
|
||||
@@ -366,6 +420,41 @@ func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// deviceHeat is one row of the ?by=device response.
|
||||
type deviceHeat struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Folders map[string]int64 `json:"folders"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func (s *Server) heatByDevice(w http.ResponseWriter, project string, since time.Time) {
|
||||
byDevice := s.Reads.AgentHeat(project, since)
|
||||
devices := make([]deviceHeat, 0, len(byDevice))
|
||||
for id, folders := range byDevice {
|
||||
d := deviceHeat{ID: id, Folders: folders}
|
||||
if info, ok := s.Devices.Get(id); ok {
|
||||
d.Name, d.OS = info.Name, info.OS
|
||||
}
|
||||
for _, n := range folders {
|
||||
d.Total += n
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
sort.Slice(devices, func(i, j int) bool {
|
||||
if devices[i].Total != devices[j].Total {
|
||||
return devices[i].Total > devices[j].Total
|
||||
}
|
||||
return devices[i].ID < devices[j].ID
|
||||
})
|
||||
out := map[string]any{"devices": devices}
|
||||
if !since.IsZero() {
|
||||
out["since"] = since.Format("2006-01-02")
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
// handleReadReport ingests agent reads from a syncing device: the client's
|
||||
// read spool, drained best-effort at sync time. Requires a device identity —
|
||||
// the device id is the actor, so reads count as agent traffic.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -234,3 +235,66 @@ func TestHeatAPI(t *testing.T) {
|
||||
t.Fatalf("disabled heat: %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatByDevice covers the coverage-matrix breakdown: agent reads per
|
||||
// device per top-level folder, device-registry joined — and, critically,
|
||||
// that human actor identities never appear in the response.
|
||||
func TestHeatByDevice(t *testing.T) {
|
||||
srv, p, root := newHub(t, false, nil)
|
||||
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
||||
f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan")
|
||||
var err error
|
||||
srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json"))
|
||||
srv.Devices.Observe(DeviceInfo{ID: "dev1", Name: "ci-agent", OS: "linux/amd64"})
|
||||
|
||||
// Agent reads from two devices, human reads carrying real emails.
|
||||
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindAgent, "dev1")
|
||||
srv.Reads.Record(p.ID, "wiki/deep.md", ReadKindAgent, "dev1")
|
||||
srv.Reads.Record(p.ID, "top.md", ReadKindAgent, "dev2")
|
||||
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindHuman, "alice@x.io")
|
||||
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindShare, "tok123/1.2.3.4")
|
||||
|
||||
h := srv.Handler()
|
||||
base := "/api/p/" + p.ID + "/"
|
||||
rec := do(t, h, "GET", base+"heat?by=device&days=30", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("by=device: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var out struct {
|
||||
Devices []deviceHeat `json:"devices"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Devices) != 2 || out.Since == "" {
|
||||
t.Fatalf("devices = %+v", out)
|
||||
}
|
||||
// Sorted by total: dev1 (2 reads, registry-joined) first.
|
||||
d1 := out.Devices[0]
|
||||
if d1.ID != "dev1" || d1.Name != "ci-agent" || d1.OS != "linux/amd64" || d1.Total != 2 {
|
||||
t.Fatalf("dev1 row = %+v", d1)
|
||||
}
|
||||
if d1.Folders["wiki"] != 2 {
|
||||
t.Fatalf("dev1 folders = %+v, want wiki:2", d1.Folders)
|
||||
}
|
||||
// Root files land under the "" folder.
|
||||
if d2 := out.Devices[1]; d2.ID != "dev2" || d2.Folders[""] != 1 {
|
||||
t.Fatalf("dev2 row = %+v", d2)
|
||||
}
|
||||
// The privacy line: human and share actors are invisible here.
|
||||
body := rec.Body.String()
|
||||
for _, leak := range []string{"alice@x.io", "tok123", "1.2.3.4", "human", "share"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("by=device leaked %q: %s", leak, body)
|
||||
}
|
||||
}
|
||||
|
||||
if rec := do(t, h, "GET", base+"heat?by=path", nil); rec.Code != 400 {
|
||||
t.Fatalf("invalid by: %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
+209
-19
@@ -1152,9 +1152,15 @@ function canSeeInsights() {
|
||||
return !!(org && org.role === "owner");
|
||||
}
|
||||
|
||||
let insightsDevices = null; // /heat?by=device breakdown, fetched per Insights open
|
||||
|
||||
async function showInsights() {
|
||||
if (!canSeeInsights()) return;
|
||||
await refreshHeat(true);
|
||||
insightsDevices = null;
|
||||
try {
|
||||
insightsDevices = (await getJSON(apiBase + "heat?by=device&days=30")).devices || [];
|
||||
} catch { /* older server: the coverage section simply doesn't render */ }
|
||||
currentPath = null;
|
||||
markActive();
|
||||
$("crumb").textContent = "Insights — " + currentProject.name;
|
||||
@@ -1166,18 +1172,36 @@ async function showInsights() {
|
||||
renderInsights(content, "all");
|
||||
}
|
||||
|
||||
/* SVG element helper for the insights charts. */
|
||||
function svgEl(parent, tag, attrs, text) {
|
||||
const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, v);
|
||||
if (text != null) n.textContent = text;
|
||||
if (parent) parent.appendChild(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Staleness color: fresh green → amber → red over 0..300 days. */
|
||||
function staleColor(days) {
|
||||
const stops = [[76, 195, 138], [232, 196, 84], [224, 93, 93]];
|
||||
const t = Math.min(1, Math.max(0, days / 300)) * (stops.length - 1);
|
||||
const i = Math.min(stops.length - 2, Math.floor(t)), f = t - i;
|
||||
const c = stops[i].map((v, k) => Math.round(v + (stops[i + 1][k] - v) * f));
|
||||
return `rgb(${c[0]},${c[1]},${c[2]})`;
|
||||
}
|
||||
|
||||
function renderInsights(content, lens) {
|
||||
content.innerHTML = "";
|
||||
const wrap = el(content, "div", "insights");
|
||||
el(wrap, "h1", "in-title", "Reads × freshness");
|
||||
el(wrap, "h1", "in-title", "Knowledge insights");
|
||||
el(wrap, "p", "dl-sub",
|
||||
"Every file by 30-day reads and days since its last change. " +
|
||||
"Hot but stale (top right) is the danger zone — read a lot, maintained by nobody.");
|
||||
"Reads over the last 30 days × how long since each file changed. " +
|
||||
"Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone.");
|
||||
const bar = el(wrap, "div", "in-lens");
|
||||
for (const l of ["all", "human", "agent"]) {
|
||||
const label = l === "all" ? "All reads" : l === "human" ? "Human reads" : "Agent reads";
|
||||
const b = el(bar, "button", "in-lens-btn" + (l === lens ? " active" : ""), label);
|
||||
b.onclick = () => renderInsights(content, lens = l);
|
||||
b.onclick = () => renderInsights(content, l);
|
||||
}
|
||||
|
||||
const readsOf = (e) => (lens === "all" ? heatTotal(e) : e[lens] || 0);
|
||||
@@ -1186,31 +1210,193 @@ function renderInsights(content, lens) {
|
||||
const e = (heatMap && heatMap[f.path]) || {};
|
||||
const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0;
|
||||
const reads = readsOf(e);
|
||||
return { path: f.path, reads, days, danger: reads >= HOT_READS && days >= STALE_DAYS };
|
||||
return { path: f.path, reads, agent: e.agent || 0, total: heatTotal(e), days,
|
||||
danger: reads >= HOT_READS && days >= STALE_DAYS };
|
||||
});
|
||||
|
||||
el(wrap, "h3", "dl-h3", "Map — cell size = reads, color = freshness");
|
||||
wrap.appendChild(insightsTreemap(pts));
|
||||
|
||||
el(wrap, "h3", "dl-h3", "Reads × freshness");
|
||||
wrap.appendChild(insightsChart(pts));
|
||||
|
||||
const danger = pts.filter((p) => p.danger)
|
||||
.sort((a, b) => b.reads - a.reads || b.days - a.days).slice(0, 15);
|
||||
el(wrap, "h3", "dl-h3", "Danger zone — fix these first");
|
||||
if (!danger.length) {
|
||||
el(wrap, "div", "dl-empty", "No hot-but-stale files. The knowledge base is healthy.");
|
||||
el(wrap, "h3", "dl-h3", "Hot path — top files by reads");
|
||||
renderHotPath(wrap, pts, lens);
|
||||
|
||||
if (insightsDevices && insightsDevices.length) {
|
||||
el(wrap, "h3", "dl-h3", "Agent coverage — which agents read which areas");
|
||||
wrap.appendChild(insightsMatrix(insightsDevices));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- treemap (the landing chart) ----
|
||||
Squarified treemap (Bruls et al.), dependency-free: items sorted by value
|
||||
fill a rect in rows along the shorter side, keeping cells near-square.
|
||||
Returns [{item, x, y, w, h}]. */
|
||||
function squarify(items, x, y, w, h) {
|
||||
const total = items.reduce((s, it) => s + it.value, 0);
|
||||
if (!total || w <= 0 || h <= 0) return [];
|
||||
let rest = items.slice().sort((a, b) => b.value - a.value)
|
||||
.map((it) => ({ it, a: (it.value / total) * w * h }));
|
||||
const worst = (row, side) => {
|
||||
const sum = row.reduce((t, r) => t + r.a, 0);
|
||||
const d = sum / side;
|
||||
let m = 0;
|
||||
for (const r of row) {
|
||||
const l = r.a / d;
|
||||
m = Math.max(m, l / d, d / l);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const out = [];
|
||||
while (rest.length) {
|
||||
const horiz = w >= h; // row = a strip along the shorter side
|
||||
const side = horiz ? h : w;
|
||||
const row = [rest.shift()];
|
||||
while (rest.length && worst(row.concat(rest[0]), side) <= worst(row, side)) {
|
||||
row.push(rest.shift());
|
||||
}
|
||||
const d = row.reduce((t, r) => t + r.a, 0) / side;
|
||||
let off = 0;
|
||||
for (const r of row) {
|
||||
const l = r.a / d;
|
||||
if (horiz) out.push({ item: r.it, x, y: y + off, w: d, h: l });
|
||||
else out.push({ item: r.it, x: x + off, y, w: l, h: d });
|
||||
off += l;
|
||||
}
|
||||
if (horiz) { x += d; w -= d; } else { y += d; h -= d; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TM_HEADER = 15; // group label strip height
|
||||
|
||||
function insightsTreemap(pts) {
|
||||
const W = 720, H = 480;
|
||||
const svg = svgEl(null, "svg", { viewBox: `0 0 ${W} ${H}`, class: "in-chart in-treemap" });
|
||||
// Two levels: top-level folder groups, files within each.
|
||||
const groups = new Map();
|
||||
for (const p of pts) {
|
||||
const top = p.path.includes("/") ? p.path.split("/")[0] : "/";
|
||||
let g = groups.get(top);
|
||||
if (!g) groups.set(top, g = { name: top, files: [], value: 0 });
|
||||
g.files.push(p);
|
||||
g.value += p.reads + 1; // +1: unread files still occupy a sliver
|
||||
}
|
||||
for (const gc of squarify([...groups.values()], 0, 0, W, H)) {
|
||||
const g = gc.item;
|
||||
const dir = g.name === "/" ? "" : g.name;
|
||||
svgEl(svg, "rect", {
|
||||
x: gc.x + 1, y: gc.y + 1, width: Math.max(0, gc.w - 2), height: Math.max(0, gc.h - 2),
|
||||
rx: 3, class: "in-tm-group", "data-dir": dir,
|
||||
});
|
||||
if (gc.w > 46 && gc.h > TM_HEADER + 10) {
|
||||
let label = g.name === "/" ? "(root)" : g.name;
|
||||
const fit = Math.floor((gc.w - 8) / 6);
|
||||
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
|
||||
svgEl(svg, "text", { x: gc.x + 5, y: gc.y + 12, class: "in-tm-glabel", "data-dir": dir }, label);
|
||||
}
|
||||
const cells = squarify(
|
||||
g.files.map((f) => ({ ...f, name: f.path.split("/").pop(), value: f.reads + 1 })),
|
||||
gc.x + 2, gc.y + TM_HEADER, Math.max(0, gc.w - 4), Math.max(0, gc.h - TM_HEADER - 2));
|
||||
for (const c of cells) {
|
||||
const cell = svgEl(svg, "rect", {
|
||||
x: c.x + 0.6, y: c.y + 0.6, width: Math.max(0.4, c.w - 1.2), height: Math.max(0.4, c.h - 1.2),
|
||||
rx: 1.5, fill: staleColor(c.item.days), class: "in-tm-cell", "data-path": c.item.path,
|
||||
});
|
||||
svgEl(cell, "title", {},
|
||||
`${c.item.path} — ${c.item.reads} read${c.item.reads === 1 ? "" : "s"}/30d · changed ${Math.round(c.item.days)}d ago`);
|
||||
if (c.w > 54 && c.h > 16) {
|
||||
const fit = Math.floor((c.w - 8) / 6);
|
||||
let label = (c.item.danger ? "⚠ " : "") + c.item.name;
|
||||
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
|
||||
if (fit >= 5) svgEl(svg, "text", { x: c.x + 4.5, y: c.y + 12.5, class: "in-tm-label", "data-path": c.item.path }, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
// One delegated click handler for thousands of cells.
|
||||
svg.addEventListener("click", (e) => {
|
||||
const t = e.target.closest("[data-path], [data-dir]");
|
||||
if (!t) return;
|
||||
const path = t.getAttribute("data-path");
|
||||
if (path) return openFile(path);
|
||||
const dir = t.getAttribute("data-dir");
|
||||
if (dir && dirIndex.has(dir)) openFolder(dir);
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
/* ---- hot path: top-20 files by reads, agent/human split ---- */
|
||||
function renderHotPath(wrap, pts, lens) {
|
||||
const top = pts.filter((p) => p.reads > 0)
|
||||
.sort((a, b) => b.reads - a.reads || b.days - a.days).slice(0, 20);
|
||||
if (!top.length) {
|
||||
el(wrap, "div", "dl-empty", "No reads in the window yet.");
|
||||
return;
|
||||
}
|
||||
const list = el(wrap, "div", "dl-items");
|
||||
for (const p of danger) {
|
||||
const row = el(list, "div", "dl-row");
|
||||
const max = top[0].reads;
|
||||
const list = el(wrap, "div", "in-hotpath");
|
||||
for (const p of top) {
|
||||
const row = el(list, "div", "in-hp-row");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
const icon = el(row, "span", "ticon");
|
||||
icon.innerHTML = svgIcon("alert");
|
||||
el(row, "span", "dl-name", p.path);
|
||||
el(row, "span", "dl-meta",
|
||||
p.reads + (p.reads === 1 ? " read" : " reads") + " · untouched " + Math.round(p.days) + "d");
|
||||
row.title = p.path + (p.danger ? " — hot + stale" : "");
|
||||
el(row, "span", "in-hp-name" + (p.danger ? " danger" : ""), (p.danger ? "⚠ " : "") + p.path);
|
||||
const barw = el(row, "span", "in-hp-bar");
|
||||
// Split of the lens reads: pure lenses are single-color by definition.
|
||||
const aFrac = lens === "agent" ? 1 : lens === "human" ? 0 : (p.total ? p.agent / p.total : 0);
|
||||
const pct = (p.reads / max) * 100;
|
||||
el(barw, "span", "in-hp-agent").style.width = (pct * aFrac).toFixed(1) + "%";
|
||||
el(barw, "span", "in-hp-human").style.width = (pct * (1 - aFrac)).toFixed(1) + "%";
|
||||
el(row, "span", "in-hp-count", p.reads);
|
||||
row.onclick = () => openFile(p.path);
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
}
|
||||
const lg = el(wrap, "p", "in-legend");
|
||||
el(lg, "span", "in-sw agent");
|
||||
lg.append(" agent reads ");
|
||||
el(lg, "span", "in-sw human");
|
||||
lg.append(" human reads");
|
||||
}
|
||||
|
||||
/* ---- agent coverage matrix: devices × top-level folders ---- */
|
||||
function insightsMatrix(devices) {
|
||||
const totals = new Map();
|
||||
for (const d of devices) {
|
||||
for (const [f, n] of Object.entries(d.folders || {})) totals.set(f, (totals.get(f) || 0) + n);
|
||||
}
|
||||
const cols = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map((e) => e[0]);
|
||||
const rows = devices.slice(0, 12); // server sorts by total desc
|
||||
const left = 140, top = 6, cw = Math.min(76, Math.max(34, (720 - left - 8) / cols.length)), ch = 26;
|
||||
const W = 720, H = top + rows.length * ch + 58;
|
||||
const svg = svgEl(null, "svg", { viewBox: `0 0 ${W} ${H}`, class: "in-chart in-matrix" });
|
||||
const max = Math.max(1, ...rows.flatMap((d) => cols.map((c) => (d.folders || {})[c] || 0)));
|
||||
const shade = (t) => { // #17191f → amber by intensity
|
||||
const a = [23, 25, 31], b = [245, 166, 35];
|
||||
const c = a.map((v, i) => Math.round(v + (b[i] - v) * t));
|
||||
return `rgb(${c[0]},${c[1]},${c[2]})`;
|
||||
};
|
||||
rows.forEach((d, i) => {
|
||||
let label = d.name || d.id;
|
||||
if (label.length > 20) label = label.slice(0, 19) + "…";
|
||||
svgEl(svg, "text", { x: left - 8, y: top + i * ch + 17, "text-anchor": "end", class: "in-label" }, label);
|
||||
cols.forEach((c, j) => {
|
||||
const v = (d.folders || {})[c] || 0;
|
||||
const cell = svgEl(svg, "rect", {
|
||||
x: left + j * cw, y: top + i * ch, width: cw - 4, height: ch - 4, rx: 3,
|
||||
fill: shade(Math.sqrt(v / max)),
|
||||
});
|
||||
svgEl(cell, "title", {}, `${d.name || d.id} × ${c || "(root)"}: ${v} read${v === 1 ? "" : "s"}/30d`);
|
||||
});
|
||||
});
|
||||
cols.forEach((c, j) => {
|
||||
const cx = left + j * cw + (cw - 4) / 2, cy = top + rows.length * ch + 14;
|
||||
svgEl(svg, "text", {
|
||||
x: cx, y: cy, class: "in-label", "text-anchor": "end",
|
||||
transform: `rotate(-28 ${cx} ${cy})`,
|
||||
}, c || "(root)");
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
/* Dependency-free SVG scatter: x = days since last write, y = reads, both
|
||||
@@ -1247,10 +1433,14 @@ function insightsChart(pts) {
|
||||
add("text", { x: W - M.r - 6, y: M.t + 14, class: "in-quad in-quad-danger", "text-anchor": "end" }, "hot + stale");
|
||||
add("text", { x: M.l + 6, y: M.t + 14, class: "in-quad" }, "hot + fresh");
|
||||
add("text", { x: W - M.r - 6, y: H - M.b - 8, class: "in-quad", "text-anchor": "end" }, "cold + stale");
|
||||
add("text", { x: W - M.r - 6, y: M.t + 28, class: "in-label", "text-anchor": "end" }, "dot size = agent share of reads");
|
||||
|
||||
for (const p of pts) {
|
||||
// Radius encodes the agent share of the file's reads; translucent dots
|
||||
// keep the cloud readable at hundreds of files.
|
||||
const share = p.total ? (p.agent || 0) / p.total : 0;
|
||||
const c = add("circle", {
|
||||
cx: X(p.days).toFixed(1), cy: Y(p.reads).toFixed(1), r: 5,
|
||||
cx: X(p.days).toFixed(1), cy: Y(p.reads).toFixed(1), r: (3 + 4 * share).toFixed(1),
|
||||
class: "in-pt" + (p.danger ? " danger" : p.reads ? "" : " cold"),
|
||||
});
|
||||
const tip = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
||||
|
||||
@@ -295,10 +295,35 @@ button, input, a.btn { font-family: inherit; }
|
||||
.in-label { fill: var(--text-ghost); font-size: 11px; }
|
||||
.in-quad { fill: var(--text-ghost); font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.in-quad-danger { fill: #e07070; }
|
||||
.in-pt { fill: var(--accent); opacity: .75; cursor: pointer; }
|
||||
.in-pt { fill: var(--accent); opacity: .5; cursor: pointer; }
|
||||
.in-pt:hover { opacity: 1; }
|
||||
.in-pt.cold { fill: var(--text-ghost); opacity: .35; }
|
||||
.in-pt.danger { fill: #e05d5d; }
|
||||
.in-pt.cold { fill: var(--text-ghost); opacity: .25; }
|
||||
.in-pt.danger { fill: #e05d5d; opacity: .6; }
|
||||
/* treemap */
|
||||
.in-treemap { background: #0c0d10; }
|
||||
.in-tm-group { fill: none; stroke: var(--border); stroke-width: 1; cursor: pointer; pointer-events: all; }
|
||||
.in-tm-glabel { fill: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; cursor: pointer; }
|
||||
.in-tm-cell { cursor: pointer; }
|
||||
.in-tm-cell:hover { stroke: #fff; stroke-width: 1; }
|
||||
.in-tm-label { fill: #0c0d10; font-size: 10.5px; font-weight: 620; cursor: pointer; pointer-events: none; }
|
||||
/* hot path */
|
||||
.in-hotpath { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; }
|
||||
.in-hp-row { display: flex; align-items: center; gap: 10px; padding: 6px 12px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.in-hp-row:last-child { border-bottom: none; }
|
||||
.in-hp-row:hover { background: var(--hover); }
|
||||
.in-hp-name { flex: 0 0 300px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text); }
|
||||
.in-hp-name.danger { color: #e07070; }
|
||||
.in-hp-bar { flex: 1; display: flex; height: 10px; border-radius: 3px; overflow: hidden; }
|
||||
.in-hp-agent { background: var(--accent); }
|
||||
.in-hp-human { background: #5b8def; }
|
||||
.in-hp-count { flex: none; width: 40px; text-align: right; font-size: 11.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.in-legend { margin: 8px 2px 0; font-size: 11.5px; color: var(--text-faint); }
|
||||
.in-sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
|
||||
.in-sw.agent { background: var(--accent); }
|
||||
.in-sw.human { background: #5b8def; }
|
||||
/* coverage matrix */
|
||||
.in-matrix rect { transition: opacity .1s; }
|
||||
.in-matrix rect:hover { opacity: .85; }
|
||||
|
||||
/* ---- history ---- */
|
||||
.history { max-width: 860px; }
|
||||
|
||||
@@ -154,10 +154,14 @@ Hubs aggregate reads per file — viewer opens and downloads count as human
|
||||
reads, share-link hits as share reads, and hook-reported agent reads as
|
||||
agent reads; `/store` sync replication never counts. The web UI shows heat
|
||||
dots and read counts on folder listings (all members), and admins / org
|
||||
owners get an **Insights** view (⋯ menu) plotting every file by 30-day
|
||||
reads × days since last change — the hot-but-stale quadrant is the list of
|
||||
files to fix first. Counts only, never reader identities. API:
|
||||
`GET /api/p/<id>/heat?prefix=&days=30`. Server config: `"reads":
|
||||
owners get an **Insights** dashboard (⋯ menu, all/human/agent lens): a
|
||||
treemap of every file (size = 30-day reads, color = staleness, ⚠ =
|
||||
hot+stale, click-through), a reads × freshness scatter (hot-but-stale
|
||||
quadrant = fix first), the hot path (top files by reads, agent/human
|
||||
split), and an agent coverage matrix (agent devices × folders). Counts
|
||||
only, never human reader identities. API:
|
||||
`GET /api/p/<id>/heat?prefix=&days=30`, plus `?by=device` for the
|
||||
agent-only per-device folder breakdown. Server config: `"reads":
|
||||
{"enabled": true, "retention_days": 400}` (on by default in hub mode).
|
||||
|
||||
### Examples to walk a user through
|
||||
|
||||
Reference in New Issue
Block a user