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