Files
WebChecker/views/admin_log_view.py
2026-04-24 08:08:35 -04:00

349 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
views/admin_log_view.py — Admin panel: Logs
Two tabs:
Activity Log — user-action audit trail (existing, from activity_log table)
App Log — application-level logging (new, from app_log table)
"""
import tkinter as tk
from tkinter import ttk, messagebox
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_SMALL, FONT_HEADING,
show_error, show_info,
)
logger = logging.getLogger("admin_log_view")
# Colour map for log level badges
_LEVEL_COLOURS = {
"DEBUG": "text_dim",
"INFO": "text",
"WARNING": "warning",
"ERROR": "danger",
"CRITICAL": "danger",
}
class AdminLogView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._build_ui()
def _build_ui(self):
# Page header
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 8))
ttk.Label(hdr, text="Logs", style="Heading.TLabel").pack(side="left")
# Notebook with two tabs
nb = ttk.Notebook(self)
nb.pack(fill="both", expand=True)
# ── Tab 1 — Activity Log ──────────────────────────────────────────────
act_tab = ttk.Frame(nb)
nb.add(act_tab, text="📋 Activity Log")
self._build_activity_tab(act_tab)
# ── Tab 2 — App Log ───────────────────────────────────────────────────
app_tab = ttk.Frame(nb)
nb.add(app_tab, text="🖥 App Log")
self._build_app_log_tab(app_tab)
nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
self._nb = nb
# Load the default tab
self._load_activity()
# ── Activity Log tab ──────────────────────────────────────────────────────
def _build_activity_tab(self, parent):
C = COLOURS
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
toolbar.pack(fill="x")
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
command=self._load_activity).pack(side="right")
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(0, 4))
self._act_search_var = tk.StringVar()
self._act_search_var.trace_add("write", lambda *_: self._load_activity())
tk.Entry(toolbar, textvariable=self._act_search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=24).pack(
side="left", ipady=4)
cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail")
widths = [140, 100, 140, 100, 70, 320]
self._act_tree = ttk.Treeview(
parent, columns=cols, show="headings", selectmode="browse")
for col, w in zip(cols, widths):
self._act_tree.heading(col, text=col)
self._act_tree.column(col, width=w, anchor="w")
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._act_tree.yview)
self._act_tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._act_tree.pack(fill="both", expand=True)
# Status bar
self._act_status = tk.Label(
parent, text="", bg=C["surface2"], fg=C["text_dim"],
font=FONT_SMALL, anchor="w")
self._act_status.pack(fill="x", side="bottom")
def _load_activity(self, *_):
from models import get_activity_log
search = self._act_search_var.get().strip() \
if hasattr(self, "_act_search_var") else ""
self._act_tree.delete(*self._act_tree.get_children())
try:
# Pass search to the DB query so filtering happens server-side.
entries = get_activity_log(limit=500, search=search)
except Exception as e:
show_error(f"Failed to load activity log:\n{e}")
return
for entry in entries:
self._act_tree.insert("", "end", values=(
str(entry.get("logged_at", ""))[:16],
entry.get("username") or "—",
entry.get("action") or "",
entry.get("entity") or "",
entry.get("entity_id") or "",
entry.get("detail") or "",
))
self._act_status.config(text=f" {len(entries)} records")
# ── App Log tab ───────────────────────────────────────────────────────────
def _build_app_log_tab(self, parent):
C = COLOURS
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
toolbar.pack(fill="x")
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
command=self._load_app_log).pack(side="right", padx=(4, 0))
# Purge button
tk.Button(
toolbar, text="🗑 Purge Old Entries",
command=self._purge_app_log,
bg=C["surface2"], fg=C["danger"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=8, pady=4,
).pack(side="right", padx=(4, 0))
# Level filter
tk.Label(toolbar, text="Level:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(0, 4))
self._level_var = tk.StringVar(value="All")
level_cb = ttk.Combobox(
toolbar, textvariable=self._level_var,
values=["All", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
state="readonly", width=10,
)
level_cb.pack(side="left")
level_cb.bind("<<ComboboxSelected>>", lambda _: self._load_app_log())
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(12, 4))
self._app_search_var = tk.StringVar()
self._app_search_var.trace_add("write", lambda *_: self._load_app_log())
tk.Entry(toolbar, textvariable=self._app_search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=24).pack(
side="left", ipady=4)
# Treeview
cols = ("Time", "Level", "Logger", "Message")
widths = [140, 75, 130, 480]
self._app_tree = ttk.Treeview(
parent, columns=cols, show="headings", selectmode="browse")
for col, w in zip(cols, widths):
self._app_tree.heading(col, text=col)
self._app_tree.column(
col, width=w,
anchor="center" if col == "Level" else "w",
)
# Level colour tags
for level, colour_key in _LEVEL_COLOURS.items():
self._app_tree.tag_configure(level, foreground=C[colour_key])
# Bold for WARNING and above
self._app_tree.tag_configure("WARNING", foreground=C["warning"],
font=FONT_BOLD)
self._app_tree.tag_configure("ERROR", foreground=C["danger"],
font=FONT_BOLD)
self._app_tree.tag_configure("CRITICAL", foreground=C["danger"],
font=FONT_BOLD)
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._app_tree.yview)
self._app_tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._app_tree.pack(fill="both", expand=True)
# Detail strip — shows full message when a row is selected
detail_frame = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10)
detail_frame.pack(fill="x", side="bottom")
self._app_detail_var = tk.StringVar(
value="Select a row to see the full message.")
tk.Label(
detail_frame, textvariable=self._app_detail_var,
bg=C["surface2"], fg=C["text"],
font=FONT_SMALL, anchor="w",
justify="left", wraplength=900,
).pack(fill="x")
self._app_tree.bind("<<TreeviewSelect>>", self._on_app_row_select)
# Status bar
self._app_status = tk.Label(
parent, text="", bg=C["surface2"], fg=C["text_dim"],
font=FONT_SMALL, anchor="w")
self._app_status.pack(fill="x", side="bottom")
# Store full messages keyed by tree iid for the detail strip
self._app_full_messages: dict[str, str] = {}
def _load_app_log(self, *_):
from models import get_app_log
level = self._level_var.get()
level_filter = "" if level == "All" else level
search = self._app_search_var.get().strip()
self._app_tree.delete(*self._app_tree.get_children())
self._app_full_messages.clear()
self._app_detail_var.set("Select a row to see the full message.")
try:
entries = get_app_log(
limit=500,
level_filter=level_filter,
search=search,
)
except Exception as e:
show_error(f"Failed to load app log:\n{e}")
return
for entry in entries:
lvl = entry.get("level", "INFO")
msg_full = entry.get("message", "")
# Truncate long messages in the tree; full text in detail strip
msg_short = msg_full[:120] + ("…" if len(msg_full) > 120 else "")
iid = str(entry["id"])
self._app_tree.insert(
"", "end", iid=iid, tags=(lvl,),
values=(
str(entry.get("logged_at", ""))[:19],
lvl,
entry.get("logger_name", ""),
msg_short,
),
)
self._app_full_messages[iid] = msg_full
count = len(entries)
self._app_status.config(text=f" {count} records")
def _on_app_row_select(self, event=None):
sel = self._app_tree.selection()
if not sel:
return
iid = sel[0]
full = self._app_full_messages.get(iid, "")
self._app_detail_var.set(full)
def _purge_app_log(self):
"""Ask for confirmation, then delete log entries older than N days."""
days_var = tk.StringVar(value="30")
dlg = tk.Toplevel(self)
dlg.title("Purge App Log")
dlg.configure(bg=COLOURS["bg"])
dlg.resizable(False, False)
dlg.grab_set()
w, h = 340, 170
x = (dlg.winfo_screenwidth() - w) // 2
y = (dlg.winfo_screenheight() - h) // 2
dlg.geometry(f"{w}x{h}+{x}+{y}")
tk.Label(dlg,
text="Delete app log entries older than:",
bg=COLOURS["bg"], fg=COLOURS["text"],
font=FONT_BOLD).pack(pady=(20, 8))
row = tk.Frame(dlg, bg=COLOURS["bg"])
row.pack()
ttk.Spinbox(row, from_=1, to=365,
textvariable=days_var, width=6).pack(side="left")
tk.Label(row, text=" days",
bg=COLOURS["bg"], fg=COLOURS["text"],
font=FONT).pack(side="left")
def _do_purge():
try:
days = int(days_var.get())
if days < 1:
raise ValueError
except ValueError:
show_error("Please enter a valid number of days (1365).")
return
if not messagebox.askyesno(
"Confirm Purge",
f"Delete all app log entries older than {days} days?\n"
"This cannot be undone.",
parent=dlg,
):
return
try:
from models import purge_app_log
deleted = purge_app_log(days)
dlg.destroy()
show_info(f"{deleted} old log record(s) deleted.")
self._load_app_log()
except Exception as e:
show_error(f"Purge failed:\n{e}")
btn_frame = tk.Frame(dlg, bg=COLOURS["bg"])
btn_frame.pack(pady=12)
tk.Button(btn_frame, text="Purge",
command=_do_purge,
bg=COLOURS["danger"], fg=COLOURS["white"],
activebackground="#c94444", activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=12, pady=6).pack(side="left", padx=(0, 8))
tk.Button(btn_frame, text="Cancel",
command=dlg.destroy,
bg=COLOURS["surface2"], fg=COLOURS["text"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=12, pady=6).pack(side="left")
# ── Tab switch ────────────────────────────────────────────────────────────
def _on_tab_changed(self, event=None):
try:
idx = self._nb.index(self._nb.select())
if idx == 0:
self._load_activity()
else:
self._load_app_log()
except Exception:
pass