Add API Status Indicator to AppSidebar and enhance ApiMonitor with localStorage support for auto-refresh and refresh interval settings

This commit is contained in:
IGNY8 VPS (Salman)
2025-11-15 14:18:08 +00:00
parent 6109369df4
commit 5a08a558ef
3 changed files with 412 additions and 17 deletions

View File

@@ -0,0 +1,284 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { API_BASE_URL } from "../../services/api";
interface GroupStatus {
name: string;
abbreviation: string;
healthy: number;
total: number;
isHealthy: boolean;
}
const endpointGroups = [
{
name: "Core Health & Auth",
abbreviation: "CO",
endpoints: [
{ path: "/v1/system/status/", method: "GET" },
{ path: "/v1/auth/login/", method: "POST" },
{ path: "/v1/auth/me/", method: "GET" },
{ path: "/v1/auth/register/", method: "POST" },
],
},
{
name: "Auth & User Management",
abbreviation: "AU",
endpoints: [
{ path: "/v1/auth/users/", method: "GET" },
{ path: "/v1/auth/accounts/", method: "GET" },
{ path: "/v1/auth/sites/", method: "GET" },
{ path: "/v1/auth/sectors/", method: "GET" },
{ path: "/v1/auth/plans/", method: "GET" },
{ path: "/v1/auth/industries/", method: "GET" },
],
},
{
name: "Planner Module",
abbreviation: "PL",
endpoints: [
{ path: "/v1/planner/keywords/", method: "GET" },
{ path: "/v1/planner/keywords/auto_cluster/", method: "POST" },
{ path: "/v1/planner/clusters/", method: "GET" },
{ path: "/v1/planner/clusters/auto_generate_ideas/", method: "POST" },
{ path: "/v1/planner/ideas/", method: "GET" },
],
},
{
name: "Writer Module",
abbreviation: "WR",
endpoints: [
{ path: "/v1/writer/tasks/", method: "GET" },
{ path: "/v1/writer/tasks/auto_generate_content/", method: "POST" },
{ path: "/v1/writer/content/", method: "GET" },
{ path: "/v1/writer/content/generate_image_prompts/", method: "POST" },
{ path: "/v1/writer/images/", method: "GET" },
{ path: "/v1/writer/images/generate_images/", method: "POST" },
],
},
{
name: "System & Billing",
abbreviation: "SY",
endpoints: [
{ path: "/v1/system/prompts/", method: "GET" },
{ path: "/v1/system/settings/integrations/1/test/", method: "POST" },
{ path: "/v1/billing/credits/balance/balance/", method: "GET" },
{ path: "/v1/billing/credits/usage/", method: "GET" },
],
},
];
export default function ApiStatusIndicator() {
const [groupStatuses, setGroupStatuses] = useState<GroupStatus[]>([]);
const [isChecking, setIsChecking] = useState(false);
const intervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkEndpoint = useCallback(async (path: string, method: string): Promise<'healthy' | 'warning' | 'error'> => {
try {
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 isExpensiveAIEndpoint =
path.includes('/auto_generate_content') ||
path.includes('/auto_cluster') ||
path.includes('/auto_generate_ideas') ||
path.includes('/generate_image_prompts') ||
path.includes('/generate_images');
let actualMethod = method;
let fetchOptions: RequestInit = {
method: actualMethod,
headers,
credentials: 'include',
};
if (method === 'POST' && isExpensiveAIEndpoint) {
actualMethod = 'OPTIONS';
fetchOptions.method = 'OPTIONS';
delete (fetchOptions as any).body;
} else if (method === 'POST') {
let body: any = {};
if (path.includes('/test/')) {
body = {};
} else if (path.includes('/login/')) {
body = { username: 'test', password: 'test' };
} else if (path.includes('/register/')) {
body = { username: 'test', email: 'test@test.com', password: 'test' };
}
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
if (actualMethod === 'OPTIONS') {
if (response.status === 200) {
return 'healthy';
} else if (response.status === 404) {
return 'error';
} else if (response.status >= 500) {
return 'error';
}
return 'warning';
} else if (method === 'GET') {
if (response.status >= 200 && response.status < 300) {
return 'healthy';
} else if (response.status === 401 || response.status === 403) {
return 'warning';
} else if (response.status === 404) {
return 'error';
} else if (response.status >= 500) {
return 'error';
}
return 'warning';
} else if (method === 'POST') {
if (response.status === 400) {
return 'healthy';
} else if (response.status >= 200 && response.status < 300) {
return 'healthy';
} else if (response.status === 401 || response.status === 403) {
return 'warning';
} else if (response.status === 404) {
return 'error';
} else if (response.status >= 500) {
return 'error';
}
return 'warning';
}
return 'warning';
} catch (err) {
return 'error';
}
}, []);
const checkAllGroups = useCallback(async () => {
setIsChecking(true);
const statusPromises = endpointGroups.map(async (group) => {
const endpointChecks = group.endpoints.map(ep => checkEndpoint(ep.path, ep.method));
const results = await Promise.all(endpointChecks);
const healthy = results.filter(s => s === 'healthy').length;
const total = results.length;
const isHealthy = healthy === total;
return {
name: group.name,
abbreviation: group.abbreviation,
healthy,
total,
isHealthy,
};
});
const statuses = await Promise.all(statusPromises);
setGroupStatuses(statuses);
setIsChecking(false);
}, [checkEndpoint]);
useEffect(() => {
// Initial check
checkAllGroups();
// Get refresh interval from localStorage (same as API Monitor page)
const getRefreshInterval = () => {
const saved = localStorage.getItem('api-monitor-refresh-interval');
return saved ? parseInt(saved, 10) * 1000 : 30000; // Convert to milliseconds
};
// Setup interval function that reads fresh interval value each time
const setupInterval = () => {
if (intervalRef.current) {
clearTimeout(intervalRef.current);
}
// Use a recursive timeout that reads the interval each time
const scheduleNext = () => {
const interval = getRefreshInterval();
intervalRef.current = setTimeout(() => {
checkAllGroups();
scheduleNext(); // Schedule next check
}, interval);
};
scheduleNext();
};
// Initial interval setup
setupInterval();
// Listen for storage changes (when user changes interval in another tab)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'api-monitor-refresh-interval') {
setupInterval();
}
};
// Listen for custom event (when user changes interval in same tab)
const handleCustomStorageChange = () => {
setupInterval();
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener('api-monitor-interval-changed', handleCustomStorageChange);
return () => {
if (intervalRef.current) {
clearTimeout(intervalRef.current);
}
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('api-monitor-interval-changed', handleCustomStorageChange);
};
}, [checkAllGroups]);
const getStatusColor = (isHealthy: boolean) => {
if (isHealthy) {
return 'bg-green-500 dark:bg-green-400'; // Success color for 100%
} else {
return 'bg-yellow-500 dark:bg-yellow-400'; // Warning color for < 100%
}
};
if (groupStatuses.length === 0 && !isChecking) {
return null;
}
return (
<div className="mb-6 px-2">
<div className="flex items-center justify-center gap-2 flex-wrap">
{groupStatuses.map((group, index) => (
<div key={index} className="flex flex-col items-center gap-1">
<div
className={`w-3 h-3 rounded-full ${getStatusColor(group.isHealthy)} transition-colors duration-300 ${
isChecking ? 'opacity-50' : ''
}`}
title={`${group.name}: ${group.healthy}/${group.total} healthy`}
/>
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
{group.abbreviation}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import { useSidebar } from "../context/SidebarContext";
import SidebarWidget from "./SidebarWidget";
import { APP_VERSION } from "../config/version";
import { useAuthStore } from "../store/authStore";
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
type NavItem = {
name: string;
@@ -495,6 +496,8 @@ const AppSidebar: React.FC = () => {
)}
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
{/* API Status Indicator - above OVERVIEW section */}
<ApiStatusIndicator />
<nav className="mb-6">
<div className="flex flex-col gap-2">
{allSections.map((section, sectionIndex) => (

View File

@@ -25,7 +25,6 @@ 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" },
@@ -108,8 +107,16 @@ const getStatusIcon = (status: string) => {
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 [autoRefresh, setAutoRefresh] = useState(() => {
// Load from localStorage
const saved = localStorage.getItem('api-monitor-auto-refresh');
return saved !== null ? saved === 'true' : true;
});
const [refreshInterval, setRefreshInterval] = useState(() => {
// Load from localStorage, default to 30 seconds
const saved = localStorage.getItem('api-monitor-refresh-interval');
return saved ? parseInt(saved, 10) : 30;
});
const checkEndpoint = useCallback(async (path: string, method: string) => {
const key = `${method}:${path}`;
@@ -127,7 +134,6 @@ export default function ApiMonitor() {
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') ||
(() => {
@@ -151,27 +157,119 @@ export default function ApiMonitor() {
headers['Authorization'] = `Bearer ${token}`;
}
const fetchOptions: RequestInit = {
method,
// Determine if this is an expensive AI endpoint that should use OPTIONS
const isExpensiveAIEndpoint =
path.includes('/auto_generate_content') ||
path.includes('/auto_cluster') ||
path.includes('/auto_generate_ideas') ||
path.includes('/generate_image_prompts') ||
path.includes('/generate_images');
let actualMethod = method;
let fetchOptions: RequestInit = {
method: actualMethod,
headers,
credentials: 'include',
};
// For POST endpoints, send empty body for monitoring
if (method === 'POST') {
fetchOptions.body = JSON.stringify({});
// For expensive AI POST endpoints, use OPTIONS to check existence without triggering function
// If OPTIONS fails, we'll fall back to POST with empty IDs (which fails validation before triggering AI)
if (method === 'POST' && isExpensiveAIEndpoint) {
actualMethod = 'OPTIONS';
fetchOptions.method = 'OPTIONS';
// OPTIONS doesn't need body - remove it if present
delete (fetchOptions as any).body;
} else if (method === 'POST') {
// For non-expensive POST endpoints, send invalid data that fails validation early
let body: any = {};
if (path.includes('/test/')) {
body = {}; // Test endpoint might accept empty body
} else if (path.includes('/login/')) {
body = { username: 'test', password: 'test' }; // Will fail validation but endpoint exists
} else if (path.includes('/register/')) {
body = { username: 'test', email: 'test@test.com', password: 'test' }; // Will fail validation but endpoint exists
}
fetchOptions.body = JSON.stringify(body);
}
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
// Determine status based on response
let status: 'healthy' | 'warning' | 'error' = 'healthy';
if (response.status >= 500) {
let responseText = '';
// Read response body for debugging (but don't log errors for expected 400s)
try {
responseText = await response.text();
} catch (e) {
// Ignore body read errors
}
if (actualMethod === 'OPTIONS') {
// OPTIONS: 200 with Allow header = healthy, anything else = error
if (response.status === 200) {
const allowHeader = response.headers.get('Allow') || response.headers.get('allow');
if (allowHeader && (allowHeader.includes('POST') || allowHeader.includes('post'))) {
status = 'healthy';
} else {
// If we can't read Allow header (CORS issue), but got 200, assume it's healthy
// OPTIONS 200 generally means endpoint exists
status = 'healthy';
}
} else if (response.status === 404) {
status = 'error'; // Endpoint doesn't exist
} else if (response.status >= 500) {
status = 'error';
} else if (response.status >= 400 && response.status < 500) {
status = 'warning'; // 4xx is warning (auth/permission/validation - endpoint exists)
} else {
status = 'warning';
}
} else if (method === 'GET') {
// GET: 2xx = healthy, 401/403 = warning (needs auth), 404 = error, 5xx = error
if (response.status >= 200 && response.status < 300) {
status = 'healthy';
} else if (response.status === 401 || response.status === 403) {
status = 'warning'; // Endpoint exists, needs authentication
} else if (response.status === 404) {
status = 'error'; // Endpoint doesn't exist
} else if (response.status >= 500) {
status = 'error';
} else {
status = 'warning';
}
} else if (method === 'POST') {
// POST: 400 = healthy (endpoint exists and validates), 401/403 = warning, 404 = error, 5xx = error
if (response.status === 400) {
// 400 means endpoint exists and validation works - this is healthy for monitoring
status = 'healthy';
} else if (response.status >= 200 && response.status < 300) {
status = 'healthy';
} else if (response.status === 401 || response.status === 403) {
status = 'warning'; // Endpoint exists, needs authentication
} else if (response.status === 404) {
status = 'error'; // Endpoint doesn't exist
} else if (response.status >= 500) {
status = 'error';
} else {
status = 'warning';
}
}
// Suppress console errors for expected monitoring responses
// Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints)
// Don't log expected 400s for POST endpoints (they indicate validation is working)
const isExpectedResponse =
(method === 'POST' && response.status === 400) || // Expected validation error
(actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success
(method === 'GET' && response.status >= 200 && response.status < 300); // Expected GET success
if (!isExpectedResponse && (response.status >= 500 ||
(method === 'GET' && response.status === 404) ||
(actualMethod === 'OPTIONS' && response.status !== 200))) {
// These are real errors worth logging
console.warn(`[API Monitor] ${method} ${path}: ${response.status}`, responseText.substring(0, 100));
}
setEndpointStatuses(prev => ({
@@ -272,7 +370,11 @@ export default function ApiMonitor() {
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
onChange={(e) => {
const newValue = e.target.checked;
setAutoRefresh(newValue);
localStorage.setItem('api-monitor-auto-refresh', String(newValue));
}}
className="rounded border-gray-300"
/>
Auto-refresh
@@ -280,7 +382,13 @@ export default function ApiMonitor() {
{autoRefresh && (
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
onChange={(e) => {
const newValue = Number(e.target.value);
setRefreshInterval(newValue);
localStorage.setItem('api-monitor-refresh-interval', String(newValue));
// Dispatch custom event to notify sidebar component
window.dispatchEvent(new Event('api-monitor-interval-changed'));
}}
className="text-sm rounded border-gray-300 dark:bg-gray-800 dark:border-gray-700"
>
<option value={30}>30s</option>