diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f42fb00d4..f53e8460d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -775,6 +775,11 @@ must flow through the canonical `/api/metrics-store/history` boundary and the disk `MetricsTarget.ResourceID` that monitoring projects for the resource, rather than reviving a browser-local collector or a lifecycle-only agent/device identity. +That shared metrics-history boundary may enforce commercial history windows +such as Relay 14-day and Pro 90-day retention for operator charts, but lifecycle +surfaces must treat those windows as presentation entitlements only. Agent +registration, heartbeat, installer status, and fleet freshness must not infer +lifecycle truth from whether a longer chart range is enabled or denied. That same adjacent API boundary now also owns internal demo-fixture runtime gating. Lifecycle-adjacent install, reporting, and demo-facing flows may share mock-mode handlers in dev and test, but release builds must authorize diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 092576f51..cfff9e7d1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -801,6 +801,13 @@ the canonical cluster source key, node history on `k8s::pod:`, and deployment history on `:deployment:`, so demo and live workload detail charts all resolve through one governed identity contract. +That same metrics-history contract also owns commercial history-range +enforcement. `frontend-modern/src/api/charts.ts` may expose `14d` as a +first-class `HistoryTimeRange`, and `/api/metrics-store/history` must parse +positive day ranges before querying the store so entitlement checks cannot be +bypassed with duration syntax. Community instances must remain capped at seven +days, Relay must allow 14 days and reject longer history, and Pro-tier +entitlements must continue to allow 90-day history. The Pulse Account commercial shell now also owns a dedicated bootstrap contract in `internal/cloudcp/portal/page.go`, `internal/cloudcp/portal/handlers.go`, and `internal/cloudcp/portal/handlers_test.go`. `/api/portal/bootstrap` and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 164418a44..954963ffa 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1447,6 +1447,12 @@ ordinary self-hosted surfaces must stay informational rather than presenting trial-start or upgrade-link actions. Future history-chart work should extend those owners instead of pushing fetch, license, commercial trial actions, or canvas math back into the shared component shell. +The shared history range catalog is also owned here. The canonical product +range sequence is `24h`, `7d`, `14d`, `30d`, and `90d`, with `14d` preserved +as the Relay entitlement surface rather than hidden behind the Pro-only +long-range controls. Lock copy must derive its target days and tier label from +the selected range instead of assuming every locked history selection is a +30-day or 90-day Pro ask. The remaining header, overlay, and tooltip render surfaces now live in `frontend-modern/src/components/shared/HistoryChartHeader.tsx`, `frontend-modern/src/components/shared/HistoryChartOverlay.tsx`, and diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 4e8305ccc..f7314b266 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -533,6 +533,12 @@ onto the shared backend `resourceType=k8s` transport, but it must preserve the canonical frontend target types for clusters, nodes, pods, and deployments so the workloads and drawer hot paths do not silently drop deployment history or split Kubernetes charts across incompatible cache keys. +That same protected metrics-store boundary also owns tiered history range +parsing as pre-query work. Day-based ranges such as `14d`, longer day ranges, +and equivalent duration syntax must be normalized and checked against the +licensed `max_history_days` before the store read is planned, so denied history +windows fail without widening DB scans or letting duration strings bypass the +Relay 14-day and Pro 90-day budgets. That same protected metrics-store boundary also owns write-path churn on local instances. `pkg/metrics/store.go` must prefer fewer, larger SQLite write transactions over tiny frequent commits: the default write buffer should stay diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fa7e03db6..67a397800 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -825,6 +825,11 @@ resource's canonical history target. Storage must not keep a drawer-local live metrics collector, agent-id/device fallback stream, or separate real-time history store once monitoring and `/api/metrics-store/history` already own the disk timeline. +Storage pool and disk detail range selectors must mirror the shared history +chart entitlement sequence. They must expose `14d` between `7d` and `30d` and +pass the selected range through to `HistoryChart` unchanged, rather than +inventing storage-local range catalogs, paid-tier labels, or alternate +metrics-history gating. Shared chart transport that storage and recovery coexist with must also stay on rendered-metric budgets. When `internal/api/router.go` batches workload history for adjacent overview or shared summary cards, it may parallelize the diff --git a/frontend-modern/src/api/__tests__/chartsApi.test.ts b/frontend-modern/src/api/__tests__/chartsApi.test.ts index 40c43d146..794ecc2fa 100644 --- a/frontend-modern/src/api/__tests__/chartsApi.test.ts +++ b/frontend-modern/src/api/__tests__/chartsApi.test.ts @@ -84,12 +84,12 @@ describe('ChartsAPI', () => { resourceType: 'agent', resourceId: 'agent-1', metric: 'cpu', - range: '7d', + range: '14d', maxPoints: 321.4, }); expect(apiFetchJSONMock).toHaveBeenCalledWith( - '/api/metrics-store/history?resourceType=agent&resourceId=agent-1&metric=cpu&range=7d&maxPoints=321', + '/api/metrics-store/history?resourceType=agent&resourceId=agent-1&metric=cpu&range=14d&maxPoints=321', ); }); diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index 49185dee6..0c20320fb 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -143,7 +143,16 @@ export interface StorageSummaryTrendResponse { } // Persistent metrics history types (SQLite-backed, longer retention) -export type HistoryTimeRange = '30m' | '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | '90d'; +export type HistoryTimeRange = + | '30m' + | '1h' + | '6h' + | '12h' + | '24h' + | '7d' + | '14d' + | '30d' + | '90d'; type MetricsHistoryAPIResourceType = | 'vm' | 'system-container' diff --git a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx index 964e73933..7af14a4af 100644 --- a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@solidjs/testing-library'; +import { fireEvent, render, screen } from '@solidjs/testing-library'; import { describe, expect, it, vi } from 'vitest'; import { StoragePoolDetail } from '@/components/Storage/StoragePoolDetail'; import type { StorageRecord } from '@/features/storageBackups/models'; @@ -7,11 +7,11 @@ import type { Resource } from '@/types/resource'; const historyChartSpy = vi.fn(); vi.mock('@/components/shared/HistoryChart', () => ({ - HistoryChart: (props: { resourceType: string; resourceId: string; metric: string }) => { + HistoryChart: (props: { resourceType: string; resourceId: string; metric: string; range?: string }) => { historyChartSpy(props); return (
- {props.resourceType}:{props.resourceId}:{props.metric} + {props.resourceType}:{props.resourceId}:{props.metric}:{props.range}
); }, @@ -60,6 +60,45 @@ describe('StoragePoolDetail', () => { resourceType: 'storage', resourceId: 'pool:tank', metric: 'usage', + range: '7d', + }), + ); + }); + + it('keeps the Relay 14-day range available in pool detail charts', () => { + historyChartSpy.mockClear(); + + render(() => ( + + + + +
+ )); + + const rangeSelector = screen.getByRole('combobox') as HTMLSelectElement; + expect(Array.from(rangeSelector.options).map((option) => option.value)).toEqual([ + '24h', + '7d', + '14d', + '30d', + '90d', + ]); + + fireEvent.change(rangeSelector, { target: { value: '14d' } }); + + expect(historyChartSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + resourceType: 'storage', + resourceId: 'pool:tank', + metric: 'usage', + range: '14d', }), ); }); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index ea8798ddd..d13507e6e 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -7,6 +7,7 @@ import historyChartModelSource from '@/components/shared/historyChartModel.ts?ra import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw'; import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw'; import { HistoryChart } from '@/components/shared/HistoryChart'; +import { HISTORY_CHART_RANGES } from '@/components/shared/historyChartModel'; if (typeof globalThis.ResizeObserver === 'undefined') { globalThis.ResizeObserver = class ResizeObserver { @@ -107,4 +108,8 @@ describe('HistoryChart', () => { expect(screen.getByText('History')).toBeInTheDocument(); }); + + it('exposes the Relay history range as a first-class chart option', () => { + expect(HISTORY_CHART_RANGES).toEqual(['24h', '7d', '14d', '30d', '90d']); + }); }); diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index 612df8b02..90d4d77b5 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -35,7 +35,7 @@ export interface HistoryChartTooltipLayout { height: number; } -export const HISTORY_CHART_RANGES: HistoryTimeRange[] = ['24h', '7d', '30d', '90d']; +export const HISTORY_CHART_RANGES: HistoryTimeRange[] = ['24h', '7d', '14d', '30d', '90d']; export function formatHistoryChartTooltipValue(value: number, unit?: string): string { if (unit === '%') return `${value.toFixed(1)}%`; @@ -48,6 +48,7 @@ export function formatHistoryChartTooltipValue(value: number, unit?: string): st export function getHistoryChartRefreshIntervalMs(range: HistoryTimeRange) { switch (range) { case '7d': + case '14d': return 30000; case '30d': return 60000; @@ -131,7 +132,7 @@ export function getHistoryChartYAxisLabels({ export function formatHistoryChartTimeLabel(timestamp: number, range: HistoryTimeRange) { const date = new Date(timestamp); - if (range === '30d' || range === '90d' || range === '7d') { + if (range === '30d' || range === '90d' || range === '14d' || range === '7d') { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); } return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index d78a48b42..c20b18e70 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -65,10 +65,22 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar }; const isLocked = createMemo(() => isRangeLocked(range())); - const lockDays = createMemo(() => (range() === '30d' ? '30' : '90')); + const lockDays = createMemo(() => { + switch (range()) { + case '14d': + return '14'; + case '30d': + return '30'; + case '90d': + return '90'; + default: + return '14'; + } + }); const lockTierLabel = createMemo(() => { const max = maxHistoryDays(); - const targetDays = range() === '30d' ? 30 : range() === '90d' ? 90 : 14; + const targetDays = + range() === '14d' ? 14 : range() === '30d' ? 30 : range() === '90d' ? 90 : 14; if (max <= 7 && targetDays <= 14) return 'Relay'; return 'Pro'; }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts index 20c4f61dd..94b3cfbef 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts @@ -99,6 +99,7 @@ describe('diskDetailPresentation', () => { '12h', '24h', '7d', + '14d', '30d', '90d', ]); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts index 80f18d100..de269d1ae 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts @@ -77,6 +77,7 @@ describe('storagePoolDetailPresentation', () => { expect(STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS.map((option) => option.value)).toEqual([ '24h', '7d', + '14d', '30d', '90d', ]); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 207ba7804..1eedf573a 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -55,6 +55,7 @@ export const DISK_DETAIL_HISTORY_RANGE_OPTIONS: readonly DiskDetailChartOption[] { value: '12h', label: 'Last 12 hours' }, { value: '24h', label: 'Last 24 hours' }, { value: '7d', label: 'Last 7 days' }, + { value: '14d', label: 'Last 14 days' }, { value: '30d', label: 'Last 30 days' }, { value: '90d', label: 'Last 90 days' }, ] as const; diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts index 99322ce3f..22eac4fd0 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts @@ -43,6 +43,7 @@ export const STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS: readonly { }[] = [ { value: '24h', label: '24h' }, { value: '7d', label: '7d' }, + { value: '14d', label: '14d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }, ] as const; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 9d4091cdb..a8d7f2a55 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5332,7 +5332,7 @@ func TestContract_SelfHostedCommunityEntitlementsJSONSnapshot(t *testing.T) { "limits":[], "subscription_state":"active", "upgrade_reasons":[ - {"key":"mobile_app","reason":"Get Relay so you can check Pulse from your phone when you are away from the dashboard.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=mobile_app"}, + {"key":"mobile_app","reason":"Get Relay so the mobile app can pair with this instance for secure handoff and notifications.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=mobile_app"}, {"key":"push_notifications","reason":"Get Relay so important alerts reach you immediately on mobile instead of waiting for you to reopen Pulse.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=push_notifications"}, {"key":"relay","reason":"Get Relay so Pulse stays reachable securely from anywhere instead of only on the local dashboard.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=relay"}, {"key":"long_term_metrics","reason":"Get Relay for 14 days of history, or Pro for 90 days, so you can see what changed before and after an incident.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=long_term_metrics"}, diff --git a/internal/api/router.go b/internal/api/router.go index 31298d869..f2707f0fa 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -8113,13 +8113,42 @@ func (r *Router) handleMetricsStoreStats(w http.ResponseWriter, req *http.Reques } } +func parseMetricsHistoryDuration(timeRange string) time.Duration { + normalizedRange := strings.ToLower(strings.TrimSpace(timeRange)) + switch normalizedRange { + case "30m": + return 30 * time.Minute + case "1h": + return time.Hour + case "6h": + return 6 * time.Hour + case "12h": + return 12 * time.Hour + case "24h", "1d", "": + return 24 * time.Hour + } + + if strings.HasSuffix(normalizedRange, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(normalizedRange, "d")) + if err == nil && days > 0 { + return time.Duration(days) * 24 * time.Hour + } + } + + duration, err := time.ParseDuration(normalizedRange) + if err != nil || duration <= 0 { + return 24 * time.Hour + } + return duration +} + // handleMetricsHistory returns historical metrics from the persistent SQLite store // Query params: // - resourceType: "node", "agent", "vm", "system-container", "oci-container", "app-container", // "docker-host", "k8s", "storage", or "disk" (required) // - resourceId: the resource identifier (required) // - metric: "cpu", "memory", "disk", etc. (optional, omit for all metrics) -// - range: time range like "1h", "24h", "7d", "30d", "90d" (optional, default "24h") +// - range: time range like "1h", "24h", "7d", "14d", "30d", "90d" (optional, default "24h") func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -8151,36 +8180,9 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } resourceID = canonicalizeMetricsHistoryResourceID(runtimeResourceType, resourceID) - // Parse time range - var duration time.Duration + duration := parseMetricsHistoryDuration(timeRange) var stepSecs int64 = 0 // Default to no downsampling (use tier resolution) - switch timeRange { - case "30m": - duration = 30 * time.Minute - case "1h": - duration = time.Hour - case "6h": - duration = 6 * time.Hour - case "12h": - duration = 12 * time.Hour - case "24h", "1d", "": - duration = 24 * time.Hour - case "7d": - duration = 7 * 24 * time.Hour - case "30d": - duration = 30 * 24 * time.Hour - case "90d": - duration = 90 * 24 * time.Hour - default: - // Try parsing as duration - var err error - duration, err = time.ParseDuration(timeRange) - if err != nil { - duration = 24 * time.Hour // Default to 24 hours - } - } - // Optional downsampling based on requested max points. // When omitted, we return the native tier resolution. if maxPointsStr := query.Get("maxPoints"); maxPointsStr != "" { diff --git a/internal/api/router_version_tenant_metrics_test.go b/internal/api/router_version_tenant_metrics_test.go index ed8d50a6a..f9aa0c4b7 100644 --- a/internal/api/router_version_tenant_metrics_test.go +++ b/internal/api/router_version_tenant_metrics_test.go @@ -12,6 +12,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/updates" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" ) @@ -133,6 +134,93 @@ func TestHandleMetricsHistory_LicenseRequired(t *testing.T) { } } +func TestParseMetricsHistoryDurationSupportsDayRanges(t *testing.T) { + tests := []struct { + input string + want time.Duration + }{ + {input: "", want: 24 * time.Hour}, + {input: "24h", want: 24 * time.Hour}, + {input: "7d", want: 7 * 24 * time.Hour}, + {input: "14d", want: 14 * 24 * time.Hour}, + {input: "15d", want: 15 * 24 * time.Hour}, + {input: "336h", want: 14 * 24 * time.Hour}, + {input: "nonsense", want: 24 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseMetricsHistoryDuration(tt.input); got != tt.want { + t.Fatalf("parseMetricsHistoryDuration(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func setMetricsHistoryLicenseTier(t *testing.T, handlers *LicenseHandlers, tier pkglicensing.Tier) { + t.Helper() + svc := handlers.Service(context.Background()) + svc.SetCurrentForTesting(&pkglicensing.License{ + Claims: pkglicensing.Claims{ + LicenseID: "metrics-history-tier-test", + Email: "metrics-history@example.test", + Tier: tier, + IssuedAt: time.Now().Add(-time.Hour).Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + }, + ValidatedAt: time.Now(), + }) +} + +func TestHandleMetricsHistory_TierAwareHistoryRanges(t *testing.T) { + tests := []struct { + name string + tier pkglicensing.Tier + range_ string + want int + }{ + {name: "community blocks relay history", range_: "14d", want: http.StatusPaymentRequired}, + {name: "relay allows 14 days", tier: pkglicensing.TierRelay, range_: "14d", want: http.StatusOK}, + {name: "relay blocks longer day range", tier: pkglicensing.TierRelay, range_: "15d", want: http.StatusPaymentRequired}, + {name: "pro allows 90 days", tier: pkglicensing.TierPro, range_: "90d", want: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore error: %v", err) + } + defer store.Close() + setUnexportedField(t, monitor, "metricsStore", store) + + mtp := config.NewMultiTenantPersistence(t.TempDir()) + if _, err := mtp.GetPersistence("default"); err != nil { + t.Fatalf("failed to init persistence: %v", err) + } + handlers := NewLicenseHandlers(mtp, false) + if tt.tier != "" { + setMetricsHistoryLicenseTier(t, handlers, tt.tier) + } + + router := &Router{ + monitor: monitor, + licenseHandlers: handlers, + } + + req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?resourceType=vm&resourceId=vm-1&metric=cpu&range="+tt.range_, nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != tt.want { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.want, rec.Body.String()) + } + }) + } +} + func TestHandleMetricsHistory_UsesStore(t *testing.T) { monitor, _, _ := newTestMonitor(t) store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir()))