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:
@@ -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"],
|
||||||
|
|||||||
@@ -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
109
src/gui/pages/_Logs.py
Normal 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."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user