mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
feat: add admin audit pagination and improve admin/chat UX.
Move admin operation audit to true offset-based pagination with SQL-side filtering and total count metadata, while keeping legacy response compatibility. Also refine admin Experts section layout and improve chat image viewer interactions and reasoning finalization behavior to reduce UX regressions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
37522f492a
commit
83f46f8aa2
6 changed files with 502 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }),
|
||||
|
|
|
|||
|
|
@ -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 ? `<div style="margin-top:8px;"><span class="muted">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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
126
tests/test_admin_audit_pagination_api.py
Normal file
126
tests/test_admin_audit_pagination_api.py
Normal file
|
|
@ -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()
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue