mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): /heat?by=device — agent coverage breakdown
AgentHeat aggregates agent-kind buckets per device per top-level folder; the handler joins the device registry (name/OS) and sorts by total. Human and share buckets are never consulted, so human actor identities cannot appear in the response — asserted by test, along with registry join, root folder bucketing, sort order, and invalid-by rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
88e361a254
commit
644d738c97
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user