feat: overhaul Logs page with search, container logs, statistics, and rotation
- Added 4-tab layout: Service Logs, Container Logs, Statistics & Rotation, Health History - Service Logs: service/level/line-count selector, keyword search, auto-refresh (5s) - Container Logs: container picker, tail lines selector, auto-refresh - Statistics & Rotation: per-service file size, entry/error/warning counts, per-service and bulk rotate buttons - Added logsAPI in api.js: getServiceLogs, searchLogs, exportLogs, getStatistics, rotateLogs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+500
-164
@@ -1,164 +1,500 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Activity, Clock, FileText, AlertTriangle } from 'lucide-react';
|
import {
|
||||||
import { monitoringAPI } from '../services/api';
|
Activity, Clock, FileText, AlertTriangle, Search, RefreshCw,
|
||||||
|
RotateCcw, Box, BarChart2, Download, ChevronDown, ChevronUp,
|
||||||
function Logs() {
|
Filter
|
||||||
const [backendLog, setBackendLog] = useState('');
|
} from 'lucide-react';
|
||||||
const [healthHistory, setHealthHistory] = useState([]);
|
import { monitoringAPI, logsAPI, containerAPI } from '../services/api';
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [tab, setTab] = useState('logs');
|
const SERVICES = ['network', 'wireguard', 'routing', 'email', 'calendar', 'files', 'vault', 'container'];
|
||||||
|
const LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||||
useEffect(() => {
|
const LEVEL_COLORS = {
|
||||||
fetchData();
|
DEBUG: 'text-gray-400',
|
||||||
}, []);
|
INFO: 'text-blue-400',
|
||||||
|
WARNING: 'text-yellow-400',
|
||||||
const fetchData = async () => {
|
ERROR: 'text-red-400',
|
||||||
setIsLoading(true);
|
CRITICAL: 'text-red-600 font-bold',
|
||||||
try {
|
};
|
||||||
const [logRes, healthRes] = await Promise.all([
|
|
||||||
monitoringAPI.getBackendLogs(100),
|
function LevelBadge({ level }) {
|
||||||
monitoringAPI.getHealthHistory(),
|
const color = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-300';
|
||||||
]);
|
return <span className={`font-mono text-xs ${color}`}>[{level}]</span>;
|
||||||
setBackendLog(logRes.data.log || '');
|
}
|
||||||
setHealthHistory(healthRes.data || []);
|
|
||||||
} catch (error) {
|
function LogLine({ entry }) {
|
||||||
console.error('Failed to fetch monitoring data:', error);
|
if (entry.raw_line) {
|
||||||
} finally {
|
return <div className="font-mono text-xs text-gray-300 py-0.5">{entry.raw_line}</div>;
|
||||||
setIsLoading(false);
|
}
|
||||||
}
|
return (
|
||||||
};
|
<div className="font-mono text-xs py-0.5 flex gap-2">
|
||||||
|
<span className="text-gray-500 shrink-0">{entry.timestamp?.slice(0, 19)}</span>
|
||||||
if (isLoading) {
|
<LevelBadge level={entry.level} />
|
||||||
return (
|
{entry.service && <span className="text-purple-400 shrink-0">[{entry.service}]</span>}
|
||||||
<div className="flex items-center justify-center h-64">
|
<span className="text-gray-200 break-all">{entry.message}</span>
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
// ── Tab: Service Logs ───────────────────────────────────────────────────────
|
||||||
return (
|
function ServiceLogsTab() {
|
||||||
<div>
|
const [service, setService] = useState('network');
|
||||||
<div className="mb-8">
|
const [level, setLevel] = useState('ALL');
|
||||||
<h1 className="text-2xl font-bold text-gray-900">System Monitoring</h1>
|
const [lines, setLines] = useState(100);
|
||||||
<p className="mt-2 text-gray-600">
|
const [query, setQuery] = useState('');
|
||||||
View backend logs and health history
|
const [logs, setLogs] = useState([]);
|
||||||
</p>
|
const [loading, setLoading] = useState(false);
|
||||||
</div>
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [searchMode, setSearchMode] = useState(false);
|
||||||
<div className="mb-4 flex gap-4">
|
const intervalRef = useRef(null);
|
||||||
<button
|
const bottomRef = useRef(null);
|
||||||
className={`px-4 py-2 rounded ${tab === 'logs' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-800'}`}
|
|
||||||
onClick={() => setTab('logs')}
|
const fetch = useCallback(async () => {
|
||||||
>
|
setLoading(true);
|
||||||
<FileText className="inline-block mr-2" /> Backend Logs
|
try {
|
||||||
</button>
|
if (searchMode && query) {
|
||||||
<button
|
const res = await logsAPI.searchLogs({
|
||||||
className={`px-4 py-2 rounded ${tab === 'health' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-800'}`}
|
query,
|
||||||
onClick={() => setTab('health')}
|
services: [service],
|
||||||
>
|
level: level === 'ALL' ? undefined : level,
|
||||||
<Clock className="inline-block mr-2" /> Health History
|
});
|
||||||
</button>
|
setLogs(res.data.results || []);
|
||||||
</div>
|
} else {
|
||||||
|
const res = await logsAPI.getServiceLogs(service, level, lines);
|
||||||
{tab === 'logs' && (
|
const raw = res.data.logs || [];
|
||||||
<div className="card">
|
const parsed = raw.map(line => {
|
||||||
<div className="flex items-center mb-4">
|
try { return JSON.parse(line); } catch { return { raw_line: line }; }
|
||||||
<FileText className="h-6 w-6 text-primary-500 mr-2" />
|
});
|
||||||
<h3 className="text-lg font-medium text-gray-900">Backend Logs (last 100 lines)</h3>
|
setLogs(parsed.reverse());
|
||||||
</div>
|
}
|
||||||
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto">
|
} catch (e) {
|
||||||
<pre>{backendLog || 'No logs available.'}</pre>
|
setLogs([{ raw_line: `Error: ${e.message}` }]);
|
||||||
</div>
|
} finally {
|
||||||
</div>
|
setLoading(false);
|
||||||
)}
|
}
|
||||||
|
}, [service, level, lines, query, searchMode]);
|
||||||
{tab === 'health' && (
|
|
||||||
<div className="card">
|
useEffect(() => {
|
||||||
<div className="flex items-center mb-4">
|
fetch();
|
||||||
<Clock className="h-6 w-6 text-primary-500 mr-2" />
|
}, [service, level, lines]);
|
||||||
<h3 className="text-lg font-medium text-gray-900">Health History (last 100 checks)</h3>
|
|
||||||
</div>
|
useEffect(() => {
|
||||||
<div className="overflow-x-auto">
|
if (autoRefresh) {
|
||||||
<table className="min-w-full text-sm">
|
intervalRef.current = setInterval(fetch, 5000);
|
||||||
<thead>
|
} else {
|
||||||
<tr className="bg-gray-100">
|
clearInterval(intervalRef.current);
|
||||||
<th className="px-2 py-1 text-left">Timestamp</th>
|
}
|
||||||
<th className="px-2 py-1 text-left">Network</th>
|
return () => clearInterval(intervalRef.current);
|
||||||
<th className="px-2 py-1 text-left">WireGuard</th>
|
}, [autoRefresh, fetch]);
|
||||||
<th className="px-2 py-1 text-left">Email</th>
|
|
||||||
<th className="px-2 py-1 text-left">Calendar</th>
|
return (
|
||||||
<th className="px-2 py-1 text-left">Files</th>
|
<div className="space-y-4">
|
||||||
<th className="px-2 py-1 text-left">Routing</th>
|
{/* Controls */}
|
||||||
<th className="px-2 py-1 text-left">Vault</th>
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<th className="px-2 py-1 text-left">Alerts</th>
|
<select
|
||||||
</tr>
|
className="border rounded px-2 py-1 text-sm"
|
||||||
</thead>
|
value={service}
|
||||||
<tbody>
|
onChange={e => setService(e.target.value)}
|
||||||
{healthHistory.map((h, i) => (
|
>
|
||||||
<tr key={i} className={h.alerts && h.alerts.length > 0 ? 'bg-red-100' : ''}>
|
{SERVICES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
<td className="px-2 py-1 font-mono">{h.timestamp}</td>
|
</select>
|
||||||
<td className="px-2 py-1">
|
|
||||||
{h.network?.status === 'online' || h.network?.running === true ?
|
<select
|
||||||
<span className="text-green-600">OK</span> :
|
className="border rounded px-2 py-1 text-sm"
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
value={level}
|
||||||
}
|
onChange={e => setLevel(e.target.value)}
|
||||||
</td>
|
>
|
||||||
<td className="px-2 py-1">
|
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||||||
{h.wireguard?.status === 'online' || h.wireguard?.running === true ?
|
</select>
|
||||||
<span className="text-green-600">OK</span> :
|
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
<select
|
||||||
}
|
className="border rounded px-2 py-1 text-sm"
|
||||||
</td>
|
value={lines}
|
||||||
<td className="px-2 py-1">
|
onChange={e => setLines(Number(e.target.value))}
|
||||||
{h.email?.status === 'online' || h.email?.running === true ?
|
>
|
||||||
<span className="text-green-600">OK</span> :
|
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
</select>
|
||||||
}
|
|
||||||
</td>
|
<div className="flex gap-1 flex-1 min-w-48">
|
||||||
<td className="px-2 py-1">
|
<input
|
||||||
{h.calendar?.status === 'online' || h.calendar?.running === true ?
|
className="border rounded px-2 py-1 text-sm flex-1"
|
||||||
<span className="text-green-600">OK</span> :
|
placeholder="Search…"
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
value={query}
|
||||||
}
|
onChange={e => setQuery(e.target.value)}
|
||||||
</td>
|
onKeyDown={e => { if (e.key === 'Enter') { setSearchMode(true); fetch(); } }}
|
||||||
<td className="px-2 py-1">
|
/>
|
||||||
{h.files?.status === 'online' || h.files?.running === true ?
|
<button
|
||||||
<span className="text-green-600">OK</span> :
|
className="btn btn-secondary text-sm px-2 py-1"
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
onClick={() => { setSearchMode(true); fetch(); }}
|
||||||
}
|
title="Search"
|
||||||
</td>
|
>
|
||||||
<td className="px-2 py-1">
|
<Search className="h-4 w-4" />
|
||||||
{h.routing?.status === 'online' || h.routing?.running === true ?
|
</button>
|
||||||
<span className="text-green-600">OK</span> :
|
{query && (
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
<button
|
||||||
}
|
className="btn btn-secondary text-sm px-2 py-1"
|
||||||
</td>
|
onClick={() => { setQuery(''); setSearchMode(false); }}
|
||||||
<td className="px-2 py-1">
|
>
|
||||||
{h.vault?.status === 'online' || h.vault?.running === true ?
|
✕
|
||||||
<span className="text-green-600">OK</span> :
|
</button>
|
||||||
<span className="text-red-600 font-bold">Down</span>
|
)}
|
||||||
}
|
</div>
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1">
|
<button
|
||||||
{h.alerts && h.alerts.length > 0 ? (
|
className={`btn text-sm px-2 py-1 ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
<div className="flex flex-col gap-1">
|
onClick={() => setAutoRefresh(v => !v)}
|
||||||
{h.alerts.map((a, j) => (
|
title="Auto-refresh every 5s"
|
||||||
<span key={j} className="text-red-700 font-semibold flex items-center"><AlertTriangle className="inline-block h-4 w-4 mr-1 text-red-500" />{a}</span>
|
>
|
||||||
))}
|
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||||
</div>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="text-green-600">None</span>
|
<button className="btn btn-secondary text-sm px-2 py-1" onClick={fetch} title="Refresh">
|
||||||
)}
|
<RefreshCw className="h-4 w-4" />
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
|
||||||
</tbody>
|
{/* Log output */}
|
||||||
</table>
|
<div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto">
|
||||||
</div>
|
{loading && logs.length === 0 ? (
|
||||||
</div>
|
<div className="text-gray-400 text-sm">Loading…</div>
|
||||||
)}
|
) : logs.length === 0 ? (
|
||||||
</div>
|
<div className="text-gray-500 text-sm">No log entries found.</div>
|
||||||
);
|
) : (
|
||||||
}
|
logs.map((entry, i) => <LogLine key={i} entry={entry} />)
|
||||||
|
)}
|
||||||
export default Logs;
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{logs.length} entries</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Container Logs ─────────────────────────────────────────────────────
|
||||||
|
function ContainerLogsTab() {
|
||||||
|
const [containers, setContainers] = useState([]);
|
||||||
|
const [selected, setSelected] = useState('cell-api');
|
||||||
|
const [tail, setTail] = useState(100);
|
||||||
|
const [logs, setLogs] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
containerAPI.listContainers()
|
||||||
|
.then(res => {
|
||||||
|
const names = (res.data || []).map(c => c.name || c.Names?.[0]?.replace('/', '')).filter(Boolean);
|
||||||
|
setContainers(names);
|
||||||
|
if (names.length > 0 && !names.includes(selected)) setSelected(names[0]);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await containerAPI.getContainerLogs(selected, tail);
|
||||||
|
setLogs(res.data.logs || '');
|
||||||
|
} catch (e) {
|
||||||
|
setLogs(`Error: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selected, tail]);
|
||||||
|
|
||||||
|
useEffect(() => { fetch(); }, [selected, tail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
intervalRef.current = setInterval(fetch, 5000);
|
||||||
|
} else {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
return () => clearInterval(intervalRef.current);
|
||||||
|
}, [autoRefresh, fetch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
value={selected}
|
||||||
|
onChange={e => setSelected(e.target.value)}
|
||||||
|
>
|
||||||
|
{containers.length > 0
|
||||||
|
? containers.map(c => <option key={c} value={c}>{c}</option>)
|
||||||
|
: <option value="cell-api">cell-api</option>}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
value={tail}
|
||||||
|
onChange={e => setTail(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn text-sm px-2 py-1 ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setAutoRefresh(v => !v)}
|
||||||
|
title="Auto-refresh every 5s"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn btn-secondary text-sm px-2 py-1" onClick={fetch}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 text-green-400 rounded-lg p-3 h-[500px] overflow-y-auto font-mono text-xs whitespace-pre-wrap">
|
||||||
|
{loading ? 'Loading…' : logs || 'No logs.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Statistics & Rotation ──────────────────────────────────────────────
|
||||||
|
function StatisticsTab() {
|
||||||
|
const [stats, setStats] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rotating, setRotating] = useState(null);
|
||||||
|
|
||||||
|
const fetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await logsAPI.getStatistics();
|
||||||
|
setStats(res.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetch(); }, []);
|
||||||
|
|
||||||
|
const rotate = async (service) => {
|
||||||
|
setRotating(service);
|
||||||
|
try {
|
||||||
|
await logsAPI.rotateLogs(service || null);
|
||||||
|
await fetch();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setRotating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtSize = (bytes) => {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = Object.keys(stats);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Log File Statistics</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={fetch}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={() => rotate(null)}
|
||||||
|
disabled={rotating === 'all'}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1 inline" /> Rotate All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-sm">No log statistics available.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm border rounded">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<th className="px-3 py-2 text-left">Service</th>
|
||||||
|
<th className="px-3 py-2 text-right">File Size</th>
|
||||||
|
<th className="px-3 py-2 text-right">Total Entries</th>
|
||||||
|
<th className="px-3 py-2 text-right">Errors</th>
|
||||||
|
<th className="px-3 py-2 text-right">Warnings</th>
|
||||||
|
<th className="px-3 py-2 text-left">Last Entry</th>
|
||||||
|
<th className="px-3 py-2 text-center">Rotate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{services.map(svc => {
|
||||||
|
const s = stats[svc];
|
||||||
|
const hasError = s?.error;
|
||||||
|
const errorCount = s?.level_counts?.ERROR || 0;
|
||||||
|
const warnCount = s?.level_counts?.WARNING || 0;
|
||||||
|
return (
|
||||||
|
<tr key={svc} className="border-t hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 font-medium">{svc}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-mono">
|
||||||
|
{hasError ? '—' : fmtSize(s.file_size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{hasError ? <span className="text-red-500 text-xs">{s.error}</span> : s.total_entries}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-2 text-right ${errorCount > 0 ? 'text-red-600 font-bold' : ''}`}>
|
||||||
|
{hasError ? '—' : errorCount}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-2 text-right ${warnCount > 0 ? 'text-yellow-600' : ''}`}>
|
||||||
|
{hasError ? '—' : warnCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-500">
|
||||||
|
{hasError ? '—' : (s.last_entry?.slice(0, 19) || '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-xs px-2 py-0.5"
|
||||||
|
onClick={() => rotate(svc)}
|
||||||
|
disabled={rotating === svc}
|
||||||
|
>
|
||||||
|
<RotateCcw className={`h-3 w-3 ${rotating === svc ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Health History ─────────────────────────────────────────────────────
|
||||||
|
function HealthHistoryTab() {
|
||||||
|
const [healthHistory, setHealthHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await monitoringAPI.getHealthHistory();
|
||||||
|
setHealthHistory(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetch(); }, []);
|
||||||
|
|
||||||
|
const ServiceCol = ({ data }) => {
|
||||||
|
const ok = data?.status === 'online' || data?.running === true;
|
||||||
|
return ok
|
||||||
|
? <span className="text-green-600">OK</span>
|
||||||
|
: <span className="text-red-600 font-bold">Down</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Health History (last 100 checks)</h3>
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={fetch}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
{['Timestamp', 'Network', 'WireGuard', 'Email', 'Calendar', 'Files', 'Routing', 'Vault', 'Alerts'].map(h => (
|
||||||
|
<th key={h} className="px-2 py-1 text-left">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{healthHistory.map((h, i) => (
|
||||||
|
<tr key={i} className={h.alerts?.length > 0 ? 'bg-red-50' : ''}>
|
||||||
|
<td className="px-2 py-1 font-mono text-xs">{h.timestamp}</td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.network} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.wireguard} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.email} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.calendar} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.files} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.routing} /></td>
|
||||||
|
<td className="px-2 py-1"><ServiceCol data={h.vault} /></td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
{h.alerts?.length > 0 ? (
|
||||||
|
h.alerts.map((a, j) => (
|
||||||
|
<span key={j} className="text-red-700 font-semibold flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-red-500" />{a}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Logs Page ──────────────────────────────────────────────────────────
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'service', label: 'Service Logs', icon: FileText },
|
||||||
|
{ id: 'container', label: 'Container Logs', icon: Box },
|
||||||
|
{ id: 'statistics', label: 'Statistics & Rotation', icon: BarChart2 },
|
||||||
|
{ id: 'health', label: 'Health History', icon: Activity },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Logs() {
|
||||||
|
const [tab, setTab] = useState('service');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Logs & Monitoring</h1>
|
||||||
|
<p className="mt-1 text-gray-600">Search service logs, view container output, and manage log rotation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-4 flex gap-2 border-b">
|
||||||
|
{TABS.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`px-4 py-2 text-sm font-medium flex items-center gap-1 border-b-2 transition-colors ${
|
||||||
|
tab === id
|
||||||
|
? 'border-primary-600 text-primary-700'
|
||||||
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{tab === 'service' && <ServiceLogsTab />}
|
||||||
|
{tab === 'container' && <ContainerLogsTab />}
|
||||||
|
{tab === 'statistics' && <StatisticsTab />}
|
||||||
|
{tab === 'health' && <HealthHistoryTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logs;
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ export const monitoringAPI = {
|
|||||||
getHealthHistory: () => api.get('/api/health/history'),
|
getHealthHistory: () => api.get('/api/health/history'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logs API
|
||||||
|
export const logsAPI = {
|
||||||
|
getServiceLogs: (service, level = 'ALL', lines = 100) =>
|
||||||
|
api.get(`/api/logs/services/${service}`, { params: { level, lines } }),
|
||||||
|
searchLogs: (data) => api.post('/api/logs/search', data),
|
||||||
|
exportLogs: (data) => api.post('/api/logs/export', data),
|
||||||
|
getStatistics: (service) =>
|
||||||
|
api.get('/api/logs/statistics', service ? { params: { service } } : {}),
|
||||||
|
rotateLogs: (service) => api.post('/api/logs/rotate', service ? { service } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Container Management API
|
// Container Management API
|
||||||
export const containerAPI = {
|
export const containerAPI = {
|
||||||
// Containers
|
// Containers
|
||||||
|
|||||||
Reference in New Issue
Block a user