diff --git a/api/app.py b/api/app.py index ecfbc4c..e248bf5 100644 --- a/api/app.py +++ b/api/app.py @@ -619,17 +619,43 @@ def get_log_statistics(): @app.route('/api/logs/rotate', methods=['POST']) def rotate_logs(): - """Manually rotate logs.""" + """Manually rotate logs (API service log or container log).""" try: data = request.get_json(silent=True) or {} - service = data.get('service') - - log_manager.rotate_logs(service) + name = data.get('name') # e.g. 'wireguard' or 'container_cell-api' + kind = data.get('kind', 'service') # 'service' or 'container' + + 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"}) except Exception as e: logger.error(f"Error rotating logs: {e}") 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/', 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 @app.route('/api/dns/records', methods=['GET']) def get_dns_records(): diff --git a/api/log_manager.py b/api/log_manager.py index fe2b324..1f11220 100644 --- a/api/log_manager.py +++ b/api/log_manager.py @@ -498,6 +498,96 @@ class LogManager: except Exception as 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): """Compress old log files to save space""" try: diff --git a/webui/src/pages/Logs.jsx b/webui/src/pages/Logs.jsx index dc5081d..4d12b1f 100644 --- a/webui/src/pages/Logs.jsx +++ b/webui/src/pages/Logs.jsx @@ -1,61 +1,58 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Activity, Clock, FileText, AlertTriangle, Search, RefreshCw, - RotateCcw, Box, BarChart2, Download, ChevronDown, ChevronUp, - Filter + RotateCcw, Box, BarChart2 } from 'lucide-react'; 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 LEVEL_COLORS = { - DEBUG: 'text-gray-400', + DEBUG: 'text-gray-500', INFO: 'text-blue-400', WARNING: 'text-yellow-400', ERROR: 'text-red-400', - CRITICAL: 'text-red-600 font-bold', + CRITICAL: 'text-red-500 font-bold', }; function LevelBadge({ level }) { - const color = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-300'; - return [{level}]; + const cls = LEVEL_COLORS[level?.toUpperCase()] || 'text-gray-400'; + return [{level || '?'}]; } function LogLine({ entry }) { - if (entry.raw_line) { - return
{entry.raw_line}
; + if (!entry || entry.raw_line !== undefined) { + return
{entry?.raw_line || ''}
; } return ( -
- {entry.timestamp?.slice(0, 19)} +
+ {String(entry.timestamp || '').slice(0, 19)} {entry.service && [{entry.service}]} - {entry.message} + {entry.message || ''}
); } -// ── Tab: Service Logs ─────────────────────────────────────────────────────── -function ServiceLogsTab() { - const [service, setService] = useState('network'); +// ── Tab: API Service Logs ─────────────────────────────────────────────────── +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 [searchMode, setSearchMode] = useState(false); const intervalRef = useRef(null); - const bottomRef = useRef(null); - const fetch = useCallback(async () => { + const doFetch = useCallback(async () => { setLoading(true); try { - const allServices = SERVICES.filter(s => s !== 'ALL'); - if (service === 'ALL' || (searchMode && query)) { + const allSvcs = API_SERVICES.filter(s => s !== 'ALL'); + if (service === 'ALL' || query) { const res = await logsAPI.searchLogs({ query: query || '', - services: service === 'ALL' ? allServices : [service], + services: service === 'ALL' ? allSvcs : [service], level: level === 'ALL' ? undefined : level, }); setLogs(res.data.results || []); @@ -72,99 +69,69 @@ function ServiceLogsTab() { } finally { setLoading(false); } - }, [service, level, lines, query, searchMode]); + }, [service, level, lines, query]); - useEffect(() => { - fetch(); - }, [service, level, lines]); + useEffect(() => { doFetch(); }, [service, level, lines]); useEffect(() => { if (autoRefresh) { - intervalRef.current = setInterval(fetch, 5000); + intervalRef.current = setInterval(doFetch, 5000); } else { clearInterval(intervalRef.current); } return () => clearInterval(intervalRef.current); - }, [autoRefresh, fetch]); + }, [autoRefresh, doFetch]); return ( -
- {/* Controls */} -
- +
+

+ These are structured logs written by the API backend for each service manager (wireguard, network, routing, etc.). + They are stored in /app/data/logs/<service>.log and can be rotated from the Statistics tab. +

- setService(e.target.value)}> + {API_SERVICES.map(s => )} + + - - - -
+ {service !== 'ALL' && ( + + )} +
setQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { setSearchMode(true); fetch(); } }} + onKeyDown={e => e.key === 'Enter' && doFetch()} /> - - {query && ( - - )} + + {query && }
- - - +
- {/* Log output */}
{loading && logs.length === 0 ? (
Loading…
) : logs.length === 0 ? ( -
No log entries found.
+
No entries found.
) : ( logs.map((entry, i) => ) )} -
-
{logs.length} entries
+
{logs.length} entries
); } @@ -174,7 +141,7 @@ function ContainerLogsTab() { const [containers, setContainers] = useState([]); const [selected, setSelected] = useState('cell-api'); const [tail, setTail] = useState(100); - const [logs, setLogs] = useState(''); + const [lines, setLines] = useState([]); const [loading, setLoading] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false); const intervalRef = useRef(null); @@ -182,88 +149,92 @@ function ContainerLogsTab() { useEffect(() => { containerAPI.listContainers() .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); if (names.length > 0 && !names.includes(selected)) setSelected(names[0]); }) .catch(() => {}); }, []); - const fetch = useCallback(async () => { + const doFetch = useCallback(async () => { + if (!selected) return; setLoading(true); try { - const res = await containerAPI.getContainerLogs(selected, tail); - setLogs(res.data.logs || ''); + const res = await logsAPI.getStoredContainerLogs(selected, tail); + setLines(res.data.lines || []); } catch (e) { - setLogs(`Error: ${e.message}`); + setLines([`Error: ${e.message}`]); } finally { setLoading(false); } }, [selected, tail]); - useEffect(() => { fetch(); }, [selected, tail]); + useEffect(() => { doFetch(); }, [selected, tail]); useEffect(() => { if (autoRefresh) { - intervalRef.current = setInterval(fetch, 5000); + intervalRef.current = setInterval(doFetch, 5000); } else { clearInterval(intervalRef.current); } return () => clearInterval(intervalRef.current); - }, [autoRefresh, fetch]); + }, [autoRefresh, doFetch]); return ( -
-
- +
+

+ Container stdout/stderr collected from Docker and stored in /app/data/logs/container_<name>.log. + Each fetch appends new lines since last collection. Rotate from the Statistics tab. +

- setSelected(e.target.value)}> + {(containers.length > 0 ? containers : ['cell-api']).map(c => ( + + ))} + + - - - +
-
- {loading ? 'Loading…' : logs || 'No logs.'} +
+ {loading && lines.length === 0 ? ( + Loading… + ) : lines.length === 0 ? ( + No stored logs. Click refresh to collect. + ) : ( + lines.map((l, i) =>
{l}
) + )}
+
{lines.length} lines stored
); } // ── Tab: Statistics & Rotation ────────────────────────────────────────────── function StatisticsTab() { - const [stats, setStats] = useState({}); + const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [rotating, setRotating] = useState(null); + const [msg, setMsg] = useState(''); - const fetch = async () => { + const doFetch = async () => { setLoading(true); try { - const res = await logsAPI.getStatistics(); - setStats(res.data || {}); + const res = await logsAPI.getLogFiles(); + setFiles(res.data || []); } catch (e) { console.error(e); } finally { @@ -271,37 +242,84 @@ function StatisticsTab() { } }; - useEffect(() => { fetch(); }, []); + useEffect(() => { doFetch(); }, []); - const rotate = async (service) => { - const label = service || 'all services'; - if (!window.confirm(`Rotate logs for ${label}? Current log file will be archived and a new one started.`)) return; - setRotating(service || 'all'); + 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); + setMsg(''); try { - await logsAPI.rotateLogs(service || null); - await fetch(); + 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(); } catch (e) { - console.error(e); + setMsg(`Error: ${e.message}`); } finally { setRotating(null); } }; - const fmtSize = (bytes) => { + 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`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + 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 }) => ( +
+

{title}

+ {rows.length === 0 ? ( +

No files yet.

+ ) : ( + + + + + + + + + + + {rows.map(f => ( + + + + + + + ))} + +
NameSizeLast ModifiedRotate
{f.label}{fmtSize(f.size)}{f.modified?.slice(0, 19)} + +
+ )} +
+ ); return (
-

Log File Statistics

+

Log Files & Rotation

-
- {loading ? ( -
Loading…
- ) : services.length === 0 ? ( -
No log statistics available.
- ) : ( -
- - - - - - - - - - - - - - {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 ( - - - - - - - - - - ); - })} - -
ServiceFile SizeTotal EntriesErrorsWarningsLast EntryRotate
{svc} - {hasError ? '—' : fmtSize(s.file_size)} - - {hasError ? {s.error} : s.total_entries} - 0 ? 'text-red-600 font-bold' : ''}`}> - {hasError ? '—' : errorCount} - 0 ? 'text-yellow-600' : ''}`}> - {hasError ? '—' : warnCount} - - {hasError ? '—' : (s.last_entry?.slice(0, 19) || '—')} - - -
-
+ {msg &&
{msg}
} + + {loading ?
Loading…
: ( + <> + + + )}
); @@ -381,7 +349,7 @@ function HealthHistoryTab() { const [healthHistory, setHealthHistory] = useState([]); const [loading, setLoading] = useState(false); - const fetch = async () => { + const doFetch = async () => { setLoading(true); try { const res = await monitoringAPI.getHealthHistory(); @@ -393,26 +361,21 @@ function HealthHistoryTab() { } }; - useEffect(() => { fetch(); }, []); + useEffect(() => { doFetch(); }, []); - const ServiceCol = ({ data }) => { - const ok = data?.status === 'online' || data?.running === true; - return ok - ? OK - : Down; - }; + const SvcCol = ({ data }) => (data?.status === 'online' || data?.running === true) + ? OK + : Down; return (
-

Health History (last 100 checks)

-
- {loading ? ( -
Loading…
- ) : ( + {loading ?
Loading…
: (
@@ -426,23 +389,21 @@ function HealthHistoryTab() { {healthHistory.map((h, i) => ( 0 ? 'bg-red-50' : ''}> - - - - - - - + + + + + + + ))} @@ -454,33 +415,30 @@ function HealthHistoryTab() { ); } -// ── Main Logs Page ────────────────────────────────────────────────────────── +// ── Main ──────────────────────────────────────────────────────────────────── 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: 'statistics', label: 'Statistics & Rotation', icon: BarChart2 }, { id: 'health', label: 'Health History', icon: Activity }, ]; function Logs() { - const [tab, setTab] = useState('service'); + const [tab, setTab] = useState('api'); return (

Logs & Monitoring

-

Search service logs, view container output, and manage log rotation.

+

API service logs, container stdout/stderr, rotation, and health history.

- {/* Tabs */}
{TABS.map(({ id, label, icon: Icon }) => (
- {tab === 'service' && } + {tab === 'api' && } {tab === 'container' && } {tab === 'statistics' && } {tab === 'health' && } diff --git a/webui/src/services/api.js b/webui/src/services/api.js index e78d7b3..86125ff 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -194,7 +194,10 @@ export const logsAPI = { 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 } : {}), + 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
{h.timestamp} - {h.alerts?.length > 0 ? ( - h.alerts.map((a, j) => ( - - {a} - - )) - ) : ( - - )} + {h.alerts?.length > 0 + ? h.alerts.map((a, j) => ( + + {a} + + )) + : }