moved most api stuff to web-api and built out a better monitoring solution for web-app

This commit is contained in:
Boki 2025-06-23 09:01:29 -04:00
parent fbff428e90
commit da1c52a841
45 changed files with 2986 additions and 312 deletions

View file

@ -0,0 +1,104 @@
/**
* System Monitoring Page
*/
import React, { useState } from 'react';
import { useSystemHealth, useCacheStats, useQueueStats, useDatabaseStats } from './hooks';
import { SystemHealthCard, CacheStatsCard, QueueStatsTable, DatabaseStatsGrid } from './components';
export function MonitoringPage() {
const [refreshInterval, setRefreshInterval] = useState(5000); // 5 seconds default
const { data: health, loading: healthLoading, error: healthError } = useSystemHealth(refreshInterval);
const { data: cache, loading: cacheLoading, error: cacheError } = useCacheStats(refreshInterval);
const { data: queues, loading: queuesLoading, error: queuesError } = useQueueStats(refreshInterval);
const { data: databases, loading: dbLoading, error: dbError } = useDatabaseStats(refreshInterval);
const handleRefreshIntervalChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setRefreshInterval(Number(e.target.value));
};
if (healthLoading || cacheLoading || queuesLoading || dbLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading monitoring data...</p>
</div>
</div>
);
}
const hasErrors = healthError || cacheError || queuesError || dbError;
return (
<div className="p-6">
<div className="mb-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">System Monitoring</h1>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label htmlFor="refresh-interval" className="text-sm text-gray-600">
Refresh interval:
</label>
<select
id="refresh-interval"
value={refreshInterval}
onChange={handleRefreshIntervalChange}
className="text-sm border rounded px-2 py-1"
>
<option value={0}>Manual</option>
<option value={5000}>5 seconds</option>
<option value={10000}>10 seconds</option>
<option value={30000}>30 seconds</option>
<option value={60000}>1 minute</option>
</select>
</div>
</div>
</div>
</div>
{hasErrors && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="font-semibold text-red-800 mb-2">Errors occurred while fetching data:</h3>
<ul className="list-disc list-inside text-sm text-red-700 space-y-1">
{healthError && <li>System Health: {healthError}</li>}
{cacheError && <li>Cache Stats: {cacheError}</li>}
{queuesError && <li>Queue Stats: {queuesError}</li>}
{dbError && <li>Database Stats: {dbError}</li>}
</ul>
</div>
)}
<div className="space-y-6">
{/* System Health */}
{health && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<SystemHealthCard health={health} />
</div>
<div className="lg:col-span-2">
{cache && <CacheStatsCard stats={cache} />}
</div>
</div>
)}
{/* Database Stats */}
{databases && databases.length > 0 && (
<div>
<h2 className="text-xl font-semibold mb-4">Database Connections</h2>
<DatabaseStatsGrid databases={databases} />
</div>
)}
{/* Queue Stats */}
{queues && queues.length > 0 && (
<div>
<h2 className="text-xl font-semibold mb-4">Queue Status</h2>
<QueueStatsTable queues={queues} />
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,96 @@
/**
* Cache Statistics Card Component
*/
import React from 'react';
import { Card } from '../../../components/ui/Card';
import type { CacheStats } from '../types';
interface CacheStatsCardProps {
stats: CacheStats;
}
export function CacheStatsCard({ stats }: CacheStatsCardProps) {
const formatBytes = (bytes: number) => {
const mb = bytes / 1024 / 1024;
return mb.toFixed(2) + ' MB';
};
const hitRate = stats.stats && (stats.stats.hits + stats.stats.misses) > 0
? (stats.stats.hits / (stats.stats.hits + stats.stats.misses) * 100).toFixed(1)
: '0';
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Cache (Dragonfly)</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
stats.connected ? 'text-green-600 bg-green-100' : 'text-red-600 bg-red-100'
}`}>
{stats.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{stats.connected ? (
<div className="space-y-4">
{stats.memoryUsage && (
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Memory Used</div>
<div className="text-xl font-semibold">{formatBytes(stats.memoryUsage.used)}</div>
</div>
<div>
<div className="text-sm text-gray-600">Peak Memory</div>
<div className="text-xl font-semibold">{formatBytes(stats.memoryUsage.peak)}</div>
</div>
</div>
)}
{stats.stats && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Hit Rate</div>
<div className="text-xl font-semibold">{hitRate}%</div>
</div>
<div>
<div className="text-sm text-gray-600">Total Keys</div>
<div className="text-xl font-semibold">{stats.stats.keys.toLocaleString()}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Hits:</span> {stats.stats.hits.toLocaleString()}
</div>
<div>
<span className="text-gray-600">Misses:</span> {stats.stats.misses.toLocaleString()}
</div>
{stats.stats.evictedKeys !== undefined && (
<div>
<span className="text-gray-600">Evicted:</span> {stats.stats.evictedKeys.toLocaleString()}
</div>
)}
{stats.stats.expiredKeys !== undefined && (
<div>
<span className="text-gray-600">Expired:</span> {stats.stats.expiredKeys.toLocaleString()}
</div>
)}
</div>
</>
)}
{stats.uptime && (
<div className="text-sm text-gray-600">
Uptime: {Math.floor(stats.uptime / 3600)}h {Math.floor((stats.uptime % 3600) / 60)}m
</div>
)}
</div>
) : (
<div className="text-center py-8 text-gray-500">
Cache service is not available
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,104 @@
/**
* Database Statistics Grid Component
*/
import React from 'react';
import { Card } from '../../../components/ui/Card';
import type { DatabaseStats } from '../types';
interface DatabaseStatsGridProps {
databases: DatabaseStats[];
}
export function DatabaseStatsGrid({ databases }: DatabaseStatsGridProps) {
const getDbIcon = (type: string) => {
switch (type) {
case 'postgres':
return '🐘';
case 'mongodb':
return '🍃';
case 'questdb':
return '⚡';
default:
return '💾';
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{databases.map((db) => (
<Card key={db.type} className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<span className="text-2xl">{getDbIcon(db.type)}</span>
<h4 className="font-semibold">{db.name}</h4>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
db.connected ? 'text-green-600 bg-green-100' : 'text-red-600 bg-red-100'
}`}>
{db.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{db.connected ? (
<div className="space-y-3">
{db.latency !== undefined && (
<div>
<div className="text-sm text-gray-600">Latency</div>
<div className="text-lg font-semibold">{db.latency}ms</div>
</div>
)}
{db.pool && (
<div>
<div className="text-sm text-gray-600 mb-1">Connection Pool</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-gray-600">Active:</span> {db.pool.active}
</div>
<div>
<span className="text-gray-600">Idle:</span> {db.pool.idle}
</div>
<div>
<span className="text-gray-600">Size:</span> {db.pool.size}
</div>
<div>
<span className="text-gray-600">Max:</span> {db.pool.max}
</div>
</div>
{db.pool.max > 0 && (
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(db.pool.size / db.pool.max) * 100}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{((db.pool.size / db.pool.max) * 100).toFixed(0)}% utilized
</div>
</div>
)}
</div>
)}
{db.type === 'mongodb' && db.stats && (
<div className="text-xs text-gray-600">
<div>Version: {db.stats.version}</div>
{db.stats.connections && (
<div>Connections: {db.stats.connections.current}/{db.stats.connections.available}</div>
)}
</div>
)}
</div>
) : (
<div className="text-center py-4 text-gray-500">
Database is not available
</div>
)}
</Card>
))}
</div>
);
}

View file

@ -0,0 +1,77 @@
/**
* Queue Statistics Table Component
*/
import React from 'react';
import { Card } from '../../../components/ui/Card';
import type { QueueStats } from '../types';
interface QueueStatsTableProps {
queues: QueueStats[];
}
export function QueueStatsTable({ queues }: QueueStatsTableProps) {
const totalJobs = (queue: QueueStats) => {
const { jobs } = queue;
return jobs.waiting + jobs.active + jobs.completed + jobs.failed + jobs.delayed + jobs.paused;
};
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Queue Statistics</h3>
{queues.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3">Queue</th>
<th className="text-center py-2 px-3">Status</th>
<th className="text-right py-2 px-3">Waiting</th>
<th className="text-right py-2 px-3">Active</th>
<th className="text-right py-2 px-3">Completed</th>
<th className="text-right py-2 px-3">Failed</th>
<th className="text-right py-2 px-3">Delayed</th>
<th className="text-right py-2 px-3">Total</th>
</tr>
</thead>
<tbody>
{queues.map((queue) => (
<tr key={queue.name} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 font-medium">{queue.name}</td>
<td className="py-2 px-3 text-center">
<span className={`inline-block w-2 h-2 rounded-full ${
queue.connected ? 'bg-green-500' : 'bg-red-500'
}`} />
</td>
<td className="py-2 px-3 text-right">{queue.jobs.waiting.toLocaleString()}</td>
<td className="py-2 px-3 text-right">
{queue.jobs.active > 0 ? (
<span className="text-blue-600 font-medium">{queue.jobs.active}</span>
) : (
queue.jobs.active
)}
</td>
<td className="py-2 px-3 text-right">{queue.jobs.completed.toLocaleString()}</td>
<td className="py-2 px-3 text-right">
{queue.jobs.failed > 0 ? (
<span className="text-red-600 font-medium">{queue.jobs.failed}</span>
) : (
queue.jobs.failed
)}
</td>
<td className="py-2 px-3 text-right">{queue.jobs.delayed.toLocaleString()}</td>
<td className="py-2 px-3 text-right font-medium">{totalJobs(queue).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
No queue data available
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,87 @@
/**
* System Health Card Component
*/
import React from 'react';
import { Card } from '../../../components/ui/Card';
import type { SystemHealth } from '../types';
interface SystemHealthCardProps {
health: SystemHealth;
}
export function SystemHealthCard({ health }: SystemHealthCardProps) {
const statusColor = {
healthy: 'text-green-600 bg-green-100',
degraded: 'text-yellow-600 bg-yellow-100',
unhealthy: 'text-red-600 bg-red-100',
}[health.status];
const formatUptime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
};
const formatBytes = (bytes: number) => {
const gb = bytes / 1024 / 1024 / 1024;
return gb.toFixed(2) + ' GB';
};
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">System Health</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColor}`}>
{health.status.toUpperCase()}
</span>
</div>
<div className="space-y-4">
<div>
<div className="text-sm text-gray-600">Uptime</div>
<div className="text-2xl font-semibold">{formatUptime(health.uptime)}</div>
</div>
<div>
<div className="text-sm text-gray-600">Memory Usage</div>
<div className="mt-1">
<div className="flex justify-between text-sm">
<span>{formatBytes(health.memory.used)} / {formatBytes(health.memory.total)}</span>
<span>{health.memory.percentage.toFixed(1)}%</span>
</div>
<div className="mt-1 w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${health.memory.percentage}%` }}
/>
</div>
</div>
</div>
{health.errors && health.errors.length > 0 && (
<div>
<div className="text-sm text-gray-600 mb-2">Issues</div>
<ul className="space-y-1">
{health.errors.map((error, index) => (
<li key={index} className="text-sm text-red-600">
{error}
</li>
))}
</ul>
</div>
)}
<div className="text-xs text-gray-500">
Last updated: {new Date(health.timestamp).toLocaleTimeString()}
</div>
</div>
</Card>
);
}

View file

@ -0,0 +1,8 @@
/**
* Monitoring components exports
*/
export { SystemHealthCard } from './SystemHealthCard';
export { CacheStatsCard } from './CacheStatsCard';
export { QueueStatsTable } from './QueueStatsTable';
export { DatabaseStatsGrid } from './DatabaseStatsGrid';

View file

@ -0,0 +1,5 @@
/**
* Monitoring hooks exports
*/
export * from './useMonitoring';

View file

@ -0,0 +1,123 @@
/**
* Custom hook for monitoring data
*/
import { useState, useEffect, useCallback } from 'react';
import { monitoringApi } from '../services/monitoringApi';
import type { SystemHealth, CacheStats, QueueStats, DatabaseStats } from '../types';
export function useSystemHealth(refreshInterval: number = 5000) {
const [data, setData] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const health = await monitoringApi.getSystemHealth();
setData(health);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch system health');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, refreshInterval]);
return { data, loading, error, refetch: fetchData };
}
export function useCacheStats(refreshInterval: number = 5000) {
const [data, setData] = useState<CacheStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const stats = await monitoringApi.getCacheStats();
setData(stats);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch cache stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, refreshInterval]);
return { data, loading, error, refetch: fetchData };
}
export function useQueueStats(refreshInterval: number = 5000) {
const [data, setData] = useState<QueueStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const result = await monitoringApi.getQueueStats();
setData(result.queues);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch queue stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, refreshInterval]);
return { data, loading, error, refetch: fetchData };
}
export function useDatabaseStats(refreshInterval: number = 5000) {
const [data, setData] = useState<DatabaseStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const result = await monitoringApi.getDatabaseStats();
setData(result.databases);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch database stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchData, refreshInterval]);
return { data, loading, error, refetch: fetchData };
}

View file

@ -0,0 +1,8 @@
/**
* Monitoring feature exports
*/
export { MonitoringPage } from './MonitoringPage';
export * from './types';
export * from './hooks/useMonitoring';
export * from './services/monitoringApi';

View file

@ -0,0 +1,87 @@
/**
* Monitoring API Service
*/
import type { SystemHealth, CacheStats, QueueStats, DatabaseStats } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:2003';
const MONITORING_BASE = `${API_BASE_URL}/api/system/monitoring`;
export const monitoringApi = {
/**
* Get overall system health
*/
async getSystemHealth(): Promise<SystemHealth> {
const response = await fetch(MONITORING_BASE);
if (!response.ok) {
throw new Error(`Failed to fetch system health: ${response.statusText}`);
}
return response.json();
},
/**
* Get cache statistics
*/
async getCacheStats(): Promise<CacheStats> {
const response = await fetch(`${MONITORING_BASE}/cache`);
if (!response.ok) {
throw new Error(`Failed to fetch cache stats: ${response.statusText}`);
}
return response.json();
},
/**
* Get queue statistics
*/
async getQueueStats(): Promise<{ queues: QueueStats[] }> {
const response = await fetch(`${MONITORING_BASE}/queues`);
if (!response.ok) {
throw new Error(`Failed to fetch queue stats: ${response.statusText}`);
}
return response.json();
},
/**
* Get specific queue statistics
*/
async getQueueStatsByName(name: string): Promise<QueueStats> {
const response = await fetch(`${MONITORING_BASE}/queues/${name}`);
if (!response.ok) {
throw new Error(`Failed to fetch queue ${name} stats: ${response.statusText}`);
}
return response.json();
},
/**
* Get database statistics
*/
async getDatabaseStats(): Promise<{ databases: DatabaseStats[] }> {
const response = await fetch(`${MONITORING_BASE}/databases`);
if (!response.ok) {
throw new Error(`Failed to fetch database stats: ${response.statusText}`);
}
return response.json();
},
/**
* Get specific database statistics
*/
async getDatabaseStatsByType(type: 'postgres' | 'mongodb' | 'questdb'): Promise<DatabaseStats> {
const response = await fetch(`${MONITORING_BASE}/databases/${type}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${type} stats: ${response.statusText}`);
}
return response.json();
},
/**
* Get detailed cache info
*/
async getCacheInfo(): Promise<{ parsed: CacheStats; raw: string }> {
const response = await fetch(`${MONITORING_BASE}/cache/info`);
if (!response.ok) {
throw new Error(`Failed to fetch cache info: ${response.statusText}`);
}
return response.json();
},
};

View file

@ -0,0 +1,80 @@
/**
* Monitoring types for system health and metrics
*/
export interface CacheStats {
provider: string;
connected: boolean;
uptime?: number;
memoryUsage?: {
used: number;
peak: number;
total?: number;
};
stats?: {
hits: number;
misses: number;
keys: number;
evictedKeys?: number;
expiredKeys?: number;
};
info?: Record<string, any>;
}
export interface QueueStats {
name: string;
connected: boolean;
jobs: {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
workers?: {
count: number;
concurrency: number;
};
throughput?: {
processed: number;
failed: number;
avgProcessingTime?: number;
};
}
export interface DatabaseStats {
type: 'postgres' | 'mongodb' | 'questdb';
name: string;
connected: boolean;
latency?: number;
pool?: {
size: number;
active: number;
idle: number;
waiting?: number;
max: number;
};
stats?: Record<string, any>;
}
export interface SystemHealth {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
uptime: number;
memory: {
used: number;
total: number;
percentage: number;
};
cpu?: {
usage: number;
loadAverage?: number[];
};
services: {
cache: CacheStats;
queues: QueueStats[];
databases: DatabaseStats[];
};
errors?: string[];
}