|
26 | 26 | import subprocess |
27 | 27 | import sys |
28 | 28 | import threading |
29 | | -from collections import namedtuple |
| 29 | +from collections import deque, namedtuple |
30 | 30 | from pathlib import Path |
31 | 31 | from urllib.parse import urlsplit, urlunsplit |
32 | 32 |
|
|
44 | 44 | from comicarr.app.acquisition.models import DispatchState |
45 | 45 | from comicarr.app.common.dates import normalize_utc_datetime |
46 | 46 | 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 | +) |
48 | 54 | from comicarr.app.config.registry import ( |
49 | 55 | readable_keys, |
50 | 56 | scheduler_job_intervals, |
@@ -830,32 +836,77 @@ def get_build_identity(ctx): |
830 | 836 | } |
831 | 837 |
|
832 | 838 |
|
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 | + |
835 | 878 | log_dir = getattr(ctx.config, "LOG_DIR", None) if ctx.config else None |
836 | 879 | if not log_dir: |
837 | 880 | log_dir = os.path.join(ctx.data_dir, "logs") if ctx.data_dir else None |
838 | 881 |
|
839 | 882 | if not log_dir: |
840 | | - return {"logs": []} |
| 883 | + return {"logs": [], "level": level, "requested": requested, "path": None} |
841 | 884 |
|
842 | 885 | log_file = os.path.join(log_dir, "comicarr.log") |
843 | 886 | if not os.path.exists(log_file): |
844 | | - return {"logs": []} |
| 887 | + return {"logs": [], "level": level, "requested": requested, "path": log_file} |
845 | 888 |
|
846 | 889 | 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. |
847 | 893 | with open(log_file, "r") as f: |
848 | | - lines = f.readlines() |
| 894 | + tail = deque(f, maxlen=requested) |
849 | 895 | provider_secrets = [] |
850 | 896 | if ctx.config: |
851 | 897 | for attr_name in ("EXTRA_NEWZNABS", "EXTRA_TORZNABS"): |
852 | 898 | for entry in getattr(ctx.config, attr_name, []) or []: |
853 | 899 | if isinstance(entry, (list, tuple)) and len(entry) > 3: |
854 | 900 | 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 | + } |
856 | 907 | except Exception as e: |
857 | 908 | 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)} |
859 | 910 |
|
860 | 911 |
|
861 | 912 | def get_job_info(ctx, include_acquisition=True): |
|
0 commit comments