AI Service Reliability Monitor & Failover Switch v1.0.0

This commit is contained in:
Bun Bun
2026-06-15 06:24:05 +00:00
commit e08e26bbc5
21 changed files with 4935 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
// Background Service Worker - Health Monitoring Engine
import { ServiceId, SERVICE_CONFIG, MonitorState, ServiceStatus } from './types'
import { getState, saveState, updateServiceStatus } from './storage'
const HEALTH_CHECK_INTERVAL = 60 * 1000 // 60 seconds
const ALERT_COOLDOWN = 5 * 60 * 1000 // 5 minutes
// Real-time page health signals from content scripts
const pageHealthSignals: Record<ServiceId, 'healthy' | 'degraded' | 'down' | 'unknown'> = {
chatgpt: 'unknown',
claude: 'unknown',
gemini: 'unknown',
grok: 'unknown'
}
// Check a single service via fetch with timeout
async function checkServiceHealth(serviceId: ServiceId): Promise<Partial<ServiceStatus>> {
const config = SERVICE_CONFIG[serviceId]
const startTime = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
const response = await fetch(config.checkUrl, {
method: 'HEAD',
mode: 'no-cors',
signal: controller.signal
}).catch(() => null)
clearTimeout(timeout)
const responseTimeMs = Date.now() - startTime
// Check page signal too
const pageSignal = pageHealthSignals[serviceId]
if (pageSignal === 'down') {
return { health: 'down', responseTimeMs, lastChecked: Date.now(), message: 'Page reports errors' }
}
if (pageSignal === 'degraded') {
return { health: 'degraded', responseTimeMs, lastChecked: Date.now(), message: 'Page reports degraded performance' }
}
// If fetch succeeds and page is OK
if (responseTimeMs < 3000) {
return { health: 'healthy', responseTimeMs, lastChecked: Date.now(), message: null }
}
if (responseTimeMs < 8000) {
return { health: 'degraded', responseTimeMs, lastChecked: Date.now(), message: 'Slow response' }
}
return { health: 'down', responseTimeMs, lastChecked: Date.now(), message: 'Timeout' }
} catch (err) {
const pageSignal = pageHealthSignals[serviceId]
if (pageSignal === 'healthy') {
return { health: 'degraded', responseTimeMs: Date.now() - startTime, lastChecked: Date.now(), message: 'Health check blocked, page OK' }
}
return { health: 'down', responseTimeMs: null, lastChecked: Date.now(), message: 'Connection failed' }
}
}
// Run health checks for all services
async function runHealthChecks(): Promise<void> {
const services: ServiceId[] = ['chatgpt', 'claude', 'gemini', 'grok']
const checks = await Promise.allSettled(services.map(checkServiceHealth))
const state = await getState()
checks.forEach((result, index) => {
const serviceId = services[index]
if (result.status === 'fulfilled') {
state.services[serviceId] = { ...state.services[serviceId], ...result.value }
}
})
await saveState(state)
updateBadge(state)
await evaluateAlerts(state)
}
// Update badge based on overall health
function updateBadge(state: MonitorState): void {
const services = Object.values(state.services)
const downCount = services.filter(s => s.health === 'down').length
const degradedCount = services.filter(s => s.health === 'degraded').length
if (downCount > 0) {
chrome.action.setBadgeText({ text: downCount.toString() })
chrome.action.setBadgeBackgroundColor({ color: '#EF4444' }) // red
} else if (degradedCount > 0) {
chrome.action.setBadgeText({ text: degradedCount.toString() })
chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' }) // amber
} else {
chrome.action.setBadgeText({ text: '' })
}
}
// Evaluate and send alerts
async function evaluateAlerts(state: MonitorState): Promise<void> {
const now = Date.now()
// Skip if alert was sent recently
if (state.lastAlert && now - state.lastAlert < ALERT_COOLDOWN) return
const downServices = Object.values(state.services).filter(s => s.health === 'down')
const degradedServices = Object.values(state.services).filter(s => s.health === 'degraded')
const nearLimit = Object.values(state.services).filter(
s => s.usageLimit && s.usageLimit.percentUsed >= state.alertThreshold
)
if (downServices.length > 0) {
const names = downServices.map(s => s.name).join(', ')
await sendAlert(`AI Service Down`, `${names} is currently down. Switch to a backup provider?`, state)
} else if (degradedServices.length > 0) {
const names = degradedServices.map(s => s.name).join(', ')
await sendAlert(`AI Service Degraded`, `${names} is experiencing issues.`, state)
} else if (nearLimit.length > 0) {
const names = nearLimit.map(s => s.name).join(', ')
await sendAlert(`Usage Limit Warning`, `${names} is at ${state.alertThreshold}% of usage limit.`, state)
}
}
async function sendAlert(title: string, message: string, state: MonitorState): Promise<void> {
await chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon128.png',
title,
message
})
state.lastAlert = Date.now()
await saveState(state)
}
// Find best failover service
export function findFailoverService(state: MonitorState): ServiceId | null {
const candidates = Object.values(state.services)
.filter(s => s.id !== state.primaryService && s.health === 'healthy')
.sort((a, b) => (a.responseTimeMs ?? Infinity) - (b.responseTimeMs ?? Infinity))
return candidates.length > 0 ? candidates[0].id : null
}
// Listen for content script health signals
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HEALTH_SIGNAL') {
const serviceId = message.serviceId as ServiceId
const health = message.health as 'healthy' | 'degraded' | 'down'
if (pageHealthSignals[serviceId] !== undefined) {
pageHealthSignals[serviceId] = health
}
sendResponse({ ok: true })
}
if (message.type === 'GET_FAILOVER') {
getState().then(state => {
const failover = findFailoverService(state)
if (failover) {
const config = SERVICE_CONFIG[failover]
sendResponse({ ok: true, serviceId: failover, url: config.url })
} else {
sendResponse({ ok: false, error: 'No healthy backup available' })
}
})
return true // async response
}
if (message.type === 'GET_STATE') {
getState().then(state => {
sendResponse({ ok: true, state })
})
return true
}
return false
})
// Alarm-based periodic checks
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'healthCheck') {
runHealthChecks()
}
})
// Install / startup
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('healthCheck', { periodInMinutes: 1 })
runHealthChecks()
})
chrome.runtime.onStartup.addListener(() => {
runHealthChecks()
})
// Listen for tab updates to detect AI service pages
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) {
const serviceId = Object.entries(SERVICE_CONFIG).find(([, config]) =>
tab.url!.includes(config.url.replace('https://', ''))
)?.[0] as ServiceId | undefined
if (serviceId) {
// Inject content script for fresh health detection
chrome.tabs.sendMessage(tabId, { type: 'PING' }).catch(() => {
// Content script not ready
})
}
}
})
// Export for testing
export { runHealthChecks, updateBadge, checkServiceHealth }
+152
View File
@@ -0,0 +1,152 @@
// Content Script - Detects AI service errors and health signals on page
import { ServiceId } from './types'
declare const chrome: typeof import('chrome')
function detectServiceId(): ServiceId | null {
const host = window.location.host
if (host.includes('chatgpt.com') || host.includes('openai.com')) return 'chatgpt'
if (host.includes('claude.ai')) return 'claude'
if (host.includes('gemini.google.com')) return 'gemini'
if (host.includes('grok.com')) return 'grok'
return null
}
let lastSignal: 'healthy' | 'degraded' | 'down' = 'healthy'
let errorCount = 0
let slowResponseCount = 0
let lastResponseTime = 0
function sendHealthSignal(health: 'healthy' | 'degraded' | 'down') {
if (health === lastSignal) return
lastSignal = health
const serviceId = detectServiceId()
if (!serviceId) return
chrome.runtime.sendMessage({ type: 'HEALTH_SIGNAL', serviceId, health })
}
function detectChatGPTIssues() {
// Detect "Hmm... something went wrong" error
const errorElements = document.querySelectorAll('[class*="error"], [class*="warning"], [class*="failed"]')
const bodyText = document.body?.innerText || ''
const hasError = bodyText.includes('Hmm... something went wrong') ||
bodyText.includes('Something went wrong') ||
bodyText.includes('error 4600') ||
bodyText.includes('error') && bodyText.includes('try again') ||
errorElements.length > 3
const hasNetworkError = bodyText.includes('network error') || bodyText.includes('connection lost')
if (hasError || hasNetworkError) {
errorCount++
if (errorCount >= 2) sendHealthSignal('down')
else if (errorCount >= 1) sendHealthSignal('degraded')
} else {
errorCount = Math.max(0, errorCount - 1)
if (errorCount === 0) sendHealthSignal('healthy')
}
}
function detectClaudeIssues() {
const bodyText = document.body?.innerText || ''
const hasError = bodyText.includes('overloaded') ||
bodyText.includes('try again') && bodyText.includes('error') ||
bodyText.includes('temporarily unavailable') ||
bodyText.includes('rate limit')
if (hasError) {
errorCount++
if (errorCount >= 2) sendHealthSignal('down')
else sendHealthSignal('degraded')
} else {
errorCount = Math.max(0, errorCount - 1)
if (errorCount === 0) sendHealthSignal('healthy')
}
}
function detectGeminiIssues() {
const bodyText = document.body?.innerText || ''
const hasError = bodyText.includes('error') && bodyText.includes('try again') ||
bodyText.includes('unavailable') ||
bodyText.includes('limit reached')
if (hasError) {
errorCount++
if (errorCount >= 2) sendHealthSignal('down')
else sendHealthSignal('degraded')
} else {
errorCount = Math.max(0, errorCount - 1)
if (errorCount === 0) sendHealthSignal('healthy')
}
}
function detectGrokIssues() {
const bodyText = document.body?.innerText || ''
const hasError = bodyText.includes('error') && bodyText.includes('try again') ||
bodyText.includes('unavailable') ||
bodyText.includes('rate limit')
if (hasError) {
errorCount++
if (errorCount >= 2) sendHealthSignal('down')
else sendHealthSignal('degraded')
} else {
errorCount = Math.max(0, errorCount - 1)
if (errorCount === 0) sendHealthSignal('healthy')
}
}
function trackUsageLimits() {
const serviceId = detectServiceId()
if (!serviceId) return
// Detect usage limit UI elements
const limitText = document.body?.innerText || ''
const limitMatch = limitText.match(/(\d+)\s*\/\s*(\d+)\s*(messages|requests|tokens)/i)
if (limitMatch) {
const used = parseInt(limitMatch[1], 10)
const limit = parseInt(limitMatch[2], 10)
chrome.runtime.sendMessage({
type: 'USAGE_LIMIT',
serviceId,
used,
limit,
unit: limitMatch[3].toLowerCase()
})
}
// Detect response time degradation
const now = Date.now()
if (lastResponseTime && now - lastResponseTime > 30000) {
slowResponseCount++
if (slowResponseCount >= 3) sendHealthSignal('degraded')
}
lastResponseTime = now
}
// Poll for page health every 5 seconds
function pollHealth() {
const serviceId = detectServiceId()
if (!serviceId) return
switch (serviceId) {
case 'chatgpt': detectChatGPTIssues(); break
case 'claude': detectClaudeIssues(); break
case 'gemini': detectGeminiIssues(); break
case 'grok': detectGrokIssues(); break
}
trackUsageLimits()
}
setInterval(pollHealth, 5000)
pollHealth()
// Listen for background ping
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'PING') {
sendResponse({ ok: true, health: lastSignal, serviceId: detectServiceId() })
}
})
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './popup'
const container = document.getElementById('root')
if (container) {
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>
)
}
+227
View File
@@ -0,0 +1,227 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.5;
background: #F9FAFB;
min-width: 360px;
max-width: 400px;
}
.popup-container {
padding: 16px;
}
.loading, .error {
padding: 24px;
text-align: center;
color: #6B7280;
}
.error {
color: #EF4444;
}
.header {
margin-bottom: 16px;
}
.header h1 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.summary {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.summary-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
}
.summary-badge.healthy {
background: #D1FAE5;
color: #065F46;
}
.summary-badge.degraded {
background: #FEF3C7;
color: #92400E;
}
.summary-badge.down {
background: #FEE2E2;
color: #991B1B;
}
.services-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.service-card {
background: white;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 12px;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.service-name {
font-weight: 600;
color: #111827;
}
.health-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
color: white;
}
.service-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #4B5563;
}
.detail-row.error-message {
color: #EF4444;
font-weight: 500;
}
.usage-bar {
margin-top: 6px;
}
.usage-label {
font-size: 11px;
color: #6B7280;
margin-bottom: 2px;
}
.usage-track {
height: 6px;
background: #E5E7EB;
border-radius: 3px;
overflow: hidden;
}
.usage-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.failover-btn, .refresh-btn, .switch-btn, .close-btn {
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 8px;
}
.failover-btn {
background: #3B82F6;
color: white;
}
.failover-btn:hover {
background: #2563EB;
}
.refresh-btn {
background: #F3F4F6;
color: #374151;
border: 1px solid #D1D5DB;
}
.refresh-btn:hover {
background: #E5E7EB;
}
.failover-suggestion {
background: #EFF6FF;
border: 1px solid #BFDBFE;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.failover-title {
font-size: 12px;
font-weight: 600;
color: #1E40AF;
margin-bottom: 4px;
}
.failover-service {
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.switch-btn {
background: #10B981;
color: white;
margin-bottom: 6px;
}
.switch-btn:hover {
background: #059669;
}
.close-btn {
background: #F3F4F6;
color: #374151;
border: 1px solid #D1D5DB;
margin-bottom: 0;
}
.close-btn:hover {
background: #E5E7EB;
}
.settings {
background: white;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.setting-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #4B5563;
padding: 4px 0;
}
+167
View File
@@ -0,0 +1,167 @@
import { useState, useEffect } from 'react'
import './popup.css'
import { MonitorState, ServiceStatus, ServiceId, SERVICE_CONFIG } from './types'
function ServiceCard({ service }: { service: ServiceStatus }) {
const healthColors = {
healthy: '#10B981',
degraded: '#F59E0B',
down: '#EF4444',
unknown: '#6B7280'
}
const healthLabels = {
healthy: 'Healthy',
degraded: 'Degraded',
down: 'Down',
unknown: 'Unknown'
}
return (
<div className="service-card">
<div className="service-header">
<div className="service-name">{service.name}</div>
<div className="health-badge" style={{ backgroundColor: healthColors[service.health] }}>
{healthLabels[service.health]}
</div>
</div>
<div className="service-details">
<div className="detail-row">
<span>Response Time</span>
<span>{service.responseTimeMs ? `${service.responseTimeMs}ms` : '—'}</span>
</div>
{service.message && (
<div className="detail-row error-message">
<span>{service.message}</span>
</div>
)}
{service.usageLimit && (
<div className="usage-bar">
<div className="usage-label">
Usage: {service.usageLimit.used}/{service.usageLimit.limit} {service.usageLimit.unit}
</div>
<div className="usage-track">
<div
className="usage-fill"
style={{
width: `${service.usageLimit.percentUsed}%`,
backgroundColor: service.usageLimit.percentUsed >= 80 ? '#EF4444' : '#10B981'
}}
/>
</div>
</div>
)}
<div className="detail-row">
<span>Last Checked</span>
<span>{service.lastChecked ? new Date(service.lastChecked).toLocaleTimeString() : 'Never'}</span>
</div>
</div>
</div>
)
}
function App() {
const [state, setState] = useState<MonitorState | null>(null)
const [loading, setLoading] = useState(true)
const [failover, setFailover] = useState<{ serviceId: ServiceId; url: string } | null>(null)
useEffect(() => {
loadState()
}, [])
async function loadState() {
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_STATE' })
if (response.ok) {
setState(response.state)
}
} catch (err) {
console.error('Failed to load state', err)
} finally {
setLoading(false)
}
}
async function handleFailover() {
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_FAILOVER' })
if (response.ok) {
setFailover({ serviceId: response.serviceId, url: response.url })
}
} catch (err) {
console.error('Failed to get failover', err)
}
}
async function switchToService(url: string) {
await chrome.tabs.create({ url })
}
if (loading) {
return <div className="loading">Loading...</div>
}
if (!state) {
return <div className="error">Failed to load monitor state</div>
}
const services = Object.values(state.services)
const downCount = services.filter(s => s.health === 'down').length
const degradedCount = services.filter(s => s.health === 'degraded').length
const healthyCount = services.filter(s => s.health === 'healthy').length
return (
<div className="popup-container">
<div className="header">
<h1>AI Service Monitor</h1>
<div className="summary">
<span className="summary-badge healthy">{healthyCount} Healthy</span>
<span className="summary-badge degraded">{degradedCount} Degraded</span>
<span className="summary-badge down">{downCount} Down</span>
</div>
</div>
<div className="services-list">
{services.map(service => (
<ServiceCard key={service.id} service={service} />
))}
</div>
{failover ? (
<div className="failover-suggestion">
<div className="failover-title">Suggested Failover</div>
<div className="failover-service">
{SERVICE_CONFIG[failover.serviceId].name}
</div>
<button className="switch-btn" onClick={() => switchToService(failover.url)}>
Switch to {SERVICE_CONFIG[failover.serviceId].name}
</button>
<button className="close-btn" onClick={() => setFailover(null)}>
Dismiss
</button>
</div>
) : (
<button className="failover-btn" onClick={handleFailover}>
Find Best Backup Service
</button>
)}
<div className="settings">
<div className="setting-row">
<span>Alert Threshold</span>
<span>{state.alertThreshold}%</span>
</div>
<div className="setting-row">
<span>Primary Service</span>
<span>{SERVICE_CONFIG[state.primaryService].name}</span>
</div>
</div>
<button className="refresh-btn" onClick={loadState}>
Refresh Status
</button>
</div>
)
}
export default App
+28
View File
@@ -0,0 +1,28 @@
import { ServiceId, ServiceStatus, MonitorState, DEFAULT_STATE } from './types'
const STORAGE_KEY = 'ai_monitor_state'
export async function getState(): Promise<MonitorState> {
try {
const result = await chrome.storage.local.get(STORAGE_KEY)
return result[STORAGE_KEY] ? JSON.parse(result[STORAGE_KEY]) : DEFAULT_STATE
} catch {
return DEFAULT_STATE
}
}
export async function saveState(state: MonitorState): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: JSON.stringify(state) })
}
export async function updateServiceStatus(serviceId: ServiceId, status: Partial<ServiceStatus>): Promise<void> {
const state = await getState()
state.services[serviceId] = { ...state.services[serviceId], ...status }
await saveState(state)
}
export async function clearAlert(): Promise<void> {
const state = await getState()
state.lastAlert = null
await saveState(state)
}
+51
View File
@@ -0,0 +1,51 @@
// Types for AI Service Monitor
export type ServiceId = 'chatgpt' | 'claude' | 'gemini' | 'grok'
export interface ServiceStatus {
id: ServiceId
name: string
url: string
health: 'healthy' | 'degraded' | 'down' | 'unknown'
lastChecked: number
responseTimeMs: number | null
errorRate: number
usageLimit: UsageLimit | null
message: string | null
}
export interface UsageLimit {
used: number
limit: number
unit: 'requests' | 'tokens' | 'messages'
resetsAt: number | null
percentUsed: number
}
export interface MonitorState {
services: Record<ServiceId, ServiceStatus>
lastAlert: number | null
alertThreshold: number
failoverEnabled: boolean
primaryService: ServiceId
}
export const SERVICE_CONFIG: Record<ServiceId, { name: string; url: string; checkUrl: string }> = {
chatgpt: { name: 'ChatGPT', url: 'https://chatgpt.com', checkUrl: 'https://chatgpt.com/backend-api/health' },
claude: { name: 'Claude AI', url: 'https://claude.ai', checkUrl: 'https://claude.ai/api/health' },
gemini: { name: 'Gemini', url: 'https://gemini.google.com', checkUrl: 'https://gemini.google.com/health' },
grok: { name: 'Grok', url: 'https://grok.com', checkUrl: 'https://grok.com/health' }
}
export const DEFAULT_STATE: MonitorState = {
services: {
chatgpt: { id: 'chatgpt', name: 'ChatGPT', url: 'https://chatgpt.com', health: 'unknown', lastChecked: 0, responseTimeMs: null, errorRate: 0, usageLimit: null, message: null },
claude: { id: 'claude', name: 'Claude AI', url: 'https://claude.ai', health: 'unknown', lastChecked: 0, responseTimeMs: null, errorRate: 0, usageLimit: null, message: null },
gemini: { id: 'gemini', name: 'Gemini', url: 'https://gemini.google.com', health: 'unknown', lastChecked: 0, responseTimeMs: null, errorRate: 0, usageLimit: null, message: null },
grok: { id: 'grok', name: 'Grok', url: 'https://grok.com', health: 'unknown', lastChecked: 0, responseTimeMs: null, errorRate: 0, usageLimit: null, message: null }
},
lastAlert: null,
alertThreshold: 80,
failoverEnabled: true,
primaryService: 'chatgpt'
}