diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 0ff9e3f2ca..28ee482cc7 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -117,16 +117,28 @@ FreeBSD `active` pages are not a total-used-memory alias. Without an explicit used series, history derives usage from the known capacity and reported free memory, subtracting separately reported ARC as the live projection does. +The legacy REST graph request also matches the request shape the native TrueNAS +13 GUI itself sends (#2077): a graph that is not scoped to a device omits the +`identifier` key instead of sending an explicit null. The reporter's CORE +13.0-U6.1 WebSocket capture shows `reporting.get_data` graphs as +`{"name":"cpu"}` (also memory, arcsize, load, swap, cputemp), with an +`identifier` present only for parameterized graphs such as +`{"name":"arcresult","identifier":"demand_data"}`. Sending `identifier: null` +is not the native contract and is a plausible whole-batch rejection that would +discard otherwise usable CPU and memory telemetry. + `TestRESTReportingGraphFailurePreservesSnapshotTelemetry`, `TestRESTReportingGraphFailureBoundaries`, -`TestRESTReportingPartialAndMalformedResponses` and -`TestReportingActiveMemoryIsNotTotalUsed` in +`TestRESTReportingPartialAndMalformedResponses`, +`TestReportingActiveMemoryIsNotTotalUsed` and +`TestRESTReportingRequestMatchesNativeGraphShape` in `internal/truenas/client_test.go` exercise request-validating synthetic REST responses, repeated full snapshots, CPU/free/ARC projections, matching history, -partial data, bounded errors and cancellation. They do not establish the native -CORE request schema, row timestamps/units, appliance acceptance or the reporter's -exact failure. Native response evidence remains required before claiming that -this repair resolves the reported telemetry journey. +partial data, bounded errors, cancellation and the native identifier-less graph +request shape. They do not establish the native CORE response schema, row +timestamps/units, appliance acceptance or the reporter's exact failure. Native +response evidence remains required before claiming that this repair resolves the +reported telemetry journey. **Availability backfill preserves concurrent discovery changes (7 September 2026)** diff --git a/internal/truenas/client.go b/internal/truenas/client.go index cd2f792645..7d19143468 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -348,15 +348,31 @@ func latestTimeSeriesValue(series []TimeSeriesPoint) (float64, bool) { return latest.Value, true } +// reportingGraph builds a reporting.get_data graph entry in the shape the +// TrueNAS 13 middleware itself accepts: a graph that is not scoped to a device +// omits the identifier key entirely rather than sending an explicit null. The +// native GUI requests CPU, memory and ARC as `{"name":"cpu"}` and only supplies +// an identifier for parameterized graphs such as `arcresult` (#2077). +func reportingGraph(name, identifier string) map[string]any { + graph := map[string]any{"name": name} + if trimmed := strings.TrimSpace(identifier); trimmed != "" { + graph["identifier"] = trimmed + } + return graph +} + // legacyRESTReportingGraphs is the reporting.get_data graph set used to -// reconstruct live telemetry and history over the legacy REST transport. +// reconstruct live telemetry and history over the legacy REST transport. The +// identifier is omitted for device-independent graphs so the request matches +// the native contract instead of sending a null identifier that the legacy +// REST schema can reject for the whole batch (#2077). func legacyRESTReportingGraphs() []map[string]any { return []map[string]any{ - {"name": "cpu", "identifier": nil}, - {"name": "memory", "identifier": nil}, - {"name": "arcsize", "identifier": nil}, - {"name": "interface", "identifier": nil}, - {"name": "disk", "identifier": nil}, + reportingGraph("cpu", ""), + reportingGraph("memory", ""), + reportingGraph("arcsize", ""), + reportingGraph("interface", ""), + reportingGraph("disk", ""), } } diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index bd18d4a2b1..0e633283d6 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -2281,3 +2281,66 @@ func TestRESTReportingPartialAndMalformedResponses(t *testing.T) { } } } + +// nativeShapeReportingTransport models the request shape the TrueNAS 13 +// middleware accepts: a graph that carries an explicit identifier key (even a +// null one) is rejected for the whole request, while a device-independent graph +// that omits the key succeeds. This is a schema-faithful synthetic transport, +// not an appliance capture; it encodes the native request shape the reporter +// supplied in #2077. +type nativeShapeReportingTransport struct { + routes alertArgsTransport + graphs []map[string]any +} + +func (s *nativeShapeReportingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/api/v2.0/reporting/get_data" { + return s.routes.RoundTrip(r) + } + var request struct { + Graphs []map[string]any `json:"graphs"` + Query map[string]any `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + return nil, err + } + s.graphs = append(s.graphs, request.Graphs...) + for _, graph := range request.Graphs { + if _, present := graph["identifier"]; present { + return (alertArgsTransport{r.URL.Path: apiResponse{ + status: http.StatusUnprocessableEntity, + body: `{"error":"identifier: Not a string"}`, + }}).RoundTrip(r) + } + } + body := `[ + {"name":"cpu","legend":["user","idle"],"data":[[1789000060,12,88]]}, + {"name":"memory","legend":["used","free","total"],"data":[[1789000060,6000000000,2000000000,16000000000]]}, + {"name":"arcsize","legend":["size"],"data":[[1789000060,8000000000]]} + ]` + return (alertArgsTransport{r.URL.Path: apiResponse{status: http.StatusOK, body: body}}).RoundTrip(r) +} + +func TestRESTReportingRequestMatchesNativeGraphShape(t *testing.T) { + routes := alertArgsTransport(defaultAPIResponses()) + routes["/api/v2.0/system/info"] = apiResponse{body: `{"hostname":"core","version":"TrueNAS-13.0-U6.1","physmem":16000000000}`} + transport := &nativeShapeReportingTransport{routes: routes} + client := newLegacyRESTReportingClient(t, routes) + client.httpClient.Transport = transport + + telemetry, err := client.GetSystemTelemetry(context.Background()) + if err != nil { + t.Fatalf("GetSystemTelemetry() error = %v", err) + } + if telemetry.CPUPercent != 12 || telemetry.MemoryAvailableBytes != 2000000000 || telemetry.ARCSizeBytes != 8000000000 { + t.Fatalf("native-shape telemetry lost: %+v", telemetry) + } + if len(transport.graphs) == 0 { + t.Fatal("no reporting graphs requested") + } + for _, graph := range transport.graphs { + if _, present := graph["identifier"]; present { + t.Fatalf("legacy REST graph %v sends an identifier key the native client omits", graph) + } + } +}