AI Service Reliability Monitor & Failover Switch v1.0.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.verdict
|
||||
*.log
|
||||
.venv/
|
||||
.env
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+3749
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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() })
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user