feat: proper logging architecture — Docker rotation, persisted service logs, verbosity config

docker-compose.yml:
- Add json-file logging driver (max-size: 10m, max-file: 5) to all 13 containers
- Docker now owns container stdout/stderr rotation automatically
- Add ./data/logs:/app/api/data/logs volume to API — service logs now persist across restarts

log_manager.py:
- Remove container log collection hack (Docker handles it natively)
- Add set_service_level(service, level) — change log level at runtime without restart
- Add get_service_levels() — return current per-service levels
- Simplify get_all_log_file_infos to return only service log files

app.py:
- Add GET /api/logs/verbosity — return current per-service log levels
- Add PUT /api/logs/verbosity — update levels at runtime, persist to config/log_levels.json
- Load persisted log level overrides at startup from log_levels.json
- Simplify rotate endpoint (service logs only, container logs owned by Docker)

wireguard_manager.py:
- get_keys(): return empty strings if key files don't exist (prevents get_status crash
  when wg0.conf is missing at startup and falls through to generate_config)

Logs page (4 tabs):
- API Service Logs: structured JSON logs from Python managers, with search/filter/rotate panel
- Container Logs: live docker logs (read via existing /api/containers/<name>/logs endpoint)
- Verbosity Config: per-service level dropdowns, apply immediately + persist
- Health History: existing health poll table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 02:07:57 -04:00
parent 7b39331417
commit f848a1d056
6 changed files with 313 additions and 289 deletions
+181 -192
View File
@@ -1,17 +1,14 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Activity, Clock, FileText, AlertTriangle, Search, RefreshCw,
RotateCcw, Box, BarChart2
} from 'lucide-react';
import { Activity, FileText, AlertTriangle, Search, RefreshCw, RotateCcw, Box, Settings } from 'lucide-react';
import { monitoringAPI, logsAPI, containerAPI } from '../services/api';
const API_SERVICES = ['ALL', 'network', 'wireguard', 'routing', 'email', 'calendar', 'files', 'vault', 'container', 'api'];
const API_SERVICES = ['ALL', 'network', 'wireguard', 'routing', 'email', 'calendar', 'files', 'vault', 'api'];
const LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const LEVEL_COLORS = {
DEBUG: 'text-gray-500',
INFO: 'text-blue-400',
WARNING: 'text-yellow-400',
ERROR: 'text-red-400',
DEBUG: 'text-gray-500',
INFO: 'text-blue-400',
WARNING: 'text-yellow-400',
ERROR: 'text-red-400',
CRITICAL: 'text-red-500 font-bold',
};
@@ -21,28 +18,32 @@ function LevelBadge({ level }) {
}
function LogLine({ entry }) {
if (!entry || entry.raw_line !== undefined) {
return <div className="font-mono text-xs text-gray-300 py-0.5 break-all">{entry?.raw_line || ''}</div>;
}
if (!entry || entry.raw_line !== undefined)
return <div className="font-mono text-xs text-gray-300 py-0.5 break-all">{entry?.raw_line ?? ''}</div>;
return (
<div className="font-mono text-xs py-0.5 flex gap-2 flex-wrap">
<span className="text-gray-500 shrink-0">{String(entry.timestamp || '').slice(0, 19)}</span>
<span className="text-gray-500 shrink-0">{String(entry.timestamp ?? '').slice(0, 19)}</span>
<LevelBadge level={entry.level} />
{entry.service && <span className="text-purple-400 shrink-0">[{entry.service}]</span>}
<span className="text-gray-200 break-all">{entry.message || ''}</span>
<span className="text-gray-200 break-all">{entry.message ?? ''}</span>
</div>
);
}
// ── Tab: API Service Logs ───────────────────────────────────────────────────
// ── Tab 1: API Service Logs ─────────────────────────────────────────────────
// These are structured JSON logs written by Python service managers.
// Stored in /app/api/data/logs/ (persisted to ./data/logs/ on the host via volume mount).
function ApiServiceLogsTab() {
const [service, setService] = useState('ALL');
const [level, setLevel] = useState('ALL');
const [lines, setLines] = useState(100);
const [query, setQuery] = useState('');
const [logs, setLogs] = useState([]);
const [level, setLevel] = useState('ALL');
const [lines, setLines] = useState(100);
const [query, setQuery] = useState('');
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [fileInfos, setFileInfos] = useState([]);
const [rotating, setRotating] = useState(null);
const [showFiles, setShowFiles] = useState(false);
const intervalRef = useRef(null);
const doFetch = useCallback(async () => {
@@ -59,9 +60,7 @@ function ApiServiceLogsTab() {
} else {
const res = await logsAPI.getServiceLogs(service, level, lines);
const raw = res.data.logs || [];
const parsed = raw.map(line => {
try { return JSON.parse(line); } catch { return { raw_line: line }; }
});
const parsed = raw.map(l => { try { return JSON.parse(l); } catch { return { raw_line: l }; } });
setLogs(parsed.reverse());
}
} catch (e) {
@@ -74,21 +73,32 @@ function ApiServiceLogsTab() {
useEffect(() => { doFetch(); }, [service, level, lines]);
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(doFetch, 5000);
} else {
clearInterval(intervalRef.current);
}
if (autoRefresh) intervalRef.current = setInterval(doFetch, 5000);
else clearInterval(intervalRef.current);
return () => clearInterval(intervalRef.current);
}, [autoRefresh, doFetch]);
const loadFileInfos = async () => {
try { setFileInfos((await logsAPI.getLogFiles()).data || []); } catch {}
};
const toggleFiles = () => {
if (!showFiles) loadFileInfos();
setShowFiles(v => !v);
};
const rotate = async (service) => {
if (!window.confirm(`Rotate log for ${service || 'all services'}?\nCurrent file will be archived.`)) return;
setRotating(service || 'all');
try { await logsAPI.rotateLogs(service || null); await loadFileInfos(); } catch {}
setRotating(null);
};
const fmtSize = b => !b ? '0 B' : b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : `${(b/1048576).toFixed(2)} MB`;
return (
<div className="space-y-3">
<p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
These are structured logs written by the API backend for each service manager (wireguard, network, routing, etc.).
They are stored in <code>/app/data/logs/&lt;service&gt;.log</code> and can be rotated from the Statistics tab.
</p>
{/* Controls */}
<div className="flex flex-wrap gap-2 items-center">
<select className="border rounded px-2 py-1 text-sm" value={service} onChange={e => setService(e.target.value)}>
{API_SERVICES.map(s => <option key={s} value={s}>{s === 'ALL' ? 'ALL services' : s}</option>)}
@@ -96,7 +106,7 @@ function ApiServiceLogsTab() {
<select className="border rounded px-2 py-1 text-sm" value={level} onChange={e => setLevel(e.target.value)}>
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
{service !== 'ALL' && (
{service !== 'ALL' && !query && (
<select className="border rounded px-2 py-1 text-sm" value={lines} onChange={e => setLines(Number(e.target.value))}>
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
</select>
@@ -110,25 +120,55 @@ function ApiServiceLogsTab() {
onKeyDown={e => e.key === 'Enter' && doFetch()}
/>
<button className="btn btn-secondary px-2 py-1 text-sm" onClick={doFetch}><Search className="h-4 w-4" /></button>
{query && <button className="btn btn-secondary px-2 py-1 text-sm" onClick={() => { setQuery(''); }}></button>}
{query && <button className="btn btn-secondary px-2 py-1 text-sm" onClick={() => setQuery('')}></button>}
</div>
<button
className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
title="Auto-refresh 5s"
onClick={() => setAutoRefresh(v => !v)}
>
<button className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} title="Auto-refresh 5s" onClick={() => setAutoRefresh(v => !v)}>
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
</button>
<button className="btn btn-secondary px-2 py-1 text-sm" onClick={doFetch}><RefreshCw className="h-4 w-4" /></button>
<button className="btn btn-secondary px-2 py-1 text-sm" onClick={toggleFiles} title="Files & Rotation">
<RotateCcw className="h-4 w-4" />
</button>
</div>
{/* File info panel */}
{showFiles && (
<div className="border rounded bg-gray-50 p-3">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Log Files (persisted to ./data/logs/)</span>
<button className="btn btn-secondary text-xs px-2 py-0.5" onClick={() => rotate(null)} disabled={rotating === 'all'}>
<RotateCcw className={`h-3 w-3 inline mr-1 ${rotating === 'all' ? 'animate-spin' : ''}`} />Rotate All
</button>
</div>
<table className="w-full text-xs">
<thead><tr className="text-gray-500"><th className="text-left py-1">Service</th><th className="text-right py-1">Size</th><th className="text-left py-1 pl-3">Modified</th><th className="text-center py-1"></th></tr></thead>
<tbody>
{fileInfos.map(f => (
<tr key={f.name} className="border-t">
<td className="py-1 font-mono">{f.name}</td>
<td className="py-1 text-right font-mono">{fmtSize(f.size)}</td>
<td className="py-1 pl-3 text-gray-500">{f.modified?.slice(0, 19)}</td>
<td className="py-1 text-center">
<button className="btn btn-secondary px-1.5 py-0.5 text-xs" onClick={() => rotate(f.name)} disabled={rotating === f.name}>
<RotateCcw className={`h-3 w-3 ${rotating === f.name ? 'animate-spin' : ''}`} />
</button>
</td>
</tr>
))}
{fileInfos.length === 0 && <tr><td colSpan={4} className="text-gray-400 py-2 text-center">No log files found.</td></tr>}
</tbody>
</table>
</div>
)}
{/* Log output */}
<div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto">
{loading && logs.length === 0 ? (
{loading && !logs.length ? (
<div className="text-gray-400 text-sm">Loading</div>
) : logs.length === 0 ? (
) : !logs.length ? (
<div className="text-gray-500 text-sm">No entries found.</div>
) : (
logs.map((entry, i) => <LogLine key={i} entry={entry} />)
logs.map((e, i) => <LogLine key={i} entry={e} />)
)}
</div>
<div className="text-xs text-gray-400">{logs.length} entries</div>
@@ -136,13 +176,15 @@ function ApiServiceLogsTab() {
);
}
// ── Tab: Container Logs ─────────────────────────────────────────────────────
// ── Tab 2: Container Logs ───────────────────────────────────────────────────
// Container stdout/stderr read live via `docker logs`.
// Docker itself rotates these files (json-file driver, max-size 10m, max-file 5 — configured in docker-compose.yml).
function ContainerLogsTab() {
const [containers, setContainers] = useState([]);
const [selected, setSelected] = useState('cell-api');
const [tail, setTail] = useState(100);
const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState('cell-api');
const [tail, setTail] = useState(100);
const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const intervalRef = useRef(null);
@@ -151,10 +193,9 @@ function ContainerLogsTab() {
.then(res => {
const names = (res.data || [])
.map(c => c.name || c.Names?.[0]?.replace('/', ''))
.filter(Boolean)
.sort();
.filter(Boolean).sort();
setContainers(names);
if (names.length > 0 && !names.includes(selected)) setSelected(names[0]);
if (names.length && !names.includes(selected)) setSelected(names[0]);
})
.catch(() => {});
}, []);
@@ -163,8 +204,9 @@ function ContainerLogsTab() {
if (!selected) return;
setLoading(true);
try {
const res = await logsAPI.getStoredContainerLogs(selected, tail);
setLines(res.data.lines || []);
const res = await containerAPI.getContainerLogs(selected, tail);
const raw = res.data.logs || '';
setLines(typeof raw === 'string' ? raw.split('\n').filter(Boolean) : raw);
} catch (e) {
setLines([`Error: ${e.message}`]);
} finally {
@@ -175,193 +217,148 @@ function ContainerLogsTab() {
useEffect(() => { doFetch(); }, [selected, tail]);
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(doFetch, 5000);
} else {
clearInterval(intervalRef.current);
}
if (autoRefresh) intervalRef.current = setInterval(doFetch, 5000);
else clearInterval(intervalRef.current);
return () => clearInterval(intervalRef.current);
}, [autoRefresh, doFetch]);
return (
<div className="space-y-3">
<p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
Container stdout/stderr collected from Docker and stored in <code>/app/data/logs/container_&lt;name&gt;.log</code>.
Each fetch appends new lines since last collection. Rotate from the Statistics tab.
</p>
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
Live stdout/stderr from Docker. Rotation is automatic: <code>json-file</code> driver,
<strong> 10 MB max-size, 5 backups</strong> per container configured in <code>docker-compose.yml</code>.
</div>
<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 : ['cell-api']).map(c => (
<option key={c} value={c}>{c}</option>
))}
{(containers.length ? containers : ['cell-api']).map(c => <option key={c} value={c}>{c}</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 px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
title="Auto-refresh 5s"
onClick={() => setAutoRefresh(v => !v)}
>
<button className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} title="Auto-refresh 5s" onClick={() => setAutoRefresh(v => !v)}>
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
</button>
<button className="btn btn-secondary px-2 py-1 text-sm" onClick={doFetch}><RefreshCw className="h-4 w-4" /></button>
</div>
<div className="bg-gray-900 text-green-300 rounded-lg p-3 h-[500px] overflow-y-auto font-mono text-xs">
{loading && lines.length === 0 ? (
{loading && !lines.length ? (
<span className="text-gray-400">Loading</span>
) : lines.length === 0 ? (
<span className="text-gray-500">No stored logs. Click refresh to collect.</span>
) : !lines.length ? (
<span className="text-gray-500">No output.</span>
) : (
lines.map((l, i) => <div key={i} className="py-0.5 break-all">{l}</div>)
)}
</div>
<div className="text-xs text-gray-400">{lines.length} lines stored</div>
<div className="text-xs text-gray-400">{lines.length} lines</div>
</div>
);
}
// ── Tab: Statistics & Rotation ──────────────────────────────────────────────
function StatisticsTab() {
const [files, setFiles] = useState([]);
// ── Tab 3: Verbosity Config ─────────────────────────────────────────────────
function VerbosityTab() {
const [levels, setLevels] = useState({});
const [pending, setPending] = useState({});
const [loading, setLoading] = useState(false);
const [rotating, setRotating] = useState(null);
const [msg, setMsg] = useState('');
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const doFetch = async () => {
const load = async () => {
setLoading(true);
try {
const res = await logsAPI.getLogFiles();
setFiles(res.data || []);
const res = await logsAPI.getVerbosity();
setLevels(res.data || {});
setPending(res.data || {});
} catch (e) {
console.error(e);
setMsg(`Error: ${e.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => { doFetch(); }, []);
useEffect(() => { load(); }, []);
const rotate = async (file) => {
const label = file ? file.label : 'all log files';
if (!window.confirm(`Rotate logs for ${label}?\nThe current file will be archived and a new one started.`)) return;
const key = file ? file.name : 'all';
setRotating(key);
const save = async () => {
const changed = Object.fromEntries(
Object.entries(pending).filter(([k, v]) => v !== levels[k])
);
if (!Object.keys(changed).length) { setMsg('No changes.'); return; }
setSaving(true);
setMsg('');
try {
if (file) {
await logsAPI.rotateLogs(file.name, file.kind);
} else {
// Rotate all: service logs via old endpoint, container logs individually
await Promise.all(files.map(f => logsAPI.rotateLogs(f.name, f.kind)));
}
setMsg('Rotation complete.');
await doFetch();
const res = await logsAPI.setVerbosity(changed);
setLevels(res.data.levels || pending);
setMsg('Levels saved and applied.');
} catch (e) {
setMsg(`Error: ${e.message}`);
} finally {
setRotating(null);
setSaving(false);
}
};
const fmtSize = bytes => {
if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
};
const services = Object.keys(pending).sort();
const serviceFiles = files.filter(f => f.kind === 'service');
const containerFiles = files.filter(f => f.kind === 'container');
return (
<div className="space-y-4 max-w-lg">
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
Changes apply immediately to the running API no restart needed. Levels are persisted to
<code> config/log_levels.json</code> and restored on container restart.
</div>
const FileTable = ({ rows, title }) => (
<div>
<h4 className="font-medium text-gray-700 mb-2">{title}</h4>
{rows.length === 0 ? (
<p className="text-sm text-gray-400">No files yet.</p>
) : (
<table className="min-w-full text-sm border rounded mb-4">
{loading ? <div className="text-gray-500 text-sm">Loading</div> : (
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="px-3 py-2 text-left">Name</th>
<th className="px-3 py-2 text-right">Size</th>
<th className="px-3 py-2 text-left">Last Modified</th>
<th className="px-3 py-2 text-center">Rotate</th>
<th className="px-3 py-2 text-left">Service</th>
<th className="px-3 py-2 text-left">Log Level</th>
</tr>
</thead>
<tbody>
{rows.map(f => (
<tr key={f.name} className="border-t hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-xs">{f.label}</td>
<td className="px-3 py-2 text-right font-mono text-xs">{fmtSize(f.size)}</td>
<td className="px-3 py-2 text-xs text-gray-500">{f.modified?.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(f)}
disabled={rotating === f.name}
{services.map(svc => (
<tr key={svc} className="border-t">
<td className="px-3 py-2 font-medium">{svc}</td>
<td className="px-3 py-2">
<select
className="border rounded px-2 py-1 text-sm"
value={pending[svc] || 'INFO'}
onChange={e => setPending(p => ({ ...p, [svc]: e.target.value }))}
>
<RotateCcw className={`h-3 w-3 inline ${rotating === f.name ? 'animate-spin' : ''}`} />
</button>
{['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
{pending[svc] !== levels[svc] && (
<span className="ml-2 text-xs text-yellow-600">changed</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Log Files & Rotation</h3>
<div className="flex gap-2">
<button className="btn btn-secondary text-sm" onClick={doFetch}>
<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 className="flex items-center gap-3">
<button className="btn btn-primary text-sm" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Apply Changes'}
</button>
<button className="btn btn-secondary text-sm" onClick={load}>Reset</button>
{msg && <span className="text-sm text-gray-600">{msg}</span>}
</div>
{msg && <div className="text-sm text-green-700 bg-green-50 rounded px-3 py-2">{msg}</div>}
{loading ? <div className="text-gray-500 text-sm">Loading</div> : (
<>
<FileTable rows={serviceFiles} title="API Service Logs" />
<FileTable rows={containerFiles} title="Container Logs (stored)" />
</>
)}
</div>
);
}
// ── Tab: Health History ─────────────────────────────────────────────────────
// ── Tab 4: Health History ───────────────────────────────────────────────────
function HealthHistoryTab() {
const [healthHistory, setHealthHistory] = useState([]);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
const doFetch = async () => {
const load = async () => {
setLoading(true);
try {
const res = await monitoringAPI.getHealthHistory();
setHealthHistory(res.data || []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
try { setHistory((await monitoringAPI.getHealthHistory()).data || []); } catch {}
setLoading(false);
};
useEffect(() => { doFetch(); }, []);
useEffect(() => { load(); }, []);
const SvcCol = ({ data }) => (data?.status === 'online' || data?.running === true)
? <span className="text-green-600">OK</span>
@@ -371,23 +368,21 @@ function HealthHistoryTab() {
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Health History</h3>
<button className="btn btn-secondary text-sm" onClick={doFetch}>
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
</button>
<button className="btn btn-secondary text-sm" onClick={load}><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 => (
{['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' : ''}>
{history.map((h, i) => (
<tr key={i} className={h.alerts?.length ? 'bg-red-50' : ''}>
<td className="px-2 py-1 font-mono text-xs">{h.timestamp}</td>
<td className="px-2 py-1"><SvcCol data={h.network} /></td>
<td className="px-2 py-1"><SvcCol data={h.wireguard} /></td>
@@ -397,7 +392,7 @@ function HealthHistoryTab() {
<td className="px-2 py-1"><SvcCol data={h.routing} /></td>
<td className="px-2 py-1"><SvcCol data={h.vault} /></td>
<td className="px-2 py-1">
{h.alerts?.length > 0
{h.alerts?.length
? 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}
@@ -417,22 +412,20 @@ function HealthHistoryTab() {
// ── Main ────────────────────────────────────────────────────────────────────
const TABS = [
{ id: 'api', label: 'API 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 },
{ id: 'api', label: 'API Service Logs', icon: FileText },
{ id: 'container', label: 'Container Logs', icon: Box },
{ id: 'verbosity', label: 'Verbosity Config', icon: Settings },
{ id: 'health', label: 'Health History', icon: Activity },
];
function Logs() {
export default function Logs() {
const [tab, setTab] = useState('api');
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">API service logs, container stdout/stderr, rotation, and health history.</p>
<p className="mt-1 text-gray-600">API service logs · Container stdout/stderr · Log level config · Health history</p>
</div>
<div className="mb-4 flex gap-2 border-b">
{TABS.map(({ id, label, icon: Icon }) => (
<button
@@ -442,20 +435,16 @@ function Logs() {
}`}
onClick={() => setTab(id)}
>
<Icon className="h-4 w-4" />
{label}
<Icon className="h-4 w-4" />{label}
</button>
))}
</div>
<div className="card">
{tab === 'api' && <ApiServiceLogsTab />}
{tab === 'api' && <ApiServiceLogsTab />}
{tab === 'container' && <ContainerLogsTab />}
{tab === 'statistics' && <StatisticsTab />}
{tab === 'health' && <HealthHistoryTab />}
{tab === 'verbosity' && <VerbosityTab />}
{tab === 'health' && <HealthHistoryTab />}
</div>
</div>
);
}
export default Logs;