AI Service Reliability Monitor & Failover Switch v1.0.0
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user