diff --git a/src/config/degradationConfig.ts b/src/config/degradationConfig.ts new file mode 100644 index 00000000..1c77180b --- /dev/null +++ b/src/config/degradationConfig.ts @@ -0,0 +1,19 @@ +export type ServiceName = 'streaming' | 'payment'; +export type FeatureFlagKey = 'streaming_courses' | 'payment_form'; + +export interface FeatureFlagEntry { + flagKey: FeatureFlagKey; + adminOverride?: boolean; +} + +export const HEALTH_TO_FEATURE_MAP: Record = { + streaming: [{ flagKey: 'streaming_courses', adminOverride: false }], + payment: [{ flagKey: 'payment_form', adminOverride: false }], +}; + +export const DEGRADED_STATUSES = ['degraded', 'down', 'error', 'unhealthy'] as const; +export type DegradedStatus = (typeof DEGRADED_STATUSES)[number]; + +export function isServiceDegraded(status: string): boolean { + return (DEGRADED_STATUSES as readonly string[]).includes(status); +} diff --git a/src/store/healthDashboardStore.ts b/src/store/healthDashboardStore.ts index fe400d14..f94a27bd 100644 --- a/src/store/healthDashboardStore.ts +++ b/src/store/healthDashboardStore.ts @@ -1,11 +1,4 @@ -/** - * healthDashboardStore — Zustand store for the real-time health dashboard. - * - * Holds the latest health snapshot, active alerts, user-configured thresholds, - * and polling state. Intentionally NOT persisted — always starts fresh. - */ - -import { create } from 'zustand'; +import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { @@ -15,33 +8,29 @@ import { MetricAlert, } from '../services/healthMetrics'; import { shallowDiff } from '../utils/stateDiff'; - -// ─── Types ──────────────────────────────────────────────────────────────────── +import { isServiceDegraded, HEALTH_TO_FEATURE_MAP, ServiceName } from '../config/degradationConfig'; +import { useFeatureFlagStore } from './featureFlagStore'; export type DashboardStatus = 'idle' | 'polling' | 'error'; +export interface ServiceHealthStatus { + service: ServiceName; + status: string; +} + interface HealthDashboardState { - // Data snapshot: HealthSnapshot | null; alerts: MetricAlert[]; thresholds: AlertThresholds; - - // UI state + serviceHealthStatuses: ServiceHealthStatus[]; status: DashboardStatus; lastUpdated: number | null; - /** Timestamp of last successful data read (cache or network) */ lastChecked: number | null; - /** Timestamp of last actual network API call */ lastNetworkCheck: number | null; - /** How often to refresh in ms */ refreshIntervalMs: number; - /** Whether auto-refresh is active */ isAutoRefresh: boolean; - /** Dismissed alert IDs (cleared on next full refresh) */ dismissedAlertIds: Set; - - // Actions - setSnapshot: (snapshot: HealthSnapshot) => void; + setSnapshot: (snapshot: HealthSnapshot, serviceStatuses?: ServiceHealthStatus[]) => void; setAlerts: (alerts: MetricAlert[]) => void; setThresholds: (thresholds: Partial) => void; setStatus: (status: DashboardStatus) => void; @@ -54,38 +43,69 @@ interface HealthDashboardState { reset: () => void; } -// ─── Initial state ──────────────────────────────────────────────────────────── - const initialState = { snapshot: null, alerts: [], thresholds: DEFAULT_THRESHOLDS, + serviceHealthStatuses: [] as ServiceHealthStatus[], status: 'idle' as DashboardStatus, lastUpdated: null, lastChecked: null, lastNetworkCheck: null, - refreshIntervalMs: 10_000, // 10 seconds default + refreshIntervalMs: 10_000, isAutoRefresh: true, dismissedAlertIds: new Set(), }; -// ─── Store ──────────────────────────────────────────────────────────────────── +function applyDegradationFlags(serviceStatuses: ServiceHealthStatus[]): void { + const flagStore = useFeatureFlagStore.getState(); + for (const { service, status } of serviceStatuses) { + const flagEntries = HEALTH_TO_FEATURE_MAP[service]; + if (!flagEntries) continue; + const degraded = isServiceDegraded(status); + for (const entry of flagEntries) { + if (entry.adminOverride) continue; + const currentDef = flagStore.getDefinition(entry.flagKey); + const updatedDef = { ...(currentDef ?? {}), enabled: !degraded }; + useFeatureFlagStore.setState(state => ({ + flags: { + ...state.flags, + flags: { ...state.flags.flags, [entry.flagKey]: updatedDef }, + }, + })); + } + } +} export const useHealthDashboardStore = create()( devtools( set => ({ ...initialState, - setSnapshot: snapshot => + setSnapshot: (snapshot, serviceStatuses) => set( state => { if (!state.snapshot && !snapshot) return state; - if (!state.snapshot) return { snapshot, lastUpdated: Date.now() }; - const diff = shallowDiff(state.snapshot, snapshot); - if (!diff) return state; + let nextSnapshot: HealthSnapshot; + if (!state.snapshot) { + nextSnapshot = snapshot; + } else { + const diff = shallowDiff(state.snapshot, snapshot); + if (!diff) { + if (serviceStatuses && serviceStatuses.length > 0) { + applyDegradationFlags(serviceStatuses); + } + return state; + } + nextSnapshot = { ...state.snapshot, ...diff } as HealthSnapshot; + } + if (serviceStatuses && serviceStatuses.length > 0) { + applyDegradationFlags(serviceStatuses); + } return { - snapshot: { ...state.snapshot, ...diff } as HealthSnapshot, + snapshot: nextSnapshot, lastUpdated: Date.now(), + serviceHealthStatuses: serviceStatuses ?? state.serviceHealthStatuses, }; }, false, @@ -98,7 +118,7 @@ export const useHealthDashboardStore = create()( set( state => { const diff = shallowDiff(state.thresholds, partial); - if (!diff) return state; // Return unchanged state to bypass allocation + if (!diff) return state; return { thresholds: { ...state.thresholds, ...diff } }; }, false, @@ -141,16 +161,22 @@ export const useHealthDashboardStore = create()( ) ); -// ─── Selectors ──────────────────────────────────────────────────────────────── - -/** Returns only non-dismissed alerts */ export const selectVisibleAlerts = (state: HealthDashboardState): MetricAlert[] => state.alerts.filter(a => !state.dismissedAlertIds.has(a.id)); -/** Returns the highest severity across all visible alerts */ -export const selectOverallStatus = (state: HealthDashboardState): 'ok' | 'warning' | 'critical' => { +export const selectOverallStatus = ( + state: HealthDashboardState +): 'ok' | 'warning' | 'critical' => { const visible = selectVisibleAlerts(state); if (visible.some(a => a.severity === 'critical')) return 'critical'; if (visible.some(a => a.severity === 'warning')) return 'warning'; return 'ok'; }; + +export const selectIsServiceDegraded = + (service: ServiceName) => + (state: HealthDashboardState): boolean => { + const entry = state.serviceHealthStatuses.find(s => s.service === service); + if (!entry) return false; + return isServiceDegraded(entry.status); + }; diff --git a/tests/store/healthDashboardStore.test.ts b/tests/store/healthDashboardStore.test.ts new file mode 100644 index 00000000..a26d3649 --- /dev/null +++ b/tests/store/healthDashboardStore.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { useHealthDashboardStore, selectIsServiceDegraded } from '../../src/store/healthDashboardStore'; +import { useFeatureFlagStore } from '../../src/store/featureFlagStore'; +import { HEALTH_TO_FEATURE_MAP } from '../../src/config/degradationConfig'; + +function makeSnapshot(overrides = {}) { + return { + capturedAt: Date.now(), + crashCount: 0, errorCount: 0, crashRate: 0, errorRatePerMinute: 0, + apiLatencyP50: 50, apiLatencyP95: 100, apiLatencyP99: 200, + apiCallCount: 10, apiErrorCount: 0, apiErrorRate: 0, + activeSessions: 5, totalSessionsInWindow: 5, + fps: 60, jsBusyRatio: 0.1, isOnline: true, networkType: 'wifi', + ...overrides, + }; +} + +beforeEach(() => { + useHealthDashboardStore.getState().reset(); + useFeatureFlagStore.setState(state => ({ + flags: { + ...state.flags, + flags: { + ...state.flags.flags, + streaming_courses: { enabled: true }, + payment_form: { enabled: true }, + }, + }, + })); +}); + +describe('Health-check to Feature Flag auto-disable', () => { + + describe('Degradation', () => { + it('disables streaming_courses when streaming is degraded', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'degraded' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(false); + }); + + it('disables streaming_courses when streaming status is down', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'down' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(false); + }); + + it('disables payment_form when payment is degraded', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'payment', status: 'degraded' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('payment_form', true)).toBe(false); + }); + }); + + describe('Fallback UI state', () => { + it('selectIsServiceDegraded returns true for a degraded service', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'degraded' }, + ]); + expect( + selectIsServiceDegraded('streaming')(useHealthDashboardStore.getState()) + ).toBe(true); + }); + + it('selectIsServiceDegraded returns false for a healthy service', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'healthy' }, + ]); + expect( + selectIsServiceDegraded('streaming')(useHealthDashboardStore.getState()) + ).toBe(false); + }); + + it('selectIsServiceDegraded returns false when no status recorded yet', () => { + expect( + selectIsServiceDegraded('streaming')(useHealthDashboardStore.getState()) + ).toBe(false); + }); + }); + + describe('Recovery', () => { + it('re-enables streaming_courses after recovery', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'degraded' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(false); + + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'healthy' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(true); + }); + + it('re-enables payment_form after payment service recovers', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'payment', status: 'down' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('payment_form', true)).toBe(false); + + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'payment', status: 'healthy' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('payment_form', true)).toBe(true); + }); + + it('only re-enables the recovered service, not unrelated flags', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'degraded' }, + { service: 'payment', status: 'degraded' }, + ]); + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'healthy' }, + { service: 'payment', status: 'degraded' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(true); + expect(useFeatureFlagStore.getState().isEnabled('payment_form', true)).toBe(false); + }); + }); + + describe('Admin override', () => { + it('does NOT disable the flag when adminOverride is true', () => { + const original = HEALTH_TO_FEATURE_MAP.streaming[0].adminOverride; + HEALTH_TO_FEATURE_MAP.streaming[0].adminOverride = true; + try { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot(), [ + { service: 'streaming', status: 'degraded' }, + ]); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(true); + } finally { + HEALTH_TO_FEATURE_MAP.streaming[0].adminOverride = original; + } + }); + }); + + describe('Backward compatibility', () => { + it('does not crash when setSnapshot is called without serviceStatuses', () => { + useHealthDashboardStore.getState().setSnapshot(makeSnapshot()); + expect(useFeatureFlagStore.getState().isEnabled('streaming_courses', true)).toBe(true); + expect(useFeatureFlagStore.getState().isEnabled('payment_form', true)).toBe(true); + }); + }); + +});