feat: persistent container log collection, unified rotation, logs page redesign

- log_manager: add collect_container_logs (appends docker logs to container_<name>.log),
  get_container_log_lines, rotate_container_log, get_all_log_file_infos
- app.py: new endpoints /api/logs/files (all log file sizes), /api/logs/containers/<name>
  (collect+return stored container logs); rotate endpoint now handles both service and container logs
- Logs page: split into API Service Logs tab (python manager logs) and Container Logs tab
  (persistent docker stdout/stderr); Statistics tab shows both kinds with per-row rotate;
  each tab has a description explaining what it shows and where files live
- wireguard_manager: test_connectivity peer_ip=None guard (already in previous commit, now rebuilt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 01:54:33 -04:00
parent 50f2200b45
commit 7b39331417
4 changed files with 308 additions and 231 deletions
+29 -3
View File
@@ -619,17 +619,43 @@ def get_log_statistics():
@app.route('/api/logs/rotate', methods=['POST']) @app.route('/api/logs/rotate', methods=['POST'])
def rotate_logs(): def rotate_logs():
"""Manually rotate logs.""" """Manually rotate logs (API service log or container log)."""
try: try:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
service = data.get('service') name = data.get('name') # e.g. 'wireguard' or 'container_cell-api'
kind = data.get('kind', 'service') # 'service' or 'container'
log_manager.rotate_logs(service) if kind == 'container':
container_name = name[len('container_'):] if name and name.startswith('container_') else name
log_manager.rotate_container_log(container_name)
else:
log_manager.rotate_logs(name)
return jsonify({"message": "Logs rotated successfully"}) return jsonify({"message": "Logs rotated successfully"})
except Exception as e: except Exception as e:
logger.error(f"Error rotating logs: {e}") logger.error(f"Error rotating logs: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/logs/files', methods=['GET'])
def get_log_file_infos():
"""List all stored log files (service + container) with sizes."""
try:
return jsonify(log_manager.get_all_log_file_infos())
except Exception as e:
logger.error(f"Error listing log files: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/logs/containers/<container_name>', methods=['GET'])
def get_stored_container_logs(container_name):
"""Collect latest docker logs into file and return last N lines."""
try:
tail = int(request.args.get('tail', 100))
log_manager.collect_container_logs(container_name)
lines = log_manager.get_container_log_lines(container_name, tail)
return jsonify({'container': container_name, 'lines': lines})
except Exception as e:
logger.error(f"Error getting stored container logs for {container_name}: {e}")
return jsonify({"error": str(e)}), 500
# Network Services API # Network Services API
@app.route('/api/dns/records', methods=['GET']) @app.route('/api/dns/records', methods=['GET'])
def get_dns_records(): def get_dns_records():
+90
View File
@@ -498,6 +498,96 @@ class LogManager:
except Exception as e: except Exception as e:
return {'error': str(e)} return {'error': str(e)}
def collect_container_logs(self, container_name: str, docker_client=None) -> int:
"""Append new docker container stdout/stderr to a persistent log file.
Returns number of new lines written, or -1 on error."""
try:
import subprocess
log_file = self.log_dir / f'container_{container_name}.log'
# Determine --since timestamp from last line of existing file
since_arg = []
if log_file.exists() and log_file.stat().st_size > 0:
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
pass
# Parse last timestamp from docker log line (format: 2006-01-02T15:04:05...)
ts_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)', line.strip())
if ts_match:
since_arg = ['--since', ts_match.group(1)]
result = subprocess.run(
['docker', 'logs', '--timestamps'] + since_arg + [container_name],
capture_output=True, text=True, timeout=30
)
output = result.stdout + result.stderr
lines = [l for l in output.splitlines() if l.strip()]
if lines:
with open(log_file, 'a', encoding='utf-8') as f:
f.write('\n'.join(lines) + '\n')
return len(lines)
except Exception as e:
logger.error(f"Error collecting container logs for {container_name}: {e}")
return -1
def get_container_log_lines(self, container_name: str, lines: int = 100) -> List[str]:
"""Read last N lines from stored container log file."""
try:
log_file = self.log_dir / f'container_{container_name}.log'
if not log_file.exists():
return []
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
all_lines = f.readlines()
return [l.rstrip() for l in all_lines[-lines:]] if lines > 0 else [l.rstrip() for l in all_lines]
except Exception as e:
logger.error(f"Error reading container log for {container_name}: {e}")
return []
def rotate_container_log(self, container_name: str):
"""Rotate a stored container log file."""
try:
log_file = self.log_dir / f'container_{container_name}.log'
if not log_file.exists():
return
# Find next available backup index
for i in range(1, self.backup_count + 1):
backup = self.log_dir / f'container_{container_name}.log.{i}'
if not backup.exists():
log_file.rename(backup)
logger.info(f"Rotated container log for {container_name}{backup.name}")
return
# All slots full — remove oldest, shift others
oldest = self.log_dir / f'container_{container_name}.log.{self.backup_count}'
oldest.unlink(missing_ok=True)
for i in range(self.backup_count - 1, 0, -1):
src = self.log_dir / f'container_{container_name}.log.{i}'
if src.exists():
src.rename(self.log_dir / f'container_{container_name}.log.{i + 1}')
log_file.rename(self.log_dir / f'container_{container_name}.log.1')
logger.info(f"Rotated container log for {container_name}")
except Exception as e:
logger.error(f"Error rotating container log for {container_name}: {e}")
def get_all_log_file_infos(self) -> List[Dict[str, Any]]:
"""Return size/mtime info for all log files (API service logs + container logs)."""
results = []
for log_file in sorted(self.log_dir.glob('*.log')):
try:
stat = log_file.stat()
name = log_file.stem # e.g. 'wireguard' or 'container_cell-api'
kind = 'container' if name.startswith('container_') else 'service'
label = name[len('container_'):] if kind == 'container' else name
results.append({
'name': name,
'label': label,
'kind': kind,
'file': log_file.name,
'size': stat.st_size,
'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
except Exception:
pass
return results
def compress_old_logs(self): def compress_old_logs(self):
"""Compress old log files to save space""" """Compress old log files to save space"""
try: try:
+176 -218
View File
@@ -1,61 +1,58 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Activity, Clock, FileText, AlertTriangle, Search, RefreshCw, Activity, Clock, FileText, AlertTriangle, Search, RefreshCw,
RotateCcw, Box, BarChart2, Download, ChevronDown, ChevronUp, RotateCcw, Box, BarChart2
Filter
} from 'lucide-react'; } from 'lucide-react';
import { monitoringAPI, logsAPI, containerAPI } from '../services/api'; import { monitoringAPI, logsAPI, containerAPI } from '../services/api';
const SERVICES = ['ALL', 'network', 'wireguard', 'routing', 'email', 'calendar', 'files', 'vault', 'container']; const API_SERVICES = ['ALL', 'network', 'wireguard', 'routing', 'email', 'calendar', 'files', 'vault', 'container', 'api'];
const LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']; const LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const LEVEL_COLORS = { const LEVEL_COLORS = {
DEBUG: 'text-gray-400', DEBUG: 'text-gray-500',
INFO: 'text-blue-400', INFO: 'text-blue-400',
WARNING: 'text-yellow-400', WARNING: 'text-yellow-400',
ERROR: 'text-red-400', ERROR: 'text-red-400',
CRITICAL: 'text-red-600 font-bold', CRITICAL: 'text-red-500 font-bold',
}; };
function LevelBadge({ level }) { function LevelBadge({ level }) {
const color = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-300'; const cls = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-400';
return <span className={`font-mono text-xs ${color}`}>[{level}]</span>; return <span className={`font-mono text-xs shrink-0 ${cls}`}>[{level || '?'}]</span>;
} }
function LogLine({ entry }) { function LogLine({ entry }) {
if (entry.raw_line) { if (!entry || entry.raw_line !== undefined) {
return <div className="font-mono text-xs text-gray-300 py-0.5">{entry.raw_line}</div>; return <div className="font-mono text-xs text-gray-300 py-0.5 break-all">{entry?.raw_line || ''}</div>;
} }
return ( return (
<div className="font-mono text-xs py-0.5 flex gap-2"> <div className="font-mono text-xs py-0.5 flex gap-2 flex-wrap">
<span className="text-gray-500 shrink-0">{entry.timestamp?.slice(0, 19)}</span> <span className="text-gray-500 shrink-0">{String(entry.timestamp || '').slice(0, 19)}</span>
<LevelBadge level={entry.level} /> <LevelBadge level={entry.level} />
{entry.service && <span className="text-purple-400 shrink-0">[{entry.service}]</span>} {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> </div>
); );
} }
// Tab: Service Logs // Tab: API Service Logs
function ServiceLogsTab() { function ApiServiceLogsTab() {
const [service, setService] = useState('network'); const [service, setService] = useState('ALL');
const [level, setLevel] = useState('ALL'); const [level, setLevel] = useState('ALL');
const [lines, setLines] = useState(100); const [lines, setLines] = useState(100);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false);
const [searchMode, setSearchMode] = useState(false);
const intervalRef = useRef(null); const intervalRef = useRef(null);
const bottomRef = useRef(null);
const fetch = useCallback(async () => { const doFetch = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const allServices = SERVICES.filter(s => s !== 'ALL'); const allSvcs = API_SERVICES.filter(s => s !== 'ALL');
if (service === 'ALL' || (searchMode && query)) { if (service === 'ALL' || query) {
const res = await logsAPI.searchLogs({ const res = await logsAPI.searchLogs({
query: query || '', query: query || '',
services: service === 'ALL' ? allServices : [service], services: service === 'ALL' ? allSvcs : [service],
level: level === 'ALL' ? undefined : level, level: level === 'ALL' ? undefined : level,
}); });
setLogs(res.data.results || []); setLogs(res.data.results || []);
@@ -72,99 +69,69 @@ function ServiceLogsTab() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [service, level, lines, query, searchMode]); }, [service, level, lines, query]);
useEffect(() => { useEffect(() => { doFetch(); }, [service, level, lines]);
fetch();
}, [service, level, lines]);
useEffect(() => { useEffect(() => {
if (autoRefresh) { if (autoRefresh) {
intervalRef.current = setInterval(fetch, 5000); intervalRef.current = setInterval(doFetch, 5000);
} else { } else {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
return () => clearInterval(intervalRef.current); return () => clearInterval(intervalRef.current);
}, [autoRefresh, fetch]); }, [autoRefresh, doFetch]);
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Controls */} <p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
<div className="flex flex-wrap gap-2 items-center"> These are structured logs written by the API backend for each service manager (wireguard, network, routing, etc.).
<select They are stored in <code>/app/data/logs/&lt;service&gt;.log</code> and can be rotated from the Statistics tab.
className="border rounded px-2 py-1 text-sm" </p>
value={service}
onChange={e => setService(e.target.value)}
>
{SERVICES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<select <div className="flex flex-wrap gap-2 items-center">
className="border rounded px-2 py-1 text-sm" <select className="border rounded px-2 py-1 text-sm" value={service} onChange={e => setService(e.target.value)}>
value={level} {API_SERVICES.map(s => <option key={s} value={s}>{s === 'ALL' ? 'ALL services' : s}</option>)}
onChange={e => setLevel(e.target.value)} </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>)} {LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select> </select>
{service !== 'ALL' && (
<select <select className="border rounded px-2 py-1 text-sm" value={lines} onChange={e => setLines(Number(e.target.value))}>
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>)} {[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
</select> </select>
)}
<div className="flex gap-1 flex-1 min-w-48"> <div className="flex gap-1 flex-1 min-w-40">
<input <input
className="border rounded px-2 py-1 text-sm flex-1" className="border rounded px-2 py-1 text-sm flex-1"
placeholder="Search…" placeholder="Search…"
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { setSearchMode(true); fetch(); } }} onKeyDown={e => e.key === 'Enter' && doFetch()}
/> />
<button <button className="btn btn-secondary px-2 py-1 text-sm" onClick={doFetch}><Search className="h-4 w-4" /></button>
className="btn btn-secondary text-sm px-2 py-1" {query && <button className="btn btn-secondary px-2 py-1 text-sm" onClick={() => { setQuery(''); }}></button>}
onClick={() => { setSearchMode(true); fetch(); }}
title="Search"
>
<Search className="h-4 w-4" />
</button>
{query && (
<button
className="btn btn-secondary text-sm px-2 py-1"
onClick={() => { setQuery(''); setSearchMode(false); }}
>
</button>
)}
</div> </div>
<button <button
className={`btn text-sm px-2 py-1 ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
title="Auto-refresh 5s"
onClick={() => setAutoRefresh(v => !v)} onClick={() => setAutoRefresh(v => !v)}
title="Auto-refresh every 5s"
> >
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
</button> </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 text-sm px-2 py-1" onClick={fetch} title="Refresh">
<RefreshCw className="h-4 w-4" />
</button>
</div> </div>
{/* Log output */}
<div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto"> <div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto">
{loading && logs.length === 0 ? ( {loading && logs.length === 0 ? (
<div className="text-gray-400 text-sm">Loading</div> <div className="text-gray-400 text-sm">Loading</div>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<div className="text-gray-500 text-sm">No log entries found.</div> <div className="text-gray-500 text-sm">No entries found.</div>
) : ( ) : (
logs.map((entry, i) => <LogLine key={i} entry={entry} />) logs.map((entry, i) => <LogLine key={i} entry={entry} />)
)} )}
<div ref={bottomRef} />
</div> </div>
<div className="text-xs text-gray-500">{logs.length} entries</div> <div className="text-xs text-gray-400">{logs.length} entries</div>
</div> </div>
); );
} }
@@ -174,7 +141,7 @@ function ContainerLogsTab() {
const [containers, setContainers] = useState([]); const [containers, setContainers] = useState([]);
const [selected, setSelected] = useState('cell-api'); const [selected, setSelected] = useState('cell-api');
const [tail, setTail] = useState(100); const [tail, setTail] = useState(100);
const [logs, setLogs] = useState(''); const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false);
const intervalRef = useRef(null); const intervalRef = useRef(null);
@@ -182,88 +149,92 @@ function ContainerLogsTab() {
useEffect(() => { useEffect(() => {
containerAPI.listContainers() containerAPI.listContainers()
.then(res => { .then(res => {
const names = (res.data || []).map(c => c.name || c.Names?.[0]?.replace('/', '')).filter(Boolean); const names = (res.data || [])
.map(c => c.name || c.Names?.[0]?.replace('/', ''))
.filter(Boolean)
.sort();
setContainers(names); setContainers(names);
if (names.length > 0 && !names.includes(selected)) setSelected(names[0]); if (names.length > 0 && !names.includes(selected)) setSelected(names[0]);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
const fetch = useCallback(async () => { const doFetch = useCallback(async () => {
if (!selected) return;
setLoading(true); setLoading(true);
try { try {
const res = await containerAPI.getContainerLogs(selected, tail); const res = await logsAPI.getStoredContainerLogs(selected, tail);
setLogs(res.data.logs || ''); setLines(res.data.lines || []);
} catch (e) { } catch (e) {
setLogs(`Error: ${e.message}`); setLines([`Error: ${e.message}`]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selected, tail]); }, [selected, tail]);
useEffect(() => { fetch(); }, [selected, tail]); useEffect(() => { doFetch(); }, [selected, tail]);
useEffect(() => { useEffect(() => {
if (autoRefresh) { if (autoRefresh) {
intervalRef.current = setInterval(fetch, 5000); intervalRef.current = setInterval(doFetch, 5000);
} else { } else {
clearInterval(intervalRef.current); clearInterval(intervalRef.current);
} }
return () => clearInterval(intervalRef.current); return () => clearInterval(intervalRef.current);
}, [autoRefresh, fetch]); }, [autoRefresh, doFetch]);
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex flex-wrap gap-2 items-center"> <p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
<select Container stdout/stderr collected from Docker and stored in <code>/app/data/logs/container_&lt;name&gt;.log</code>.
className="border rounded px-2 py-1 text-sm" Each fetch appends new lines since last collection. Rotate from the Statistics tab.
value={selected} </p>
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 <div className="flex flex-wrap gap-2 items-center">
className="border rounded px-2 py-1 text-sm" <select className="border rounded px-2 py-1 text-sm" value={selected} onChange={e => setSelected(e.target.value)}>
value={tail} {(containers.length > 0 ? containers : ['cell-api']).map(c => (
onChange={e => setTail(Number(e.target.value))} <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>)} {[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
</select> </select>
<button <button
className={`btn text-sm px-2 py-1 ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`}
title="Auto-refresh 5s"
onClick={() => setAutoRefresh(v => !v)} onClick={() => setAutoRefresh(v => !v)}
title="Auto-refresh every 5s"
> >
<RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${autoRefresh ? 'animate-spin' : ''}`} />
</button> </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 text-sm px-2 py-1" onClick={fetch}>
<RefreshCw className="h-4 w-4" />
</button>
</div> </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"> <div className="bg-gray-900 text-green-300 rounded-lg p-3 h-[500px] overflow-y-auto font-mono text-xs">
{loading ? 'Loading…' : logs || 'No logs.'} {loading && lines.length === 0 ? (
<span className="text-gray-400">Loading</span>
) : lines.length === 0 ? (
<span className="text-gray-500">No stored logs. Click refresh to collect.</span>
) : (
lines.map((l, i) => <div key={i} className="py-0.5 break-all">{l}</div>)
)}
</div> </div>
<div className="text-xs text-gray-400">{lines.length} lines stored</div>
</div> </div>
); );
} }
// Tab: Statistics & Rotation // Tab: Statistics & Rotation
function StatisticsTab() { function StatisticsTab() {
const [stats, setStats] = useState({}); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [rotating, setRotating] = useState(null); const [rotating, setRotating] = useState(null);
const [msg, setMsg] = useState('');
const fetch = async () => { const doFetch = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await logsAPI.getStatistics(); const res = await logsAPI.getLogFiles();
setStats(res.data || {}); setFiles(res.data || []);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -271,37 +242,84 @@ function StatisticsTab() {
} }
}; };
useEffect(() => { fetch(); }, []); useEffect(() => { doFetch(); }, []);
const rotate = async (service) => { const rotate = async (file) => {
const label = service || 'all services'; const label = file ? file.label : 'all log files';
if (!window.confirm(`Rotate logs for ${label}? Current log file will be archived and a new one started.`)) return; if (!window.confirm(`Rotate logs for ${label}?\nThe current file will be archived and a new one started.`)) return;
setRotating(service || 'all'); const key = file ? file.name : 'all';
setRotating(key);
setMsg('');
try { try {
await logsAPI.rotateLogs(service || null); if (file) {
await fetch(); 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();
} catch (e) { } catch (e) {
console.error(e); setMsg(`Error: ${e.message}`);
} finally { } finally {
setRotating(null); setRotating(null);
} }
}; };
const fmtSize = (bytes) => { const fmtSize = bytes => {
if (!bytes) return '0 B'; if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`; return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
}; };
const services = Object.keys(stats); const serviceFiles = files.filter(f => f.kind === 'service');
const containerFiles = files.filter(f => f.kind === 'container');
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">
<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>
</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}
>
<RotateCcw className={`h-3 w-3 inline ${rotating === f.name ? 'animate-spin' : ''}`} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Log File Statistics</h3> <h3 className="text-lg font-medium text-gray-900">Log Files & Rotation</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="btn btn-secondary text-sm" onClick={fetch}> <button className="btn btn-secondary text-sm" onClick={doFetch}>
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh <RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
</button> </button>
<button <button
@@ -314,63 +332,13 @@ function StatisticsTab() {
</div> </div>
</div> </div>
{loading ? ( {msg && <div className="text-sm text-green-700 bg-green-50 rounded px-3 py-2">{msg}</div>}
<div className="text-gray-500 text-sm">Loading</div>
) : services.length === 0 ? ( {loading ? <div className="text-gray-500 text-sm">Loading</div> : (
<div className="text-gray-500 text-sm">No log statistics available.</div> <>
) : ( <FileTable rows={serviceFiles} title="API Service Logs" />
<div className="overflow-x-auto"> <FileTable rows={containerFiles} title="Container Logs (stored)" />
<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> </div>
); );
@@ -381,7 +349,7 @@ function HealthHistoryTab() {
const [healthHistory, setHealthHistory] = useState([]); const [healthHistory, setHealthHistory] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetch = async () => { const doFetch = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await monitoringAPI.getHealthHistory(); const res = await monitoringAPI.getHealthHistory();
@@ -393,26 +361,21 @@ function HealthHistoryTab() {
} }
}; };
useEffect(() => { fetch(); }, []); useEffect(() => { doFetch(); }, []);
const ServiceCol = ({ data }) => { const SvcCol = ({ data }) => (data?.status === 'online' || data?.running === true)
const ok = data?.status === 'online' || data?.running === true;
return ok
? <span className="text-green-600">OK</span> ? <span className="text-green-600">OK</span>
: <span className="text-red-600 font-bold">Down</span>; : <span className="text-red-600 font-bold">Down</span>;
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Health History (last 100 checks)</h3> <h3 className="text-lg font-medium text-gray-900">Health History</h3>
<button className="btn btn-secondary text-sm" onClick={fetch}> <button className="btn btn-secondary text-sm" onClick={doFetch}>
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh <RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
</button> </button>
</div> </div>
{loading ? ( {loading ? <div className="text-gray-500 text-sm">Loading</div> : (
<div className="text-gray-500 text-sm">Loading</div>
) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <thead>
@@ -426,23 +389,21 @@ function HealthHistoryTab() {
{healthHistory.map((h, i) => ( {healthHistory.map((h, i) => (
<tr key={i} className={h.alerts?.length > 0 ? 'bg-red-50' : ''}> <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 font-mono text-xs">{h.timestamp}</td>
<td className="px-2 py-1"><ServiceCol data={h.network} /></td> <td className="px-2 py-1"><SvcCol data={h.network} /></td>
<td className="px-2 py-1"><ServiceCol data={h.wireguard} /></td> <td className="px-2 py-1"><SvcCol data={h.wireguard} /></td>
<td className="px-2 py-1"><ServiceCol data={h.email} /></td> <td className="px-2 py-1"><SvcCol data={h.email} /></td>
<td className="px-2 py-1"><ServiceCol data={h.calendar} /></td> <td className="px-2 py-1"><SvcCol data={h.calendar} /></td>
<td className="px-2 py-1"><ServiceCol data={h.files} /></td> <td className="px-2 py-1"><SvcCol data={h.files} /></td>
<td className="px-2 py-1"><ServiceCol data={h.routing} /></td> <td className="px-2 py-1"><SvcCol data={h.routing} /></td>
<td className="px-2 py-1"><ServiceCol data={h.vault} /></td> <td className="px-2 py-1"><SvcCol data={h.vault} /></td>
<td className="px-2 py-1"> <td className="px-2 py-1">
{h.alerts?.length > 0 ? ( {h.alerts?.length > 0
h.alerts.map((a, j) => ( ? h.alerts.map((a, j) => (
<span key={j} className="text-red-700 font-semibold flex items-center gap-1"> <span key={j} className="text-red-700 font-semibold flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-red-500" />{a} <AlertTriangle className="h-3 w-3 text-red-500" />{a}
</span> </span>
)) ))
) : ( : <span className="text-green-600"></span>}
<span className="text-green-600"></span>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -454,33 +415,30 @@ function HealthHistoryTab() {
); );
} }
// Main Logs Page // Main
const TABS = [ const TABS = [
{ id: 'service', label: 'Service Logs', icon: FileText }, { id: 'api', label: 'API Service Logs', icon: FileText },
{ id: 'container', label: 'Container Logs', icon: Box }, { id: 'container', label: 'Container Logs', icon: Box },
{ id: 'statistics', label: 'Statistics & Rotation', icon: BarChart2 }, { id: 'statistics', label: 'Statistics & Rotation', icon: BarChart2 },
{ id: 'health', label: 'Health History', icon: Activity }, { id: 'health', label: 'Health History', icon: Activity },
]; ];
function Logs() { function Logs() {
const [tab, setTab] = useState('service'); const [tab, setTab] = useState('api');
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Logs & Monitoring</h1> <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> <p className="mt-1 text-gray-600">API service logs, container stdout/stderr, rotation, and health history.</p>
</div> </div>
{/* Tabs */}
<div className="mb-4 flex gap-2 border-b"> <div className="mb-4 flex gap-2 border-b">
{TABS.map(({ id, label, icon: Icon }) => ( {TABS.map(({ id, label, icon: Icon }) => (
<button <button
key={id} key={id}
className={`px-4 py-2 text-sm font-medium flex items-center gap-1 border-b-2 transition-colors ${ className={`px-4 py-2 text-sm font-medium flex items-center gap-1 border-b-2 transition-colors ${
tab === id tab === id ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-600 hover:text-gray-900'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`} }`}
onClick={() => setTab(id)} onClick={() => setTab(id)}
> >
@@ -491,7 +449,7 @@ function Logs() {
</div> </div>
<div className="card"> <div className="card">
{tab === 'service' && <ServiceLogsTab />} {tab === 'api' && <ApiServiceLogsTab />}
{tab === 'container' && <ContainerLogsTab />} {tab === 'container' && <ContainerLogsTab />}
{tab === 'statistics' && <StatisticsTab />} {tab === 'statistics' && <StatisticsTab />}
{tab === 'health' && <HealthHistoryTab />} {tab === 'health' && <HealthHistoryTab />}
+4 -1
View File
@@ -194,7 +194,10 @@ export const logsAPI = {
exportLogs: (data) => api.post('/api/logs/export', data), exportLogs: (data) => api.post('/api/logs/export', data),
getStatistics: (service) => getStatistics: (service) =>
api.get('/api/logs/statistics', service ? { params: { service } } : {}), api.get('/api/logs/statistics', service ? { params: { service } } : {}),
rotateLogs: (service) => api.post('/api/logs/rotate', service ? { service } : {}), rotateLogs: (name, kind = 'service') => api.post('/api/logs/rotate', { name, kind }),
getLogFiles: () => api.get('/api/logs/files'),
getStoredContainerLogs: (containerName, tail = 100) =>
api.get(`/api/logs/containers/${containerName}`, { params: { tail } }),
}; };
// Container Management API // Container Management API