""" 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("<>", 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("<>", 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("<>", 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 (1–365).") 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