mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +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
|
|
@ -99,8 +99,14 @@ def enqueue_audit(
|
|||
) -> None:
|
||||
global _dropped
|
||||
act = str(action or "")
|
||||
# Always persist auth / security-relevant events.
|
||||
if act.startswith("auth.") or act.startswith("users.") or act.startswith("api_tokens.") or act.startswith("webcrt."):
|
||||
# Always persist auth / security / device-op events.
|
||||
if (
|
||||
act.startswith("auth.")
|
||||
or act.startswith("users.")
|
||||
or act.startswith("api_tokens.")
|
||||
or act.startswith("webcrt.")
|
||||
or act.startswith("ne.")
|
||||
):
|
||||
pass
|
||||
elif act.startswith("http.") and not _sample_ok():
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_deps import AuthContext, require_user
|
||||
from .auth_service import write_audit
|
||||
from .db import get_db
|
||||
from .device_types import SUPPORTED_VENDORS
|
||||
from .ne_connect import schedule_connect_tests
|
||||
|
|
@ -39,6 +43,16 @@ from .models import ManagedNE
|
|||
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
|
||||
|
||||
|
||||
def _actor(ctx: AuthContext | None = None, request: Request | None = None) -> tuple[str, str]:
|
||||
if ctx is not None and ctx.user is not None:
|
||||
return str(ctx.user.id or ""), str(ctx.user.username or "")
|
||||
if request is not None:
|
||||
user = getattr(request.state, "auth_user", None)
|
||||
if user:
|
||||
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
|
||||
return "", ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def api_list_managed_ne(
|
||||
keyword: str | None = Query(default=None),
|
||||
|
|
@ -131,24 +145,55 @@ def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
|
|||
|
||||
|
||||
@router.post("/exec")
|
||||
def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)):
|
||||
def api_exec_managed_ne(
|
||||
body: ManagedNeExecRequest,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping/traceroute)."""
|
||||
return execute_managed_ne_commands(
|
||||
uid, uname = _actor(ctx)
|
||||
out = execute_managed_ne_commands(
|
||||
db,
|
||||
body.commands,
|
||||
ne_id=body.ne_id,
|
||||
ume_ne_id=body.ume_ne_id,
|
||||
read_timeout_sec=body.read_timeout_sec,
|
||||
)
|
||||
device = out.get("device") if isinstance(out.get("device"), dict) else {}
|
||||
write_audit(
|
||||
db,
|
||||
action="ne.exec",
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="POST",
|
||||
path="/v1/managed-ne/exec",
|
||||
status_code=200 if out.get("ok") else 502,
|
||||
detail={
|
||||
"ne_id": body.ne_id or "",
|
||||
"ume_ne_id": body.ume_ne_id or "",
|
||||
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
|
||||
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
|
||||
"commands": list(out.get("commands") or body.commands or [])[:20],
|
||||
"ok": bool(out.get("ok")),
|
||||
"error": str(out.get("error") or "")[:500],
|
||||
"output_len": len(str(out.get("output") or "")),
|
||||
},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/exec-batch")
|
||||
def api_exec_managed_ne_batch(body: ManagedNeExecBatchRequest):
|
||||
def api_exec_managed_ne_batch(
|
||||
body: ManagedNeExecBatchRequest,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
|
||||
uid, uname = _actor(ctx)
|
||||
targets = None
|
||||
if body.targets:
|
||||
targets = [t.model_dump() for t in body.targets]
|
||||
return execute_managed_ne_commands_batch(
|
||||
out = execute_managed_ne_commands_batch(
|
||||
targets=targets,
|
||||
ne_ids=body.ne_ids,
|
||||
ume_ne_ids=body.ume_ne_ids,
|
||||
|
|
@ -156,6 +201,33 @@ def api_exec_managed_ne_batch(body: ManagedNeExecBatchRequest):
|
|||
read_timeout_sec=body.read_timeout_sec,
|
||||
concurrency=body.concurrency,
|
||||
)
|
||||
items = out.get("items") if isinstance(out, dict) else None
|
||||
ok_n = 0
|
||||
fail_n = 0
|
||||
if isinstance(items, list):
|
||||
for row in items:
|
||||
if isinstance(row, dict) and row.get("ok"):
|
||||
ok_n += 1
|
||||
else:
|
||||
fail_n += 1
|
||||
write_audit(
|
||||
db,
|
||||
action="ne.exec_batch",
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="POST",
|
||||
path="/v1/managed-ne/exec-batch",
|
||||
status_code=200,
|
||||
detail={
|
||||
"ne_ids": list(body.ne_ids or [])[:100],
|
||||
"ume_ne_ids": list(body.ume_ne_ids or [])[:100],
|
||||
"target_count": len(targets or []) + len(body.ne_ids or []) + len(body.ume_ne_ids or []),
|
||||
"commands": list(body.commands or [])[:20],
|
||||
"ok_count": ok_n,
|
||||
"fail_count": fail_n,
|
||||
},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/connect-test")
|
||||
|
|
|
|||
|
|
@ -468,7 +468,86 @@ def read_session_log_tail(session_id: str, *, max_bytes: int = 49152) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
# Lifecycle + command events also land in audit_log (ops UI). Attach/detach/sftp stay file-only.
|
||||
_DB_AUDIT_EVENTS = frozenset(
|
||||
{
|
||||
"session_connecting",
|
||||
"session_created",
|
||||
"session_open_failed",
|
||||
"session_closed",
|
||||
"command",
|
||||
}
|
||||
)
|
||||
|
||||
_PASSWORD_PROMPT_RE = re.compile(
|
||||
r"(?:enter\s+)?(?:password|密码|passwd)\s*[:>]\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def looks_like_password_prompt(text: str) -> bool:
|
||||
"""True when device stdout tail asks for a password (interactive auth)."""
|
||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
# Drop ANSI so prompt detection is stable.
|
||||
s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", s)
|
||||
parts = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
||||
if not parts:
|
||||
return False
|
||||
return bool(_PASSWORD_PROMPT_RE.search(parts[-1]))
|
||||
|
||||
|
||||
def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tuple[str, list[str]]:
|
||||
"""Accumulate stdin into completed command lines (Enter / CR / LF).
|
||||
|
||||
Handles backspace, ignores most control chars, truncates over-long lines.
|
||||
Returns ``(new_buffer, completed_lines)``.
|
||||
"""
|
||||
cur = str(buf or "")
|
||||
completed: list[str] = []
|
||||
limit = max(64, min(int(max_line or 512), 4096))
|
||||
for ch in str(data or ""):
|
||||
if ch in ("\r", "\n"):
|
||||
if cur:
|
||||
completed.append(cur[:limit])
|
||||
cur = ""
|
||||
continue
|
||||
if ch in ("\b", "\x7f"):
|
||||
cur = cur[:-1] if cur else ""
|
||||
continue
|
||||
if ch == "\x03": # Ctrl-C — abandon current line
|
||||
cur = ""
|
||||
continue
|
||||
if ord(ch) < 32 and ch != "\t":
|
||||
continue
|
||||
if len(cur) < limit:
|
||||
cur += ch
|
||||
return cur, completed
|
||||
|
||||
|
||||
def _audit(event: str, **fields: Any) -> None:
|
||||
"""Write WebCRT audit to jsonl; dual-write selected events into audit_log."""
|
||||
# Enrich actor / device fields from the live session when callers omit them.
|
||||
sid = str(fields.get("session_id") or "").strip()
|
||||
if sid and (
|
||||
not fields.get("owner_user_id")
|
||||
or not fields.get("owner_username")
|
||||
or not fields.get("ne_name")
|
||||
or "protocol" not in fields
|
||||
):
|
||||
try:
|
||||
from .webcrt_session_registry import get_session
|
||||
|
||||
sess = get_session(sid)
|
||||
if sess is not None:
|
||||
fields.setdefault("owner_user_id", sess.owner_user_id)
|
||||
fields.setdefault("owner_username", sess.owner_username)
|
||||
fields.setdefault("ne_id", sess.ne_id)
|
||||
fields.setdefault("ne_name", sess.ne_name)
|
||||
fields.setdefault("ne_ip", sess.ne_ip)
|
||||
fields.setdefault("protocol", sess.protocol)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
record = {"ts": _utc_iso(), "event": event, **fields}
|
||||
try:
|
||||
path = webcrt_data_root() / "audit.jsonl"
|
||||
|
|
@ -477,3 +556,35 @@ def _audit(event: str, **fields: Any) -> None:
|
|||
except Exception:
|
||||
_log.exception("webcrt audit write failed")
|
||||
_log.info("webcrt.%s %s", event, {k: v for k, v in fields.items() if k != "detail"})
|
||||
|
||||
if str(event or "") not in _DB_AUDIT_EVENTS:
|
||||
return
|
||||
try:
|
||||
from .audit_async import enqueue_audit
|
||||
|
||||
actor_uid = str(fields.get("owner_user_id") or fields.get("actor_user_id") or "")
|
||||
actor_name = str(fields.get("owner_username") or fields.get("actor_username") or "")
|
||||
detail = {
|
||||
k: v
|
||||
for k, v in fields.items()
|
||||
if k
|
||||
not in {
|
||||
"owner_user_id",
|
||||
"owner_username",
|
||||
"actor_user_id",
|
||||
"actor_username",
|
||||
}
|
||||
}
|
||||
enqueue_audit(
|
||||
action=f"webcrt.{event}",
|
||||
actor_user_id=actor_uid,
|
||||
actor_username=actor_name,
|
||||
method="",
|
||||
path=f"/v1/webcrt/sessions/{sid}" if sid else "/v1/webcrt",
|
||||
status_code=0,
|
||||
client_ip=str(fields.get("client_ip") or ""),
|
||||
user_agent=str(fields.get("client") or "")[:512],
|
||||
detail=detail,
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("webcrt audit_log enqueue failed event=%s", event)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,13 @@ from .ne_session_factory import (
|
|||
)
|
||||
from .webcrt_channel import (
|
||||
_BoundedByteQueue,
|
||||
_audit,
|
||||
_decode_bytes,
|
||||
_encode_text,
|
||||
_session_log_path,
|
||||
_utc_iso,
|
||||
feed_command_line_buffer,
|
||||
looks_like_password_prompt,
|
||||
map_network_cli_enter,
|
||||
map_network_cli_keys,
|
||||
)
|
||||
|
|
@ -87,6 +90,11 @@ class WebcrtSession:
|
|||
sftp_ready: bool = False
|
||||
_sftp: Any = field(default=None, repr=False)
|
||||
_sftp_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
|
||||
# Interactive command audit: line buffer + password-prompt redaction.
|
||||
_cmd_buf: str = field(default="", repr=False)
|
||||
_cmd_buf_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
_password_mode: bool = field(default=False, repr=False)
|
||||
_stdout_tail: str = field(default="", repr=False)
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_activity = time.time()
|
||||
|
|
@ -302,7 +310,7 @@ class WebcrtSession:
|
|||
return "stale"
|
||||
return chunk # bytes | None
|
||||
|
||||
def write_stdin(self, data: str) -> None:
|
||||
def write_stdin(self, data: str, *, audit_source: str = "stdin") -> None:
|
||||
if self.closed or self.conn is None:
|
||||
raise RuntimeError("session_closed")
|
||||
text = str(data or "")
|
||||
|
|
@ -318,6 +326,7 @@ class WebcrtSession:
|
|||
text = map_network_cli_enter(text, self.conn)
|
||||
if not text:
|
||||
return
|
||||
self._note_stdin_for_audit(text, source=audit_source)
|
||||
with self._write_lock:
|
||||
# Prefer raw channel I/O for interactive typing (char echo / backspace).
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
|
|
@ -345,6 +354,43 @@ class WebcrtSession:
|
|||
self.bytes_in += len(text)
|
||||
self.touch()
|
||||
|
||||
def _note_stdin_for_audit(self, text: str, *, source: str = "stdin") -> None:
|
||||
"""Extract completed command lines from stdin and emit webcrt.command audits."""
|
||||
with self._cmd_buf_lock:
|
||||
self._cmd_buf, lines = feed_command_line_buffer(self._cmd_buf, text)
|
||||
redacted = bool(self._password_mode)
|
||||
if redacted and lines:
|
||||
self._password_mode = False
|
||||
for cmd in lines:
|
||||
if not str(cmd).strip():
|
||||
continue
|
||||
try:
|
||||
_audit(
|
||||
"command",
|
||||
session_id=self.session_id,
|
||||
ne_id=self.ne_id,
|
||||
ne_name=self.ne_name,
|
||||
ne_ip=self.ne_ip,
|
||||
protocol=self.protocol,
|
||||
owner_user_id=self.owner_user_id,
|
||||
owner_username=self.owner_username,
|
||||
command="***" if redacted else str(cmd)[:512],
|
||||
redacted=bool(redacted),
|
||||
source=str(source or "stdin")[:32],
|
||||
)
|
||||
except Exception:
|
||||
_log.debug("webcrt command audit failed session=%s", self.session_id, exc_info=True)
|
||||
|
||||
def _note_stdout_for_audit(self, text: str) -> None:
|
||||
"""Track device prompts so the next typed line can be redacted if it is a password."""
|
||||
chunk = str(text or "")
|
||||
if not chunk:
|
||||
return
|
||||
with self._cmd_buf_lock:
|
||||
self._stdout_tail = (self._stdout_tail + chunk)[-4000:]
|
||||
if looks_like_password_prompt(self._stdout_tail):
|
||||
self._password_mode = True
|
||||
|
||||
def send_break(self) -> None:
|
||||
"""Send SSH break / Telnet IAC BREAK to interrupt paging or hung commands."""
|
||||
if self.closed or self.conn is None:
|
||||
|
|
@ -472,7 +518,9 @@ class WebcrtSession:
|
|||
self.bytes_out += len(chunk)
|
||||
self.out_queue.put(chunk)
|
||||
try:
|
||||
self.append_session_log(_decode_bytes(chunk, self.encoding))
|
||||
decoded = _decode_bytes(chunk, self.encoding)
|
||||
self.append_session_log(decoded)
|
||||
self._note_stdout_for_audit(decoded)
|
||||
except Exception:
|
||||
pass
|
||||
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
|
||||
|
|
@ -516,7 +564,7 @@ class WebcrtSession:
|
|||
return
|
||||
for cmd in cmds[:20]:
|
||||
try:
|
||||
self.write_stdin(cmd + "\r")
|
||||
self.write_stdin(cmd + "\r", audit_source="post_login")
|
||||
time.sleep(0.15)
|
||||
except Exception:
|
||||
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)
|
||||
|
|
|
|||
|
|
@ -261,8 +261,12 @@ def _finish_connect(
|
|||
"session_open_failed",
|
||||
session_id=sess.session_id,
|
||||
ne_id=sess.ne_id,
|
||||
ne_name=sess.ne_name,
|
||||
ne_ip=sess.ne_ip,
|
||||
protocol=sess.protocol,
|
||||
source=str(device.get("source") or ""),
|
||||
owner_user_id=sess.owner_user_id,
|
||||
owner_username=sess.owner_username,
|
||||
client=client or "",
|
||||
error=str(exc)[:500],
|
||||
transcript_len=len(partial),
|
||||
|
|
@ -500,6 +504,8 @@ def _finish_connect(
|
|||
protocol=sess.protocol,
|
||||
encoding=sess.encoding,
|
||||
source=str(device.get("source") or ""),
|
||||
owner_user_id=sess.owner_user_id,
|
||||
owner_username=sess.owner_username,
|
||||
hop_enabled=bool(creds.get("hop_enabled")),
|
||||
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
|
||||
cli_hop_guard=bool(hop_guard),
|
||||
|
|
@ -608,9 +614,12 @@ def create_session(
|
|||
"session_connecting",
|
||||
session_id=session_id,
|
||||
ne_id=sess.ne_id,
|
||||
ne_name=sess.ne_name,
|
||||
ne_ip=sess.ne_ip,
|
||||
protocol=sess.protocol,
|
||||
encoding=enc,
|
||||
owner_user_id=sess.owner_user_id,
|
||||
owner_username=sess.owner_username,
|
||||
client=client or "",
|
||||
async_connect=bool(async_connect),
|
||||
)
|
||||
|
|
@ -746,7 +755,11 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "")
|
|||
"session_closed",
|
||||
session_id=session_id,
|
||||
ne_id=sess.ne_id,
|
||||
ne_name=sess.ne_name,
|
||||
ne_ip=sess.ne_ip,
|
||||
protocol=sess.protocol,
|
||||
owner_user_id=sess.owner_user_id,
|
||||
owner_username=sess.owner_username,
|
||||
reason=reason,
|
||||
client=client or "",
|
||||
bytes_in=sess.bytes_in,
|
||||
|
|
|
|||
78
tests/test_ne_exec_audit.py
Normal file
78
tests/test_ne_exec_audit.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Device exec audit coverage for managed-ne /exec endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from netx_api.auth_deps import AuthContext, require_user
|
||||
from netx_api.db import get_db
|
||||
from netx_api.managed_ne_router import router as managed_ne_router
|
||||
from netx_api.models import AppUser
|
||||
|
||||
|
||||
class ManagedNeExecAuditTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(managed_ne_router)
|
||||
|
||||
fake = AppUser(id="u1", username="alice", role="admin", password_hash="x")
|
||||
fake.is_active = True
|
||||
|
||||
def _user() -> AuthContext:
|
||||
return AuthContext(user=fake, auth_via="disabled", scopes=frozenset({"ne:exec"}))
|
||||
|
||||
def _db():
|
||||
yield MagicMock()
|
||||
|
||||
self.app.dependency_overrides[require_user] = _user
|
||||
self.app.dependency_overrides[get_db] = _db
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.app.dependency_overrides.clear()
|
||||
|
||||
@patch("netx_api.managed_ne_router.write_audit")
|
||||
@patch("netx_api.managed_ne_router.execute_managed_ne_commands")
|
||||
def test_exec_writes_audit(self, mock_exec: MagicMock, mock_audit: MagicMock) -> None:
|
||||
mock_exec.return_value = {
|
||||
"ok": True,
|
||||
"device": {"name": "core-sw", "ip_address": "10.0.0.1"},
|
||||
"commands": ["display version"],
|
||||
"output": "VRP",
|
||||
}
|
||||
r = self.client.post(
|
||||
"/v1/managed-ne/exec",
|
||||
json={"ne_id": "ne1", "commands": ["display version"]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
mock_audit.assert_called_once()
|
||||
kwargs = mock_audit.call_args.kwargs
|
||||
self.assertEqual(kwargs["action"], "ne.exec")
|
||||
self.assertEqual(kwargs["actor_username"], "alice")
|
||||
self.assertEqual(kwargs["detail"]["ne_name"], "core-sw")
|
||||
self.assertEqual(kwargs["detail"]["commands"], ["display version"])
|
||||
|
||||
@patch("netx_api.managed_ne_router.write_audit")
|
||||
@patch("netx_api.managed_ne_router.execute_managed_ne_commands_batch")
|
||||
def test_exec_batch_writes_audit(self, mock_batch: MagicMock, mock_audit: MagicMock) -> None:
|
||||
mock_batch.return_value = {
|
||||
"items": [{"ok": True}, {"ok": False}],
|
||||
}
|
||||
r = self.client.post(
|
||||
"/v1/managed-ne/exec-batch",
|
||||
json={"ne_ids": ["a", "b"], "commands": ["display clock"]},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
mock_audit.assert_called_once()
|
||||
kwargs = mock_audit.call_args.kwargs
|
||||
self.assertEqual(kwargs["action"], "ne.exec_batch")
|
||||
self.assertEqual(kwargs["detail"]["ok_count"], 1)
|
||||
self.assertEqual(kwargs["detail"]["fail_count"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -44,7 +44,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
def tearDown(self) -> None:
|
||||
self.setUp()
|
||||
|
||||
def test_session_write_resize_and_close(self) -> None:
|
||||
@patch("netx_api.webcrt_session_model._audit")
|
||||
def test_session_write_resize_and_close(self, _mock_audit: MagicMock) -> None:
|
||||
conn = _FakeConn()
|
||||
sess = svc.WebcrtSession(
|
||||
session_id="s1",
|
||||
|
|
@ -342,7 +343,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertIn("<r1>", echo)
|
||||
self.assertFalse(sess.needs_live_prompt)
|
||||
before = list(fake.written)
|
||||
sess.write_stdin("\n")
|
||||
with patch("netx_api.webcrt_session_model._audit"):
|
||||
sess.write_stdin("\n")
|
||||
self.assertEqual(fake.written[len(before) :], ["\n"])
|
||||
svc.close_session(out["session_id"], reason="test")
|
||||
|
||||
|
|
|
|||
208
tests/test_webcrt_audit.py
Normal file
208
tests/test_webcrt_audit.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Tests for WebCRT / device operation audit (command lines + audit_log dual-write)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netx_api.webcrt_channel import (
|
||||
_DB_AUDIT_EVENTS,
|
||||
_audit,
|
||||
feed_command_line_buffer,
|
||||
looks_like_password_prompt,
|
||||
)
|
||||
from netx_api.webcrt_session_model import WebcrtSession
|
||||
|
||||
|
||||
class FeedCommandLineBufferTests(unittest.TestCase):
|
||||
def test_enter_emits_line(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("", "display version\r")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, ["display version"])
|
||||
|
||||
def test_backspace(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("", "disX\x08play\n")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, ["display"])
|
||||
|
||||
def test_multiline_paste(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("", "a\nb\nc\n")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, ["a", "b", "c"])
|
||||
|
||||
def test_partial_stays_in_buffer(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("", "sho")
|
||||
self.assertEqual(buf, "sho")
|
||||
self.assertEqual(lines, [])
|
||||
buf, lines = feed_command_line_buffer(buf, "w ver\n")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, ["show ver"])
|
||||
|
||||
def test_empty_line_skipped(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("", "\r\n")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, [])
|
||||
|
||||
def test_ctrl_c_clears(self) -> None:
|
||||
buf, lines = feed_command_line_buffer("half", "\x03show\n")
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(lines, ["show"])
|
||||
|
||||
def test_truncates_long_line(self) -> None:
|
||||
long = "x" * 600
|
||||
buf, lines = feed_command_line_buffer("", long + "\n", max_line=512)
|
||||
self.assertEqual(buf, "")
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertEqual(len(lines[0]), 512)
|
||||
|
||||
|
||||
class PasswordPromptTests(unittest.TestCase):
|
||||
def test_detects_password_prompt(self) -> None:
|
||||
self.assertTrue(looks_like_password_prompt("Password:"))
|
||||
self.assertTrue(looks_like_password_prompt("Please enter password:"))
|
||||
self.assertTrue(looks_like_password_prompt("请输入密码:"))
|
||||
self.assertFalse(looks_like_password_prompt("<SW>"))
|
||||
self.assertFalse(looks_like_password_prompt("Username:"))
|
||||
|
||||
|
||||
class AuditDualWriteTests(unittest.TestCase):
|
||||
def test_db_events_set(self) -> None:
|
||||
self.assertIn("session_created", _DB_AUDIT_EVENTS)
|
||||
self.assertIn("command", _DB_AUDIT_EVENTS)
|
||||
self.assertNotIn("session_attached", _DB_AUDIT_EVENTS)
|
||||
|
||||
@patch("netx_api.webcrt_channel.webcrt_data_root")
|
||||
@patch("netx_api.audit_async.enqueue_audit")
|
||||
def test_lifecycle_enqueues_audit_log(self, mock_enq: MagicMock, mock_root: MagicMock) -> None:
|
||||
root = MagicMock()
|
||||
path = MagicMock()
|
||||
mock_root.return_value = root
|
||||
root.__truediv__ = MagicMock(return_value=path)
|
||||
path.open = MagicMock()
|
||||
fh = MagicMock()
|
||||
path.open.return_value.__enter__ = MagicMock(return_value=fh)
|
||||
path.open.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
_audit(
|
||||
"session_created",
|
||||
session_id="sid1",
|
||||
ne_id="ne1",
|
||||
ne_name="core-sw",
|
||||
ne_ip="10.0.0.1",
|
||||
protocol="ssh",
|
||||
owner_user_id="u1",
|
||||
owner_username="alice",
|
||||
)
|
||||
mock_enq.assert_called_once()
|
||||
kwargs = mock_enq.call_args.kwargs
|
||||
self.assertEqual(kwargs["action"], "webcrt.session_created")
|
||||
self.assertEqual(kwargs["actor_username"], "alice")
|
||||
self.assertEqual(kwargs["actor_user_id"], "u1")
|
||||
self.assertEqual(kwargs["detail"]["ne_name"], "core-sw")
|
||||
self.assertEqual(kwargs["detail"]["ne_ip"], "10.0.0.1")
|
||||
|
||||
@patch("netx_api.webcrt_channel.webcrt_data_root")
|
||||
@patch("netx_api.audit_async.enqueue_audit")
|
||||
def test_attach_does_not_enqueue(self, mock_enq: MagicMock, mock_root: MagicMock) -> None:
|
||||
root = MagicMock()
|
||||
path = MagicMock()
|
||||
mock_root.return_value = root
|
||||
root.__truediv__ = MagicMock(return_value=path)
|
||||
path.open = MagicMock()
|
||||
fh = MagicMock()
|
||||
path.open.return_value.__enter__ = MagicMock(return_value=fh)
|
||||
path.open.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
_audit("session_attached", session_id="sid1", ne_id="ne1")
|
||||
mock_enq.assert_not_called()
|
||||
|
||||
|
||||
class SessionCommandAuditTests(unittest.TestCase):
|
||||
@patch("netx_api.webcrt_session_model._audit")
|
||||
def test_write_stdin_audits_completed_command(self, mock_audit: MagicMock) -> None:
|
||||
conn = MagicMock()
|
||||
conn.RETURN = "\n"
|
||||
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||
# Force write_channel path (no send).
|
||||
del conn.remote_conn.send
|
||||
conn.write_channel = MagicMock()
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id="s-cmd",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
cli_keymap=False,
|
||||
owner_user_id="u1",
|
||||
owner_username="bob",
|
||||
conn=conn,
|
||||
)
|
||||
sess.write_stdin("display version\r")
|
||||
mock_audit.assert_called()
|
||||
event = mock_audit.call_args[0][0]
|
||||
self.assertEqual(event, "command")
|
||||
kwargs = mock_audit.call_args.kwargs
|
||||
self.assertEqual(kwargs["command"], "display version")
|
||||
self.assertEqual(kwargs["owner_username"], "bob")
|
||||
self.assertEqual(kwargs["ne_name"], "lab")
|
||||
self.assertFalse(kwargs["redacted"])
|
||||
|
||||
@patch("netx_api.webcrt_session_model._audit")
|
||||
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
|
||||
conn = MagicMock()
|
||||
conn.RETURN = "\n"
|
||||
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||
del conn.remote_conn.send
|
||||
conn.write_channel = MagicMock()
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id="s-pw",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
cli_keymap=False,
|
||||
conn=conn,
|
||||
)
|
||||
sess._note_stdout_for_audit("Password:")
|
||||
self.assertTrue(sess._password_mode)
|
||||
sess.write_stdin("secret-pass\r")
|
||||
kwargs = mock_audit.call_args.kwargs
|
||||
self.assertEqual(kwargs["command"], "***")
|
||||
self.assertTrue(kwargs["redacted"])
|
||||
self.assertFalse(sess._password_mode)
|
||||
|
||||
@patch("netx_api.webcrt_session_model._audit")
|
||||
def test_post_login_source(self, mock_audit: MagicMock) -> None:
|
||||
conn = MagicMock()
|
||||
conn.RETURN = "\n"
|
||||
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||
del conn.remote_conn.send
|
||||
conn.write_channel = MagicMock()
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id="s-pl",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
cli_keymap=False,
|
||||
conn=conn,
|
||||
post_login_commands=["screen-length 0 temporary"],
|
||||
)
|
||||
with patch("netx_api.webcrt_session_model.time.sleep"):
|
||||
sess.run_post_login_commands()
|
||||
kwargs = mock_audit.call_args.kwargs
|
||||
self.assertEqual(kwargs["source"], "post_login")
|
||||
self.assertEqual(kwargs["command"], "screen-length 0 temporary")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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