diff --git a/.changeset/support-dump-zip.md b/.changeset/support-dump-zip.md new file mode 100644 index 00000000..0518652e --- /dev/null +++ b/.changeset/support-dump-zip.md @@ -0,0 +1,27 @@ +--- +"ftw": patch +--- + +One button, one file when asking for help. The plan card's "Something looks +wrong?" button now downloads `ftw-help-.zip` — the help report as +`00-help-report.md`, sorted first, with the redacted config, driver health, +recent logs and an hour of telemetry behind it. + +Before this there were two downloads and the user had to guess which one we +wanted: the report from the plan card, the log bundle from a driver's Diagnose +modal. They would send one and we would ask for the other. + +The archive is a zip rather than a `.tar.gz` because its whole purpose is to be +handed to somebody else, and Windows and every chat client open a zip without a +second tool. Around 10 kB on a two-driver install. + +`GET /api/support/report` still returns the bare Markdown for anyone who wants +only the text. + +The report also now carries the slot's energy books — what the plan asked for, +what the batteries actually moved, and what the energy-allocation path thinks +it delivered — plus a finding when a slot is a quarter of the way through and +delivery is under half the rate the plan needs. That is the shape of the +reports that keep arriving: a plan card reading "charge 4.5 kW, now", a live +target of 0 W, and nothing in between to show whether the plan reached +dispatch at all. diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index aded6677..a9e82b5a 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -7,8 +7,7 @@ package api import ( - "archive/tar" - "compress/gzip" + "archive/zip" "context" "encoding/json" "fmt" @@ -318,11 +317,17 @@ func (s *Server) handleGlobalLogs(w http.ResponseWriter, r *http.Request) { }) } -// GET /api/support/dump — gzipped tarball with everything a developer -// needs to triage a support incident: redacted config, full driver -// health JSON, recent global + per-driver logs, last 1 h of TS samples -// per (driver, metric), and a manifest. SQLite is NOT included; the -// dump is intended to be small enough to email or paste-link. +// GET /api/support/dump — zip archive with everything a developer needs +// to triage a support incident: redacted config, full driver health JSON, +// recent global + per-driver logs, last 1 h of TS samples per +// (driver, metric), and a manifest. SQLite is NOT included; the dump is +// intended to be small enough to attach to a chat message — measured at +// ~6 kB on a two-driver install. +// +// Zip rather than tar.gz because this file's whole purpose is to be +// handed to somebody else. Windows and every chat client open a zip +// without a second tool; a .tar.gz asks the person you need help from to +// go find one first. func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { if s.deps.LogRing == nil { writeJSON(w, 503, map[string]string{"error": "log ring not configured"}) @@ -330,28 +335,34 @@ func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { } now := time.Now().UTC() stamp := now.Format("20060102-150405") - w.Header().Set("Content-Type", "application/gzip") - w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.tar.gz"`) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.zip"`) w.Header().Set("Cache-Control", "no-store") - gz := gzip.NewWriter(w) - defer gz.Close() - tw := tar.NewWriter(gz) - defer tw.Close() + zw := zip.NewWriter(w) + defer zw.Close() addFile := func(name string, body []byte) { - hdr := &tar.Header{ - Name: "ftw-support-" + stamp + "/" + name, - Mode: 0o644, - Size: int64(len(body)), - ModTime: now, + hdr := &zip.FileHeader{ + Name: "ftw-support-" + stamp + "/" + name, + Method: zip.Deflate, + Modified: now, + } + hdr.SetMode(0o644) + f, err := zw.CreateHeader(hdr) + if err != nil { + return } - _ = tw.WriteHeader(hdr) - _, _ = tw.Write(body) + _, _ = f.Write(body) } - // Manifest first so a curious recipient can `tar -xOzf … manifest.json` - // and see what they've got without unpacking the whole bundle. + // The help report goes in first and is named to sort first, because it + // is the only file in here most recipients need to read. Everything + // below it is what you reach for when the report does not settle the + // question. Shipping them together means the person asking for help + // sends one file and never has to pick the right one. + addFile("00-help-report.md", []byte(s.buildSupportReport(r.Context(), time.Now()))) + manifest := map[string]any{ "generated_at": now.Format(time.RFC3339), "version": s.deps.Version, @@ -359,7 +370,9 @@ func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { "goos": runtime.GOOS, "goarch": runtime.GOARCH, "hostname": hostnameOrEmpty(), + "read_first": "00-help-report.md", "contents": []string{ + "00-help-report.md", "config.redacted.yaml", "drivers.json", "logs/global.log", diff --git a/go/internal/api/api_support_dump_zip_test.go b/go/internal/api/api_support_dump_zip_test.go new file mode 100644 index 00000000..554945a1 --- /dev/null +++ b/go/internal/api/api_support_dump_zip_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The dump exists to be handed to somebody else, so the archive has to +// open with no extra tooling. This asserts it is a real zip, not just a +// renamed one. +func TestSupportDumpIsAReadableZip(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("meter").RecordSuccess() + ring := telemetry.NewLogRing() + ring.Append(telemetry.LogEntry{Level: "WARN", Msg: "something to log"}) + srv := New(&Deps{ + Ctrl: control.NewState(0, 50, "meter"), CtrlMu: &sync.Mutex{}, + Tel: tel, LogRing: ring, Version: "test", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/support/dump", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/support/dump = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { + t.Errorf("Content-Type = %q, want application/zip", ct) + } + if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, ".zip") { + t.Errorf("Content-Disposition = %q, want a .zip filename", cd) + } + + body := rec.Body.Bytes() + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("archive does not open as a zip: %v", err) + } + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + rc, err := f.Open() + if err != nil { + t.Errorf("entry %s will not open: %v", f.Name, err) + continue + } + rc.Close() + } + joined := strings.Join(names, " ") + // The report is the point of the archive for most recipients, so it + // has to be in there and has to sort first. + if len(zr.File) == 0 || !strings.HasSuffix(zr.File[0].Name, "00-help-report.md") { + t.Errorf("first entry is %q, want the help report", names[0]) + } + var reportBody string + for _, f := range zr.File { + if strings.HasSuffix(f.Name, "00-help-report.md") { + rc, _ := f.Open() + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(rc) + rc.Close() + reportBody = buf.String() + } + } + if !strings.Contains(reportBody, "# FTW help report") { + t.Error("the embedded report is not a help report") + } + if !strings.Contains(reportBody, "## Findings") { + t.Error("the embedded report is missing Findings") + } + + for _, want := range []string{"00-help-report.md", "manifest.json", "drivers.json", "logs/global.log"} { + if !strings.Contains(joined, want) { + t.Errorf("archive is missing %s; has %v", want, names) + } + } +} diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index c57d80a6..c467904c 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -63,6 +63,21 @@ const forecastMissRatio = 1.5 // the comparison to mean anything. const forecastMissFloorW = 500 +// slotEnergyIdleWh matches the dispatcher's own idle gate: below this the +// slot is not asking the battery for anything and cannot fall behind. +const slotEnergyIdleWh = 50 + +// slotPaceMinElapsed is how much of a slot must have passed before its +// delivery rate says anything. A slot is allowed to start slowly and catch +// up; a quarter of the way through is not. +const slotPaceMinElapsed = 0.25 + +// slotPaceFloor is the fraction of the required rate below which delivery +// counts as not happening. Half is generous — a slot tracking its plan at +// all clears it easily — so what trips this is a slot doing essentially +// nothing, or moving energy the wrong way. +const slotPaceFloor = 0.5 + // loadmodelWarmBucketsForTrust is the point past which the weekly load // pattern is filled in enough that its predictions carry the plan. Below // it the planner is guessing, which is worth saying out loud before @@ -163,6 +178,7 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string { s.deps.CtrlMu.Lock() ctrl := *s.deps.Ctrl targets := append([]control.DispatchTarget{}, s.deps.Ctrl.LastTargets...) + slotEnergy := s.deps.Ctrl.SlotEnergy() s.deps.CtrlMu.Unlock() snap := s.liveNow(ctrl, now) @@ -178,12 +194,12 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string { } health := s.deps.Tel.AllHealth() - findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, now) + findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, slotEnergy, now) var b strings.Builder writeReportHeader(&b, s.deps.Version, now) writeFindings(&b, findings) - writeRightNow(&b, ctrl, snap, activeSlot, targets, now) + writeRightNow(&b, ctrl, snap, activeSlot, targets, slotEnergy, now) writePlanSection(&b, plan, lastReplanAt, lastReplanReason, now) writeForecastSection(&b, s, snap, activeSlot, now) writeDeviceSection(&b, health, now) @@ -232,6 +248,7 @@ func writeRightNow( snap liveSnapshot, activeSlot *mpc.Action, targets []control.DispatchTarget, + slotEnergy control.SlotEnergySnapshot, now time.Time, ) { b.WriteString("## Right now\n\n") @@ -308,6 +325,26 @@ func writeRightNow( "the log for clamp messages.\n\n") } + // The slot's energy books. Without these a report can show a plan + // asking for 4.5 kW and a target of 0 W and give no way to tell + // whether the plan never reached dispatch, or dispatch already + // finished the slot's energy early and is coasting. + if slotEnergy.HasSlot && !slotEnergy.SlotEnd.IsZero() { + elapsed := now.Sub(slotEnergy.SlotStart) + remaining := slotEnergy.SlotEnd.Sub(now) + fmt.Fprintf(b, "Energy booked for this slot: plan asked for **%s**, "+ + "the batteries have moved **%s** so far, %s elapsed and %s left.\n\n", + fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh), + fmtReportAge(elapsed), fmtReportAge(remaining)) + if slotEnergy.EnergyPathWh != 0 || slotEnergy.PlannedWh != 0 { + fmt.Fprintf(b, "The energy-allocation path counts %s delivered. "+ + "That figure only moves while that path is executing, so a "+ + "real plan figure beside a zero here means the slot is being "+ + "run by a reactive path instead.\n\n", + fmtReportWh(slotEnergy.EnergyPathWh)) + } + } + st := ctrl.SlotDeliveryStats if st.OverDeliveryCount+st.UnderDeliveryCount+st.SignMismatchCount > 0 { fmt.Fprintf(b, "Slot delivery misses since start: %d over, %d under, "+ @@ -598,6 +635,7 @@ func (s *Server) collectFindings( activeSlot *mpc.Action, targets []control.DispatchTarget, health map[string]telemetry.DriverHealth, + slotEnergy control.SlotEnergySnapshot, now time.Time, ) []finding { var out []finding @@ -674,6 +712,26 @@ func (s *Server) collectFindings( } } + // The slot is asking for energy and the batteries are not moving it. + // This is the shape of the reports that keep arriving: a plan card + // reading "charge 4.5 kW, now", a live target of 0 W, and no way from + // the outside to tell whether the plan reached dispatch at all. + if pace, ok := slotPaceShortfall(slotEnergy, now); ok { + detail := fmt.Sprintf("This slot asked for %s and the batteries have "+ + "moved %s with %s of it gone — about %.0f%% of the rate the plan "+ + "needs.", + fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh), + fmtReportAge(now.Sub(slotEnergy.SlotStart)), pace*100) + if slotEnergy.EnergyPathWh == 0 && slotEnergy.PlannedWh != 0 { + detail += " The energy-allocation path has delivered nothing this " + + "slot, so a reactive path is driving instead of the plan." + } + detail += " Safety limits, a charge ceiling and a device that cannot " + + "follow the command all look like this from here — the dispatch " + + "table and the log below separate them." + out = append(out, finding{sevProblem, "The slot's energy is not being delivered", detail}) + } + var clamped []string for _, t := range targets { if t.Clamped { @@ -801,8 +859,52 @@ func forecastMiss(predicted, actual float64) bool { return hi/lo >= forecastMissRatio } +// slotPaceShortfall reports how far behind the slot's required rate the +// batteries actually are, as a fraction of it, and whether that is worth +// saying. Returns (pace, true) only when the slot has a real energy ask, +// enough of it has passed to judge, and delivery is meaningfully behind. +// +// Pace rather than a plain energy comparison, because a slot is allowed +// to be behind early and catch up. What is not normal is being a quarter +// of the way through having moved almost nothing. +func slotPaceShortfall(e control.SlotEnergySnapshot, now time.Time) (float64, bool) { + if !e.HasSlot || e.SlotEnd.IsZero() { + return 0, false + } + if math.Abs(e.PlannedWh) < slotEnergyIdleWh { + return 0, false // an idle slot cannot fall behind + } + total := e.SlotEnd.Sub(e.SlotStart) + elapsed := now.Sub(e.SlotStart) + if total <= 0 || elapsed <= 0 || elapsed > total { + return 0, false + } + fraction := elapsed.Seconds() / total.Seconds() + if fraction < slotPaceMinElapsed { + return 0, false // too early to tell + } + expected := e.PlannedWh * fraction + if expected == 0 { + return 0, false + } + // Signed ratio: wrong-direction delivery lands negative and is + // therefore always a shortfall, which is what it should be. + pace := e.ActualWh / expected + if pace >= slotPaceFloor { + return 0, false + } + return pace, true +} + // ---- helpers ---- +func fmtReportWh(v float64) string { + if math.Abs(v) >= 1000 { + return fmt.Sprintf("%.2f kWh", v/1000) + } + return fmt.Sprintf("%.0f Wh", v) +} + func activeAction(plan *mpc.Plan, now time.Time) *mpc.Action { if plan == nil { return nil diff --git a/go/internal/api/api_support_report_test.go b/go/internal/api/api_support_report_test.go index b500a489..695c823d 100644 --- a/go/internal/api/api_support_report_test.go +++ b/go/internal/api/api_support_report_test.go @@ -94,7 +94,7 @@ func TestSupportReportFlagsLoadForecastMiss(t *testing.T) { LoadW: 7900, PredictedLd: 383, } - findings := srv.collectFindings(*ctrl, snap, nil, nil, nil, nil, now) + findings := srv.collectFindings(*ctrl, snap, nil, nil, nil, nil, control.SlotEnergySnapshot{}, now) var got *finding for i := range findings { @@ -196,7 +196,7 @@ func TestSupportReportMarksTheActiveSlot(t *testing.T) { // And the live section should state the active slot's intent in prose. var live strings.Builder - writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, BatW: -7500}, &plan.Actions[0], nil, now) + writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, BatW: -7500}, &plan.Actions[0], nil, control.SlotEnergySnapshot{}, now) if !strings.Contains(live.String(), "-8.40 kW") { t.Errorf("active-slot intent missing from Right now:\n%s", live.String()) } @@ -214,7 +214,7 @@ func TestSupportReportFlagsFallbackSolver(t *testing.T) { } findings := srv.collectFindings(*ctrl, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - plan, nil, nil, nil, time.Now()) + plan, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) found := false for _, f := range findings { @@ -239,7 +239,7 @@ func TestSupportReportFlagsOfflineAndFaultedDevices(t *testing.T) { } findings := srv.collectFindings(*ctrl, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - nil, nil, nil, health, time.Now()) + nil, nil, nil, health, control.SlotEnergySnapshot{}, time.Now()) var sawOffline, sawFault bool for _, f := range findings { @@ -356,7 +356,7 @@ func TestRepeatedWarningBecomesAFinding(t *testing.T) { findings := srv.collectFindings(*st, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - nil, nil, nil, nil, time.Now()) + nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding for i := range findings { @@ -383,8 +383,7 @@ func TestDispatchTableDisclaimsSiteLevelClamps(t *testing.T) { var b strings.Builder writeRightNow(&b, *st, liveSnapshot{HaveGrid: true}, nil, - []control.DispatchTarget{{Driver: "ferroamp", TargetW: 6400, Clamped: false}}, - time.Now()) + []control.DispatchTarget{{Driver: "ferroamp", TargetW: 6400, Clamped: false}}, control.SlotEnergySnapshot{}, time.Now()) out := b.String() if !strings.Contains(out, "per-device limits") { t.Errorf("dispatch table lacks its scope caveat:\n%s", out) @@ -409,7 +408,7 @@ func TestForecastMissIsANoteWhileTheModelIsStillLearning(t *testing.T) { findings := srv.collectFindings(*st, liveSnapshot{HaveGrid: true, LoadW: 3650, PredictedLd: 1470}, - nil, nil, nil, nil, time.Now()) + nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding for i := range findings { @@ -431,3 +430,103 @@ func TestForecastMissIsANoteWhileTheModelIsStillLearning(t *testing.T) { t.Error("a reset must not be recommended to someone whose model is still filling in") } } + +// Björn's second report: plan card reading "Charge battery at 4.5 kW · +// Now, until 15:00", live target 0 W, 4 kW going out to the grid. The +// report showed the plan and the target and gave no way to tell whether +// the plan reached dispatch at all. +func TestSlotEnergyShortfallIsAFinding(t *testing.T) { + srv, ctrl, _ := reportTestServer(t) + now := time.Now() + slot := control.SlotEnergySnapshot{ + HasSlot: true, + PlannedWh: 1125, // 4.5 kW across a 15-minute slot + ActualWh: 20, // the batteries are doing nothing + SlotStart: now.Add(-8 * time.Minute), + SlotEnd: now.Add(7 * time.Minute), + } + findings := srv.collectFindings(*ctrl, + liveSnapshot{HaveGrid: true, LoadW: 700, PredictedLd: 700}, + nil, nil, nil, nil, slot, now) + + var got *finding + for i := range findings { + if strings.Contains(findings[i].Title, "energy is not being delivered") { + got = &findings[i] + } + } + if got == nil { + t.Fatalf("a slot delivering nothing produced no finding: %+v", findings) + } + if got.Severity != sevProblem { + t.Errorf("severity = %q, want %q", got.Severity, sevProblem) + } + if !strings.Contains(got.Detail, "1.12 kWh") { + t.Errorf("detail should quote the planned energy, got %q", got.Detail) + } + if !strings.Contains(got.Detail, "energy-allocation path has delivered nothing") { + t.Errorf("detail should call out the idle energy path, got %q", got.Detail) + } +} + +func TestSlotPaceShortfall(t *testing.T) { + now := time.Now() + slot := func(planned, actual float64, elapsed, total time.Duration) control.SlotEnergySnapshot { + return control.SlotEnergySnapshot{ + HasSlot: true, PlannedWh: planned, ActualWh: actual, + SlotStart: now.Add(-elapsed), SlotEnd: now.Add(total - elapsed), + } + } + cases := []struct { + name string + in control.SlotEnergySnapshot + want bool + }{ + {"delivering nothing against a real ask", + slot(1125, 20, 8*time.Minute, 15*time.Minute), true}, + {"moving energy the wrong way", + slot(1125, -300, 8*time.Minute, 15*time.Minute), true}, + {"tracking the plan", + slot(1125, 560, 8*time.Minute, 15*time.Minute), false}, + {"a little behind but working", + slot(1125, 400, 8*time.Minute, 15*time.Minute), false}, + // A slot is allowed to start slowly and catch up. + {"barely started", + slot(1125, 0, 30*time.Second, 15*time.Minute), false}, + // An idle slot asks for nothing and cannot fall behind. + {"idle slot", + slot(10, 0, 8*time.Minute, 15*time.Minute), false}, + {"discharge slot delivering", + slot(-1125, -560, 8*time.Minute, 15*time.Minute), false}, + {"discharge slot doing nothing", + slot(-1125, -20, 8*time.Minute, 15*time.Minute), true}, + {"no slot in flight", control.SlotEnergySnapshot{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, got := slotPaceShortfall(tc.in, now) + if got != tc.want { + t.Errorf("slotPaceShortfall = %v, want %v", got, tc.want) + } + }) + } +} + +// The books have to be in the report even when nothing is wrong — that is +// what lets somebody else check the reasoning rather than trust a verdict. +func TestSlotEnergyBooksAreInTheReport(t *testing.T) { + _, ctrl, _ := reportTestServer(t) + now := time.Now() + var b strings.Builder + writeRightNow(&b, *ctrl, liveSnapshot{HaveGrid: true}, nil, nil, + control.SlotEnergySnapshot{ + HasSlot: true, PlannedWh: 1125, ActualWh: 560, EnergyPathWh: 545, + SlotStart: now.Add(-8 * time.Minute), SlotEnd: now.Add(7 * time.Minute), + }, now) + out := b.String() + for _, want := range []string{"1.12 kWh", "560 Wh", "545 Wh", "Energy booked for this slot"} { + if !strings.Contains(out, want) { + t.Errorf("Right now is missing %q:\n%s", want, out) + } + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7da1879b..f200ffca 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -648,6 +648,52 @@ func (s *State) GetBatteryManualHold(now time.Time) (BatteryManualHold, bool) { return s.ManualHold, true } +// SlotEnergySnapshot is the per-slot energy accounting, exposed for +// diagnostics. Both accumulators are here because they answer different +// questions and can disagree — which is itself the interesting signal. +// +// ActualWh updates on every dispatch tick regardless of which path ran, +// so it is the honest record of what the fleet moved. EnergyPathWh is +// what the energy-allocation path believes it delivered, and only moves +// while that path is executing. A slot with a real PlannedWh, an +// EnergyPathWh of zero and an ActualWh going nowhere means the plan is +// not reaching the hardware — a case a support report otherwise cannot +// distinguish from "the plan asked for nothing". +type SlotEnergySnapshot struct { + HasSlot bool + // PlannedWh is the plan's BatteryEnergyWh for the slot in flight. + // Site-signed: positive charges. + PlannedWh float64 + // ActualWh is what the fleet has moved since the slot began, counted + // on every tick and every path. + ActualWh float64 + // EnergyPathWh is the energy path's own delivered count. Zero when + // that path has not run this slot. + EnergyPathWh float64 + SlotStart time.Time + SlotEnd time.Time +} + +// SlotEnergy returns the current slot's energy accounting. Caller must +// hold the outer ctrlMu. +func (s *State) SlotEnergy() SlotEnergySnapshot { + out := SlotEnergySnapshot{ + PlannedWh: s.slotActualPlannedWh, + ActualWh: s.slotActualWh, + EnergyPathWh: s.slotDelivered, + SlotStart: s.slotActualSlotStart, + SlotEnd: s.currentDirective.SlotEnd, + } + // The path-agnostic accumulator carries the authoritative slot start; + // the energy path's directive carries the end. Either being unset + // means no slot is in flight yet. + out.HasSlot = !out.SlotStart.IsZero() + if out.SlotEnd.IsZero() && !s.currentDirective.SlotStart.IsZero() { + out.SlotEnd = s.currentDirective.SlotStart + } + return out +} + // PVManualHold is an operator-installed PV curtail override. See // State.ManualPVHold for invariants. // diff --git a/web/diagnostics-modal.js b/web/diagnostics-modal.js index ee106f7d..258a9dbb 100644 --- a/web/diagnostics-modal.js +++ b/web/diagnostics-modal.js @@ -501,7 +501,7 @@ "/api/support/dump", "Preparing support bundle", "Collecting logs, redacted config, driver health, and recent telemetry.", - "ftw-support.tar.gz" + "ftw-support.zip" ); }); state.bodyEl.querySelector('[data-role="research"]').addEventListener("click", function () { diff --git a/web/help-report.test.mjs b/web/help-report.test.mjs index 7a2402bd..4457c18b 100644 --- a/web/help-report.test.mjs +++ b/web/help-report.test.mjs @@ -14,10 +14,13 @@ test("the plan card offers a help report", () => { assert.match(index, /Something looks wrong\?/); }); -test("the help-report button downloads from the report endpoint", () => { +// One button, one file. The archive leads with the readable report and +// carries the logs behind it, so a user asking for help never has to work +// out which of two downloads we meant. +test("the help-report button downloads the bundle, not just the text", () => { assert.match(plan, /getElementById\('plan-help-report'\)/); - assert.match(plan, /apiFetch\('\/api\/support\/report'\)/); - assert.match(plan, /a\.download = 'ftw-help-'/); + assert.match(plan, /apiFetch\('\/api\/support\/dump'\)/); + assert.match(plan, /a\.download = 'ftw-help-' \+ stamp \+ '\.zip'/); }); test("the help-report button reports failure instead of failing silently", () => { diff --git a/web/index.html b/web/index.html index 567aa68a..4256c5d0 100644 --- a/web/index.html +++ b/web/index.html @@ -577,7 +577,7 @@

Plan

- One file describing what FTW is doing and why. Share it when asking for help. + One file describing what FTW is doing and why, with the logs behind it. Share it when asking for help. diff --git a/web/plan.js b/web/plan.js index ab891bed..02efd617 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1028,9 +1028,15 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. if (reportBtn) reportBtn.addEventListener('click', downloadHelpReport); } - // Pulls GET /api/support/report and saves it. Kept here rather than in - // the driver Diagnose modal because the question it answers ("why is it - // doing that?") is asked while looking at the plan, not at a device. + // Pulls GET /api/support/dump and saves it. That archive leads with + // 00-help-report.md — the readable answer to "why is it doing that?" — + // and carries the logs, config and telemetry behind it. One button and + // one file, because a user asking for help should not have to work out + // which of two downloads we wanted. The bare report is still at + // /api/support/report for anyone who wants only the text. + // + // Lives here rather than in the driver Diagnose modal because the + // question is asked while looking at the plan, not at a device. function downloadHelpReport() { const btn = document.getElementById('plan-help-report'); if (!btn || btn.disabled) return; @@ -1044,7 +1050,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. btn.textContent = original; }, 4000); }; - apiFetch('/api/support/report') + apiFetch('/api/support/dump') .then(function (resp) { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.blob().then(function (blob) { @@ -1052,7 +1058,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'ftw-help-' + stamp + '.md'; + a.download = 'ftw-help-' + stamp + '.zip'; document.body.appendChild(a); a.click(); a.remove();