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." +)