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
+103 -38
View File
@@ -260,9 +260,38 @@ function ContainerLogsTab() {
}
// ── Tab 3: Verbosity Config ─────────────────────────────────────────────────
const VERBOSITY_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
// Container services that need a container recreate before a level change applies.
const PENDING_RESTART_CONTAINERS = ['wireguard', 'mailserver'];
function LevelRow({ name, value, original, badge, onChange }) {
return (
<tr className="border-t">
<td className="px-3 py-2 font-medium">
{name}
{badge && (
<span className="ml-2 text-xs bg-orange-100 text-orange-700 rounded px-1.5 py-0.5">
needs restart
</span>
)}
</td>
<td className="px-3 py-2">
<select
className="border rounded px-2 py-1 text-sm"
value={value || 'INFO'}
onChange={e => onChange(e.target.value)}
>
{VERBOSITY_LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
{value !== original && <span className="ml-2 text-xs text-yellow-600">changed</span>}
</td>
</tr>
);
}
function VerbosityTab() {
const [levels, setLevels] = useState({});
const [pending, setPending] = useState({});
const [config, setConfig] = useState(null); // last-saved server state
const [pending, setPending] = useState(null); // editable copy
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
@@ -271,8 +300,8 @@ function VerbosityTab() {
setLoading(true);
try {
const res = await logsAPI.getVerbosity();
setLevels(res.data || {});
setPending(res.data || {});
setConfig(res.data);
setPending(JSON.parse(JSON.stringify(res.data)));
} catch (e) {
setMsg(`Error: ${e.message}`);
} finally {
@@ -282,34 +311,46 @@ function VerbosityTab() {
useEffect(() => { load(); }, []);
const setRoot = (v) =>
setPending(p => ({ ...p, python: { ...p.python, root: v } }));
const setService = (svc, v) =>
setPending(p => ({ ...p, python: { ...p.python, services: { ...p.python.services, [svc]: v } } }));
const setContainer = (c, v) =>
setPending(p => ({ ...p, containers: { ...p.containers, [c]: v } }));
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 {
const res = await logsAPI.setVerbosity(changed);
setLevels(res.data.levels || pending);
setMsg('Levels saved and applied.');
const res = await logsAPI.setVerbosity(pending);
setConfig(res.data.logging);
setPending(JSON.parse(JSON.stringify(res.data.logging)));
const restarts = Object.entries(res.data.applied || {})
.filter(([, v]) => v === 'pending_restart')
.map(([k]) => k);
setMsg(restarts.length
? `Saved. ${restarts.join(', ')} will apply on next container restart.`
: 'Levels saved and applied.');
} catch (e) {
setMsg(`Error: ${e.message}`);
setMsg(`Error: ${e.response?.data?.error || e.message}`);
} finally {
setSaving(false);
}
};
const services = Object.keys(pending).sort();
if (loading || !pending) return <div className="text-gray-500 text-sm">Loading</div>;
const services = Object.keys(pending.python.services).sort();
const containers = Object.keys(pending.containers).sort();
return (
<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>
{loading ? <div className="text-gray-500 text-sm">Loading</div> : (
<div className="space-y-6 max-w-lg">
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-1">Python services</h3>
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">
Applies immediately to the running API no restart needed. Persisted in cell_config and
restored on restart.
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
@@ -318,28 +359,52 @@ function VerbosityTab() {
</tr>
</thead>
<tbody>
<LevelRow
name="root (all bare-module loggers)"
value={pending.python.root}
original={config.python.root}
onChange={setRoot}
/>
{services.map(svc => (
<tr key={svc} className="border-t">
<td className="px-3 py-2 font-medium">{svc}</td>
<td className="px-3 py-2">
<select
className="border rounded px-2 py-1 text-sm"
value={pending[svc] || 'INFO'}
onChange={e => setPending(p => ({ ...p, [svc]: e.target.value }))}
>
{['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
{pending[svc] !== levels[svc] && (
<span className="ml-2 text-xs text-yellow-600">changed</span>
)}
</td>
</tr>
<LevelRow
key={svc}
name={svc}
value={pending.python.services[svc]}
original={config.python.services[svc]}
onChange={v => setService(svc, v)}
/>
))}
</tbody>
</table>
)}
</div>
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-1">Container services</h3>
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">
caddy and coredns reload immediately. wireguard and mailserver are container-ENV driven
their level applies on the next container restart.
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="px-3 py-2 text-left">Container</th>
<th className="px-3 py-2 text-left">Log Level</th>
</tr>
</thead>
<tbody>
{containers.map(c => (
<LevelRow
key={c}
name={c}
value={pending.containers[c]}
original={config.containers[c]}
badge={PENDING_RESTART_CONTAINERS.includes(c)}
onChange={v => setContainer(c, v)}
/>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-3">
<button className="btn btn-primary text-sm" onClick={save} disabled={saving}>