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:
+19
-72
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user