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
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.verdict
*.log
.venv/
.env
+67
View File
@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
function crc32(buf) {
let c = ~0;
const table = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
}
return c >>> 0;
});
for (let i = 0; i < buf.length; i++) {
c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 1);
}
return ~(c >>> 0) >>> 0;
}
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const buf = Buffer.concat([typeBuf, data]);
const crc = crc32(buf);
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.allocUnsafe(4);
crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, buf, crcBuf]);
}
function createSolidPng(size, r, g, b) {
const header = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const ihdr = Buffer.allocUnsafe(13);
ihdr.writeUInt32BE(size, 0); // width
ihdr.writeUInt32BE(size, 4); // height
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type (RGB)
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter method
ihdr[12] = 0; // interlace
const row = Buffer.allocUnsafe(1 + size * 3);
row[0] = 0; // filter byte
for (let x = 0; x < size; x++) {
row[1 + x * 3] = r;
row[1 + x * 3 + 1] = g;
row[1 + x * 3 + 2] = b;
}
const rawData = Buffer.concat(Array.from({ length: size }, () => row));
const idat = zlib.deflateSync(rawData, { level: 9 });
return Buffer.concat([header, pngChunk('IHDR', ihdr), pngChunk('IDAT', idat), pngChunk('IEND', Buffer.alloc(0))]);
}
const dir = path.join(__dirname, 'public', 'icons');
fs.mkdirSync(dir, { recursive: true });
const sizes = [16, 48, 128];
const colors = { 16: [59, 130, 246], 48: [59, 130, 246], 128: [59, 130, 246] };
for (const size of sizes) {
const [r, g, b] = colors[size];
const png = createSolidPng(size, r, g, b);
fs.writeFileSync(path.join(dir, `icon${size}.png`), png);
console.log(`Generated icon${size}.png`);
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Service Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/popup-main.tsx"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
{
"manifest_version": 3,
"name": "AI Service Reliability Monitor & Failover Switch",
"version": "1.0.0",
"description": "Monitor AI service health across ChatGPT, Claude, Gemini, and Grok. Track usage limits, get alerts, and auto-suggest failover when services are down.",
"permissions": [
"storage",
"activeTab",
"tabs",
"alarms",
"notifications"
],
"host_permissions": [
"https://chat.openai.com/*",
"https://chatgpt.com/*",
"https://claude.ai/*",
"https://gemini.google.com/*",
"https://grok.com/*",
"https://api.openai.com/*",
"https://api.anthropic.com/*"
],
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": [
"https://chat.openai.com/*",
"https://chatgpt.com/*",
"https://claude.ai/*",
"https://gemini.google.com/*",
"https://grok.com/*"
],
"js": ["src/content.ts"],
"run_at": "document_end"
}
],
"action": {
"default_popup": "index.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+3749
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "ai-service-reliability-monitor",
"version": "1.0.0",
"description": "AI Service Reliability Monitor & Failover Switch",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vitest": "^1.6.0",
"jsdom": "^24.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

+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'
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'
import { findFailoverService } from '../src/background'
import { MonitorState, ServiceId, ServiceStatus } from '../src/types'
function makeStatus(id: ServiceId, health: ServiceStatus['health'], responseTime: number | null): ServiceStatus {
return {
id,
name: id,
url: `https://${id}.com`,
health,
lastChecked: Date.now(),
responseTimeMs: responseTime,
errorRate: 0,
usageLimit: null,
message: null
}
}
describe('findFailoverService', () => {
it('should return the fastest healthy backup', () => {
const state: MonitorState = {
services: {
chatgpt: makeStatus('chatgpt', 'healthy', 100),
claude: makeStatus('claude', 'healthy', 50),
gemini: makeStatus('gemini', 'healthy', 200),
grok: makeStatus('grok', 'down', null)
},
lastAlert: null,
alertThreshold: 80,
failoverEnabled: true,
primaryService: 'chatgpt'
}
const result = findFailoverService(state)
expect(result).toBe('claude')
})
it('should return null when no healthy backup exists', () => {
const state: MonitorState = {
services: {
chatgpt: makeStatus('chatgpt', 'down', null),
claude: makeStatus('claude', 'down', null),
gemini: makeStatus('gemini', 'degraded', 1000),
grok: makeStatus('grok', 'down', null)
},
lastAlert: null,
alertThreshold: 80,
failoverEnabled: true,
primaryService: 'chatgpt'
}
const result = findFailoverService(state)
expect(result).toBeNull()
})
it('should exclude the primary service', () => {
const state: MonitorState = {
services: {
chatgpt: makeStatus('chatgpt', 'healthy', 50),
claude: makeStatus('claude', 'healthy', 100),
gemini: makeStatus('gemini', 'healthy', 200),
grok: makeStatus('grok', 'healthy', 300)
},
lastAlert: null,
alertThreshold: 80,
failoverEnabled: true,
primaryService: 'chatgpt'
}
const result = findFailoverService(state)
expect(result).toBe('claude')
})
it('should pick gemini when claude is degraded', () => {
const state: MonitorState = {
services: {
chatgpt: makeStatus('chatgpt', 'down', null),
claude: makeStatus('claude', 'degraded', 5000),
gemini: makeStatus('gemini', 'healthy', 150),
grok: makeStatus('grok', 'down', null)
},
lastAlert: null,
alertThreshold: 80,
failoverEnabled: true,
primaryService: 'chatgpt'
}
const result = findFailoverService(state)
expect(result).toBe('gemini')
})
})
+39
View File
@@ -0,0 +1,39 @@
globalThis.chrome = {
runtime: {
onMessage: {
addListener: () => {},
},
onInstalled: {
addListener: () => {},
},
onStartup: {
addListener: () => {},
},
},
alarms: {
onAlarm: {
addListener: () => {},
},
create: async () => {},
},
tabs: {
onUpdated: {
addListener: () => {},
},
create: async () => {},
sendMessage: async () => {},
},
action: {
setBadgeText: async () => {},
setBadgeBackgroundColor: async () => {},
},
notifications: {
create: async () => {},
},
storage: {
local: {
get: async () => ({}),
set: async () => {},
},
},
} as any;
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": ".",
"types": ["chrome", "vitest/globals"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json' with { type: 'json' }
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
},
})