From ea745338baf7592e5f9944f3de7d2ae648eb5247 Mon Sep 17 00:00:00 2001 From: favour-GL Date: Sun, 28 Jun 2026 14:51:59 +0100 Subject: [PATCH] feat:HealthDashboard service checks timeout silently --- package-lock.json | 95 ++++++++ src/components/mobile/ServiceHealthPanel.tsx | 120 ++++++++++ src/components/mobile/ServiceStatusBadge.tsx | 126 ++++++++++ .../api/axiosCircuitBreakerInterceptor.ts | 112 +++++++++ src/services/api/circuitBreaker.ts | 214 +++++++++++++++++ src/store/healthDashboardStore.ts | 222 ++++++++++++++++-- src/types/serviceHealth.ts | 29 +++ 7 files changed, 903 insertions(+), 15 deletions(-) create mode 100644 src/components/mobile/ServiceHealthPanel.tsx create mode 100644 src/components/mobile/ServiceStatusBadge.tsx create mode 100644 src/services/api/axiosCircuitBreakerInterceptor.ts create mode 100644 src/services/api/circuitBreaker.ts create mode 100644 src/types/serviceHealth.ts diff --git a/package-lock.json b/package-lock.json index 33937ab..03732cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "expo-battery": "^55.0.13", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~14.0.1", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.23", @@ -1580,6 +1581,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -1595,6 +1597,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -1610,6 +1613,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1625,6 +1629,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1640,6 +1645,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1655,6 +1661,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1670,6 +1677,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1685,6 +1693,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1700,6 +1709,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1715,6 +1725,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1730,6 +1741,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1745,6 +1757,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1760,6 +1773,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1775,6 +1789,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1790,6 +1805,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1805,6 +1821,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1820,6 +1837,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1835,6 +1853,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1850,6 +1869,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1865,6 +1885,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1880,6 +1901,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1895,6 +1917,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1910,6 +1933,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -1925,6 +1949,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1940,6 +1965,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1955,6 +1981,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2360,6 +2387,21 @@ "node": ">=10" } }, + "node_modules/@expo/image-utils/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@expo/json-file": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", @@ -9798,6 +9840,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-device": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", @@ -21518,156 +21572,182 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -21976,6 +22056,13 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "optional": true, + "peer": true } } }, @@ -27428,6 +27515,14 @@ "@expo/env": "~2.0.8" } }, + "expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "requires": { + "base64-js": "^1.3.0" + } + }, "expo-device": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", diff --git a/src/components/mobile/ServiceHealthPanel.tsx b/src/components/mobile/ServiceHealthPanel.tsx new file mode 100644 index 0000000..3d953dd --- /dev/null +++ b/src/components/mobile/ServiceHealthPanel.tsx @@ -0,0 +1,120 @@ +/** + * src/components/mobile/ServiceHealthPanel.tsx + * + * Drop-in panel for HealthDashboard that shows per-service status rows. + * Renders ServiceStatusBadge for each service, including the circuit + * breaker chip when the circuit is not CLOSED. + * + * Usage (inside HealthDashboard): + * import { ServiceHealthPanel } from './ServiceHealthPanel'; + * ... + * + */ + +import { useHealthDashboardStore } from '@store/healthDashboardStore'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { ServiceStatus } from '../../types/serviceHealth'; +import { ServiceStatusBadge } from './ServiceStatusBadge'; + +// ─── Label overrides ─────────────────────────────────────────────────────── + +const SERVICE_LABELS: Record = { + auth: 'Auth', + sync: 'Sync', + notifications: 'Notifications', + payments: 'Payments', +}; + +const STATUS_DESCRIPTIONS: Record = { + ok: 'Healthy', + timeout: 'Slow response', + degraded: 'Degraded', + error: 'Error', + unknown: 'Not checked', +}; + +// ─── Component ───────────────────────────────────────────────────────────── + +export const ServiceHealthPanel: React.FC = () => { + const statuses = useHealthDashboardStore(s => s.serviceHealthStatuses); + + if (statuses.length === 0) return null; + + return ( + + Service Health + + {statuses.map(entry => ( + + {/* Service name + description */} + + + {SERVICE_LABELS[entry.service] ?? entry.service} + + + {STATUS_DESCRIPTIONS[entry.status] ?? entry.status} + {entry.status === 'timeout' && entry.consecutiveTimeouts != null && ( + entry.consecutiveTimeouts > 1 + ? ` · ${entry.consecutiveTimeouts}× consecutive` + : '' + )} + + + + {/* Status badge + circuit chip */} + + + ))} + + ); +}; + +// ─── Styles ──────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOpacity: 0.04, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 1, + }, + heading: { + fontSize: 13, + fontWeight: '700', + color: '#64748b', + letterSpacing: 0.8, + textTransform: 'uppercase', + marginBottom: 12, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#f1f5f9', + }, + labelCol: { + flex: 1, + marginRight: 12, + }, + serviceName: { + fontSize: 14, + fontWeight: '600', + color: '#0f172a', + }, + description: { + fontSize: 12, + color: '#64748b', + marginTop: 2, + }, +}); \ No newline at end of file diff --git a/src/components/mobile/ServiceStatusBadge.tsx b/src/components/mobile/ServiceStatusBadge.tsx new file mode 100644 index 0000000..f825c09 --- /dev/null +++ b/src/components/mobile/ServiceStatusBadge.tsx @@ -0,0 +1,126 @@ +/** + * src/components/mobile/ServiceStatusBadge.tsx + * + * Renders a coloured pill for a ServiceStatus value. + * + * Color mapping (matches spec): + * ok → green (#22c55e) + * timeout → orange (#f97316) ← new — between green and red + * degraded → red (#ef4444) + * error → red (#ef4444) + * unknown → grey (#94a3b8) ← only for not-yet-run checks + * + * Also renders a circuit-breaker chip when circuitState is provided. + */ + +import React from 'react'; +import { StyleSheet, Text, View, ViewStyle } from 'react-native'; +import { CircuitState, ServiceStatus } from '../../types/serviceHealth'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +interface Props { + status: ServiceStatus; + circuitState?: CircuitState; + /** Override the display label (defaults to capitalised status string). */ + label?: string; + style?: ViewStyle; +} + +// ─── Color config ────────────────────────────────────────────────────────── + +const STATUS_COLORS: Record = { + ok: { bg: '#dcfce7', text: '#15803d', dot: '#22c55e' }, + timeout: { bg: '#ffedd5', text: '#c2410c', dot: '#f97316' }, + degraded: { bg: '#fee2e2', text: '#b91c1c', dot: '#ef4444' }, + error: { bg: '#fee2e2', text: '#b91c1c', dot: '#ef4444' }, + unknown: { bg: '#f1f5f9', text: '#64748b', dot: '#94a3b8' }, +}; + +const CIRCUIT_COLORS: Record = { + CLOSED: { bg: '#dcfce7', text: '#15803d' }, + HALF_OPEN: { bg: '#fef9c3', text: '#a16207' }, + OPEN: { bg: '#fee2e2', text: '#b91c1c' }, +}; + +const CIRCUIT_LABELS: Record = { + CLOSED: 'Circuit OK', + HALF_OPEN: 'Probing…', + OPEN: 'Circuit Open', +}; + +// ─── Component ───────────────────────────────────────────────────────────── + +export const ServiceStatusBadge: React.FC = ({ + status, + circuitState, + label, + style, +}) => { + const colors = STATUS_COLORS[status] ?? STATUS_COLORS.unknown; + const displayLabel = label ?? (status.charAt(0).toUpperCase() + status.slice(1)); + + return ( + + {/* Status pill */} + + + {displayLabel} + + + {/* Circuit breaker chip — only shown when relevant */} + {circuitState && circuitState !== 'CLOSED' && ( + + + {CIRCUIT_LABELS[circuitState]} + + + )} + + ); +}; + +// ─── Styles ──────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 6, + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 9999, + gap: 5, + }, + dot: { + width: 7, + height: 7, + borderRadius: 9999, + }, + pillText: { + fontSize: 12, + fontWeight: '600', + letterSpacing: 0.2, + }, + circuitChip: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + circuitText: { + fontSize: 11, + fontWeight: '500', + }, +}); \ No newline at end of file diff --git a/src/services/api/axiosCircuitBreakerInterceptor.ts b/src/services/api/axiosCircuitBreakerInterceptor.ts new file mode 100644 index 0000000..33e42eb --- /dev/null +++ b/src/services/api/axiosCircuitBreakerInterceptor.ts @@ -0,0 +1,112 @@ +/** + * src/services/api/axiosCircuitBreakerInterceptor.ts + * + * Plugs the circuit breaker registry into the axios pipeline. + * + * Call `installCircuitBreakerInterceptor(apiClient)` once, right after + * the existing interceptors are installed in axios.config.ts. + * + * How it works: + * REQUEST side — if the breaker for the matched group is OPEN, + * the request is fast-failed immediately with CircuitOpenError (no network call). + * + * RESPONSE side — records success/failure into the breaker so the CLOSED → OPEN + * threshold is maintained independently of the health-check runner. + * + * Endpoint → group mapping: + * /auth/* → 'auth' + * /sync/* → 'sync' + * /notifications/* → 'notifications' + * /payments/* → 'payments' + * everything else → no breaker applied + */ + +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; +import { appLogger } from '../../utils/logger'; +import { circuitBreakerRegistry, CircuitOpenError } from './circuitBreaker'; + +// ─── Endpoint group routing ──────────────────────────────────────────────── + +const ENDPOINT_GROUPS: Array<{ pattern: RegExp; group: string }> = [ + { pattern: /\/auth\//, group: 'auth' }, + { pattern: /\/sync\//, group: 'sync' }, + { pattern: /\/notifications\//, group: 'notifications' }, + { pattern: /\/payments?\//, group: 'payments' }, +]; + +function resolveGroup(url: string | undefined): string | null { + if (!url) return null; + for (const { pattern, group } of ENDPOINT_GROUPS) { + if (pattern.test(url)) return group; + } + return null; +} + +// ─── Interceptor installer ───────────────────────────────────────────────── + +export function installCircuitBreakerInterceptor(client: AxiosInstance): void { + // ── Request interceptor — fast-fail when circuit is OPEN ───────────────── + client.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + const group = resolveGroup(config.url); + if (!group) return config; + + const breaker = circuitBreakerRegistry.get(group); + + // tick() is internal; we call execute with a no-op to let the FSM + // decide whether to throw. We piggyback on the existing execute path + // by attaching the group to the config so the response interceptor + // can record the outcome. + if (breaker.getState() === 'OPEN') { + appLogger.warnSync( + `[CircuitBreaker] Fast-failing "${config.url}" — circuit OPEN for group "${group}"` + ); + return Promise.reject(new CircuitOpenError(group)); + } + + // Tag the config so the response interceptor knows which group to update + (config as InternalAxiosRequestConfig & { _circuitGroup?: string })._circuitGroup = group; + return config; + }, + (error: unknown) => Promise.reject(error) + ); + + // ── Response interceptor — record success / failure ─────────────────────── + client.interceptors.response.use( + response => { + const group = ( + response.config as InternalAxiosRequestConfig & { _circuitGroup?: string } + )._circuitGroup; + if (group) { + // A successful response closes the success path in the breaker. + // We call execute with a resolved promise so onSuccess() runs. + circuitBreakerRegistry.get(group).execute(() => Promise.resolve()).catch(() => { + // onSuccess already ran — ignore the resolved value + }); + } + return response; + }, + async (error: AxiosError) => { + // Don't double-count CircuitOpenError as a failure + if (error instanceof CircuitOpenError) { + return Promise.reject(error); + } + + const group = ( + error.config as (InternalAxiosRequestConfig & { _circuitGroup?: string }) | undefined + )?._circuitGroup; + + if (group && error.response) { + // Only count server errors (5xx) as breaker failures. + // 4xx are client errors and should not open the circuit. + if (error.response.status >= 500) { + circuitBreakerRegistry.get(group).execute(() => Promise.reject(error)).catch(() => { + // failure recorded — error will propagate normally below + }); + } + } + + return Promise.reject(error); + } + ); +} \ No newline at end of file diff --git a/src/services/api/circuitBreaker.ts b/src/services/api/circuitBreaker.ts new file mode 100644 index 0000000..ac9b6f4 --- /dev/null +++ b/src/services/api/circuitBreaker.ts @@ -0,0 +1,214 @@ +/** + * src/services/api/circuitBreaker.ts + * + * Circuit breaker pattern per endpoint group. + * + * State machine: + * CLOSED ──(5 failures / 60 s)──► OPEN ──(30 s)──► HALF_OPEN + * HALF_OPEN ──(probe ok)──► CLOSED + * HALF_OPEN ──(probe fail)──► OPEN + * + * Usage: + * const breaker = circuitBreakerRegistry.get('auth'); + * breaker.execute(() => apiClient.get('/auth/me')); + */ + +import { CircuitState } from '../../types/serviceHealth'; +import { appLogger } from '../../utils/logger'; + +// ─── Config ──────────────────────────────────────────────────────────────── + +const FAILURE_THRESHOLD = 5; // failures within the window to open +const FAILURE_WINDOW_MS = 60_000; // 60-second rolling window +const RECOVERY_WINDOW_MS = 30_000; // wait before HALF_OPEN probe + +// ─── Errors ──────────────────────────────────────────────────────────────── + +export class CircuitOpenError extends Error { + readonly code = 'CIRCUIT_OPEN'; + readonly service: string; + + constructor(service: string) { + super(`Circuit breaker OPEN for service "${service}" — fast-failing request`); + this.name = 'CircuitOpenError'; + this.service = service; + } +} + +// ─── Listener type ───────────────────────────────────────────────────────── + +export type CircuitStateListener = (service: string, state: CircuitState) => void; + +// ─── CircuitBreaker ──────────────────────────────────────────────────────── + +export class CircuitBreaker { + private state: CircuitState = 'CLOSED'; + private failureTimestamps: number[] = []; + private openedAt: number | null = null; + private probeInFlight = false; + private listeners: CircuitStateListener[] = []; + + constructor(readonly service: string) {} + + // ── Public API ──────────────────────────────────────────────────────────── + + getState(): CircuitState { + return this.state; + } + + /** + * Wraps an async operation with circuit-breaker logic. + * Throws CircuitOpenError immediately when state is OPEN (and the recovery + * window has not elapsed) so callers can return cached data or surface a + * graceful error without waiting for a timeout. + */ + async execute(fn: () => Promise): Promise { + this.tick(); // re-evaluate state before every call + + if (this.state === 'OPEN') { + throw new CircuitOpenError(this.service); + } + + // HALF_OPEN: allow exactly one probe at a time + if (this.state === 'HALF_OPEN' && this.probeInFlight) { + throw new CircuitOpenError(this.service); + } + + if (this.state === 'HALF_OPEN') { + this.probeInFlight = true; + } + + try { + const result = await fn(); + this.onSuccess(); + return result; + } catch (err) { + this.onFailure(); + throw err; + } finally { + if (this.state === 'HALF_OPEN') { + this.probeInFlight = false; + } + } + } + + /** Subscribe to state-change events (used by healthDashboardStore). */ + addListener(listener: CircuitStateListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter(l => l !== listener); + }; + } + + // ── Internal ────────────────────────────────────────────────────────────── + + /** + * Re-evaluate OPEN → HALF_OPEN transition based on elapsed time. + * Called at the start of every execute() to handle recovery passively + * without needing a background timer. + */ + private tick(): void { + if (this.state === 'OPEN' && this.openedAt !== null) { + if (Date.now() - this.openedAt >= RECOVERY_WINDOW_MS) { + this.transition('HALF_OPEN'); + } + } + } + + private onSuccess(): void { + if (this.state === 'HALF_OPEN') { + this.failureTimestamps = []; + this.openedAt = null; + this.transition('CLOSED'); + } + // CLOSED: success doesn't reset timestamps (window handles staleness) + } + + private onFailure(): void { + const now = Date.now(); + + if (this.state === 'HALF_OPEN') { + // Probe failed — back to OPEN, reset the recovery clock + this.openedAt = now; + this.transition('OPEN'); + return; + } + + // Prune timestamps outside the rolling window + this.failureTimestamps = this.failureTimestamps.filter( + t => now - t < FAILURE_WINDOW_MS + ); + this.failureTimestamps.push(now); + + if ( + this.state === 'CLOSED' && + this.failureTimestamps.length >= FAILURE_THRESHOLD + ) { + this.openedAt = now; + this.transition('OPEN'); + } + } + + private transition(next: CircuitState): void { + if (this.state === next) return; + const prev = this.state; + this.state = next; + + appLogger.warnSync( + `[CircuitBreaker] "${this.service}": ${prev} → ${next}`, + { + service: this.service, + failureCount: this.failureTimestamps.length, + openedAt: this.openedAt, + } + ); + + for (const listener of this.listeners) { + try { + listener(this.service, next); + } catch { + // listeners must never crash the breaker + } + } + } +} + +// ─── Registry ────────────────────────────────────────────────────────────── + +/** + * Singleton registry — one CircuitBreaker per endpoint group. + * Groups mirror the health-check service names so the dashboard store + * can read circuit state directly. + */ +class CircuitBreakerRegistry { + private breakers = new Map(); + + get(service: string): CircuitBreaker { + let breaker = this.breakers.get(service); + if (!breaker) { + breaker = new CircuitBreaker(service); + this.breakers.set(service, breaker); + } + return breaker; + } + + all(): Map { + return this.breakers; + } + + /** Snapshot of every known breaker's state — for health dashboard. */ + getStates(): Record { + const result: Record = {}; + for (const [service, breaker] of this.breakers) { + result[service] = breaker.getState(); + } + return result; + } +} + +export const circuitBreakerRegistry = new CircuitBreakerRegistry(); + +// Pre-register the four service groups referenced by the health checks +(['auth', 'sync', 'notifications', 'payments'] as const).forEach(s => + circuitBreakerRegistry.get(s) +); \ No newline at end of file diff --git a/src/store/healthDashboardStore.ts b/src/store/healthDashboardStore.ts index f94a27b..1d55ee6 100644 --- a/src/store/healthDashboardStore.ts +++ b/src/store/healthDashboardStore.ts @@ -1,22 +1,42 @@ -import { create } from 'zustand'; +/** + * src/store/slices/healthDashboardStore.ts + * + * Changes vs original: + * - ServiceHealthStatus imported from shared types (status is now ServiceStatus) + * - Tracks consecutiveTimeouts per service; escalates to 'degraded' after 3 + * - Integrates circuit breaker state from circuitBreakerRegistry + * - selectIsServiceDegraded now covers both 'degraded' status AND open circuit + */ + +import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; +import { HEALTH_TO_FEATURE_MAP, isServiceDegraded, ServiceName } from '../config/degradationConfig'; +import { circuitBreakerRegistry, CircuitOpenError } from '../services/api/circuitBreaker'; import { AlertThresholds, DEFAULT_THRESHOLDS, HealthSnapshot, MetricAlert, } from '../services/healthMetrics'; +import { CircuitState, ServiceHealthStatus, ServiceStatus } from '../types/serviceHealth'; +import { appLogger } from '../utils/logger'; import { shallowDiff } from '../utils/stateDiff'; -import { isServiceDegraded, HEALTH_TO_FEATURE_MAP, ServiceName } from '../config/degradationConfig'; import { useFeatureFlagStore } from './featureFlagStore'; +export type { ServiceHealthStatus }; + export type DashboardStatus = 'idle' | 'polling' | 'error'; -export interface ServiceHealthStatus { - service: ServiceName; - status: string; -} +// ─── Constants ───────────────────────────────────────────────────────────── + +/** Number of consecutive timeouts before a service is escalated to 'degraded'. */ +const TIMEOUT_ESCALATION_THRESHOLD = 3; + +/** Per-request timeout budget for health checks (ms). */ +const HEALTH_CHECK_TIMEOUT_MS = 5_000; + +// ─── State shape ─────────────────────────────────────────────────────────── interface HealthDashboardState { snapshot: HealthSnapshot | null; @@ -30,6 +50,8 @@ interface HealthDashboardState { refreshIntervalMs: number; isAutoRefresh: boolean; dismissedAlertIds: Set; + + // Actions setSnapshot: (snapshot: HealthSnapshot, serviceStatuses?: ServiceHealthStatus[]) => void; setAlerts: (alerts: MetricAlert[]) => void; setThresholds: (thresholds: Partial) => void; @@ -41,6 +63,15 @@ interface HealthDashboardState { dismissAlert: (id: string) => void; clearDismissed: () => void; reset: () => void; + + // Health check runner (Issue 1 — timeout distinction) + runHealthCheck: ( + service: ServiceName, + checkFn: () => Promise + ) => Promise; + + // Circuit breaker state sync (Issue 2) + syncCircuitBreakerStates: () => void; } const initialState = { @@ -57,10 +88,12 @@ const initialState = { dismissedAlertIds: new Set(), }; +// ─── Helpers ─────────────────────────────────────────────────────────────── + function applyDegradationFlags(serviceStatuses: ServiceHealthStatus[]): void { const flagStore = useFeatureFlagStore.getState(); for (const { service, status } of serviceStatuses) { - const flagEntries = HEALTH_TO_FEATURE_MAP[service]; + const flagEntries = HEALTH_TO_FEATURE_MAP[service as ServiceName]; if (!flagEntries) continue; const degraded = isServiceDegraded(status); for (const entry of flagEntries) { @@ -77,41 +110,188 @@ function applyDegradationFlags(serviceStatuses: ServiceHealthStatus[]): void { } } +/** + * Merge an updated ServiceHealthStatus into the existing array. + * Preserves consecutiveTimeouts from the previous entry when the status + * is still 'timeout'; resets it on any other outcome. + */ +function mergeServiceStatus( + existing: ServiceHealthStatus[], + next: ServiceHealthStatus +): ServiceHealthStatus[] { + const idx = existing.findIndex(s => s.service === next.service); + const prev = existing[idx]; + + let consecutiveTimeouts = 0; + if (next.status === 'timeout') { + consecutiveTimeouts = (prev?.consecutiveTimeouts ?? 0) + 1; + } + + // Escalate to 'degraded' after threshold consecutive timeouts + const resolvedStatus: ServiceStatus = + consecutiveTimeouts >= TIMEOUT_ESCALATION_THRESHOLD ? 'degraded' : next.status; + + const merged: ServiceHealthStatus = { + ...next, + status: resolvedStatus, + consecutiveTimeouts, + lastCheckedAt: Date.now(), + }; + + if (idx === -1) return [...existing, merged]; + const updated = [...existing]; + updated[idx] = merged; + return updated; +} + +// ─── Store ───────────────────────────────────────────────────────────────── + export const useHealthDashboardStore = create()( devtools( - set => ({ + (set, get) => ({ ...initialState, + // ── setSnapshot ────────────────────────────────────────────────────── + setSnapshot: (snapshot, serviceStatuses) => set( state => { if (!state.snapshot && !snapshot) 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); - } + if (serviceStatuses?.length) applyDegradationFlags(serviceStatuses); return state; } nextSnapshot = { ...state.snapshot, ...diff } as HealthSnapshot; } - if (serviceStatuses && serviceStatuses.length > 0) { - applyDegradationFlags(serviceStatuses); + + // Merge each incoming service status through the timeout-tracking + // helper so consecutiveTimeouts is maintained correctly. + let nextStatuses = state.serviceHealthStatuses; + if (serviceStatuses?.length) { + for (const s of serviceStatuses) { + nextStatuses = mergeServiceStatus(nextStatuses, s); + } + applyDegradationFlags(nextStatuses); } + return { snapshot: nextSnapshot, lastUpdated: Date.now(), - serviceHealthStatuses: serviceStatuses ?? state.serviceHealthStatuses, + serviceHealthStatuses: nextStatuses, }; }, false, 'setSnapshot' ), + // ── runHealthCheck (Issue 1) ───────────────────────────────────────── + // + // Wraps a caller-supplied async health probe with: + // 1. AbortController timeout → status 'timeout' + // 2. Consecutive timeout escalation → status 'degraded' + // 3. Non-timeout errors → status 'error' + // 4. Success → status 'ok' + // + // Also routes the call through the per-service circuit breaker (Issue 2). + + runHealthCheck: async (service, checkFn) => { + const breaker = circuitBreakerRegistry.get(service); + + const wrappedCheck = async () => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + HEALTH_CHECK_TIMEOUT_MS + ); + + try { + await checkFn(); + clearTimeout(timeoutHandle); + } catch (err) { + clearTimeout(timeoutHandle); + throw err; + } + }; + + let nextStatus: ServiceStatus; + + try { + await breaker.execute(wrappedCheck); + nextStatus = 'ok'; + } catch (err) { + if (err instanceof CircuitOpenError) { + // Circuit is open — treat as degraded without running the probe + nextStatus = 'degraded'; + appLogger.warnSync(`[HealthCheck] Circuit open for "${service}" — skipping probe`); + } else if ( + err instanceof Error && + (err.name === 'AbortError' || err.message?.toLowerCase().includes('abort')) + ) { + nextStatus = 'timeout'; + appLogger.warnSync(`[HealthCheck] Timeout for "${service}"`, { service }); + } else { + nextStatus = 'error'; + appLogger.errorSync(`[HealthCheck] Error for "${service}"`, err as Error, { service }); + } + } + + // Write the result back into the store via mergeServiceStatus + set( + state => { + const circuitState = breaker.getState(); + const incoming: ServiceHealthStatus = { + service, + status: nextStatus, + circuitState, + }; + const nextStatuses = mergeServiceStatus(state.serviceHealthStatuses, incoming); + applyDegradationFlags(nextStatuses); + return { serviceHealthStatuses: nextStatuses }; + }, + false, + 'runHealthCheck' + ); + }, + + // ── syncCircuitBreakerStates (Issue 2) ────────────────────────────── + // + // Pulls the current state from every registered circuit breaker and + // patches it onto the matching serviceHealthStatuses entry without + // disturbing the status or consecutiveTimeouts fields. + + syncCircuitBreakerStates: () => { + const states = circuitBreakerRegistry.getStates(); + set( + state => { + let updated = [...state.serviceHealthStatuses]; + for (const [service, circuitState] of Object.entries(states)) { + const idx = updated.findIndex(s => s.service === service); + if (idx === -1) { + // Service not yet in the list — seed with 'unknown' + updated.push({ service, status: 'unknown', circuitState }); + } else { + updated[idx] = { ...updated[idx], circuitState }; + // If the circuit just opened, ensure status reflects degraded + if (circuitState === 'OPEN' && updated[idx].status !== 'degraded') { + updated[idx] = { ...updated[idx], status: 'degraded' }; + } + } + } + return { serviceHealthStatuses: updated }; + }, + false, + 'syncCircuitBreakerStates' + ); + }, + + // ── Standard actions (unchanged) ───────────────────────────────────── + setAlerts: alerts => set({ alerts }, false, 'setAlerts'), setThresholds: partial => @@ -161,6 +341,8 @@ export const useHealthDashboardStore = create()( ) ); +// ─── Selectors ───────────────────────────────────────────────────────────── + export const selectVisibleAlerts = (state: HealthDashboardState): MetricAlert[] => state.alerts.filter(a => !state.dismissedAlertIds.has(a.id)); @@ -178,5 +360,15 @@ export const selectIsServiceDegraded = (state: HealthDashboardState): boolean => { const entry = state.serviceHealthStatuses.find(s => s.service === service); if (!entry) return false; - return isServiceDegraded(entry.status); + // Treat 'degraded', 'error', and an OPEN circuit as degraded + return ( + isServiceDegraded(entry.status) || + entry.circuitState === 'OPEN' + ); }; + +/** Returns the circuit breaker state for a service, or undefined if unknown. */ +export const selectCircuitState = + (service: string) => + (state: HealthDashboardState): CircuitState | undefined => + state.serviceHealthStatuses.find(s => s.service === service)?.circuitState; \ No newline at end of file diff --git a/src/types/serviceHealth.ts b/src/types/serviceHealth.ts new file mode 100644 index 0000000..3d92a3d --- /dev/null +++ b/src/types/serviceHealth.ts @@ -0,0 +1,29 @@ +/** + * src/types/serviceHealth.ts + * + * Shared types for service health status tracking. + * Referenced by healthDashboardStore, CircuitBreaker, and UI components. + */ + +/** Fine-grained status for a monitored service. */ +export type ServiceStatus = + | 'ok' // Responding within thresholds + | 'timeout' // Last check exceeded the timeout budget + | 'degraded' // 3+ consecutive timeouts, or circuit breaker open + | 'error' // Non-timeout failure (5xx, network error, etc.) + | 'unknown'; // Check has not run yet + +/** Circuit breaker FSM states. */ +export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +/** Per-service health entry stored in the dashboard store. */ +export interface ServiceHealthStatus { + service: string; + status: ServiceStatus; + /** ISO timestamp of the last completed check. */ + lastCheckedAt?: number; + /** Number of consecutive timeouts (resets on any non-timeout outcome). */ + consecutiveTimeouts?: number; + /** Current circuit breaker state for this service. */ + circuitState?: CircuitState; +} \ No newline at end of file