fix: logging verbosity now actually applies + per-service log levels
Unit Tests / test (push) Successful in 12m34s

Root causes fixed:
- Dead LOG_LEVEL globals() lookup pinned root logger at INFO regardless of
  PIC_LOG_LEVEL env or config; replaced with _resolve_root_log_level() +
  apply_root_log_level() which sets both root logger and all attached handlers
  at startup and on runtime re-apply.
- set_service_level() only set the named 'pic.<service>' logger; bare module
  loggers (e.g. 'caddy_manager') were never reached, so per-service log files
  stayed 0 bytes. Fixed via _SERVICE_MODULE_LOGGERS map covering all managers.
- Log viewer GET /api/logs had no level filter; added ?level= query param.
- Per-service log levels lived in an out-of-band config/api/log_levels.json
  side-file with no validation; migrated into ConfigManager under a new
  'logging' section ({python:{root,services}, containers:{caddy,coredns,
  wireguard,mailserver,api}}) with get/set helpers, invalid-level rejection,
  and one-time migration from the old file on first load.

New capabilities:
- Container log levels: Caddy (injects global log { level X } + hot reload),
  CoreDNS (DEBUG enables log plugin, else errors-only), WireGuard/mailserver
  via pending_restart path.
- PUT /api/logs/verbosity accepts {python, containers} dict; returns per-entry
  applied:hot|pending_restart status.
- Webui Logs page gains two-section Verbosity tab (Python services + Container
  services) with needs-restart badges.
- managers.py wires per-service loggers before manager instantiation and
  re-applies persisted levels from ConfigManager; legacy log_levels.json read
  removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:14:01 -04:00
parent 89aed4efe0
commit 13074f56cb
15 changed files with 726 additions and 158 deletions
+31 -2
View File
@@ -111,6 +111,30 @@ class CaddyManager(BaseServiceManager):
# ── Caddyfile generation ──────────────────────────────────────────────
# Python logging level → Caddy log level. Caddy only knows
# DEBUG/INFO/WARN/ERROR (no CRITICAL).
_CADDY_LEVEL_MAP = {
'DEBUG': 'DEBUG', 'INFO': 'INFO', 'WARNING': 'WARN',
'ERROR': 'ERROR', 'CRITICAL': 'ERROR',
}
def _resolve_caddy_level(self) -> str:
"""Read the configured caddy container log level (Python level name)."""
if self.config_manager is not None:
try:
return self.config_manager.get_logging_config()['containers'].get('caddy', 'INFO')
except Exception:
pass
return 'INFO'
def _global_log_block(self) -> str:
"""Return the global-options `log { level <X> }` line(s), or '' for the
Caddy default (INFO). Injected inside the global `{ ... }` block."""
level = self._CADDY_LEVEL_MAP.get(self._resolve_caddy_level(), 'INFO')
if level == 'INFO':
return ''
return f" log {{\n level {level}\n }}"
def generate_caddyfile(self, identity: Dict[str, Any],
installed_services: List[Dict[str, Any]]) -> str:
"""Generate a complete Caddyfile based on identity and services.
@@ -172,13 +196,15 @@ class CaddyManager(BaseServiceManager):
# ── per-mode generators ───────────────────────────────────────────────
@staticmethod
def _global_acme_block(email: Optional[str]) -> str:
def _global_acme_block(self, email: Optional[str]) -> str:
"""Return the ``{ ... }`` global block for an ACME-enabled mode."""
lines = ["{"]
# Bind admin API on all interfaces so cell-api can reach cell-caddy
# across the Docker bridge (default 127.0.0.1 is unreachable cross-container).
lines.append(" admin 0.0.0.0:2019")
log_block = self._global_log_block()
if log_block:
lines.append(log_block)
if email:
lines.append(f" email {email}")
# Only write acme_ca when a URL is configured — an empty ACME_CA_URL
@@ -290,9 +316,12 @@ class CaddyManager(BaseServiceManager):
body.append(self._indent_routes(service_routes))
body.append(core_routes)
inner = "\n".join(body)
log_block = self._global_log_block()
log_line = (log_block + "\n") if log_block else ""
return (
"{\n"
" admin 0.0.0.0:2019\n"
f"{log_line}"
" auto_https off\n"
"}\n"
"\n"