feat(audit): /logs page — view + download recent audit log files

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 21:24:46 +00:00
parent b3ae913bb9
commit 7c9139f199
3 changed files with 120 additions and 1 deletions

View File

@@ -116,6 +116,12 @@ def _build_navigation() -> dict[str, list]:
icon=":material/key:", icon=":material/key:",
url_path="activate", url_path="activate",
) )
logs = st.Page(
"pages/_Logs.py",
title="Logs",
icon=":material/description:",
url_path="logs",
)
close = st.Page( close = st.Page(
"pages/99_Close.py", "pages/99_Close.py",
title=_t("nav.close_title") or "Close", 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 / # ``href``, leaving Home visible and no orphan section header /
# drilldown marker in the sidebar. # drilldown marker in the sidebar.
return { return {
"": [home, activate, close], "": [home, activate, logs, close],
section_label("cleaners"): by_section["cleaners"], section_label("cleaners"): by_section["cleaners"],
section_label("transformations"): by_section["transformations"], section_label("transformations"): by_section["transformations"],
section_label("automations"): by_section["automations"], section_label("automations"): by_section["automations"],

View File

@@ -79,6 +79,8 @@ footer {
container's spacing would still occupy a row. */ 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$="/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"]),
[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/close/"]) { [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"]:has(a[href$="/close/"]) {
display: none !important; display: none !important;
@@ -87,6 +89,8 @@ footer {
least hide the anchor itself so the entry isn't clickable. */ 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$="/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"],
[data-testid="stSidebarNav"] a[href$="/close/"] { [data-testid="stSidebarNav"] a[href$="/close/"] {
display: none !important; display: none !important;

109
src/gui/pages/_Logs.py Normal file
View File

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