Mayank Chugh commited on
Commit ·
482fce4
1
Parent(s): bdfb32d
Add Milestone 9 for Streamlit UI Integration
Browse filesIntroduce Milestone 9 in milestones.md, detailing the integration of Streamlit UI with the backend API. Update dependencies in pyproject.toml and requirements.txt to include Streamlit. Enhance the uv.lock file with new package entries and ensure the backend server setup instructions are clear for both FastAPI and Streamlit. Verification checks and pass criteria for the UI flow are also defined.
- pyproject.toml +1 -0
- requirements.txt +2 -1
- streamlit_app.py +355 -0
- uv.lock +0 -0
pyproject.toml
CHANGED
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
| 24 |
"uvicorn[standard]==0.29.0",
|
| 25 |
"huggingface-hub>=1.13.0",
|
| 26 |
"langchain-huggingface>=0.0.3",
|
|
|
|
| 27 |
"onnxruntime==1.23.2 ; sys_platform == 'darwin' and platform_machine == 'x86_64'",
|
| 28 |
"torch==2.2.2 ; sys_platform == 'darwin' and platform_machine == 'x86_64'",
|
| 29 |
]
|
|
|
|
| 24 |
"uvicorn[standard]==0.29.0",
|
| 25 |
"huggingface-hub>=1.13.0",
|
| 26 |
"langchain-huggingface>=0.0.3",
|
| 27 |
+
"streamlit>=1.39.0",
|
| 28 |
"onnxruntime==1.23.2 ; sys_platform == 'darwin' and platform_machine == 'x86_64'",
|
| 29 |
"torch==2.2.2 ; sys_platform == 'darwin' and platform_machine == 'x86_64'",
|
| 30 |
]
|
requirements.txt
CHANGED
|
@@ -16,4 +16,5 @@ python-multipart==0.0.9
|
|
| 16 |
aiosqlite
|
| 17 |
httpx>=0.27.0
|
| 18 |
huggingface-hub
|
| 19 |
-
langchain-huggingface
|
|
|
|
|
|
| 16 |
aiosqlite
|
| 17 |
httpx>=0.27.0
|
| 18 |
huggingface-hub
|
| 19 |
+
langchain-huggingface
|
| 20 |
+
streamlit>=1.39.0
|
streamlit_app.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streamlit UI for doc-audi-ai — talks to the FastAPI backend only."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
import streamlit as st
|
| 11 |
+
|
| 12 |
+
DEFAULT_API_BASE = os.environ.get("DOC_AUDI_API_BASE", "http://127.0.0.1:8000")
|
| 13 |
+
HTTP_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _api_base() -> str:
|
| 17 |
+
return (st.session_state.get("api_base") or DEFAULT_API_BASE).rstrip("/")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _client() -> httpx.Client:
|
| 21 |
+
return httpx.Client(base_url=_api_base(), timeout=HTTP_TIMEOUT)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _fmt_api_error(exc: httpx.HTTPStatusError) -> str:
|
| 25 |
+
try:
|
| 26 |
+
body = exc.response.json()
|
| 27 |
+
except Exception:
|
| 28 |
+
return f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
| 29 |
+
detail = body.get("detail")
|
| 30 |
+
if isinstance(detail, list):
|
| 31 |
+
parts = []
|
| 32 |
+
for item in detail:
|
| 33 |
+
if isinstance(item, dict):
|
| 34 |
+
loc = item.get("loc", ())
|
| 35 |
+
msg = item.get("msg", "")
|
| 36 |
+
parts.append(f"{'/'.join(str(x) for x in loc)}: {msg}")
|
| 37 |
+
else:
|
| 38 |
+
parts.append(str(item))
|
| 39 |
+
return f"HTTP {exc.response.status_code}: " + "; ".join(parts)
|
| 40 |
+
if detail is not None:
|
| 41 |
+
return f"HTTP {exc.response.status_code}: {detail}"
|
| 42 |
+
return f"HTTP {exc.response.status_code}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _health_check() -> tuple[bool, str]:
|
| 46 |
+
try:
|
| 47 |
+
with _client() as c:
|
| 48 |
+
r = c.get("/health")
|
| 49 |
+
r.raise_for_status()
|
| 50 |
+
data = r.json()
|
| 51 |
+
return True, str(data)
|
| 52 |
+
except httpx.ConnectError as e:
|
| 53 |
+
return False, f"Cannot connect to {_api_base()}: {e}"
|
| 54 |
+
except httpx.HTTPStatusError as e:
|
| 55 |
+
return False, _fmt_api_error(e)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
return False, str(e)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> None:
|
| 61 |
+
st.set_page_config(page_title="doc-audi-ai", layout="wide")
|
| 62 |
+
st.title("doc-audi-ai")
|
| 63 |
+
st.caption("Ingest, query, and audit via the FastAPI backend.")
|
| 64 |
+
|
| 65 |
+
if "api_base" not in st.session_state:
|
| 66 |
+
st.session_state.api_base = DEFAULT_API_BASE
|
| 67 |
+
|
| 68 |
+
with st.sidebar:
|
| 69 |
+
st.subheader("Backend")
|
| 70 |
+
st.text_input("API base URL", key="api_base")
|
| 71 |
+
if st.button("Test connection"):
|
| 72 |
+
ok, msg = _health_check()
|
| 73 |
+
if ok:
|
| 74 |
+
st.success(msg)
|
| 75 |
+
else:
|
| 76 |
+
st.error(msg)
|
| 77 |
+
|
| 78 |
+
tab_upload, tab_jobs, tab_ask, tab_sum, tab_audit = st.tabs(
|
| 79 |
+
["Upload", "Jobs", "Ask", "Summarise", "Audit"]
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
with tab_upload:
|
| 83 |
+
st.subheader("Upload document")
|
| 84 |
+
col_u1, col_u2 = st.columns(2)
|
| 85 |
+
with col_u1:
|
| 86 |
+
up_collection = st.text_input("Collection", value="default", key="up_col")
|
| 87 |
+
uploaded = st.file_uploader("PDF, TXT, or Markdown", type=["pdf", "txt", "md"], key="up_file")
|
| 88 |
+
with col_u2:
|
| 89 |
+
if st.button("Submit upload", key="btn_upload", disabled=uploaded is None):
|
| 90 |
+
if uploaded is None:
|
| 91 |
+
st.warning("Choose a file first.")
|
| 92 |
+
else:
|
| 93 |
+
try:
|
| 94 |
+
files = {"file": (uploaded.name, uploaded.getvalue(), uploaded.type or "application/octet-stream")}
|
| 95 |
+
data = {"collection_name": up_collection}
|
| 96 |
+
with _client() as c:
|
| 97 |
+
r = c.post("/ingest/upload", files=files, data=data)
|
| 98 |
+
r.raise_for_status()
|
| 99 |
+
out = r.json()
|
| 100 |
+
st.success(out.get("message", "Queued"))
|
| 101 |
+
st.json(out)
|
| 102 |
+
if out.get("job_id"):
|
| 103 |
+
st.session_state["last_job_id"] = out["job_id"]
|
| 104 |
+
except httpx.HTTPStatusError as e:
|
| 105 |
+
st.error(_fmt_api_error(e))
|
| 106 |
+
except httpx.ConnectError as e:
|
| 107 |
+
st.error(f"Connection failed: {e}")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
st.exception(e)
|
| 110 |
+
|
| 111 |
+
st.subheader("Ingest from URL")
|
| 112 |
+
url_col = st.columns([3, 1])
|
| 113 |
+
with url_col[0]:
|
| 114 |
+
ingest_url = st.text_input("Document URL (http/https)", key="ingest_url")
|
| 115 |
+
with url_col[1]:
|
| 116 |
+
url_collection = st.text_input("Collection", value="default", key="url_col")
|
| 117 |
+
if st.button("Queue URL ingest", key="btn_url"):
|
| 118 |
+
if not ingest_url.strip():
|
| 119 |
+
st.warning("Enter a URL.")
|
| 120 |
+
else:
|
| 121 |
+
try:
|
| 122 |
+
with _client() as c:
|
| 123 |
+
r = c.post("/ingest/url", json={"url": ingest_url.strip(), "collection_name": url_collection})
|
| 124 |
+
r.raise_for_status()
|
| 125 |
+
out = r.json()
|
| 126 |
+
st.success(out.get("message", "Queued"))
|
| 127 |
+
st.json(out)
|
| 128 |
+
if out.get("job_id"):
|
| 129 |
+
st.session_state["last_job_id"] = out["job_id"]
|
| 130 |
+
except httpx.HTTPStatusError as e:
|
| 131 |
+
st.error(_fmt_api_error(e))
|
| 132 |
+
except httpx.ConnectError as e:
|
| 133 |
+
st.error(f"Connection failed: {e}")
|
| 134 |
+
except Exception as e:
|
| 135 |
+
st.exception(e)
|
| 136 |
+
|
| 137 |
+
st.subheader("Collections")
|
| 138 |
+
if st.button("Refresh collections", key="btn_collections"):
|
| 139 |
+
try:
|
| 140 |
+
with _client() as c:
|
| 141 |
+
r = c.get("/ingest/collections")
|
| 142 |
+
r.raise_for_status()
|
| 143 |
+
cols = r.json()
|
| 144 |
+
names = [x["name"] for x in cols.get("collections", [])]
|
| 145 |
+
st.write(cols.get("message", ""))
|
| 146 |
+
if names:
|
| 147 |
+
st.dataframe({"name": names}, hide_index=True, use_container_width=True)
|
| 148 |
+
else:
|
| 149 |
+
st.info("No collections yet.")
|
| 150 |
+
except httpx.HTTPStatusError as e:
|
| 151 |
+
st.error(_fmt_api_error(e))
|
| 152 |
+
except httpx.ConnectError as e:
|
| 153 |
+
st.error(f"Connection failed: {e}")
|
| 154 |
+
except Exception as e:
|
| 155 |
+
st.exception(e)
|
| 156 |
+
|
| 157 |
+
del_name = st.text_input("Delete collection name (optional)", key="del_col")
|
| 158 |
+
if st.button("Delete collection", key="btn_del_col"):
|
| 159 |
+
if not del_name.strip():
|
| 160 |
+
st.warning("Enter a collection name.")
|
| 161 |
+
else:
|
| 162 |
+
try:
|
| 163 |
+
with _client() as c:
|
| 164 |
+
r = c.delete(f"/ingest/collection/{del_name.strip()}")
|
| 165 |
+
r.raise_for_status()
|
| 166 |
+
st.success(r.json().get("message", "Deleted"))
|
| 167 |
+
except httpx.HTTPStatusError as e:
|
| 168 |
+
st.error(_fmt_api_error(e))
|
| 169 |
+
except httpx.ConnectError as e:
|
| 170 |
+
st.error(f"Connection failed: {e}")
|
| 171 |
+
except Exception as e:
|
| 172 |
+
st.exception(e)
|
| 173 |
+
|
| 174 |
+
with tab_jobs:
|
| 175 |
+
st.subheader("Job list")
|
| 176 |
+
j1, j2 = st.columns(2)
|
| 177 |
+
with j1:
|
| 178 |
+
j_limit = st.number_input("Limit", min_value=1, max_value=100, value=20, key="j_lim")
|
| 179 |
+
with j2:
|
| 180 |
+
j_offset = st.number_input("Offset", min_value=0, value=0, key="j_off")
|
| 181 |
+
if st.button("List jobs", key="btn_jobs"):
|
| 182 |
+
try:
|
| 183 |
+
with _client() as c:
|
| 184 |
+
r = c.get("/jobs", params={"limit": int(j_limit), "offset": int(j_offset)})
|
| 185 |
+
r.raise_for_status()
|
| 186 |
+
payload = r.json()
|
| 187 |
+
jobs: list[dict[str, Any]] = payload.get("jobs", [])
|
| 188 |
+
st.caption(payload.get("message", ""))
|
| 189 |
+
if jobs:
|
| 190 |
+
st.dataframe(jobs, hide_index=True, use_container_width=True)
|
| 191 |
+
else:
|
| 192 |
+
st.info("No jobs in this window.")
|
| 193 |
+
except httpx.HTTPStatusError as e:
|
| 194 |
+
st.error(_fmt_api_error(e))
|
| 195 |
+
except httpx.ConnectError as e:
|
| 196 |
+
st.error(f"Connection failed: {e}")
|
| 197 |
+
except Exception as e:
|
| 198 |
+
st.exception(e)
|
| 199 |
+
|
| 200 |
+
st.subheader("Job detail")
|
| 201 |
+
default_job = st.session_state.get("last_job_id", "")
|
| 202 |
+
job_id = st.text_input("Job ID", value=default_job, key="job_id_in")
|
| 203 |
+
c1, c2 = st.columns(2)
|
| 204 |
+
with c1:
|
| 205 |
+
fetch_job = st.button("Fetch job", key="btn_job_one")
|
| 206 |
+
with c2:
|
| 207 |
+
poll_job = st.button("Poll until completed/failed", key="btn_job_poll")
|
| 208 |
+
|
| 209 |
+
if fetch_job and job_id.strip():
|
| 210 |
+
try:
|
| 211 |
+
with _client() as c:
|
| 212 |
+
r = c.get(f"/jobs/{job_id.strip()}")
|
| 213 |
+
r.raise_for_status()
|
| 214 |
+
detail = r.json()
|
| 215 |
+
st.json(detail)
|
| 216 |
+
except httpx.HTTPStatusError as e:
|
| 217 |
+
st.error(_fmt_api_error(e))
|
| 218 |
+
except httpx.ConnectError as e:
|
| 219 |
+
st.error(f"Connection failed: {e}")
|
| 220 |
+
except Exception as e:
|
| 221 |
+
st.exception(e)
|
| 222 |
+
|
| 223 |
+
if poll_job and job_id.strip():
|
| 224 |
+
status_ph = st.empty()
|
| 225 |
+
try:
|
| 226 |
+
with _client() as c:
|
| 227 |
+
for i in range(120):
|
| 228 |
+
r = c.get(f"/jobs/{job_id.strip()}")
|
| 229 |
+
r.raise_for_status()
|
| 230 |
+
body = r.json()
|
| 231 |
+
job = body.get("job") or {}
|
| 232 |
+
st_ = job.get("status", "")
|
| 233 |
+
status_ph.write(f"Poll {i + 1}: **{st_}** — {job.get('message', '')}")
|
| 234 |
+
if st_ in ("completed", "failed"):
|
| 235 |
+
st.json(body)
|
| 236 |
+
break
|
| 237 |
+
time.sleep(1)
|
| 238 |
+
else:
|
| 239 |
+
status_ph.write("Stopped after 120 attempts (~2 min).")
|
| 240 |
+
st.json(body)
|
| 241 |
+
except httpx.HTTPStatusError as e:
|
| 242 |
+
st.error(_fmt_api_error(e))
|
| 243 |
+
except httpx.ConnectError as e:
|
| 244 |
+
st.error(f"Connection failed: {e}")
|
| 245 |
+
except Exception as e:
|
| 246 |
+
st.exception(e)
|
| 247 |
+
|
| 248 |
+
with tab_ask:
|
| 249 |
+
st.subheader("Ask a question")
|
| 250 |
+
q_col = st.text_input("Collection", value="default", key="ask_col")
|
| 251 |
+
question = st.text_area("Question", height=120, key="ask_q")
|
| 252 |
+
if st.button("Ask", key="btn_ask"):
|
| 253 |
+
if not question.strip():
|
| 254 |
+
st.warning("Enter a question.")
|
| 255 |
+
else:
|
| 256 |
+
try:
|
| 257 |
+
with _client() as c:
|
| 258 |
+
r = c.post("/query/ask", json={"question": question.strip(), "collection_name": q_col})
|
| 259 |
+
r.raise_for_status()
|
| 260 |
+
ans = r.json()
|
| 261 |
+
st.success(ans.get("message", ""))
|
| 262 |
+
if ans.get("answer"):
|
| 263 |
+
st.markdown("### Answer")
|
| 264 |
+
st.markdown(ans["answer"])
|
| 265 |
+
src = ans.get("sources") or []
|
| 266 |
+
if src:
|
| 267 |
+
with st.expander(f"Sources ({len(src)})"):
|
| 268 |
+
st.json(src)
|
| 269 |
+
except httpx.HTTPStatusError as e:
|
| 270 |
+
st.error(_fmt_api_error(e))
|
| 271 |
+
except httpx.ConnectError as e:
|
| 272 |
+
st.error(f"Connection failed: {e}")
|
| 273 |
+
except Exception as e:
|
| 274 |
+
st.exception(e)
|
| 275 |
+
|
| 276 |
+
with tab_sum:
|
| 277 |
+
st.subheader("Summarise collection")
|
| 278 |
+
s_col = st.text_input("Collection", value="default", key="sum_col")
|
| 279 |
+
focus = st.text_input("Optional focus / angle", value="", key="sum_focus")
|
| 280 |
+
if st.button("Summarise", key="btn_sum"):
|
| 281 |
+
try:
|
| 282 |
+
body: dict[str, Any] = {"collection_name": s_col}
|
| 283 |
+
if focus.strip():
|
| 284 |
+
body["focus"] = focus.strip()
|
| 285 |
+
with _client() as c:
|
| 286 |
+
r = c.post("/query/summarise", json=body)
|
| 287 |
+
r.raise_for_status()
|
| 288 |
+
ans = r.json()
|
| 289 |
+
st.success(ans.get("message", ""))
|
| 290 |
+
if ans.get("answer"):
|
| 291 |
+
st.markdown("### Summary")
|
| 292 |
+
st.markdown(ans["answer"])
|
| 293 |
+
src = ans.get("sources") or []
|
| 294 |
+
if src:
|
| 295 |
+
with st.expander(f"Sources ({len(src)})"):
|
| 296 |
+
st.json(src)
|
| 297 |
+
except httpx.HTTPStatusError as e:
|
| 298 |
+
st.error(_fmt_api_error(e))
|
| 299 |
+
except httpx.ConnectError as e:
|
| 300 |
+
st.error(f"Connection failed: {e}")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
st.exception(e)
|
| 303 |
+
|
| 304 |
+
with tab_audit:
|
| 305 |
+
st.subheader("Audit log")
|
| 306 |
+
a1, a2 = st.columns(2)
|
| 307 |
+
with a1:
|
| 308 |
+
a_limit = st.number_input("Limit", min_value=1, max_value=100, value=20, key="a_lim")
|
| 309 |
+
with a2:
|
| 310 |
+
a_offset = st.number_input("Offset", min_value=0, value=0, key="a_off")
|
| 311 |
+
if st.button("List audit events", key="btn_audit_list"):
|
| 312 |
+
try:
|
| 313 |
+
with _client() as c:
|
| 314 |
+
r = c.get("/audit/logs", params={"limit": int(a_limit), "offset": int(a_offset)})
|
| 315 |
+
r.raise_for_status()
|
| 316 |
+
payload = r.json()
|
| 317 |
+
events = payload.get("events", [])
|
| 318 |
+
st.caption(payload.get("message", ""))
|
| 319 |
+
if events:
|
| 320 |
+
st.dataframe(events, hide_index=True, use_container_width=True)
|
| 321 |
+
ids = [e["event_id"] for e in events if isinstance(e, dict) and "event_id" in e]
|
| 322 |
+
if ids:
|
| 323 |
+
st.session_state["_audit_ids"] = ids
|
| 324 |
+
else:
|
| 325 |
+
st.info("No audit events.")
|
| 326 |
+
except httpx.HTTPStatusError as e:
|
| 327 |
+
st.error(_fmt_api_error(e))
|
| 328 |
+
except httpx.ConnectError as e:
|
| 329 |
+
st.error(f"Connection failed: {e}")
|
| 330 |
+
except Exception as e:
|
| 331 |
+
st.exception(e)
|
| 332 |
+
|
| 333 |
+
st.subheader("Audit event detail")
|
| 334 |
+
ids_for_select = st.session_state.get("_audit_ids", [])
|
| 335 |
+
pick = ""
|
| 336 |
+
if ids_for_select:
|
| 337 |
+
pick = st.selectbox("Event ID", options=[""] + list(ids_for_select), key="audit_pick")
|
| 338 |
+
manual_id = st.text_input("Or enter event ID", key="audit_manual")
|
| 339 |
+
ev_id = (manual_id.strip() or (pick or "").strip()).strip()
|
| 340 |
+
if st.button("Load detail", key="btn_audit_detail") and ev_id:
|
| 341 |
+
try:
|
| 342 |
+
with _client() as c:
|
| 343 |
+
r = c.get(f"/audit/logs/{ev_id}")
|
| 344 |
+
r.raise_for_status()
|
| 345 |
+
st.json(r.json())
|
| 346 |
+
except httpx.HTTPStatusError as e:
|
| 347 |
+
st.error(_fmt_api_error(e))
|
| 348 |
+
except httpx.ConnectError as e:
|
| 349 |
+
st.error(f"Connection failed: {e}")
|
| 350 |
+
except Exception as e:
|
| 351 |
+
st.exception(e)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
if __name__ == "__main__":
|
| 355 |
+
main()
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|