mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Record WebCRT device login/commands and NE exec in operation audit.
Make session lifecycle and typed CLI lines searchable in audit_log with password redaction, and surface summaries plus device filters in the ops UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
497a5e048e
commit
8915455b52
12 changed files with 783 additions and 40 deletions
|
|
@ -1867,6 +1867,24 @@ const en = {
|
|||
audit: {
|
||||
title: "Audit & ops",
|
||||
logsTitle: "Operation logs",
|
||||
colSummary: "Summary",
|
||||
colDetail: "Detail",
|
||||
showDetail: "Expand",
|
||||
hideDetail: "Collapse",
|
||||
filterAll: "All",
|
||||
filterWebcrt: "Device terminal",
|
||||
filterNeExec: "Device exec",
|
||||
filterAuth: "Auth",
|
||||
summary: {
|
||||
connecting: "Connecting to {{device}}",
|
||||
loginOk: "Logged in to {{device}}",
|
||||
loginFail: "Login failed {{device}}: {{error}}",
|
||||
logout: "Disconnected {{device}}",
|
||||
logoutReason: "Disconnected {{device}} ({{reason}})",
|
||||
command: "{{device}} · cmd: {{command}}",
|
||||
neExec: "{{device}} · exec: {{commands}}",
|
||||
neExecBatch: "Batch exec {{n}} NE(s) (ok {{ok}} / fail {{fail}})",
|
||||
},
|
||||
nav: {
|
||||
tasks: "Task overview",
|
||||
logs: "Operation logs",
|
||||
|
|
|
|||
|
|
@ -1845,6 +1845,24 @@ const zh = {
|
|||
audit: {
|
||||
title: "操作审计",
|
||||
logsTitle: "操作日志",
|
||||
colSummary: "摘要",
|
||||
colDetail: "详情",
|
||||
showDetail: "展开",
|
||||
hideDetail: "收起",
|
||||
filterAll: "全部",
|
||||
filterWebcrt: "设备终端",
|
||||
filterNeExec: "设备执行",
|
||||
filterAuth: "登录认证",
|
||||
summary: {
|
||||
connecting: "正在登录 {{device}}",
|
||||
loginOk: "登录 {{device}}",
|
||||
loginFail: "登录失败 {{device}}:{{error}}",
|
||||
logout: "断开 {{device}}",
|
||||
logoutReason: "断开 {{device}}({{reason}})",
|
||||
command: "{{device}} · 命令: {{command}}",
|
||||
neExec: "{{device}} · 执行: {{commands}}",
|
||||
neExecBatch: "批量执行 {{n}} 台(成功 {{ok}} / 失败 {{fail}})",
|
||||
},
|
||||
nav: {
|
||||
tasks: "任务概览",
|
||||
logs: "操作日志",
|
||||
|
|
|
|||
|
|
@ -10664,3 +10664,49 @@ option {
|
|||
border-color: rgba(148, 163, 184, 0.2) !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Operation audit logs */
|
||||
.system-page .audit-quick-filters button.is-active {
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
color: #1e3a8a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.system-page .audit-summary-cell {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-page button.linkish {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.system-page .audit-detail-row td {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.system-page .audit-detail-pre {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.system-page .audit-detail-meta {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -17,6 +17,8 @@ type AuditItem = {
|
|||
detail: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type QuickFilter = "" | "webcrt." | "ne.exec" | "auth.";
|
||||
|
||||
function statusClass(code: number): string {
|
||||
if (code >= 500) return "pt-list-status--failed";
|
||||
if (code >= 400) return "pt-list-status--warning";
|
||||
|
|
@ -24,21 +26,89 @@ function statusClass(code: number): string {
|
|||
return "pt-list-status--other";
|
||||
}
|
||||
|
||||
function detailStr(detail: Record<string, unknown>, key: string): string {
|
||||
const v = detail?.[key];
|
||||
if (v == null) return "";
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
function deviceLabel(detail: Record<string, unknown>): string {
|
||||
const name = detailStr(detail, "ne_name");
|
||||
const ip = detailStr(detail, "ne_ip");
|
||||
if (name && ip) return `${name} (${ip})`;
|
||||
return name || ip || detailStr(detail, "ne_id") || "";
|
||||
}
|
||||
|
||||
export function auditSummary(
|
||||
action: string,
|
||||
detail: Record<string, unknown>,
|
||||
t: (key: string, vars?: Record<string, string>) => string,
|
||||
): string {
|
||||
const d = detail || {};
|
||||
const device = deviceLabel(d);
|
||||
if (action === "webcrt.session_connecting") {
|
||||
return t("audit.summary.connecting", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.session_created") {
|
||||
return t("audit.summary.loginOk", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.session_open_failed") {
|
||||
const err = detailStr(d, "error") || t("common.empty");
|
||||
return t("audit.summary.loginFail", { device: device || t("common.empty"), error: err.slice(0, 80) });
|
||||
}
|
||||
if (action === "webcrt.session_closed") {
|
||||
const reason = detailStr(d, "reason");
|
||||
return reason
|
||||
? t("audit.summary.logoutReason", { device: device || t("common.empty"), reason })
|
||||
: t("audit.summary.logout", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.command") {
|
||||
const cmd = detailStr(d, "command") || t("common.empty");
|
||||
const redacted = Boolean(d.redacted);
|
||||
return t("audit.summary.command", {
|
||||
device: device || t("common.empty"),
|
||||
command: redacted ? "***" : cmd,
|
||||
});
|
||||
}
|
||||
if (action === "ne.exec") {
|
||||
const cmds = Array.isArray(d.commands) ? d.commands.map(String).filter(Boolean) : [];
|
||||
return t("audit.summary.neExec", {
|
||||
device: device || t("common.empty"),
|
||||
commands: cmds.slice(0, 3).join("; ") || t("common.empty"),
|
||||
});
|
||||
}
|
||||
if (action === "ne.exec_batch") {
|
||||
return t("audit.summary.neExecBatch", {
|
||||
n: String(d.target_count ?? ((d.ne_ids as unknown[]) || []).length || 0),
|
||||
ok: String(d.ok_count ?? 0),
|
||||
fail: String(d.fail_count ?? 0),
|
||||
});
|
||||
}
|
||||
if (action.startsWith("auth.")) {
|
||||
return action.replace(/^auth\./, "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, isAdmin } = useAuth();
|
||||
const [page, setPage] = useState(1);
|
||||
const [username, setUsername] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
const [quick, setQuick] = useState<QuickFilter>("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const effectiveAction = action.trim() || quick;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["auditLogs", page, username, action],
|
||||
queryKey: ["auditLogs", page, username, effectiveAction],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
p.set("page_size", "50");
|
||||
if (username.trim()) p.set("username", username.trim());
|
||||
if (action.trim()) p.set("action", action.trim());
|
||||
if (effectiveAction) p.set("action", effectiveAction);
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
|
||||
`/v1/audit-logs?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -49,7 +119,13 @@ export function AuditPage() {
|
|||
const items = useMemo(() => query.data?.items || [], [query.data]);
|
||||
const total = query.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / 50));
|
||||
const hasFilters = Boolean(username.trim() || action.trim());
|
||||
const hasFilters = Boolean(username.trim() || action.trim() || quick);
|
||||
|
||||
const setQuickFilter = (next: QuickFilter) => {
|
||||
setPage(1);
|
||||
setQuick(next);
|
||||
if (next) setAction("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack system-page">
|
||||
|
|
@ -60,6 +136,26 @@ export function AuditPage() {
|
|||
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline audit-quick-filters">
|
||||
{(
|
||||
[
|
||||
["", "audit.filterAll"],
|
||||
["webcrt.", "audit.filterWebcrt"],
|
||||
["ne.exec", "audit.filterNeExec"],
|
||||
["auth.", "audit.filterAuth"],
|
||||
] as const
|
||||
).map(([value, labelKey]) => (
|
||||
<button
|
||||
key={value || "all"}
|
||||
type="button"
|
||||
className={quick === value && !action.trim() ? "is-active" : undefined}
|
||||
onClick={() => setQuickFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
{isAdmin ? (
|
||||
<input
|
||||
|
|
@ -77,6 +173,7 @@ export function AuditPage() {
|
|||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setAction(e.target.value);
|
||||
if (e.target.value.trim()) setQuick("");
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
|
||||
|
|
@ -88,6 +185,7 @@ export function AuditPage() {
|
|||
onClick={() => {
|
||||
setUsername("");
|
||||
setAction("");
|
||||
setQuick("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
|
|
@ -109,35 +207,60 @@ export function AuditPage() {
|
|||
<th>{t("auth.colTime")}</th>
|
||||
<th>{t("auth.colUser")}</th>
|
||||
<th>{t("auth.colAction")}</th>
|
||||
<th>{t("auth.colMethod")}</th>
|
||||
<th>{t("auth.colPath")}</th>
|
||||
<th>{t("audit.colSummary")}</th>
|
||||
<th>{t("auth.colStatus")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("audit.colDetail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
|
||||
<td className="pt-list-time">
|
||||
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
|
||||
</td>
|
||||
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
|
||||
<td>{row.action}</td>
|
||||
<td className="pt-list-num">{row.method}</td>
|
||||
<td
|
||||
className="pt-list-num"
|
||||
style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{row.path}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
|
||||
{row.status_code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.map((row) => {
|
||||
const open = expanded === row.id;
|
||||
const summary = auditSummary(row.action, row.detail || {}, t);
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<tr>
|
||||
<td className="pt-list-time">
|
||||
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
|
||||
</td>
|
||||
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
|
||||
<td className="pt-list-num">{row.action}</td>
|
||||
<td className="audit-summary-cell">{summary || row.path || t("common.empty")}</td>
|
||||
<td>
|
||||
{row.status_code ? (
|
||||
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
|
||||
{row.status_code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
onClick={() => setExpanded(open ? null : row.id)}
|
||||
>
|
||||
{open ? t("audit.hideDetail") : t("audit.showDetail")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{open ? (
|
||||
<tr className="audit-detail-row">
|
||||
<td colSpan={7}>
|
||||
<pre className="audit-detail-pre">{JSON.stringify(row.detail || {}, null, 2)}</pre>
|
||||
{row.method || row.path ? (
|
||||
<p className="muted audit-detail-meta">
|
||||
{row.method} {row.path}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue