From f848a1d056345257a3d815c68b9e4e6c756e1dd4 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 21 Apr 2026 02:07:57 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20proper=20logging=20architecture=20?= =?UTF-8?q?=E2=80=94=20Docker=20rotation,=20persisted=20service=20logs,=20?= =?UTF-8?q?verbosity=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//logs endpoint) - Verbosity Config: per-service level dropdowns, apply immediately + persist - Health History: existing health poll table Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 62 +++++-- api/log_manager.py | 91 ++-------- api/wireguard_manager.py | 6 +- docker-compose.yml | 61 +++++++ webui/src/pages/Logs.jsx | 373 ++++++++++++++++++-------------------- webui/src/services/api.js | 9 +- 6 files changed, 313 insertions(+), 289 deletions(-) diff --git a/api/app.py b/api/app.py index e248bf5..2c8ae1a 100644 --- a/api/app.py +++ b/api/app.py @@ -125,6 +125,16 @@ service_log_configs = { for service, config in service_log_configs.items(): log_manager.add_service_logger(service, config) +# Apply any persisted log level overrides +_levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json') +if os.path.exists(_levels_file): + try: + with open(_levels_file) as _f: + for _svc, _lvl in json.load(_f).items(): + log_manager.set_service_level(_svc, _lvl) + except Exception: + pass + # Start service bus service_bus.start() @@ -619,17 +629,11 @@ def get_log_statistics(): @app.route('/api/logs/rotate', methods=['POST']) def rotate_logs(): - """Manually rotate logs (API service log or container log).""" + """Manually rotate an API service log file.""" try: data = request.get_json(silent=True) or {} - 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) + service = data.get('service') # None = rotate all + log_manager.rotate_logs(service) return jsonify({"message": "Logs rotated successfully"}) except Exception as e: logger.error(f"Error rotating logs: {e}") @@ -637,23 +641,45 @@ def rotate_logs(): @app.route('/api/logs/files', methods=['GET']) def get_log_file_infos(): - """List all stored log files (service + container) with sizes.""" + """List service log files 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.""" +@app.route('/api/logs/verbosity', methods=['GET']) +def get_log_verbosity(): + """Return current per-service log levels.""" 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}) + return jsonify(log_manager.get_service_levels()) except Exception as e: - logger.error(f"Error getting stored container logs for {container_name}: {e}") + logger.error(f"Error getting log verbosity: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/logs/verbosity', methods=['PUT']) +def set_log_verbosity(): + """Update log levels for one or all services. Body: {service: level} map.""" + try: + data = request.get_json(silent=True) or {} + for service, level in data.items(): + log_manager.set_service_level(service, level) + # Persist to config so levels survive API restarts + levels_file = os.path.join(os.path.dirname(__file__), 'config', 'log_levels.json') + os.makedirs(os.path.dirname(levels_file), exist_ok=True) + current = {} + if os.path.exists(levels_file): + try: + with open(levels_file) as f: + current = json.load(f) + except Exception: + pass + current.update(data) + with open(levels_file, 'w') as f: + json.dump(current, f, indent=2) + return jsonify({"message": "Log levels updated", "levels": log_manager.get_service_levels()}) + except Exception as e: + logger.error(f"Error setting log verbosity: {e}") return jsonify({"error": str(e)}), 500 # Network Services API diff --git a/api/log_manager.py b/api/log_manager.py index 1f11220..460cb62 100644 --- a/api/log_manager.py +++ b/api/log_manager.py @@ -498,88 +498,35 @@ 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.""" + def set_service_level(self, service: str, level: str): + """Change log level for a service at runtime.""" 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) + log_level = getattr(logging, level.upper(), logging.INFO) + if service in self.service_loggers: + self.service_loggers[service].setLevel(log_level) + if service in self.handlers and 'file' in self.handlers[service]: + self.handlers[service]['file'].setLevel(log_level) + logger.info(f"Set log level for {service} to {level}") + else: + logger.warning(f"Service logger not found: {service}") except Exception as e: - logger.error(f"Error collecting container logs for {container_name}: {e}") - return -1 + logger.error(f"Error setting log level for {service}: {e}") - 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_service_levels(self) -> Dict[str, str]: + """Return current log level for each service logger.""" + return { + svc: logging.getLevelName(lgr.level) + for svc, lgr in self.service_loggers.items() + } def get_all_log_file_infos(self) -> List[Dict[str, Any]]: - """Return size/mtime info for all log files (API service logs + container logs).""" + """Return size/mtime info for all service log files.""" 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, + 'name': log_file.stem, 'file': log_file.name, 'size': stat.st_size, 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(), diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index b818919..0b759b7 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -74,9 +74,13 @@ class WireGuardManager(BaseServiceManager): pass def get_keys(self) -> Dict[str, str]: - """Return server public/private keys as base64 strings.""" + """Return server public/private keys as base64 strings. Generates them if missing.""" priv_file = os.path.join(self.keys_dir, 'private.key') pub_file = os.path.join(self.keys_dir, 'public.key') + if not os.path.exists(priv_file): + self._ensure_server_keys() + if not os.path.exists(priv_file): + return {'private_key': '', 'public_key': ''} with open(priv_file, 'rb') as f: priv = f.read() with open(pub_file, 'rb') as f: diff --git a/docker-compose.yml b/docker-compose.yml index 88e5b43..52da41c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,11 @@ services: networks: cell-network: ipv4_address: 172.20.0.2 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # DNS Server - CoreDNS for .cell TLD resolution dns: @@ -34,6 +39,11 @@ services: networks: cell-network: ipv4_address: 172.20.0.3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # DHCP Server - dnsmasq for IP leasing dhcp: @@ -51,6 +61,11 @@ services: command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"] cap_add: - NET_ADMIN + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # NTP Server - chrony for time synchronization ntp: @@ -67,6 +82,11 @@ services: cap_add: - SYS_TIME command: ["/bin/sh", "-c", "apk add --no-cache chrony && rm -f /var/run/chrony/chronyd.pid && exec chronyd -d -f /etc/chrony/chrony.conf -n"] + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # Email Server - Postfix + Dovecot mail: @@ -91,6 +111,11 @@ services: ipv4_address: 172.20.0.6 cap_add: - NET_ADMIN + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # Calendar & Contacts - Radicale radicale: @@ -105,6 +130,11 @@ services: networks: cell-network: ipv4_address: 172.20.0.7 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # File Storage - WebDAV webdav: @@ -122,6 +152,11 @@ services: networks: cell-network: ipv4_address: 172.20.0.8 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # WireGuard VPN wireguard: @@ -146,6 +181,11 @@ services: sysctls: - net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.ip_forward=1 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # API Server api: @@ -159,6 +199,7 @@ services: - ./config/api:/app/config - ./config/wireguard:/app/config/wireguard - ./config/dns:/app/config/dns + - ./data/logs:/app/api/data/logs - /var/run/docker.sock:/var/run/docker.sock pid: host restart: unless-stopped @@ -168,6 +209,11 @@ services: depends_on: - wireguard - dns + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # Web UI - React + Vite webui: @@ -179,6 +225,11 @@ services: networks: cell-network: ipv4_address: 172.20.0.11 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # Webmail - RainLoop rainloop: @@ -192,6 +243,11 @@ services: - "8888:8888" volumes: - ./data/rainloop:/rainloop/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" # File Manager - FileGator filegator: @@ -205,6 +261,11 @@ services: - "8082:8080" volumes: - ./data/filegator:/var/www/filegator/private + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" networks: cell-network: diff --git a/webui/src/pages/Logs.jsx b/webui/src/pages/Logs.jsx index 4d12b1f..461fe95 100644 --- a/webui/src/pages/Logs.jsx +++ b/webui/src/pages/Logs.jsx @@ -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
{entry?.raw_line || ''}
; - } + if (!entry || entry.raw_line !== undefined) + return
{entry?.raw_line ?? ''}
; return (
- {String(entry.timestamp || '').slice(0, 19)} + {String(entry.timestamp ?? '').slice(0, 19)} {entry.service && [{entry.service}]} - {entry.message || ''} + {entry.message ?? ''}
); } -// ── 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 (
-

- 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. -

- + {/* Controls */}
setLevel(e.target.value)}> {LEVELS.map(l => )} - {service !== 'ALL' && ( + {service !== 'ALL' && !query && ( @@ -110,25 +120,55 @@ function ApiServiceLogsTab() { onKeyDown={e => e.key === 'Enter' && doFetch()} /> - {query && } + {query && }
- +
+ {/* File info panel */} + {showFiles && ( +
+
+ Log Files (persisted to ./data/logs/) + +
+ + + + {fileInfos.map(f => ( + + + + + + + ))} + {fileInfos.length === 0 && } + +
ServiceSizeModified
{f.name}{fmtSize(f.size)}{f.modified?.slice(0, 19)} + +
No log files found.
+
+ )} + + {/* Log output */}
- {loading && logs.length === 0 ? ( + {loading && !logs.length ? (
Loading…
- ) : logs.length === 0 ? ( + ) : !logs.length ? (
No entries found.
) : ( - logs.map((entry, i) => ) + logs.map((e, i) => ) )}
{logs.length} entries
@@ -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 (
-

- 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. -

- +
+ Live stdout/stderr from Docker. Rotation is automatic: json-file driver, + 10 MB max-size, 5 backups per container — configured in docker-compose.yml. +
-
-
- {loading && lines.length === 0 ? ( + {loading && !lines.length ? ( Loading… - ) : lines.length === 0 ? ( - No stored logs. Click refresh to collect. + ) : !lines.length ? ( + No output. ) : ( lines.map((l, i) =>
{l}
) )}
-
{lines.length} lines stored
+
{lines.length} lines
); } -// ── 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 ( +
+
+ Changes apply immediately to the running API — no restart needed. Levels are persisted to + config/log_levels.json and restored on container restart. +
- const FileTable = ({ rows, title }) => ( -
-

{title}

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

No files yet.

- ) : ( - + {loading ?
Loading…
: ( +
- - - - + + - {rows.map(f => ( - - - - - + + ))}
NameSizeLast ModifiedRotateServiceLog Level
{f.label}{fmtSize(f.size)}{f.modified?.slice(0, 19)} -
{svc} + + {pending[svc] !== levels[svc] && ( + changed + )}
)} -
- ); - return ( -
-
-

Log Files & Rotation

-
- - -
+
+ + + {msg && {msg}}
- - {msg &&
{msg}
} - - {loading ?
Loading…
: ( - <> - - - - )}
); } -// ── 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) ? OK @@ -371,23 +368,21 @@ function HealthHistoryTab() {

Health History

- +
{loading ?
Loading…
: (
- {['Timestamp', 'Network', 'WireGuard', 'Email', 'Calendar', 'Files', 'Routing', 'Vault', 'Alerts'].map(h => ( + {['Timestamp','Network','WireGuard','Email','Calendar','Files','Routing','Vault','Alerts'].map(h => ( ))} - {healthHistory.map((h, i) => ( - 0 ? 'bg-red-50' : ''}> + {history.map((h, i) => ( + @@ -397,7 +392,7 @@ function HealthHistoryTab() {
{h}
{h.timestamp} - {h.alerts?.length > 0 + {h.alerts?.length ? h.alerts.map((a, j) => ( {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 (

Logs & Monitoring

-

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

+

API service logs · Container stdout/stderr · Log level config · Health history

-
{TABS.map(({ id, label, icon: Icon }) => ( ))}
-
- {tab === 'api' && } + {tab === 'api' && } {tab === 'container' && } - {tab === 'statistics' && } - {tab === 'health' && } + {tab === 'verbosity' && } + {tab === 'health' && }
); } - -export default Logs; diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 86125ff..86ced8c 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -191,13 +191,10 @@ export const logsAPI = { getServiceLogs: (service, level = 'ALL', lines = 100) => api.get(`/api/logs/services/${service}`, { params: { level, lines } }), searchLogs: (data) => api.post('/api/logs/search', data), - exportLogs: (data) => api.post('/api/logs/export', data), - getStatistics: (service) => - api.get('/api/logs/statistics', service ? { params: { service } } : {}), - rotateLogs: (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 } }), + rotateLogs: (service) => api.post('/api/logs/rotate', service ? { service } : {}), + getVerbosity: () => api.get('/api/logs/verbosity'), + setVerbosity: (levels) => api.put('/api/logs/verbosity', levels), }; // Container Management API