Add API Monitor route and sidebar entry
This commit is contained in:
402
frontend/src/pages/Settings/ApiMonitor.tsx
Normal file
402
frontend/src/pages/Settings/ApiMonitor.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { API_BASE_URL } from "../../services/api";
|
||||
|
||||
interface EndpointStatus {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
status: 'healthy' | 'warning' | 'error' | 'checking';
|
||||
responseTime?: number;
|
||||
lastChecked?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface EndpointGroup {
|
||||
name: string;
|
||||
endpoints: {
|
||||
path: string;
|
||||
method: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const endpointGroups: EndpointGroup[] = [
|
||||
{
|
||||
name: "Core Health & Auth",
|
||||
endpoints: [
|
||||
{ path: "/api/ping/", method: "GET", description: "Health check" },
|
||||
{ path: "/v1/system/status/", method: "GET", description: "System status" },
|
||||
{ path: "/v1/auth/login/", method: "POST", description: "Login" },
|
||||
{ path: "/v1/auth/me/", method: "GET", description: "Current user" },
|
||||
{ path: "/v1/auth/register/", method: "POST", description: "Registration" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Auth & User Management",
|
||||
endpoints: [
|
||||
{ path: "/v1/auth/users/", method: "GET", description: "List users" },
|
||||
{ path: "/v1/auth/accounts/", method: "GET", description: "List accounts" },
|
||||
{ path: "/v1/auth/sites/", method: "GET", description: "List sites" },
|
||||
{ path: "/v1/auth/sectors/", method: "GET", description: "List sectors" },
|
||||
{ path: "/v1/auth/plans/", method: "GET", description: "List plans" },
|
||||
{ path: "/v1/auth/industries/", method: "GET", description: "List industries" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Planner Module",
|
||||
endpoints: [
|
||||
{ path: "/v1/planner/keywords/", method: "GET", description: "List keywords" },
|
||||
{ path: "/v1/planner/keywords/auto_cluster/", method: "POST", description: "AI clustering" },
|
||||
{ path: "/v1/planner/clusters/", method: "GET", description: "List clusters" },
|
||||
{ path: "/v1/planner/clusters/auto_generate_ideas/", method: "POST", description: "AI ideas" },
|
||||
{ path: "/v1/planner/ideas/", method: "GET", description: "List ideas" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Writer Module",
|
||||
endpoints: [
|
||||
{ path: "/v1/writer/tasks/", method: "GET", description: "List tasks" },
|
||||
{ path: "/v1/writer/tasks/auto_generate_content/", method: "POST", description: "AI content" },
|
||||
{ path: "/v1/writer/content/", method: "GET", description: "List content" },
|
||||
{ path: "/v1/writer/content/generate_image_prompts/", method: "POST", description: "Image prompts" },
|
||||
{ path: "/v1/writer/images/", method: "GET", description: "List images" },
|
||||
{ path: "/v1/writer/images/generate_images/", method: "POST", description: "AI images" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "System & Billing",
|
||||
endpoints: [
|
||||
{ path: "/v1/system/prompts/", method: "GET", description: "List prompts" },
|
||||
{ path: "/v1/system/settings/integrations/1/test/", method: "POST", description: "Test integration" },
|
||||
{ path: "/v1/billing/credits/balance/balance/", method: "GET", description: "Credit balance" },
|
||||
{ path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'text-green-600 dark:text-green-400';
|
||||
case 'warning': return 'text-yellow-600 dark:text-yellow-400';
|
||||
case 'error': return 'text-red-600 dark:text-red-400';
|
||||
case 'checking': return 'text-blue-600 dark:text-blue-400';
|
||||
default: return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'warning': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'error': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'checking': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return '✓';
|
||||
case 'warning': return '⚠';
|
||||
case 'error': return '✗';
|
||||
case 'checking': return '⟳';
|
||||
default: return '?';
|
||||
}
|
||||
};
|
||||
|
||||
export default function ApiMonitor() {
|
||||
const [endpointStatuses, setEndpointStatuses] = useState<Record<string, EndpointStatus>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [refreshInterval, setRefreshInterval] = useState(30); // seconds
|
||||
|
||||
const checkEndpoint = useCallback(async (path: string, method: string) => {
|
||||
const key = `${method}:${path}`;
|
||||
|
||||
// Set checking status
|
||||
setEndpointStatuses(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
endpoint: path,
|
||||
method,
|
||||
status: 'checking',
|
||||
},
|
||||
}));
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Use fetch directly for monitoring to get response status
|
||||
// Get token from auth store or localStorage
|
||||
const token = localStorage.getItem('auth_token') ||
|
||||
(() => {
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
return parsed?.state?.token || '';
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
};
|
||||
|
||||
// For POST endpoints, send empty body for monitoring
|
||||
if (method === 'POST') {
|
||||
fetchOptions.body = JSON.stringify({});
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Check response status
|
||||
// 2xx = healthy, 4xx = warning (endpoint exists but auth/validation issue), 5xx = error
|
||||
let status: 'healthy' | 'warning' | 'error' = 'healthy';
|
||||
if (response.status >= 500) {
|
||||
status = 'error';
|
||||
} else if (response.status >= 400 && response.status < 500) {
|
||||
status = 'warning'; // 4xx is warning (auth/permission/validation - endpoint exists)
|
||||
}
|
||||
|
||||
setEndpointStatuses(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
endpoint: path,
|
||||
method,
|
||||
status,
|
||||
responseTime,
|
||||
lastChecked: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
} catch (err: any) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// Network errors or timeouts are real errors
|
||||
setEndpointStatuses(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
endpoint: path,
|
||||
method,
|
||||
status: 'error',
|
||||
responseTime,
|
||||
lastChecked: new Date().toISOString(),
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAllEndpoints = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
// Check all endpoints in parallel (but limit concurrency)
|
||||
const allChecks = endpointGroups.flatMap(group =>
|
||||
group.endpoints.map(ep => checkEndpoint(ep.path, ep.method))
|
||||
);
|
||||
|
||||
// Check in batches of 5 to avoid overwhelming the server
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < allChecks.length; i += batchSize) {
|
||||
const batch = allChecks.slice(i, i + batchSize);
|
||||
await Promise.all(batch);
|
||||
// Small delay between batches
|
||||
if (i + batchSize < allChecks.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [checkEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only check when component is mounted (page is visible)
|
||||
checkAllEndpoints();
|
||||
|
||||
// Set up auto-refresh if enabled
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(() => {
|
||||
checkAllEndpoints();
|
||||
}, refreshInterval * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [autoRefresh, refreshInterval, checkAllEndpoints]);
|
||||
|
||||
const getEndpointStatus = (path: string, method: string): EndpointStatus => {
|
||||
const key = `${method}:${path}`;
|
||||
return endpointStatuses[key] || {
|
||||
endpoint: path,
|
||||
method,
|
||||
status: 'checking',
|
||||
};
|
||||
};
|
||||
|
||||
const getGroupHealth = (group: EndpointGroup) => {
|
||||
const statuses = group.endpoints.map(ep => getEndpointStatus(ep.path, ep.method).status);
|
||||
const healthy = statuses.filter(s => s === 'healthy').length;
|
||||
const total = statuses.length;
|
||||
return { healthy, total };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="API Monitor - IGNY8" description="API endpoint monitoring" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header Controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-800 dark:text-white/90">API Monitor</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Monitor API endpoint health and response times
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
{autoRefresh && (
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
className="text-sm rounded border-gray-300 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value={30}>30s</option>
|
||||
<option value={60}>1min</option>
|
||||
<option value={300}>5min</option>
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={checkAllEndpoints}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{loading ? 'Checking...' : 'Refresh All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monitoring Tables - 3 per row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{endpointGroups.map((group, groupIndex) => {
|
||||
const groupHealth = getGroupHealth(group);
|
||||
return (
|
||||
<ComponentCard
|
||||
key={groupIndex}
|
||||
title={group.name}
|
||||
desc={`${groupHealth.healthy}/${groupHealth.total} healthy`}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Endpoint
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.endpoints.map((endpoint, epIndex) => {
|
||||
const status = getEndpointStatus(endpoint.path, endpoint.method);
|
||||
return (
|
||||
<tr key={epIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-xs">
|
||||
<span className="font-mono text-gray-600 dark:text-gray-400">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 mt-0.5 truncate max-w-[200px]">
|
||||
{endpoint.path}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-600 mt-0.5">
|
||||
{endpoint.description}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${getStatusBadge(status.status)}`}
|
||||
title={status.error || status.status}
|
||||
>
|
||||
<span>{getStatusIcon(status.status)}</span>
|
||||
<span className="capitalize">{status.status}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{status.responseTime ? (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{status.responseTime}ms
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<ComponentCard title="Summary" desc="Overall API health statistics">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{endpointGroups.map((group, index) => {
|
||||
const groupHealth = getGroupHealth(group);
|
||||
const percentage = groupHealth.total > 0
|
||||
? Math.round((groupHealth.healthy / groupHealth.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-2xl font-semibold text-gray-800 dark:text-white/90">
|
||||
{percentage}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{groupHealth.healthy}/{groupHealth.total} healthy
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user