diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 94b52514..44b12cb7 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -3813,6 +3813,7 @@ def build_admin_router() -> APIRouter: @router.get("/admin/api/admin-audit") def api_admin_audit( limit: int = Query(default=200), + offset: int = Query(default=0), action: str | None = Query(default=None), actor_user_id: str | None = Query(default=None), status: str | None = Query(default=None), @@ -3821,17 +3822,34 @@ def build_admin_router() -> APIRouter: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") - rows = store.list_admin_audit_logs(tenant_id=str(ctx.get("tenant_id") or ""), limit=limit) a = str(action or "").strip() actor = str(actor_user_id or "").strip() st = str(status or "").strip() - if a: - rows = [r for r in rows if str(r.get("action") or "") == a] - if actor: - rows = [r for r in rows if str(r.get("actor_user_id") or "") == actor] - if st: - rows = [r for r in rows if str(r.get("status") or "") == st] - return {"ok": True, "items": rows} + lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + tenant_id = str(ctx.get("tenant_id") or "") + rows = store.list_admin_audit_logs( + tenant_id=tenant_id, + action=a or None, + actor_user_id=actor or None, + status=st or None, + limit=lim, + offset=off, + ) + total = store.count_admin_audit_logs( + tenant_id=tenant_id, + action=a or None, + actor_user_id=actor or None, + status=st or None, + ) + return { + "ok": True, + "items": rows, + "logs": rows, + "total": int(total), + "limit": int(lim), + "offset": int(off), + } from oclaw.interfaces.admin.chat_api import include_chat_routes from oclaw.interfaces.admin.models_api import include_model_mgmt_routes diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 15a66fb1..175654fd 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -306,6 +306,9 @@ const I18N = { "adminAudit.action": "动作", "adminAudit.actor": "操作者", "adminAudit.status": "状态", + "adminAudit.total": "共 {total} 条", + "adminAudit.jumpPlaceholder": "页码", + "adminAudit.jump": "跳转", "models.sectionActive": "当前模型配置", "models.sectionBindings": "智能体与模型绑定", "models.sectionNew": "新建模型配置", @@ -735,6 +738,9 @@ const I18N = { "adminAudit.action": "Action", "adminAudit.actor": "Actor", "adminAudit.status": "Status", + "adminAudit.total": "Total {total}", + "adminAudit.jumpPlaceholder": "Page", + "adminAudit.jump": "Go", "models.sectionActive": "Active profile", "models.sectionBindings": "Agent ↔ profile bindings", "models.sectionNew": "New profile", @@ -4162,19 +4168,26 @@ async function renderModels() { el("div", { class: "row" }, [btnSave]), el("div", { class: "row" }, [btnDelete]), ], { id: "models-api" }), - renderSectionCard("Experts (runtime/workspaces)", "English name required; Chinese optional. SOUL.md or ROLE_SYSTEM.md is required.", [ - el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]), - el("div", { class: "row" }, [btnExpertDelete]), - el("div", { class: "row" }, [el("label", { text: "Create ID" }), expertNewId]), - el("div", { class: "row" }, [el("label", { text: "Create Name(en)" }), expertNewNameEn]), - el("div", { class: "row" }, [el("label", { text: "Create Name(zh)" }), expertNewNameZh]), - el("div", { class: "row" }, [btnExpertCreate]), - el("div", { class: "row" }, [el("label", { text: "Name(en)" }), expertNameEn]), - el("div", { class: "row" }, [el("label", { text: "Name(zh)" }), expertNameZh]), - el("div", { class: "row" }, [el("label", { text: "Role" }), expertRoleSel]), - el("div", { class: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]), - el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]), - el("div", { class: "row" }, [btnExpertSave]), + renderSectionCard("Experts", "Runtime registry and workspace prompt files are split into two sections below.", [ + el("div", { class: "card", style: "margin:8px 0;padding:10px;" }, [ + el("div", { class: "card__title", text: "Runtime Expert Registry" }), + el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]), + el("div", { class: "row" }, [btnExpertDelete]), + el("div", { class: "row" }, [el("label", { text: "Create ID" }), expertNewId]), + el("div", { class: "row" }, [el("label", { text: "Create Name(en)" }), expertNewNameEn]), + el("div", { class: "row" }, [el("label", { text: "Create Name(zh)" }), expertNewNameZh]), + el("div", { class: "row" }, [btnExpertCreate]), + el("div", { class: "row" }, [el("label", { text: "Name(en)" }), expertNameEn]), + el("div", { class: "row" }, [el("label", { text: "Name(zh)" }), expertNameZh]), + el("div", { class: "row" }, [el("label", { text: "Role" }), expertRoleSel]), + ]), + el("div", { class: "card", style: "margin:8px 0;padding:10px;" }, [ + el("div", { class: "card__title", text: "Workspace Prompt Files" }), + el("div", { class: "muted", text: "SOUL.md or ROLE_SYSTEM.md is required." }), + el("div", { class: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]), + el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]), + el("div", { class: "row" }, [btnExpertSave]), + ]), expertsStatus, ], { id: "models-experts" }), el("div", { class: "card section-card", id: "models-eval" }, [evalDetails]), @@ -6308,17 +6321,43 @@ async function renderAdminAudit() { const actor = el("input", { class: "input", placeholder: t("adminAudit.actor") }); const status = el("input", { class: "input", placeholder: t("adminAudit.status") }); const tbody = el("tbody"); + const pager = el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px;" }); + const pageInfo = el("span", { class: "muted", text: tf("sessionMonitor.pageInfo", { page: 1, totalPages: 1 }) }); + const totalInfo = el("span", { class: "muted", text: tf("adminAudit.total", { total: 0 }) }); + const pageInput = el("input", { + class: "input", + type: "number", + min: "1", + step: "1", + placeholder: t("adminAudit.jumpPlaceholder"), + style: "width:90px;", + }); + let page = 1; + const pageSize = 50; + let total = 0; + const totalPages = () => Math.max(1, Math.ceil((Number(total) || 0) / pageSize)); + const setPager = () => { + const tp = totalPages(); + pageInfo.textContent = tf("sessionMonitor.pageInfo", { page, totalPages: tp }); + totalInfo.textContent = tf("adminAudit.total", { total }); + pageInput.value = String(page); + btnPrev.disabled = page <= 1; + btnNext.disabled = page >= tp; + }; const load = async () => { const p = new URLSearchParams(); - p.set("limit", "300"); + p.set("limit", String(pageSize)); + p.set("offset", String((Math.max(1, page) - 1) * pageSize)); if (action.value.trim()) p.set("action", action.value.trim()); if (actor.value.trim()) p.set("actor_user_id", actor.value.trim()); if (status.value.trim()) p.set("status", status.value.trim()); const resp = await apiGet("/admin/api/admin-audit?" + p.toString()); + total = Math.max(0, Number(resp.total || 0) || 0); const rows = Array.isArray(resp.items) ? resp.items : []; tbody.innerHTML = ""; if (!rows.length) { tbody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); + setPager(); return; } rows.forEach((r) => { @@ -6333,13 +6372,70 @@ async function renderAdminAudit() { tdCell(JSON.stringify(r.detail || {}), 120), ])); }); + setPager(); }; + const btn = el("button", { + class: "btn btn--primary", + text: t("audit.query"), + onclick: async () => { + page = 1; + await load(); + }, + }); + const btnPrev = el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pagePrev"), + disabled: true, + onclick: async () => { + if (page <= 1) return; + page -= 1; + await load(); + }, + }); + const btnNext = el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pageNext"), + disabled: true, + onclick: async () => { + const tp = totalPages(); + if (page >= tp) return; + page += 1; + await load(); + }, + }); + const btnJump = el("button", { + class: "btn btn--small", + text: t("adminAudit.jump"), + onclick: async () => { + const tp = totalPages(); + let target = parseInt(String(pageInput.value || "").trim(), 10); + if (!Number.isFinite(target)) target = page; + target = Math.max(1, Math.min(tp, target)); + if (target === page) { + setPager(); + return; + } + page = target; + await load(); + }, + }); + pageInput.addEventListener("keydown", async (ev) => { + if (ev.key !== "Enter") return; + ev.preventDefault(); + btnJump.click(); + }); + pager.appendChild(btnPrev); + pager.appendChild(btnNext); + pager.appendChild(pageInfo); + pager.appendChild(totalInfo); + pager.appendChild(pageInput); + pager.appendChild(btnJump); await load(); - const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: load }); return el("div", {}, [ el("div", { class: "card" }, [ el("div", { class: "card__title", text: t("adminAudit.title") }), el("div", { class: "row" }, [action, actor, status, btn]), + pager, el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ el("thead", {}, [el("tr", {}, [ el("th", { text: t("table.timestamp") }), diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index 07ed59eb..306c29a9 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -69,6 +69,7 @@ const I18N = { "lang.switch": "English", "chat.imageViewerClose": "关闭", "chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭", + "chat.imageViewerDownload": "下载图片", "chat.specialistLabel": "专家", "chat.modeLabel": "模式", "chat.modeComprehensive": "综合", @@ -236,6 +237,7 @@ const I18N = { "lang.switch": "中文", "chat.imageViewerClose": "Close", "chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close", + "chat.imageViewerDownload": "Download image", "chat.specialistLabel": "Specialist", "chat.modeLabel": "Mode", "chat.modeComprehensive": "Comprehensive", @@ -1091,6 +1093,75 @@ function openChatImageLightbox(src, alt) { "aria-label": t("chat.imageViewerHint"), }); const inner = el("div", { class: "chat-img-lightbox__inner" }); + let scale = 1.0; + const clamp = (v) => Math.max(0.2, Math.min(5.0, Number(v || 1))); + const applyScale = () => { + scale = clamp(scale); + viewport.style.transform = `scale(${scale})`; + zoomText.textContent = `${Math.round(scale * 100)}%`; + }; + const fileStem = (() => { + const raw = String(alt || "").trim() || "image"; + const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, "_").slice(0, 64); + return safe || "image"; + })(); + const ext = (() => { + const s = String(src || ""); + if (/^data:image\/png/i.test(s)) return ".png"; + if (/^data:image\/webp/i.test(s)) return ".webp"; + if (/^data:image\/gif/i.test(s)) return ".gif"; + if (/^data:image\/bmp/i.test(s)) return ".bmp"; + if (/^data:image\/jpeg/i.test(s) || /^data:image\/jpg/i.test(s)) return ".jpg"; + return ".png"; + })(); + const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" }); + const btnMinus = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "−", + title: currentLang === "zh" ? "缩小" : "Zoom out", + onclick: (e) => { + e.stopPropagation(); + scale = clamp(scale - 0.1); + applyScale(); + }, + }); + const btnReset = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "100%", + title: currentLang === "zh" ? "重置缩放" : "Reset zoom", + onclick: (e) => { + e.stopPropagation(); + scale = 1.0; + applyScale(); + wrap.scrollLeft = 0; + wrap.scrollTop = 0; + }, + }); + const btnPlus = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "+", + title: currentLang === "zh" ? "放大" : "Zoom in", + onclick: (e) => { + e.stopPropagation(); + scale = clamp(scale + 0.1); + applyScale(); + }, + }); + const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" }); + const saveBtn = el("a", { + class: "chat-mermaid-lightbox__btn chat-img-lightbox__btn--icon", + href: String(src), + download: `${fileStem}${ext}`, + text: "⤓", + title: t("chat.imageViewerDownload"), + "aria-label": t("chat.imageViewerDownload"), + onclick: (e) => e.stopPropagation(), + }); + const wrap = el("div", { class: "chat-img-lightbox__viewportWrap" }); + const viewport = el("div", { class: "chat-img-lightbox__viewport" }); const big = el("img", { class: "chat-img-lightbox__img", src: String(src), @@ -1107,13 +1178,65 @@ function openChatImageLightbox(src, alt) { closeChatImageLightbox(); }, }); + // Keep native image context menu so users can right-click save. + big.addEventListener("contextmenu", (e) => { + e.stopPropagation(); + }); + toolbar.appendChild(btnMinus); + toolbar.appendChild(btnReset); + toolbar.appendChild(btnPlus); + toolbar.appendChild(zoomText); + toolbar.appendChild(saveBtn); + viewport.appendChild(big); + wrap.appendChild(viewport); + let dragging = false; + let dragStartX = 0; + let dragStartY = 0; + let dragScrollLeft = 0; + let dragScrollTop = 0; + const onDragStart = (e) => { + if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return; + dragging = true; + wrap.classList.add("chat-img-lightbox__viewportWrap--dragging"); + dragStartX = Number(e.clientX || 0); + dragStartY = Number(e.clientY || 0); + dragScrollLeft = wrap.scrollLeft; + dragScrollTop = wrap.scrollTop; + }; + const onDragMove = (e) => { + if (!dragging) return; + const x = Number(e.clientX || 0); + const y = Number(e.clientY || 0); + wrap.scrollLeft = dragScrollLeft - (x - dragStartX); + wrap.scrollTop = dragScrollTop - (y - dragStartY); + }; + const onDragEnd = () => { + dragging = false; + wrap.classList.remove("chat-img-lightbox__viewportWrap--dragging"); + }; + wrap.addEventListener("mousedown", (e) => onDragStart(e)); + window.addEventListener("mousemove", (e) => onDragMove(e)); + window.addEventListener("mouseup", () => onDragEnd()); + wrap.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const dy = Number(e.deltaY || 0); + const step = dy > 0 ? -0.08 : 0.08; + scale = clamp(scale + step); + applyScale(); + }, + { passive: false }, + ); + inner.appendChild(toolbar); inner.appendChild(closeBtn); - inner.appendChild(big); + inner.appendChild(wrap); backdrop.appendChild(inner); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeChatImageLightbox(); }); document.body.appendChild(backdrop); + applyScale(); } function openChatMermaidLightbox(svg) { @@ -4404,6 +4527,16 @@ ${autoLimit ? `
auto-added claus renderStreamComposite(); _markStreamTerminal("end", t("chat.status.end")); ok = true; + } else if (adminChatShowToolOutput) { + // WS final message may only contain the last assistant_text snapshot and + // omit persisted reasoning rows. Hydrate from history to avoid + // end-of-turn "reasoning disappears until refresh". + try { + if (streamRow && streamRow.parentNode) streamRow.remove(); + } catch (_) {} + await loadMessagesForActive(); + scrollMessagesToBottom(true); + ok = true; } else if (sawStreamToolRefAttachments) { // Streaming UI cannot render image_ref/relay_pointer; recover from persisted history so images appear // without requiring a manual refresh. diff --git a/interfaces/admin/static/theme-deepseek.css b/interfaces/admin/static/theme-deepseek.css index b8ab7cb0..4d1497ed 100644 --- a/interfaces/admin/static/theme-deepseek.css +++ b/interfaces/admin/static/theme-deepseek.css @@ -681,11 +681,29 @@ body.theme-ds-body .card { } .chat-img-lightbox__img { - max-width: 96vw; - max-height: 92vh; + max-width: none; + max-height: none; width: auto; height: auto; object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.chat-img-lightbox__viewportWrap { + width: min(96vw, 1400px); + max-height: 92vh; + overflow: auto; + padding: 14px; + cursor: grab; +} + +.chat-img-lightbox__viewportWrap--dragging { + cursor: grabbing; +} + +.chat-img-lightbox__viewport { + transform-origin: 0 0; } .chat-mermaid-lightbox__svg { @@ -734,6 +752,18 @@ body.theme-ds-body .card { background: rgba(30, 41, 59, 0.75); } +.chat-img-lightbox__btn--icon { + width: 40px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 22px; + font-weight: 800; + line-height: 1; +} + .chat-mermaid-lightbox__zoom { color: rgba(226, 232, 240, 0.9); font-size: 12px; diff --git a/platform/persistence/sqlite_store.py b/platform/persistence/sqlite_store.py index 20413394..270d5c25 100644 --- a/platform/persistence/sqlite_store.py +++ b/platform/persistence/sqlite_store.py @@ -4745,34 +4745,49 @@ class SqliteStore: ), ) - def list_admin_audit_logs(self, *, tenant_id: str | None = None, limit: int = 200) -> list[dict[str, Any]]: + def list_admin_audit_logs( + self, + *, + tenant_id: str | None = None, + action: str | None = None, + actor_user_id: str | None = None, + status: str | None = None, + limit: int = 200, + offset: int = 0, + ) -> list[dict[str, Any]]: lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + clauses: list[str] = [] + params: list[Any] = [] + tid = str(tenant_id or "").strip() + act = str(action or "").strip() + actor = str(actor_user_id or "").strip() + st = str(status or "").strip() + if tid: + clauses.append("l.actor_tenant_id = ?") + params.append(tid) + if act: + clauses.append("l.action = ?") + params.append(act) + if actor: + clauses.append("l.actor_user_id = ?") + params.append(actor) + if st: + clauses.append("l.status = ?") + params.append(st) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else "" + sql = f""" + SELECT l.actor_tenant_id, l.actor_user_id, l.action, l.target_type, l.target_id, l.status, l.detail, + l.timestamp, u.username AS actor_username, u.display_name AS actor_display_name + FROM admin_audit_log l + LEFT JOIN app_user u ON u.tenant_id = l.actor_tenant_id AND u.id = l.actor_user_id + {where_sql} + ORDER BY l.id DESC + LIMIT ? + OFFSET ? + """ with self._connect() as conn: - if tenant_id: - rows = conn.execute( - """ - SELECT l.actor_tenant_id, l.actor_user_id, l.action, l.target_type, l.target_id, l.status, l.detail, - l.timestamp, u.username AS actor_username, u.display_name AS actor_display_name - FROM admin_audit_log l - LEFT JOIN app_user u ON u.tenant_id = l.actor_tenant_id AND u.id = l.actor_user_id - WHERE l.actor_tenant_id = ? - ORDER BY l.id DESC - LIMIT ? - """, - (str(tenant_id), lim), - ).fetchall() - else: - rows = conn.execute( - """ - SELECT l.actor_tenant_id, l.actor_user_id, l.action, l.target_type, l.target_id, l.status, l.detail, - l.timestamp, u.username AS actor_username, u.display_name AS actor_display_name - FROM admin_audit_log l - LEFT JOIN app_user u ON u.tenant_id = l.actor_tenant_id AND u.id = l.actor_user_id - ORDER BY l.id DESC - LIMIT ? - """, - (lim,), - ).fetchall() + rows = conn.execute(sql, tuple(params + [lim, off])).fetchall() out: list[dict[str, Any]] = [] for r in rows: try: @@ -4795,6 +4810,38 @@ class SqliteStore: ) return out + def count_admin_audit_logs( + self, + *, + tenant_id: str | None = None, + action: str | None = None, + actor_user_id: str | None = None, + status: str | None = None, + ) -> int: + clauses: list[str] = [] + params: list[Any] = [] + tid = str(tenant_id or "").strip() + act = str(action or "").strip() + actor = str(actor_user_id or "").strip() + st = str(status or "").strip() + if tid: + clauses.append("actor_tenant_id = ?") + params.append(tid) + if act: + clauses.append("action = ?") + params.append(act) + if actor: + clauses.append("actor_user_id = ?") + params.append(actor) + if st: + clauses.append("status = ?") + params.append(st) + where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else "" + sql = f"SELECT COUNT(1) AS c FROM admin_audit_log {where_sql}" + with self._connect() as conn: + row = conn.execute(sql, tuple(params)).fetchone() + return int((row["c"] if row and row["c"] is not None else 0) or 0) + def upsert_channel_identity( self, *, diff --git a/tests/test_admin_audit_pagination_api.py b/tests/test_admin_audit_pagination_api.py new file mode 100644 index 00000000..821b1284 --- /dev/null +++ b/tests/test_admin_audit_pagination_api.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import hashlib +import tempfile +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + +from oclaw.interfaces.http.fastapi_app import create_app +from oclaw.platform.config.paths import db_path +from oclaw.platform.persistence.sqlite_store import SqliteStore + + +class AdminAuditPaginationApiTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + db = Path(self._tmp.name) / "ops.sqlite" + import os + + os.environ["OPS_ASSISTANT_DB_PATH"] = str(db) + os.environ["OPS_ASSISTANT_PASSWORD"] = "audit-test-pass" + store = SqliteStore(str(db)) + tenant = store.create_tenant("Team") + self.tenant_id = str(tenant["id"]) + store.create_user_account( + tenant_id=self.tenant_id, + username="administrator", + display_name="Admin", + role="owner", + password_hash=hashlib.sha256("audit-test-pass".encode("utf-8")).hexdigest(), + is_active=True, + ) + store.create_user_account( + tenant_id=self.tenant_id, + username="operator", + display_name="Operator", + role="member", + password_hash=hashlib.sha256("operator-pass".encode("utf-8")).hexdigest(), + is_active=True, + ) + self.store = store + app = create_app() + self.client = TestClient(app) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _login(self, username: str = "administrator", password: str = "audit-test-pass") -> str: + self.client.post("/admin/api/auth/bootstrap", json={}) + resp = self.client.post( + "/admin/api/auth/login", + json={ + "tenant_id": self.tenant_id, + "username": username, + "password": password, + "purpose": "console", + }, + ) + data = resp.json() + self.assertTrue(data.get("ok"), data) + return str(data.get("token") or "") + + def _seed_logs(self) -> None: + # Insert logs in order; endpoint sorts DESC by id. + rows = [ + ("administrator", "user.create", "ok"), + ("operator", "user.create", "ok"), + ("administrator", "user.disable", "ok"), + ("administrator", "user.delete", "failed"), + ("operator", "user.delete", "ok"), + ("administrator", "user.disable", "failed"), + ] + for actor, action, status in rows: + self.store.add_admin_audit_log( + actor_tenant_id=self.tenant_id, + actor_user_id=actor, + action=action, + target_type="user", + target_id="u-1", + status=status, + detail={"k": f"{actor}:{action}:{status}"}, + ) + + def test_admin_audit_pagination_returns_total_and_offset_slice(self) -> None: + self._seed_logs() + token = self._login() + resp = self.client.get( + "/admin/api/admin-audit?limit=2&offset=2", + headers={"authorization": f"Bearer {token}"}, + ) + self.assertEqual(resp.status_code, 200, resp.text) + data = resp.json() + self.assertTrue(data.get("ok"), data) + self.assertEqual(int(data.get("limit") or 0), 2) + self.assertEqual(int(data.get("offset") or 0), 2) + self.assertEqual(int(data.get("total") or 0), 6) + items = data.get("items") or [] + self.assertEqual(len(items), 2) + # Desc by id => offset 2 starts from the 3rd newest. + self.assertEqual(str(items[0].get("action") or ""), "user.delete") + self.assertEqual(str(items[0].get("status") or ""), "failed") + self.assertEqual(str(items[1].get("action") or ""), "user.disable") + self.assertEqual(str(items[1].get("status") or ""), "ok") + # Legacy compatibility field kept. + self.assertEqual(len(data.get("logs") or []), len(items)) + + def test_admin_audit_filtering_applies_to_total_and_items(self) -> None: + self._seed_logs() + token = self._login() + resp = self.client.get( + "/admin/api/admin-audit?limit=5&offset=0&action=user.disable&status=failed", + headers={"authorization": f"Bearer {token}"}, + ) + self.assertEqual(resp.status_code, 200, resp.text) + data = resp.json() + items = data.get("items") or [] + self.assertEqual(int(data.get("total") or 0), 1) + self.assertEqual(len(items), 1) + self.assertEqual(str(items[0].get("action") or ""), "user.disable") + self.assertEqual(str(items[0].get("status") or ""), "failed") + + +if __name__ == "__main__": + unittest.main() +