diff --git a/README.md b/README.md index 52eba19..05650b8 100644 --- a/README.md +++ b/README.md @@ -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//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 diff --git a/docs/design/read-heatmap.md b/docs/design/read-heatmap.md index 15772d2..08ed636 100644 --- a/docs/design/read-heatmap.md +++ b/docs/design/read-heatmap.md @@ -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//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. diff --git a/internal/webapp/reads.go b/internal/webapp/reads.go index d0b5d13..3f424ec 100644 --- a/internal/webapp/reads.go +++ b/internal/webapp/reads.go @@ -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. diff --git a/internal/webapp/reads_test.go b/internal/webapp/reads_test.go index ed4d6b8..2fe60d3 100644 --- a/internal/webapp/reads_test.go +++ b/internal/webapp/reads_test.go @@ -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) + } +} diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index a7baa5f..c56800e 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -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"); diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 25bf24b..c29f073 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -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; } diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 25fbadb..e13d12f 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -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//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//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