349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""
|
||
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 (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
|