feat: proper logging architecture — Docker rotation, persisted service logs, verbosity config
docker-compose.yml: - Add json-file logging driver (max-size: 10m, max-file: 5) to all 13 containers - Docker now owns container stdout/stderr rotation automatically - Add ./data/logs:/app/api/data/logs volume to API — service logs now persist across restarts log_manager.py: - Remove container log collection hack (Docker handles it natively) - Add set_service_level(service, level) — change log level at runtime without restart - Add get_service_levels() — return current per-service levels - Simplify get_all_log_file_infos to return only service log files app.py: - Add GET /api/logs/verbosity — return current per-service log levels - Add PUT /api/logs/verbosity — update levels at runtime, persist to config/log_levels.json - Load persisted log level overrides at startup from log_levels.json - Simplify rotate endpoint (service logs only, container logs owned by Docker) wireguard_manager.py: - get_keys(): return empty strings if key files don't exist (prevents get_status crash when wg0.conf is missing at startup and falls through to generate_config) Logs page (4 tabs): - API Service Logs: structured JSON logs from Python managers, with search/filter/rotate panel - Container Logs: live docker logs (read via existing /api/containers/<name>/logs endpoint) - Verbosity Config: per-service level dropdowns, apply immediately + persist - Health History: existing health poll table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+44
-18
@@ -125,6 +125,16 @@ service_log_configs = {
|
|||||||
for service, config in service_log_configs.items():
|
for service, config in service_log_configs.items():
|
||||||
log_manager.add_service_logger(service, config)
|
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
|
# Start service bus
|
||||||
service_bus.start()
|
service_bus.start()
|
||||||
|
|
||||||
@@ -619,17 +629,11 @@ 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 (API service log or container log)."""
|
"""Manually rotate an API service log file."""
|
||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
name = data.get('name') # e.g. 'wireguard' or 'container_cell-api'
|
service = data.get('service') # None = rotate all
|
||||||
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}")
|
||||||
@@ -637,23 +641,45 @@ def rotate_logs():
|
|||||||
|
|
||||||
@app.route('/api/logs/files', methods=['GET'])
|
@app.route('/api/logs/files', methods=['GET'])
|
||||||
def get_log_file_infos():
|
def get_log_file_infos():
|
||||||
"""List all stored log files (service + container) with sizes."""
|
"""List service log files with sizes."""
|
||||||
try:
|
try:
|
||||||
return jsonify(log_manager.get_all_log_file_infos())
|
return jsonify(log_manager.get_all_log_file_infos())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing log files: {e}")
|
logger.error(f"Error listing log files: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/logs/containers/<container_name>', methods=['GET'])
|
@app.route('/api/logs/verbosity', methods=['GET'])
|
||||||
def get_stored_container_logs(container_name):
|
def get_log_verbosity():
|
||||||
"""Collect latest docker logs into file and return last N lines."""
|
"""Return current per-service log levels."""
|
||||||
try:
|
try:
|
||||||
tail = int(request.args.get('tail', 100))
|
return jsonify(log_manager.get_service_levels())
|
||||||
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:
|
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
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
# Network Services API
|
# Network Services API
|
||||||
|
|||||||
+19
-72
@@ -498,88 +498,35 @@ 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:
|
def set_service_level(self, service: str, level: str):
|
||||||
"""Append new docker container stdout/stderr to a persistent log file.
|
"""Change log level for a service at runtime."""
|
||||||
Returns number of new lines written, or -1 on error."""
|
|
||||||
try:
|
try:
|
||||||
import subprocess
|
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
log_file = self.log_dir / f'container_{container_name}.log'
|
if service in self.service_loggers:
|
||||||
# Determine --since timestamp from last line of existing file
|
self.service_loggers[service].setLevel(log_level)
|
||||||
since_arg = []
|
if service in self.handlers and 'file' in self.handlers[service]:
|
||||||
if log_file.exists() and log_file.stat().st_size > 0:
|
self.handlers[service]['file'].setLevel(log_level)
|
||||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
logger.info(f"Set log level for {service} to {level}")
|
||||||
for line in f:
|
else:
|
||||||
pass
|
logger.warning(f"Service logger not found: {service}")
|
||||||
# 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:
|
except Exception as e:
|
||||||
logger.error(f"Error collecting container logs for {container_name}: {e}")
|
logger.error(f"Error setting log level for {service}: {e}")
|
||||||
return -1
|
|
||||||
|
|
||||||
def get_container_log_lines(self, container_name: str, lines: int = 100) -> List[str]:
|
def get_service_levels(self) -> Dict[str, str]:
|
||||||
"""Read last N lines from stored container log file."""
|
"""Return current log level for each service logger."""
|
||||||
try:
|
return {
|
||||||
log_file = self.log_dir / f'container_{container_name}.log'
|
svc: logging.getLevelName(lgr.level)
|
||||||
if not log_file.exists():
|
for svc, lgr in self.service_loggers.items()
|
||||||
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]]:
|
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 = []
|
results = []
|
||||||
for log_file in sorted(self.log_dir.glob('*.log')):
|
for log_file in sorted(self.log_dir.glob('*.log')):
|
||||||
try:
|
try:
|
||||||
stat = log_file.stat()
|
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({
|
results.append({
|
||||||
'name': name,
|
'name': log_file.stem,
|
||||||
'label': label,
|
|
||||||
'kind': kind,
|
|
||||||
'file': log_file.name,
|
'file': log_file.name,
|
||||||
'size': stat.st_size,
|
'size': stat.st_size,
|
||||||
'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
'modified': datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
|||||||
@@ -74,9 +74,13 @@ class WireGuardManager(BaseServiceManager):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def get_keys(self) -> Dict[str, str]:
|
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')
|
priv_file = os.path.join(self.keys_dir, 'private.key')
|
||||||
pub_file = os.path.join(self.keys_dir, 'public.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:
|
with open(priv_file, 'rb') as f:
|
||||||
priv = f.read()
|
priv = f.read()
|
||||||
with open(pub_file, 'rb') as f:
|
with open(pub_file, 'rb') as f:
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: 172.20.0.2
|
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 Server - CoreDNS for .cell TLD resolution
|
||||||
dns:
|
dns:
|
||||||
@@ -34,6 +39,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: 172.20.0.3
|
ipv4_address: 172.20.0.3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# DHCP Server - dnsmasq for IP leasing
|
# DHCP Server - dnsmasq for IP leasing
|
||||||
dhcp:
|
dhcp:
|
||||||
@@ -51,6 +61,11 @@ services:
|
|||||||
command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"]
|
command: ["/bin/sh", "-c", "apk add --no-cache dnsmasq && dnsmasq -d -C /etc/dnsmasq.conf"]
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# NTP Server - chrony for time synchronization
|
# NTP Server - chrony for time synchronization
|
||||||
ntp:
|
ntp:
|
||||||
@@ -67,6 +82,11 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- SYS_TIME
|
- 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"]
|
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
|
# Email Server - Postfix + Dovecot
|
||||||
mail:
|
mail:
|
||||||
@@ -91,6 +111,11 @@ services:
|
|||||||
ipv4_address: 172.20.0.6
|
ipv4_address: 172.20.0.6
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# Calendar & Contacts - Radicale
|
# Calendar & Contacts - Radicale
|
||||||
radicale:
|
radicale:
|
||||||
@@ -105,6 +130,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: 172.20.0.7
|
ipv4_address: 172.20.0.7
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# File Storage - WebDAV
|
# File Storage - WebDAV
|
||||||
webdav:
|
webdav:
|
||||||
@@ -122,6 +152,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: 172.20.0.8
|
ipv4_address: 172.20.0.8
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# WireGuard VPN
|
# WireGuard VPN
|
||||||
wireguard:
|
wireguard:
|
||||||
@@ -146,6 +181,11 @@ services:
|
|||||||
sysctls:
|
sysctls:
|
||||||
- net.ipv4.conf.all.src_valid_mark=1
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
- net.ipv4.ip_forward=1
|
- net.ipv4.ip_forward=1
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# API Server
|
# API Server
|
||||||
api:
|
api:
|
||||||
@@ -159,6 +199,7 @@ services:
|
|||||||
- ./config/api:/app/config
|
- ./config/api:/app/config
|
||||||
- ./config/wireguard:/app/config/wireguard
|
- ./config/wireguard:/app/config/wireguard
|
||||||
- ./config/dns:/app/config/dns
|
- ./config/dns:/app/config/dns
|
||||||
|
- ./data/logs:/app/api/data/logs
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
pid: host
|
pid: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -168,6 +209,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- wireguard
|
- wireguard
|
||||||
- dns
|
- dns
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# Web UI - React + Vite
|
# Web UI - React + Vite
|
||||||
webui:
|
webui:
|
||||||
@@ -179,6 +225,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
ipv4_address: 172.20.0.11
|
ipv4_address: 172.20.0.11
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# Webmail - RainLoop
|
# Webmail - RainLoop
|
||||||
rainloop:
|
rainloop:
|
||||||
@@ -192,6 +243,11 @@ services:
|
|||||||
- "8888:8888"
|
- "8888:8888"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/rainloop:/rainloop/data
|
- ./data/rainloop:/rainloop/data
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
# File Manager - FileGator
|
# File Manager - FileGator
|
||||||
filegator:
|
filegator:
|
||||||
@@ -205,6 +261,11 @@ services:
|
|||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/filegator:/var/www/filegator/private
|
- ./data/filegator:/var/www/filegator/private
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cell-network:
|
cell-network:
|
||||||
|
|||||||
+160
-171
@@ -1,11 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import { Activity, FileText, AlertTriangle, Search, RefreshCw, RotateCcw, Box, Settings } from 'lucide-react';
|
||||||
Activity, Clock, FileText, AlertTriangle, Search, RefreshCw,
|
|
||||||
RotateCcw, Box, BarChart2
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { monitoringAPI, logsAPI, containerAPI } from '../services/api';
|
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 LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||||
const LEVEL_COLORS = {
|
const LEVEL_COLORS = {
|
||||||
DEBUG: 'text-gray-500',
|
DEBUG: 'text-gray-500',
|
||||||
@@ -21,20 +18,21 @@ function LevelBadge({ level }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LogLine({ entry }) {
|
function LogLine({ entry }) {
|
||||||
if (!entry || entry.raw_line !== undefined) {
|
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 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 flex-wrap">
|
<div className="font-mono text-xs py-0.5 flex gap-2 flex-wrap">
|
||||||
<span className="text-gray-500 shrink-0">{String(entry.timestamp || '').slice(0, 19)}</span>
|
<span className="text-gray-500 shrink-0">{String(entry.timestamp ?? '').slice(0, 19)}</span>
|
||||||
<LevelBadge level={entry.level} />
|
<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: 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() {
|
function ApiServiceLogsTab() {
|
||||||
const [service, setService] = useState('ALL');
|
const [service, setService] = useState('ALL');
|
||||||
const [level, setLevel] = useState('ALL');
|
const [level, setLevel] = useState('ALL');
|
||||||
@@ -43,6 +41,9 @@ function ApiServiceLogsTab() {
|
|||||||
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 [fileInfos, setFileInfos] = useState([]);
|
||||||
|
const [rotating, setRotating] = useState(null);
|
||||||
|
const [showFiles, setShowFiles] = useState(false);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
const doFetch = useCallback(async () => {
|
const doFetch = useCallback(async () => {
|
||||||
@@ -59,9 +60,7 @@ function ApiServiceLogsTab() {
|
|||||||
} else {
|
} else {
|
||||||
const res = await logsAPI.getServiceLogs(service, level, lines);
|
const res = await logsAPI.getServiceLogs(service, level, lines);
|
||||||
const raw = res.data.logs || [];
|
const raw = res.data.logs || [];
|
||||||
const parsed = raw.map(line => {
|
const parsed = raw.map(l => { try { return JSON.parse(l); } catch { return { raw_line: l }; } });
|
||||||
try { return JSON.parse(line); } catch { return { raw_line: line }; }
|
|
||||||
});
|
|
||||||
setLogs(parsed.reverse());
|
setLogs(parsed.reverse());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -74,21 +73,32 @@ function ApiServiceLogsTab() {
|
|||||||
useEffect(() => { doFetch(); }, [service, level, lines]);
|
useEffect(() => { doFetch(); }, [service, level, lines]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) intervalRef.current = setInterval(doFetch, 5000);
|
||||||
intervalRef.current = setInterval(doFetch, 5000);
|
else clearInterval(intervalRef.current);
|
||||||
} else {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
return () => clearInterval(intervalRef.current);
|
return () => clearInterval(intervalRef.current);
|
||||||
}, [autoRefresh, doFetch]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
|
{/* Controls */}
|
||||||
These are structured logs written by the API backend for each service manager (wireguard, network, routing, etc.).
|
|
||||||
They are stored in <code>/app/data/logs/<service>.log</code> and can be rotated from the Statistics tab.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<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)}>
|
<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>)}
|
{API_SERVICES.map(s => <option key={s} value={s}>{s === 'ALL' ? 'ALL services' : s}</option>)}
|
||||||
@@ -96,7 +106,7 @@ function ApiServiceLogsTab() {
|
|||||||
<select className="border rounded px-2 py-1 text-sm" value={level} onChange={e => setLevel(e.target.value)}>
|
<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' && (
|
{service !== 'ALL' && !query && (
|
||||||
<select className="border rounded px-2 py-1 text-sm" value={lines} onChange={e => setLines(Number(e.target.value))}>
|
<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>)}
|
{[50, 100, 200, 500].map(n => <option key={n} value={n}>{n} lines</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -110,25 +120,55 @@ function ApiServiceLogsTab() {
|
|||||||
onKeyDown={e => e.key === 'Enter' && doFetch()}
|
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>
|
<button className="btn btn-secondary px-2 py-1 text-sm" onClick={doFetch}><Search className="h-4 w-4" /></button>
|
||||||
{query && <button className="btn btn-secondary px-2 py-1 text-sm" onClick={() => { setQuery(''); }}>✕</button>}
|
{query && <button className="btn btn-secondary px-2 py-1 text-sm" onClick={() => setQuery('')}>✕</button>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button className={`btn px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} title="Auto-refresh 5s" onClick={() => setAutoRefresh(v => !v)}>
|
||||||
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' : ''}`} />
|
<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 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>
|
</div>
|
||||||
|
|
||||||
|
{/* File info panel */}
|
||||||
|
{showFiles && (
|
||||||
|
<div className="border rounded bg-gray-50 p-3">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Log Files (persisted to ./data/logs/)</span>
|
||||||
|
<button className="btn btn-secondary text-xs px-2 py-0.5" onClick={() => rotate(null)} disabled={rotating === 'all'}>
|
||||||
|
<RotateCcw className={`h-3 w-3 inline mr-1 ${rotating === 'all' ? 'animate-spin' : ''}`} />Rotate All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr className="text-gray-500"><th className="text-left py-1">Service</th><th className="text-right py-1">Size</th><th className="text-left py-1 pl-3">Modified</th><th className="text-center py-1"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{fileInfos.map(f => (
|
||||||
|
<tr key={f.name} className="border-t">
|
||||||
|
<td className="py-1 font-mono">{f.name}</td>
|
||||||
|
<td className="py-1 text-right font-mono">{fmtSize(f.size)}</td>
|
||||||
|
<td className="py-1 pl-3 text-gray-500">{f.modified?.slice(0, 19)}</td>
|
||||||
|
<td className="py-1 text-center">
|
||||||
|
<button className="btn btn-secondary px-1.5 py-0.5 text-xs" onClick={() => rotate(f.name)} disabled={rotating === f.name}>
|
||||||
|
<RotateCcw className={`h-3 w-3 ${rotating === f.name ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{fileInfos.length === 0 && <tr><td colSpan={4} className="text-gray-400 py-2 text-center">No log files found.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log output */}
|
||||||
<div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto">
|
<div className="bg-gray-900 rounded-lg p-3 h-[500px] overflow-y-auto">
|
||||||
{loading && logs.length === 0 ? (
|
{loading && !logs.length ? (
|
||||||
<div className="text-gray-400 text-sm">Loading…</div>
|
<div className="text-gray-400 text-sm">Loading…</div>
|
||||||
) : logs.length === 0 ? (
|
) : !logs.length ? (
|
||||||
<div className="text-gray-500 text-sm">No entries found.</div>
|
<div className="text-gray-500 text-sm">No entries found.</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((entry, i) => <LogLine key={i} entry={entry} />)
|
logs.map((e, i) => <LogLine key={i} entry={e} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">{logs.length} entries</div>
|
<div className="text-xs text-gray-400">{logs.length} entries</div>
|
||||||
@@ -136,7 +176,9 @@ 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() {
|
function ContainerLogsTab() {
|
||||||
const [containers, setContainers] = useState([]);
|
const [containers, setContainers] = useState([]);
|
||||||
const [selected, setSelected] = useState('cell-api');
|
const [selected, setSelected] = useState('cell-api');
|
||||||
@@ -151,10 +193,9 @@ function ContainerLogsTab() {
|
|||||||
.then(res => {
|
.then(res => {
|
||||||
const names = (res.data || [])
|
const names = (res.data || [])
|
||||||
.map(c => c.name || c.Names?.[0]?.replace('/', ''))
|
.map(c => c.name || c.Names?.[0]?.replace('/', ''))
|
||||||
.filter(Boolean)
|
.filter(Boolean).sort();
|
||||||
.sort();
|
|
||||||
setContainers(names);
|
setContainers(names);
|
||||||
if (names.length > 0 && !names.includes(selected)) setSelected(names[0]);
|
if (names.length && !names.includes(selected)) setSelected(names[0]);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -163,8 +204,9 @@ function ContainerLogsTab() {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await logsAPI.getStoredContainerLogs(selected, tail);
|
const res = await containerAPI.getContainerLogs(selected, tail);
|
||||||
setLines(res.data.lines || []);
|
const raw = res.data.logs || '';
|
||||||
|
setLines(typeof raw === 'string' ? raw.split('\n').filter(Boolean) : raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLines([`Error: ${e.message}`]);
|
setLines([`Error: ${e.message}`]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,193 +217,148 @@ function ContainerLogsTab() {
|
|||||||
useEffect(() => { doFetch(); }, [selected, tail]);
|
useEffect(() => { doFetch(); }, [selected, tail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) intervalRef.current = setInterval(doFetch, 5000);
|
||||||
intervalRef.current = setInterval(doFetch, 5000);
|
else clearInterval(intervalRef.current);
|
||||||
} else {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
return () => clearInterval(intervalRef.current);
|
return () => clearInterval(intervalRef.current);
|
||||||
}, [autoRefresh, doFetch]);
|
}, [autoRefresh, doFetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
|
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
|
||||||
Container stdout/stderr collected from Docker and stored in <code>/app/data/logs/container_<name>.log</code>.
|
Live stdout/stderr from Docker. Rotation is automatic: <code>json-file</code> driver,
|
||||||
Each fetch appends new lines since last collection. Rotate from the Statistics tab.
|
<strong> 10 MB max-size, 5 backups</strong> per container — configured in <code>docker-compose.yml</code>.
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<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)}>
|
<select className="border rounded px-2 py-1 text-sm" value={selected} onChange={e => setSelected(e.target.value)}>
|
||||||
{(containers.length > 0 ? containers : ['cell-api']).map(c => (
|
{(containers.length ? containers : ['cell-api']).map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
<option key={c} value={c}>{c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<select className="border rounded px-2 py-1 text-sm" value={tail} onChange={e => setTail(Number(e.target.value))}>
|
<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 px-2 py-1 text-sm ${autoRefresh ? 'btn-primary' : 'btn-secondary'}`} title="Auto-refresh 5s" onClick={() => setAutoRefresh(v => !v)}>
|
||||||
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' : ''}`} />
|
<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 px-2 py-1 text-sm" onClick={doFetch}><RefreshCw className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 text-green-300 rounded-lg p-3 h-[500px] overflow-y-auto font-mono text-xs">
|
<div className="bg-gray-900 text-green-300 rounded-lg p-3 h-[500px] overflow-y-auto font-mono text-xs">
|
||||||
{loading && lines.length === 0 ? (
|
{loading && !lines.length ? (
|
||||||
<span className="text-gray-400">Loading…</span>
|
<span className="text-gray-400">Loading…</span>
|
||||||
) : lines.length === 0 ? (
|
) : !lines.length ? (
|
||||||
<span className="text-gray-500">No stored logs. Click refresh to collect.</span>
|
<span className="text-gray-500">No output.</span>
|
||||||
) : (
|
) : (
|
||||||
lines.map((l, i) => <div key={i} className="py-0.5 break-all">{l}</div>)
|
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 className="text-xs text-gray-400">{lines.length} lines</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab: Statistics & Rotation ──────────────────────────────────────────────
|
// ── Tab 3: Verbosity Config ─────────────────────────────────────────────────
|
||||||
function StatisticsTab() {
|
function VerbosityTab() {
|
||||||
const [files, setFiles] = useState([]);
|
const [levels, setLevels] = useState({});
|
||||||
|
const [pending, setPending] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [rotating, setRotating] = useState(null);
|
const [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
const doFetch = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await logsAPI.getLogFiles();
|
const res = await logsAPI.getVerbosity();
|
||||||
setFiles(res.data || []);
|
setLevels(res.data || {});
|
||||||
|
setPending(res.data || {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
setMsg(`Error: ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { doFetch(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const rotate = async (file) => {
|
const save = async () => {
|
||||||
const label = file ? file.label : 'all log files';
|
const changed = Object.fromEntries(
|
||||||
if (!window.confirm(`Rotate logs for ${label}?\nThe current file will be archived and a new one started.`)) return;
|
Object.entries(pending).filter(([k, v]) => v !== levels[k])
|
||||||
const key = file ? file.name : 'all';
|
);
|
||||||
setRotating(key);
|
if (!Object.keys(changed).length) { setMsg('No changes.'); return; }
|
||||||
|
setSaving(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
if (file) {
|
const res = await logsAPI.setVerbosity(changed);
|
||||||
await logsAPI.rotateLogs(file.name, file.kind);
|
setLevels(res.data.levels || pending);
|
||||||
} else {
|
setMsg('Levels saved and applied.');
|
||||||
// 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) {
|
||||||
setMsg(`Error: ${e.message}`);
|
setMsg(`Error: ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setRotating(null);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtSize = bytes => {
|
const services = Object.keys(pending).sort();
|
||||||
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 serviceFiles = files.filter(f => f.kind === 'service');
|
return (
|
||||||
const containerFiles = files.filter(f => f.kind === 'container');
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2">
|
||||||
|
Changes apply immediately to the running API — no restart needed. Levels are persisted to
|
||||||
|
<code> config/log_levels.json</code> and restored on container restart.
|
||||||
|
</div>
|
||||||
|
|
||||||
const FileTable = ({ rows, title }) => (
|
{loading ? <div className="text-gray-500 text-sm">Loading…</div> : (
|
||||||
<div>
|
<table className="w-full text-sm">
|
||||||
<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>
|
<thead>
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
<th className="px-3 py-2 text-left">Name</th>
|
<th className="px-3 py-2 text-left">Service</th>
|
||||||
<th className="px-3 py-2 text-right">Size</th>
|
<th className="px-3 py-2 text-left">Log Level</th>
|
||||||
<th className="px-3 py-2 text-left">Last Modified</th>
|
|
||||||
<th className="px-3 py-2 text-center">Rotate</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(f => (
|
{services.map(svc => (
|
||||||
<tr key={f.name} className="border-t hover:bg-gray-50">
|
<tr key={svc} className="border-t">
|
||||||
<td className="px-3 py-2 font-mono text-xs">{f.label}</td>
|
<td className="px-3 py-2 font-medium">{svc}</td>
|
||||||
<td className="px-3 py-2 text-right font-mono text-xs">{fmtSize(f.size)}</td>
|
<td className="px-3 py-2">
|
||||||
<td className="px-3 py-2 text-xs text-gray-500">{f.modified?.slice(0, 19)}</td>
|
<select
|
||||||
<td className="px-3 py-2 text-center">
|
className="border rounded px-2 py-1 text-sm"
|
||||||
<button
|
value={pending[svc] || 'INFO'}
|
||||||
className="btn btn-secondary text-xs px-2 py-0.5"
|
onChange={e => setPending(p => ({ ...p, [svc]: e.target.value }))}
|
||||||
onClick={() => rotate(f)}
|
|
||||||
disabled={rotating === f.name}
|
|
||||||
>
|
>
|
||||||
<RotateCcw className={`h-3 w-3 inline ${rotating === f.name ? 'animate-spin' : ''}`} />
|
{['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l => (
|
||||||
</button>
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{pending[svc] !== levels[svc] && (
|
||||||
|
<span className="ml-2 text-xs text-yellow-600">changed</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
<div className="flex items-center gap-3">
|
||||||
<div className="space-y-4">
|
<button className="btn btn-primary text-sm" onClick={save} disabled={saving}>
|
||||||
<div className="flex justify-between items-center">
|
{saving ? 'Saving…' : 'Apply Changes'}
|
||||||
<h3 className="text-lg font-medium text-gray-900">Log Files & Rotation</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="btn btn-secondary text-sm" onClick={doFetch}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary text-sm"
|
|
||||||
onClick={() => rotate(null)}
|
|
||||||
disabled={rotating === 'all'}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4 mr-1 inline" /> Rotate All
|
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-secondary text-sm" onClick={load}>Reset</button>
|
||||||
|
{msg && <span className="text-sm text-gray-600">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg && <div className="text-sm text-green-700 bg-green-50 rounded px-3 py-2">{msg}</div>}
|
|
||||||
|
|
||||||
{loading ? <div className="text-gray-500 text-sm">Loading…</div> : (
|
|
||||||
<>
|
|
||||||
<FileTable rows={serviceFiles} title="API Service Logs" />
|
|
||||||
<FileTable rows={containerFiles} title="Container Logs (stored)" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab: Health History ─────────────────────────────────────────────────────
|
// ── Tab 4: Health History ───────────────────────────────────────────────────
|
||||||
function HealthHistoryTab() {
|
function HealthHistoryTab() {
|
||||||
const [healthHistory, setHealthHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const doFetch = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try { setHistory((await monitoringAPI.getHealthHistory()).data || []); } catch {}
|
||||||
const res = await monitoringAPI.getHealthHistory();
|
|
||||||
setHealthHistory(res.data || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { doFetch(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const SvcCol = ({ data }) => (data?.status === 'online' || data?.running === true)
|
const SvcCol = ({ data }) => (data?.status === 'online' || data?.running === true)
|
||||||
? <span className="text-green-600">OK</span>
|
? <span className="text-green-600">OK</span>
|
||||||
@@ -371,9 +368,7 @@ function HealthHistoryTab() {
|
|||||||
<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</h3>
|
<h3 className="text-lg font-medium text-gray-900">Health History</h3>
|
||||||
<button className="btn btn-secondary text-sm" onClick={doFetch}>
|
<button className="btn btn-secondary text-sm" onClick={load}><RefreshCw className="h-4 w-4 mr-1 inline" />Refresh</button>
|
||||||
<RefreshCw className="h-4 w-4 mr-1 inline" /> Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{loading ? <div className="text-gray-500 text-sm">Loading…</div> : (
|
{loading ? <div className="text-gray-500 text-sm">Loading…</div> : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -386,8 +381,8 @@ function HealthHistoryTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{healthHistory.map((h, i) => (
|
{history.map((h, i) => (
|
||||||
<tr key={i} className={h.alerts?.length > 0 ? 'bg-red-50' : ''}>
|
<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 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.network} /></td>
|
||||||
<td className="px-2 py-1"><SvcCol data={h.wireguard} /></td>
|
<td className="px-2 py-1"><SvcCol data={h.wireguard} /></td>
|
||||||
@@ -397,7 +392,7 @@ function HealthHistoryTab() {
|
|||||||
<td className="px-2 py-1"><SvcCol data={h.routing} /></td>
|
<td className="px-2 py-1"><SvcCol data={h.routing} /></td>
|
||||||
<td className="px-2 py-1"><SvcCol 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
|
||||||
? 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}
|
||||||
@@ -419,20 +414,18 @@ function HealthHistoryTab() {
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'api', label: 'API 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: 'verbosity', label: 'Verbosity Config', icon: Settings },
|
||||||
{ id: 'health', label: 'Health History', icon: Activity },
|
{ id: 'health', label: 'Health History', icon: Activity },
|
||||||
];
|
];
|
||||||
|
|
||||||
function Logs() {
|
export default function Logs() {
|
||||||
const [tab, setTab] = useState('api');
|
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">API service logs, container stdout/stderr, rotation, and health history.</p>
|
<p className="mt-1 text-gray-600">API service logs · Container stdout/stderr · Log level config · Health history</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
||||||
@@ -442,20 +435,16 @@ function Logs() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => setTab(id)}
|
onClick={() => setTab(id)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />{label}
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{tab === 'api' && <ApiServiceLogsTab />}
|
{tab === 'api' && <ApiServiceLogsTab />}
|
||||||
{tab === 'container' && <ContainerLogsTab />}
|
{tab === 'container' && <ContainerLogsTab />}
|
||||||
{tab === 'statistics' && <StatisticsTab />}
|
{tab === 'verbosity' && <VerbosityTab />}
|
||||||
{tab === 'health' && <HealthHistoryTab />}
|
{tab === 'health' && <HealthHistoryTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Logs;
|
|
||||||
|
|||||||
@@ -191,13 +191,10 @@ export const logsAPI = {
|
|||||||
getServiceLogs: (service, level = 'ALL', lines = 100) =>
|
getServiceLogs: (service, level = 'ALL', lines = 100) =>
|
||||||
api.get(`/api/logs/services/${service}`, { params: { level, lines } }),
|
api.get(`/api/logs/services/${service}`, { params: { level, lines } }),
|
||||||
searchLogs: (data) => api.post('/api/logs/search', data),
|
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'),
|
getLogFiles: () => api.get('/api/logs/files'),
|
||||||
getStoredContainerLogs: (containerName, tail = 100) =>
|
rotateLogs: (service) => api.post('/api/logs/rotate', service ? { service } : {}),
|
||||||
api.get(`/api/logs/containers/${containerName}`, { params: { tail } }),
|
getVerbosity: () => api.get('/api/logs/verbosity'),
|
||||||
|
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Container Management API
|
// Container Management API
|
||||||
|
|||||||
Reference in New Issue
Block a user