Make Relay history entitlement enforceable

This commit is contained in:
rcourtman 2026-04-29 13:15:21 +01:00
parent f060f261cd
commit e16f15b398
18 changed files with 229 additions and 40 deletions

View file

@ -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

View file

@ -801,6 +801,13 @@ the canonical cluster source key, node history on
`k8s:<cluster>:pod:<uid-or-namespace/name>`, and deployment history on
`<cluster>:deployment:<uid-or-namespace/name>`, 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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',
);
});

View file

@ -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'

View file

@ -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 (
<div data-testid="history-chart">
{props.resourceType}:{props.resourceId}:{props.metric}
{props.resourceType}:{props.resourceId}:{props.metric}:{props.range}
</div>
);
},
@ -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(() => (
<table>
<tbody>
<StoragePoolDetail
record={makeRecord({
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
})}
physicalDisks={[]}
summarySeriesId="pool:tank"
/>
</tbody>
</table>
));
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',
}),
);
});

View file

@ -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']);
});
});

View file

@ -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' });

View file

@ -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';
});

View file

@ -99,6 +99,7 @@ describe('diskDetailPresentation', () => {
'12h',
'24h',
'7d',
'14d',
'30d',
'90d',
]);

View file

@ -77,6 +77,7 @@ describe('storagePoolDetailPresentation', () => {
expect(STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS.map((option) => option.value)).toEqual([
'24h',
'7d',
'14d',
'30d',
'90d',
]);

View file

@ -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;

View file

@ -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;

View file

@ -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"},

View file

@ -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 != "" {

View file

@ -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()))