From 7c9139f199480eb5a42eb5880b43e718a7dfed97 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 19 May 2026 21:24:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(audit):=20/logs=20page=20=E2=80=94=20view?= =?UTF-8?q?=20+=20download=20recent=20audit=20log=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Streamlit page at ``/logs`` listing every ``datatools-*.jsonl`` file in ``audit_log_dir()`` (7-day window per the retention sweep in b3ae913). Each entry shows filename, mtime, byte size, and a ``st.download_button``. Today's file gets its own section at the top. The page also surfaces both paths as copyable monospace text: the active log path (so users can grep/cat it directly on their machine) and the folder path (so they can paste into Explorer / Finder). Wired into navigation via ``st.Page("pages/_Logs.py", ...)`` with ``url_path="logs"``. The sidebar entry is hidden by the same ``hide_streamlit_chrome`` CSS rule that hides ``/activate`` and ``/close`` — same pattern, same ``:has()`` + plain-fallback selectors so the LinkContainer collapses cleanly in modern browsers and the anchor is at least un-clickable in older ones. License gate is OFF for this page (``gate_license=False``) — if a user's license expires they may need logs to file a support request; locking them out of their own audit history would be hostile. Next commit will wire the popover link. Rollback: ``git revert HEAD`` removes the page and its nav entry; the audit log itself keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gui/app.py | 8 ++- src/gui/components/_legacy.py | 4 ++ src/gui/pages/_Logs.py | 109 ++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/gui/pages/_Logs.py diff --git a/src/gui/app.py b/src/gui/app.py index 053da55..67b1057 100644 --- a/src/gui/app.py +++ b/src/gui/app.py @@ -116,6 +116,12 @@ def _build_navigation() -> dict[str, list]: icon=":material/key:", url_path="activate", ) + logs = st.Page( + "pages/_Logs.py", + title="Logs", + icon=":material/description:", + url_path="logs", + ) close = st.Page( "pages/99_Close.py", title=_t("nav.close_title") or "Close", @@ -129,7 +135,7 @@ def _build_navigation() -> dict[str, list]: # ``href``, leaving Home visible and no orphan section header / # drilldown marker in the sidebar. return { - "": [home, activate, close], + "": [home, activate, logs, close], section_label("cleaners"): by_section["cleaners"], section_label("transformations"): by_section["transformations"], section_label("automations"): by_section["automations"], diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index 88cc2ff..ec65670 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -79,6 +79,8 @@ footer { container's spacing would still occupy a row. */ [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/activate"]), [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/activate/"]), +[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/logs"]), +[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/logs/"]), [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/close"]), [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/close/"]) { display: none !important; @@ -87,6 +89,8 @@ footer { least hide the anchor itself so the entry isn't clickable. */ [data-testid="stSidebarNav"] a[href$="/activate"], [data-testid="stSidebarNav"] a[href$="/activate/"], +[data-testid="stSidebarNav"] a[href$="/logs"], +[data-testid="stSidebarNav"] a[href$="/logs/"], [data-testid="stSidebarNav"] a[href$="/close"], [data-testid="stSidebarNav"] a[href$="/close/"] { display: none !important; diff --git a/src/gui/pages/_Logs.py b/src/gui/pages/_Logs.py new file mode 100644 index 0000000..2fffdae --- /dev/null +++ b/src/gui/pages/_Logs.py @@ -0,0 +1,109 @@ +"""Logs — view and download recent audit log files. + +Reached from the sticky-footer Help popover (no sidebar entry — the +nav link is hidden via CSS in ``hide_streamlit_chrome``). URL path +is ``/logs``. Lists every ``datatools-*.jsonl`` file in +``audit_log_dir()`` so users can download today's session or a +prior day's file for support requests. + +The 7-day retention sweep in ``src/audit.py`` runs on the writer +thread, so this page is read-only — no delete buttons. Users +wanting a longer retention can copy files out of the folder. +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +import streamlit as st + +_project_root = Path(__file__).resolve().parent.parent.parent.parent +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) + +from src.audit import _RETENTION_DAYS, audit_log_dir, audit_log_path +from src.gui.components import hide_streamlit_chrome, render_sticky_footer + +_ICON_PATH = str(Path(__file__).parent.parent / "assets" / "datatools_icon_256.png") +st.set_page_config( + page_title="Logs · DataTools", + page_icon=_ICON_PATH, + layout="wide", +) + +hide_streamlit_chrome(gate_license=False) +render_sticky_footer() + + +st.markdown("# Logs") +st.caption( + f"Activity logs are written to disk one file per day and kept " + f"for {_RETENTION_DAYS} days. Event timestamps inside each file " + f"are UTC; filenames use your local date." +) + +log_dir = audit_log_dir() +today_path = audit_log_path() + +st.markdown("### Today") +st.code(str(today_path), language="text") +if today_path.exists(): + size = today_path.stat().st_size + st.caption(f"{size:,} bytes") + try: + data = today_path.read_bytes() + st.download_button( + label=f"Download {today_path.name}", + data=data, + file_name=today_path.name, + mime="application/x-ndjson", + key="dl_today", + ) + except Exception as e: + st.warning(f"Could not read today's log: {type(e).__name__}: {e}") +else: + st.caption("No events written yet this session.") + +st.markdown("### Older sessions") +if not log_dir.exists(): + st.caption("No logs folder yet.") +else: + others = sorted( + (p for p in log_dir.glob("datatools-*.jsonl") if p != today_path), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not others: + st.caption("No older log files.") + else: + for p in others: + try: + st_mtime = datetime.fromtimestamp(p.stat().st_mtime) + size = p.stat().st_size + cols = st.columns([4, 2, 2]) + cols[0].markdown(f"`{p.name}`") + cols[1].caption( + f"{st_mtime:%Y-%m-%d %H:%M} · {size:,} bytes" + ) + with cols[2]: + st.download_button( + label="Download", + data=p.read_bytes(), + file_name=p.name, + mime="application/x-ndjson", + key=f"dl_{p.name}", + ) + except Exception as e: + st.caption( + f"`{p.name}` — could not read: " + f"{type(e).__name__}: {e}" + ) + +st.markdown("### Folder") +st.code(str(log_dir), language="text") +st.caption( + "Copy this path into Explorer / Finder / your file manager to " + "open the folder directly." +)