Skip to content

Commit b1b75fa

Browse files
Merge pull request #630 from frankieramirez/feat/settings-log-viewer
feat: Read the logs from Settings, with an honest level dial
2 parents 104e906 + c5b7cd4 commit b1b75fa

25 files changed

Lines changed: 1034 additions & 1030 deletions

.changeset/settings-log-viewer.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"comicarr": minor
3+
---
4+
5+
Read your logs without leaving Comicarr. Settings has a new **Logs** section: the tail of `comicarr.log` in a console you can filter by severity and copy straight into a bug report, with the log level dial sitting right above it. Raise the level, reproduce the problem, and read what happened — without a shell, a `docker exec`, or a restart.
6+
7+
The dial is honest about who is in charge. If a `--log-level` flag or `COMICARR_LOG_LEVEL` is setting the level, the page says so, names the level actually running and the one the next restart will bring back, and explains that the value you save here applies immediately but will not survive that restart until the pin is removed. When nothing overrides it, the page stays quiet and the dial simply is what runs.
8+
9+
The header shows where the log file lives and how much history is kept (`10 MB × 5 files`) so you can see the ceiling before turning verbosity up. You can pull the last 200, 1,000, or 5,000 lines. Provider secrets are still redacted before any line leaves the server, and only the current log file is read — rotated files stay where they are.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Conventional PR titles keep history readable, but they do not control releases.
6161
- **Do NOT call `useReactTable` directly** - `no-restricted-imports` allows it only in `frontend/src/components/data-table/useTableState.ts`. Call `useTableState`, which wraps it so `getRowId` is required and row identity can never fall back to TanStack's index default. Tables still awaiting migration are listed in an `overrides` allowlist in `eslint.config.js`; that list only ever shrinks — never add to it.
6262
- **Do NOT reintroduce `GLOBAL_MESSAGES`** - The pre-EventBus message bus is retired. Deleting the declaration cannot make its return fail (Python creates the attribute on first assignment), so `npm run lint:guards` scans source for it instead. Narrate through `comicarr.app.activity.events.record_activity`; that facade publishes the single `activity` SSE event after a durable commit. Add further retired names to `RETIRED_GLOBALS` in `scripts/check_retired_globals.py`. Contributor-only gate — no changeset.
6363
- **Log verbosity has exactly one dial** - `comicarr.LOG_LEVEL` (0/1/2) resolves through `logger.threshold_for_level()` and is applied identically to the logger, file, console, and Web UI sinks; ask for the current value with `logger.current_log_level()`, never by reading a global. Level 0 means warnings and errors, not silence. Whether a console sink exists at all is the orthogonal `console=` argument to `initLogger()`. The second dial (`comicarr.QUIET`) is retired and guarded by `RETIRED_GLOBALS`; it caused #610, where raising verbosity under Docker *removed* console output. Contract and history: `docs/architecture/logging-levels.md`.
64+
- **Do NOT show the saved log level as if it were the running one** - Settings writes `LOG_LEVEL` at the *bottom* of the precedence chain and the write applies live, so the running level, the saved level, and the level the next restart resolves to can all differ. Any surface that reports the level must use `resolve_effective_log_level` (which returns all three plus `pinned`) rather than reading `config.log_level` alone; `GET /system/logs` already carries it. Showing one number where three exist is #610 restated in the UI.
6465
- **`db.upsert` / `db.upsert_conn` table names must be lowercase `TABLE_MAP` keys** - The table is resolved by dict lookup, so `"Issues"` for `"issues"` lints clean and raises `ValueError: Unknown table for upsert` only when that write branch runs — it broke series refresh in production (#561). `scripts/check_upsert_tables.py` (under `lint:guards`) AST-scans every literal table argument. Runtime-built names are skipped; if you must build one, lowercase it at the source. Contributor-only gate — no changeset.
6566
- **Every `fail_reason` base token must be classified** in `comicarr/app/activity/reasons.py` before merge (`scripts/check_fail_reason_registry.py` under `lint:guards`). Runtime is fail-open; CI is the gate. Excluding a token requires a reconciliation obligation (never leave `Snatched`). See ADR-0001 / #523 / #541.
6667
- **Do NOT make a handoff route depend on the download client reaching back into Comicarr** - A handoff delivers the content, never a pointer to Comicarr, and must be verifiable from the client's own response alone (ADR-0002 / #552 / #564). `blackhole` and `watchdir` are the named exceptions to *verifiability* only — they pay for it by staying out of `_RESTART_SAFE_ROUTES`. There is no cheap static signal for "callback URL", so this is a review gate, not a lint one.

comicarr/app/config/log_level.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141

4242
MIN_LEVEL = 0
4343
MAX_LEVEL = 2
44+
# What the dial sits at when nothing supplies a level. Matches the `LOG_LEVEL`
45+
# default in the config registry.
46+
DEFAULT_LEVEL = 1
4447

4548
# One name per level, and the threshold picks it: level 0 is `WARNING`, so it is
4649
# called `warning`. The tempting `quiet`/`normal`/`verbose` triple is not here on
@@ -77,6 +80,30 @@ class LogLevelResolution:
7780
notices: list[str] = field(default_factory=list)
7881

7982

83+
@dataclass
84+
class EffectiveLogLevel:
85+
"""What the process is logging at now, and what a restart would make of it.
86+
87+
The Settings dial writes `config.ini`, the *bottom* of the chain, and the
88+
write applies live. So three numbers can disagree at once: the level running
89+
right now, the level saved in the config file, and the level the next start
90+
resolves to. `pinned` is the one an operator needs -- when it is true, a
91+
source the UI cannot edit wins the chain, and the dial's value will not
92+
survive a restart. That is the #610 failure said out loud instead of
93+
discovered later.
94+
"""
95+
96+
level: int
97+
saved: int
98+
restart_level: int
99+
restart_source: str
100+
101+
@property
102+
def pinned(self) -> bool:
103+
"""True when something above the config file decides the level."""
104+
return self.restart_source in (SOURCE_ARGUMENT, SOURCE_ENVIRONMENT)
105+
106+
80107
def clamp_level(level: int) -> int:
81108
"""Hold a level inside the dial's range, matching `threshold_for_level`."""
82109
return max(MIN_LEVEL, min(MAX_LEVEL, level))
@@ -130,6 +157,25 @@ def parse_level(raw, origin: str) -> tuple[int | None, list[str]]:
130157
return clamped, notices
131158

132159

160+
# The one thing about the chain that cannot be re-read later. The environment
161+
# and the config file are still there to be consulted at any moment; the startup
162+
# argument is consumed once at boot and `comicarr.LOG_LEVEL` is overwritten with
163+
# the resolved level straight after, so without this the Settings page could not
164+
# tell an operator that a `--log-level` flag is what keeps overruling their dial.
165+
_startup_argument: int | None = None
166+
167+
168+
def record_startup_argument(level) -> None:
169+
"""Remember the level a startup argument supplied, if one did."""
170+
global _startup_argument
171+
_startup_argument = level
172+
173+
174+
def startup_argument() -> int | None:
175+
"""The level the startup argument supplied, or None when none was passed."""
176+
return _startup_argument
177+
178+
133179
def resolve_startup_log_level(
134180
argument_level=None,
135181
config_level=None,
@@ -153,7 +199,7 @@ def resolve_startup_log_level(
153199
supplied.append((level, source))
154200

155201
if not supplied:
156-
return LogLevelResolution(level=1, source=SOURCE_DEFAULT, notices=notices)
202+
return LogLevelResolution(level=DEFAULT_LEVEL, source=SOURCE_DEFAULT, notices=notices)
157203

158204
level, source = supplied[0]
159205
# Say what lost, so an operator who edits the Settings dial and sees nothing
@@ -166,3 +212,32 @@ def resolve_startup_log_level(
166212
if overridden:
167213
notices.append(f"Log level {describe_level(level)} from {source} overrides {', '.join(overridden)}.")
168214
return LogLevelResolution(level=level, source=source, notices=notices)
215+
216+
217+
def resolve_effective_log_level(
218+
running_level,
219+
config_level=None,
220+
environ: Mapping[str, str] | None = None,
221+
) -> EffectiveLogLevel:
222+
"""Report the running level next to the one a restart would resolve to.
223+
224+
The restart half is the startup chain run again, now: the argument is the
225+
recorded one, the environment and the config file are read fresh. Re-running
226+
it rather than replaying the boot resolution is what keeps the answer true
227+
after the operator saves a new level -- the chain's bottom rung has moved.
228+
"""
229+
restart = resolve_startup_log_level(
230+
argument_level=startup_argument(),
231+
config_level=config_level,
232+
environ=environ,
233+
)
234+
# A config file with no usable `LOG_LEVEL` still has a dial position: the
235+
# registry default. Borrowing the resolved level instead would show the
236+
# operator a startup argument's value in a field that edits the config.
237+
saved, _ = parse_level(config_level, SOURCE_CONFIG)
238+
return EffectiveLogLevel(
239+
level=clamp_level(int(running_level or 0)),
240+
saved=DEFAULT_LEVEL if saved is None else saved,
241+
restart_level=restart.level,
242+
restart_source=restart.source,
243+
)

comicarr/app/config/registry.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,12 @@ def as_definition(self) -> tuple[type, str, Any]:
260260
ConfigKey("MAL_ENABLED", bool, "MAL", False, readable=True, writable=True),
261261
ConfigKey("MAL_CLIENT_ID", str, "MAL", None, writable=True),
262262
ConfigKey("LOG_DIR", str, "Logs", None, readable=True),
263-
ConfigKey("MAX_LOGSIZE", int, "Logs", 10000000),
264-
ConfigKey("MAX_LOGFILES", int, "Logs", 5),
263+
# Readable, never writable: Settings → Logs shows the retention ceiling as
264+
# context because turning the level up to debug fills it fast, but the knob
265+
# itself is one almost nobody should turn and every writable key is a
266+
# permanent API surface.
267+
ConfigKey("MAX_LOGSIZE", int, "Logs", 10000000, readable=True),
268+
ConfigKey("MAX_LOGFILES", int, "Logs", 5, readable=True),
265269
ConfigKey("LOG_LEVEL", int, "Logs", 1, readable=True, writable=True),
266270
ConfigKey("GIT_PATH", str, "Git", None),
267271
ConfigKey("GIT_USER", str, "Git", "frankieramirez"),

comicarr/app/system/router.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import json
1919
import threading
2020

21-
from fastapi import APIRouter, Depends, Request, Response
21+
from fastapi import APIRouter, Depends, Query, Request, Response
2222
from fastapi.responses import JSONResponse
2323
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
2424

@@ -346,9 +346,17 @@ def check_version_now(ctx: AppContext = Depends(get_context)):
346346

347347

348348
@router.get("/system/logs", dependencies=[Depends(require_session)])
349-
def get_logs(ctx: AppContext = Depends(get_context)):
350-
"""Return recent log entries."""
351-
return system_service.get_recent_logs(ctx)
349+
def get_logs(
350+
lines: int = Query(
351+
system_service.DEFAULT_LOG_LINES,
352+
ge=1,
353+
le=system_service.MAX_LOG_LINES,
354+
description="How many trailing lines of comicarr.log to return.",
355+
),
356+
ctx: AppContext = Depends(get_context),
357+
):
358+
"""Return the tail of the current log file plus the effective log level."""
359+
return system_service.get_recent_logs(ctx, lines=lines)
352360

353361

354362
@router.get("/system/jobs", dependencies=[Depends(require_session)])

comicarr/app/system/service.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import subprocess
2727
import sys
2828
import threading
29-
from collections import namedtuple
29+
from collections import deque, namedtuple
3030
from pathlib import Path
3131
from urllib.parse import urlsplit, urlunsplit
3232

@@ -44,7 +44,13 @@
4444
from comicarr.app.acquisition.models import DispatchState
4545
from comicarr.app.common.dates import normalize_utc_datetime
4646
from comicarr.app.common.redaction import redact_sensitive_text
47-
from comicarr.app.config.log_level import ACCEPTED_FORMS, SOURCE_SETTINGS, parse_level
47+
from comicarr.app.config.log_level import (
48+
ACCEPTED_FORMS,
49+
NAME_FOR_LEVEL,
50+
SOURCE_SETTINGS,
51+
parse_level,
52+
resolve_effective_log_level,
53+
)
4854
from comicarr.app.config.registry import (
4955
readable_keys,
5056
scheduler_job_intervals,
@@ -830,32 +836,77 @@ def get_build_identity(ctx):
830836
}
831837

832838

833-
def get_recent_logs(ctx):
834-
"""Return recent log entries."""
839+
# How many trailing lines Settings → Logs asks for by default, and the ceiling
840+
# on what it may ask for. The file is capped at MAX_LOGSIZE anyway; the ceiling
841+
# is here so one request cannot be made to hold an entire rotation in memory.
842+
DEFAULT_LOG_LINES = 200
843+
MAX_LOG_LINES = 5000
844+
845+
846+
def _log_level_context(ctx):
847+
"""The three levels the Settings dial has to be honest about.
848+
849+
`saved` is what the dial edits, `effective` is what the process is logging
850+
at this second, and `restart` is what the startup chain resolves to next
851+
time. They can all differ, and #610 is what happens when the UI shows only
852+
the first one.
853+
"""
854+
config_level = getattr(ctx.config, "LOG_LEVEL", None) if ctx.config else None
855+
effective = resolve_effective_log_level(logger.current_log_level(), config_level=config_level)
856+
return {
857+
"effective": effective.level,
858+
"effective_name": NAME_FOR_LEVEL[effective.level],
859+
"saved": effective.saved,
860+
"saved_name": NAME_FOR_LEVEL[effective.saved],
861+
"restart_level": effective.restart_level,
862+
"restart_name": NAME_FOR_LEVEL[effective.restart_level],
863+
"restart_source": effective.restart_source,
864+
"pinned": effective.pinned,
865+
}
866+
867+
868+
def get_recent_logs(ctx, lines=DEFAULT_LOG_LINES):
869+
"""Return the tail of `comicarr.log`, with the level context the dial needs.
870+
871+
Only the current file: rotated `comicarr.log.1` and friends are deliberately
872+
unreachable here, and there is no pagination — the surface exists so an
873+
operator can raise the level, reproduce, and paste, not to browse history.
874+
"""
875+
requested = max(1, min(int(lines or DEFAULT_LOG_LINES), MAX_LOG_LINES))
876+
level = _log_level_context(ctx)
877+
835878
log_dir = getattr(ctx.config, "LOG_DIR", None) if ctx.config else None
836879
if not log_dir:
837880
log_dir = os.path.join(ctx.data_dir, "logs") if ctx.data_dir else None
838881

839882
if not log_dir:
840-
return {"logs": []}
883+
return {"logs": [], "level": level, "requested": requested, "path": None}
841884

842885
log_file = os.path.join(log_dir, "comicarr.log")
843886
if not os.path.exists(log_file):
844-
return {"logs": []}
887+
return {"logs": [], "level": level, "requested": requested, "path": log_file}
845888

846889
try:
890+
# A deque with a maxlen keeps only the tail in memory. `readlines()` on a
891+
# 10 MB log allocated the whole file on every Refresh, and the viewer was
892+
# always going to throw all but the last N away.
847893
with open(log_file, "r") as f:
848-
lines = f.readlines()
894+
tail = deque(f, maxlen=requested)
849895
provider_secrets = []
850896
if ctx.config:
851897
for attr_name in ("EXTRA_NEWZNABS", "EXTRA_TORZNABS"):
852898
for entry in getattr(ctx.config, attr_name, []) or []:
853899
if isinstance(entry, (list, tuple)) and len(entry) > 3:
854900
provider_secrets.append(entry[3])
855-
return {"logs": [redact_sensitive_text(line, provider_secrets) for line in lines[-200:]]}
901+
return {
902+
"logs": [redact_sensitive_text(line, provider_secrets) for line in tail],
903+
"level": level,
904+
"requested": requested,
905+
"path": log_file,
906+
}
856907
except Exception as e:
857908
logger.error("[SYSTEM] Error reading logs: %s" % e)
858-
return {"logs": [], "error": str(e)}
909+
return {"logs": [], "level": level, "requested": requested, "path": log_file, "error": str(e)}
859910

860911

861912
def get_job_info(ctx, include_acquisition=True):

comicarr/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
import comicarr
3737
from comicarr import db, encrypted, filechecker, helpers, logger, maintenance
38-
from comicarr.app.config.log_level import resolve_startup_log_level
38+
from comicarr.app.config.log_level import record_startup_argument, resolve_startup_log_level
3939
from comicarr.app.config.registry import as_legacy_definitions
4040

4141
config = configparser.ConfigParser()
@@ -415,6 +415,10 @@ def read(self, startup=False):
415415
# COMICARR_LOG_LEVEL, then the config file. See
416416
# comicarr/app/config/log_level.py and
417417
# docs/architecture/logging-levels.md.
418+
# Capture the argument before the line below overwrites the global
419+
# with the resolved level: the Settings page has to be able to name
420+
# a `--log-level` flag as the reason its dial keeps losing.
421+
record_startup_argument(comicarr.LOG_LEVEL)
418422
resolution = resolve_startup_log_level(
419423
argument_level=comicarr.LOG_LEVEL,
420424
config_level=self.LOG_LEVEL,

docs/architecture/logging-levels.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,30 @@ consequence is what stops the next "I set it to 0 and expected silence". This
125125
supersedes the `quiet / normal / verbose` labels locked while designing the
126126
surface; the layout of that design is unaffected.
127127

128+
### The dial must say when it is not the one deciding
129+
130+
Because Settings writes the bottom rung of the chain and the write applies live,
131+
three numbers can disagree at once: the level the process is logging at, the
132+
level saved in `config.ini`, and the level the next start will resolve to. A
133+
page that shows only the saved number is #610 in miniature — the UI stating one
134+
thing while the process does another.
135+
136+
`resolve_effective_log_level` (`comicarr/app/config/log_level.py`) reports all
137+
three, and `GET /system/logs` carries them alongside the log lines. The restart
138+
half is the startup chain *re-run now*, not a replay of the boot resolution:
139+
the environment and the config file are read fresh, and only the startup
140+
argument is remembered, because it is the one input that cannot be re-read
141+
later. `record_startup_argument` captures it in `comicarr/config.py` before
142+
`comicarr.LOG_LEVEL` is overwritten with the resolved level.
143+
144+
`pinned` is true when the winner is a startup argument or `COMICARR_LOG_LEVEL`.
145+
Settings → Logs shows its override callout on exactly that flag, and on nothing
146+
else: when the config file is the top of the chain there is nothing to say, and
147+
an always-visible status card would be noise. Note that `pinned` is a statement
148+
about *source*, not about a mismatch in numbers — `--log-level info` against a
149+
saved `1` shows no disagreement and still means the dial cannot change what a
150+
restart does.
151+
128152
### `--quiet` and `--verbose`
129153

130154
Both are deprecated aliases — `--quiet` for `--log-level warning`, `--verbose`

0 commit comments

Comments
 (0)