diff --git a/README.md b/README.md index 031b41e..83560b3 100644 --- a/README.md +++ b/README.md @@ -524,8 +524,7 @@ partial; missing data is unknown. One instance supplies its local shard only; public multi-shard totals need an explicit safe collector. The separate anonymous daily-report feed is `GET /v1/fleet/public`. It exposes -only daily counts and dimension labels with at least 5 reports, for at most -90 days. It leaves the health counts out; only `/v1/ops` carries them. It contains imported relay history and new reports received by Cloud; +daily counts and labels from one report up, for at most 90 days. It leaves the health counts out; only `/v1/ops` carries them. It contains imported relay history and new reports received by Cloud; consumers replace the old archive rather than adding both. It needs no hourly energy activation. Updated Core sends it only after the current project statistics answer; legacy versions retain their existing daily-report setting. Averaging seven complete UTC days gives an estimate of active diff --git a/go.mod b/go.mod index fe3683c..cdcdf04 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1 + github.com/srcfl/ftw/go/protocol v0.0.0-20261108151528-95f1c85b9c07 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index fe04cb6..cfd0e19 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1 h1:jMTkobIdBd+6uLxjfuWrBXGLOroK7EhubT6g8giZCbg= -github.com/srcfl/ftw/go/protocol v0.0.0-20261108080825-fd4426a107c1/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261108151528-95f1c85b9c07 h1:2Nyg0erLi0eClgq73hYmQLH4wRRrhFYvi081rH5/9v0= +github.com/srcfl/ftw/go/protocol v0.0.0-20261108151528-95f1c85b9c07/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/fleet/public.go b/internal/fleet/public.go index 1b879b9..a995412 100644 --- a/internal/fleet/public.go +++ b/internal/fleet/public.go @@ -8,10 +8,11 @@ import ( "github.com/srcfl/ftw/go/protocol/fleetwire" ) -// Public exports only cells already large enough for the project dashboard. -// A day below the minimum has a null count and no dimension labels. Only -// closed UTC days are listed: today's running counts would let whoever just -// forwarded a report, such as the relay, see which count it moved. +// Public exports each day's totals, from one report up (Fredrik, 8 November +// 2026: a version, a price zone or a battery to the nearest kWh is not +// sensitive). Health counts stay out. Only closed UTC days are listed: today's +// running counts would let whoever just forwarded a report, such as the relay, +// see which count it moved. func (s *Service) Public(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=60") if r.Method != http.MethodGet { @@ -23,32 +24,17 @@ func (s *Service) Public(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) return } - // Five since 23 October 2026 (Fredrik): the fleet is small and the - // reports carry no home id. A day and each label below it stay hidden. - const minimum = 5 today := s.now().UTC().Format(time.DateOnly) days := make([]map[string]any, 0, len(view.Days)) for _, d := range view.Days { if d.Date >= today { continue } - row := map[string]any{"date": d.Date, "reports": nil} - for key, values := range map[string]map[string]int{"ftw_versions": d.Versions, "channels": d.Channels, "drivers": d.Drivers, "battery_kwh": d.Battery, "price_zones": d.Zones, "install_age": d.Age} { - safe := map[string]int{} - if d.Reports >= minimum { - for label, n := range values { - if n >= minimum { - safe[label] = n - } - } - } - row[key] = safe - } - if d.Reports >= minimum { - row["reports"] = d.Reports - } - days = append(days, row) + days = append(days, map[string]any{ + "date": d.Date, "reports": d.Reports, "ftw_versions": d.Versions, "channels": d.Channels, "drivers": d.Drivers, + "battery_kwh": d.Battery, "price_zones": d.Zones, "install_age": d.Age, + }) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"schema": "ftw.fleet-public/1", "meaning": fleetwire.Meaning, "observed_at": s.now().UTC().Format(time.RFC3339), "minimum": minimum, "days": days}) + _ = json.NewEncoder(w).Encode(map[string]any{"schema": "ftw.fleet-public/1", "meaning": fleetwire.Meaning, "observed_at": s.now().UTC().Format(time.RFC3339), "minimum": 1, "days": days}) } diff --git a/internal/fleet/public_test.go b/internal/fleet/public_test.go index 1b834d5..96a95df 100644 --- a/internal/fleet/public_test.go +++ b/internal/fleet/public_test.go @@ -2,6 +2,7 @@ package fleet import ( "encoding/json" + "net/http" "net/http/httptest" "path/filepath" "strings" @@ -12,57 +13,6 @@ import ( "github.com/srcfl/ftw/go/protocol/fleetwire" ) -func TestPublicFleetHidesSmallDaysAndEachSmallLabel(t *testing.T) { - reg, err := registry.Open(filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer reg.Close() - svc, err := New(reg) - if err != nil { - t.Fatal(err) - } - now := time.Now() - svc.now = func() time.Time { return now } - report := fleetwire.Report{Schema: fleetwire.SchemaV1, Version: "v0.147.0", Channel: "stable", Drivers: []string{}, Battery: "none", Zone: "SE4", Age: "unknown"} - for range 4 { - _, err = reg.FleetPut(report, now) - if err != nil { - t.Fatal(err) - } - } - get := func() map[string]any { - w := httptest.NewRecorder() - svc.Public(w, httptest.NewRequest("GET", "/v1/fleet/public", nil)) - var v map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { - t.Fatal(err) - } - return v - } - // Today is still open, so its running counts are not listed: whoever - // forwarded a report could see which count it moved. - if days := get()["days"].([]any); len(days) != 0 { - t.Fatalf("today listed: %v", days) - } - svc.now = func() time.Time { return now.Add(24 * time.Hour) } - v := get() - d := v["days"].([]any)[0].(map[string]any) - if d["reports"] != nil || len(d["ftw_versions"].(map[string]any)) != 0 { - t.Fatal("small day exposed") - } - report.Version = "v0.147.1" - _, _ = reg.FleetPut(report, now) - v = get() - d = v["days"].([]any)[0].(map[string]any) - if d["reports"] != float64(5) || len(d["ftw_versions"].(map[string]any)) != 0 { - t.Fatal("small version exposed") - } - if d["channels"].(map[string]any)["stable"] != float64(5) { - t.Fatal("safe cell hidden") - } -} - // A report that says how the box ran is taken beside the older shape. Its // health counts stay out of the public view, which lists its fields by name. func TestHealthIsCountedButNotPublished(t *testing.T) { @@ -101,3 +51,42 @@ func TestHealthIsCountedButNotPublished(t *testing.T) { } } } + +// Every field counts from one, however small the day, and today stays out +// until it is closed. +func TestTheDaysTotalsCountFromOne(t *testing.T) { + reg, err := registry.Open(filepath.Join(t.TempDir(), "db")) + if err != nil { + t.Fatal(err) + } + defer reg.Close() + svc, err := New(reg) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 8, 12, 0, 0, 0, time.UTC) + report := fleetwire.Report{Schema: fleetwire.SchemaV1, Version: "v0.162.0-beta.1", Channel: "beta", Drivers: []string{"sma_pv@1.1.1"}, + Battery: "12", Zone: "SE3", Age: "0-1m"} + if _, err := reg.FleetPut(report, now); err != nil { + t.Fatal(err) + } + read := func() map[string]any { + rr := httptest.NewRecorder() + svc.Public(rr, httptest.NewRequest(http.MethodGet, "/v1/fleet/public", nil)) + var v map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + return v + } + svc.now = func() time.Time { return now } + if days := read()["days"].([]any); len(days) != 0 { + t.Fatalf("today listed: %v", days) + } + svc.now = func() time.Time { return now.Add(24 * time.Hour) } + d := read()["days"].([]any)[0].(map[string]any) + if d["reports"] != float64(1) || d["drivers"].(map[string]any)["sma_pv@1.1.1"] != float64(1) || + d["battery_kwh"].(map[string]any)["12"] != float64(1) || d["price_zones"].(map[string]any)["SE3"] != float64(1) { + t.Fatalf("a day of one report: %v", d) + } +} diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go index cd78237..8cde40f 100644 --- a/internal/ops/ops_test.go +++ b/internal/ops/ops_test.go @@ -291,10 +291,9 @@ func TestTheDaysAreExactOnceClosed(t *testing.T) { w = httptest.NewRecorder() fl.Public(w, httptest.NewRequest(http.MethodGet, "/v1/fleet/public", nil)) _ = json.Unmarshal(w.Body.Bytes(), &public) - for _, d := range public.Days { - if d["reports"] != nil { - t.Fatalf("the public view should hide days below five: %s", w.Body.String()) - } + // The public view counts from one report, and health stays out of it. + if len(public.Days) != 1 || public.Days[0]["reports"] != float64(2) || public.Days[0]["plan_solve"] != nil { + t.Fatalf("public view: %s", w.Body.String()) } }