13074f56cb
Unit Tests / test (push) Successful in 12m34s
Root causes fixed:
- Dead LOG_LEVEL globals() lookup pinned root logger at INFO regardless of
PIC_LOG_LEVEL env or config; replaced with _resolve_root_log_level() +
apply_root_log_level() which sets both root logger and all attached handlers
at startup and on runtime re-apply.
- set_service_level() only set the named 'pic.<service>' logger; bare module
loggers (e.g. 'caddy_manager') were never reached, so per-service log files
stayed 0 bytes. Fixed via _SERVICE_MODULE_LOGGERS map covering all managers.
- Log viewer GET /api/logs had no level filter; added ?level= query param.
- Per-service log levels lived in an out-of-band config/api/log_levels.json
side-file with no validation; migrated into ConfigManager under a new
'logging' section ({python:{root,services}, containers:{caddy,coredns,
wireguard,mailserver,api}}) with get/set helpers, invalid-level rejection,
and one-time migration from the old file on first load.
New capabilities:
- Container log levels: Caddy (injects global log { level X } + hot reload),
CoreDNS (DEBUG enables log plugin, else errors-only), WireGuard/mailserver
via pending_restart path.
- PUT /api/logs/verbosity accepts {python, containers} dict; returns per-entry
applied:hot|pending_restart status.
- Webui Logs page gains two-section Verbosity tab (Python services + Container
services) with needs-restart badges.
- managers.py wires per-service loggers before manager instantiation and
re-applies persisted levels from ConfigManager; legacy log_levels.json read
removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
530 lines
22 KiB
React
530 lines
22 KiB
React
import { useState, useEffect, useRef, useCallback } from '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', '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',
|
|
CRITICAL: 'text-red-500 font-bold',
|
|
};
|
|
|
|
function LevelBadge({ level }) {
|
|
const cls = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-400';
|
|
return <span className={`font-mono text-xs shrink-0 ${cls}`}>[{level || '?'}]</span>;
|
|
}
|
|
|
|
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>;
|
|
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>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 [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 () => {
|
|
setLoading(true);
|
|
try {
|
|
const allSvcs = API_SERVICES.filter(s => s !== 'ALL');
|
|
if (service === 'ALL' || query) {
|
|
const res = await logsAPI.searchLogs({
|
|
query: query || '',
|
|
services: service === 'ALL' ? allSvcs : [service],
|
|
level: level === 'ALL' ? undefined : level,
|
|
});
|
|
setLogs(res.data.results || []);
|
|
} else {
|
|
const res = await logsAPI.getServiceLogs(service, level, lines);
|
|
const raw = res.data.logs || [];
|
|
const parsed = raw.map(l => { try { return JSON.parse(l); } catch { return { raw_line: l }; } });
|
|
setLogs(parsed.reverse());
|
|
}
|
|
} catch (e) {
|
|
setLogs([{ raw_line: `Error: ${e.message}` }]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [service, level, lines, query]);
|
|
|
|
useEffect(() => { doFetch(); }, [service, level, lines]);
|
|
|
|
useEffect(() => {
|
|
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">
|
|
{/* 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>)}
|
|
</select>
|
|
<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' && !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>
|
|
)}
|
|
<div className="flex gap-1 flex-1 min-w-40">
|
|
<input
|
|
className="border rounded px-2 py-1 text-sm flex-1"
|
|
placeholder="Search…"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
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>}
|
|
</div>
|
|
<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-1">
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-700">Log Files</span>
|
|
<span className="ml-2 text-xs text-gray-400">host path: <code>./data/logs/</code> — rotated backups saved as <code>wireguard.log.1</code>, <code>wireguard.log.2</code> …</span>
|
|
</div>
|
|
<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">File</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.file} className={`border-t ${f.backup ? 'text-gray-400' : ''}`}>
|
|
<td className="py-1 font-mono">{f.file}</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">
|
|
{!f.backup && (
|
|
<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 ? (
|
|
<div className="text-gray-400 text-sm">Loading…</div>
|
|
) : !logs.length ? (
|
|
<div className="text-gray-500 text-sm">No entries found.</div>
|
|
) : (
|
|
logs.map((e, i) => <LogLine key={i} entry={e} />)
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-400">{logs.length} entries</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 [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).sort();
|
|
setContainers(names);
|
|
if (names.length && !names.includes(selected)) setSelected(names[0]);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const doFetch = useCallback(async () => {
|
|
if (!selected) return;
|
|
setLoading(true);
|
|
try {
|
|
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 {
|
|
setLoading(false);
|
|
}
|
|
}, [selected, tail]);
|
|
|
|
useEffect(() => { doFetch(); }, [selected, tail]);
|
|
|
|
useEffect(() => {
|
|
if (autoRefresh) intervalRef.current = setInterval(doFetch, 5000);
|
|
else clearInterval(intervalRef.current);
|
|
return () => clearInterval(intervalRef.current);
|
|
}, [autoRefresh, doFetch]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<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 ? 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)}>
|
|
<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 ? (
|
|
<span className="text-gray-400">Loading…</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</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab 3: Verbosity Config ─────────────────────────────────────────────────
|
|
const VERBOSITY_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
|
// Container services that need a container recreate before a level change applies.
|
|
const PENDING_RESTART_CONTAINERS = ['wireguard', 'mailserver'];
|
|
|
|
function LevelRow({ name, value, original, badge, onChange }) {
|
|
return (
|
|
<tr className="border-t">
|
|
<td className="px-3 py-2 font-medium">
|
|
{name}
|
|
{badge && (
|
|
<span className="ml-2 text-xs bg-orange-100 text-orange-700 rounded px-1.5 py-0.5">
|
|
needs restart
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<select
|
|
className="border rounded px-2 py-1 text-sm"
|
|
value={value || 'INFO'}
|
|
onChange={e => onChange(e.target.value)}
|
|
>
|
|
{VERBOSITY_LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
|
</select>
|
|
{value !== original && <span className="ml-2 text-xs text-yellow-600">changed</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function VerbosityTab() {
|
|
const [config, setConfig] = useState(null); // last-saved server state
|
|
const [pending, setPending] = useState(null); // editable copy
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [msg, setMsg] = useState('');
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await logsAPI.getVerbosity();
|
|
setConfig(res.data);
|
|
setPending(JSON.parse(JSON.stringify(res.data)));
|
|
} catch (e) {
|
|
setMsg(`Error: ${e.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const setRoot = (v) =>
|
|
setPending(p => ({ ...p, python: { ...p.python, root: v } }));
|
|
const setService = (svc, v) =>
|
|
setPending(p => ({ ...p, python: { ...p.python, services: { ...p.python.services, [svc]: v } } }));
|
|
const setContainer = (c, v) =>
|
|
setPending(p => ({ ...p, containers: { ...p.containers, [c]: v } }));
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
setMsg('');
|
|
try {
|
|
const res = await logsAPI.setVerbosity(pending);
|
|
setConfig(res.data.logging);
|
|
setPending(JSON.parse(JSON.stringify(res.data.logging)));
|
|
const restarts = Object.entries(res.data.applied || {})
|
|
.filter(([, v]) => v === 'pending_restart')
|
|
.map(([k]) => k);
|
|
setMsg(restarts.length
|
|
? `Saved. ${restarts.join(', ')} will apply on next container restart.`
|
|
: 'Levels saved and applied.');
|
|
} catch (e) {
|
|
setMsg(`Error: ${e.response?.data?.error || e.message}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading || !pending) return <div className="text-gray-500 text-sm">Loading…</div>;
|
|
|
|
const services = Object.keys(pending.python.services).sort();
|
|
const containers = Object.keys(pending.containers).sort();
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-lg">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-1">Python services</h3>
|
|
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">
|
|
Applies immediately to the running API — no restart needed. Persisted in cell_config and
|
|
restored on restart.
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-100">
|
|
<th className="px-3 py-2 text-left">Service</th>
|
|
<th className="px-3 py-2 text-left">Log Level</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<LevelRow
|
|
name="root (all bare-module loggers)"
|
|
value={pending.python.root}
|
|
original={config.python.root}
|
|
onChange={setRoot}
|
|
/>
|
|
{services.map(svc => (
|
|
<LevelRow
|
|
key={svc}
|
|
name={svc}
|
|
value={pending.python.services[svc]}
|
|
original={config.python.services[svc]}
|
|
onChange={v => setService(svc, v)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-1">Container services</h3>
|
|
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">
|
|
caddy and coredns reload immediately. wireguard and mailserver are container-ENV driven —
|
|
their level applies on the next container restart.
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-100">
|
|
<th className="px-3 py-2 text-left">Container</th>
|
|
<th className="px-3 py-2 text-left">Log Level</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{containers.map(c => (
|
|
<LevelRow
|
|
key={c}
|
|
name={c}
|
|
value={pending.containers[c]}
|
|
original={config.containers[c]}
|
|
badge={PENDING_RESTART_CONTAINERS.includes(c)}
|
|
onChange={v => setContainer(c, v)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Tab 4: Health History ───────────────────────────────────────────────────
|
|
function HealthHistoryTab() {
|
|
const [history, setHistory] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try { setHistory((await monitoringAPI.getHealthHistory()).data || []); } catch {}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
// health history entries have shape: { status: {running, status}, healthy, connectivity, ... }
|
|
const SvcCol = ({ data }) => {
|
|
const running = data?.status?.running === true || data?.status?.status === 'online'
|
|
|| data?.running === true || data?.status === 'online';
|
|
return running
|
|
? <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</h3>
|
|
<div className="flex gap-2">
|
|
<button className="btn btn-secondary text-sm" onClick={load}><RefreshCw className="h-4 w-4 mr-1 inline" />Refresh</button>
|
|
<button className="btn btn-secondary text-sm text-red-600" onClick={async () => {
|
|
if (!window.confirm('Clear all health history and reset alert counters?')) return;
|
|
await monitoringAPI.clearHealthHistory();
|
|
await load();
|
|
}}>Clear</button>
|
|
</div>
|
|
</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','Routing','Vault','Alerts'].map(h => (
|
|
<th key={h} className="px-2 py-1 text-left">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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>
|
|
<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
|
|
? 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 ────────────────────────────────────────────────────────────────────
|
|
const TABS = [
|
|
{ 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 },
|
|
];
|
|
|
|
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 · Log level config · Health history</p>
|
|
</div>
|
|
<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 === 'api' && <ApiServiceLogsTab />}
|
|
{tab === 'container' && <ContainerLogsTab />}
|
|
{tab === 'verbosity' && <VerbosityTab />}
|
|
{tab === 'health' && <HealthHistoryTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|