diff --git a/backend/alembic/versions/20260709_01_add_audit_access_context.py b/backend/alembic/versions/20260709_01_add_audit_access_context.py new file mode 100644 index 00000000..5075334e --- /dev/null +++ b/backend/alembic/versions/20260709_01_add_audit_access_context.py @@ -0,0 +1,46 @@ +"""add access context to audit logs + +Revision ID: 20260709_01 +Revises: 20260630_02 +Create Date: 2026-07-09 10:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "20260709_01" +down_revision: Union[str, None] = "20260630_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True)) + op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True)) + op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True)) + op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True)) + op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True)) + op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True)) + op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True)) + op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"]) + op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"]) + op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs") + op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs") + op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs") + for column in ( + "build_commit", + "build_channel", + "client_platform", + "client_version", + "client_type", + "user_agent", + "client_ip", + ): + op.drop_column("audit_logs", column) diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index dc6526ae..252928f8 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -1,13 +1,18 @@ import json import uuid +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission from app.crud import audit as audit_crud from app.crud import study as study_crud +from app.models.user import User from app.schemas.audit import AuditEventCreate, AuditLogRead +from app.services.ip_location import resolve_ip_location router = APIRouter() @@ -40,6 +45,10 @@ async def list_audit_logs( entity_id: uuid.UUID | None = None, action: str | None = None, operator_id: uuid.UUID | None = None, + client_ip: str | None = None, + client_type: str | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db_session), @@ -51,10 +60,51 @@ async def list_audit_logs( entity_id=entity_id, action=action, operator_id=operator_id, + client_ip=client_ip, + client_type=client_type, + start_time=start_time, + end_time=end_time, skip=skip, limit=limit, ) - return list(logs) + operator_ids = {log.operator_id for log in logs} + operator_map: dict[uuid.UUID, User] = {} + if operator_ids: + result = await db.execute(select(User).where(User.id.in_(operator_ids))) + operator_map = {user.id: user for user in result.scalars().all()} + + items: list[AuditLogRead] = [] + for log in logs: + ip_location = resolve_ip_location(log.client_ip) + operator = operator_map.get(log.operator_id) + items.append( + AuditLogRead( + id=log.id, + study_id=log.study_id, + entity_type=log.entity_type, + entity_id=log.entity_id, + action=log.action, + detail=log.detail, + operator_id=log.operator_id, + operator_name=operator.full_name if operator else None, + operator_email=operator.email if operator else None, + operator_role=log.operator_role, + client_ip=log.client_ip, + ip_location=ip_location.location, + ip_country=ip_location.country, + ip_province=ip_location.province, + ip_city=ip_location.city, + ip_isp=ip_location.isp, + user_agent=log.user_agent, + client_type=log.client_type, + client_version=log.client_version, + client_platform=log.client_platform, + build_channel=log.build_channel, + build_commit=log.build_commit, + created_at=log.created_at, + ) + ) + return items @router.post( diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py new file mode 100644 index 00000000..1d38a908 --- /dev/null +++ b/backend/app/core/request_context.py @@ -0,0 +1,73 @@ +"""Request-scoped metadata used by server-side audit writers.""" + +from __future__ import annotations + +import re +from contextvars import ContextVar, Token +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class RequestAuditContext: + client_ip: str | None = None + user_agent: str | None = None + client_type: str | None = None + client_version: str | None = None + client_platform: str | None = None + build_channel: str | None = None + build_commit: str | None = None + + +_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar( + "request_audit_context", + default=None, +) + +_SENSITIVE_TEXT_PATTERN = re.compile( + r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)" +) + + +def _clean_header_value(value: str | None, max_length: int) -> str | None: + if value is None: + return None + cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip()) + if not cleaned: + return None + return cleaned[:max_length] + + +def resolve_client_ip(request: Any) -> str | None: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return _clean_header_value(forwarded.split(",")[0].strip(), 45) + real_ip = request.headers.get("x-real-ip") + if real_ip: + return _clean_header_value(real_ip.strip(), 45) + return _clean_header_value(request.client.host if request.client else None, 45) + + +def build_request_audit_context(request: Any) -> RequestAuditContext: + headers = request.headers + return RequestAuditContext( + client_ip=resolve_client_ip(request), + user_agent=_clean_header_value(headers.get("user-agent"), 500), + client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16), + client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32), + client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16), + build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16), + build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64), + ) + + +def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]: + return _request_audit_context.set(context) + + +def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None: + _request_audit_context.reset(token) + + +def get_request_audit_context() -> RequestAuditContext | None: + return _request_audit_context.get() diff --git a/backend/app/crud/audit.py b/backend/app/crud/audit.py index 4581098e..cf0dbfdc 100644 --- a/backend/app/crud/audit.py +++ b/backend/app/crud/audit.py @@ -4,8 +4,10 @@ import uuid from typing import Sequence from sqlalchemy import select +from sqlalchemy import or_ from sqlalchemy.ext.asyncio import AsyncSession +from app.core.request_context import get_request_audit_context from app.models.audit_log import AuditLog @@ -19,8 +21,16 @@ async def log_action( detail: str | None, operator_id: uuid.UUID, operator_role: str, + client_ip: str | None = None, + user_agent: str | None = None, + client_type: str | None = None, + client_version: str | None = None, + client_platform: str | None = None, + build_channel: str | None = None, + build_commit: str | None = None, auto_commit: bool = True, ) -> AuditLog: + request_context = get_request_audit_context() log = AuditLog( study_id=study_id, entity_type=entity_type, @@ -29,6 +39,13 @@ async def log_action( detail=detail, operator_id=operator_id, operator_role=operator_role, + client_ip=client_ip if client_ip is not None else (request_context.client_ip if request_context else None), + user_agent=user_agent if user_agent is not None else (request_context.user_agent if request_context else None), + client_type=client_type if client_type is not None else (request_context.client_type if request_context else None), + client_version=client_version if client_version is not None else (request_context.client_version if request_context else None), + client_platform=client_platform if client_platform is not None else (request_context.client_platform if request_context else None), + build_channel=build_channel if build_channel is not None else (request_context.build_channel if request_context else None), + build_commit=build_commit if build_commit is not None else (request_context.build_commit if request_context else None), ) db.add(log) if auto_commit: @@ -47,6 +64,10 @@ async def list_logs( entity_id: uuid.UUID | None = None, action: str | None = None, operator_id: uuid.UUID | None = None, + client_ip: str | None = None, + client_type: str | None = None, + start_time=None, + end_time=None, skip: int = 0, limit: int = 100, ) -> Sequence[AuditLog]: @@ -59,6 +80,24 @@ async def list_logs( stmt = stmt.where(AuditLog.action == action) if operator_id: stmt = stmt.where(AuditLog.operator_id == operator_id) + if client_ip: + stmt = stmt.where(AuditLog.client_ip.ilike(f"%{client_ip.strip()}%")) + if client_type: + normalized_client_type = client_type.strip().lower() + if normalized_client_type == "unknown": + stmt = stmt.where( + or_( + AuditLog.client_type.is_(None), + AuditLog.client_type == "", + ~AuditLog.client_type.in_(("web", "desktop")), + ) + ) + else: + stmt = stmt.where(AuditLog.client_type == normalized_client_type) + if start_time: + stmt = stmt.where(AuditLog.created_at >= start_time) + if end_time: + stmt = stmt.where(AuditLog.created_at <= end_time) stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/main.py b/backend/app/main.py index 9ef74f6a..39dbcc67 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,12 @@ from app.services.security_access_log_writer import ( stop_security_log_writer, ) from app.core.security import decode_token +from app.core.request_context import ( + build_request_audit_context, + reset_request_audit_context, + resolve_client_ip, + set_request_audit_context, +) logger = logging.getLogger("ctms.setup_config") UUID_RE = re.compile( @@ -137,60 +143,65 @@ def create_app() -> FastAPI: is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path should_security_log = path.startswith("/api/") started_at = time.perf_counter() + audit_context = build_request_audit_context(request) + audit_context_token = set_request_audit_context(audit_context) status_code = 500 try: - response = await call_next(request) - except Exception: + try: + response = await call_next(request) + except Exception: + if is_setup_config_path: + normalized_path = UUID_RE.sub("{study_id}", path) + duration_ms = int((time.perf_counter() - started_at) * 1000) + key = f"{request.method} {normalized_path} 5xx" + setup_config_stats[key] += 1 + logger.exception( + "setup_config_request_error method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + 500, + duration_ms, + ) + if should_security_log: + _enqueue_security_access_log(request, path, status_code, started_at) + raise + + status_code = int(response.status_code) if is_setup_config_path: normalized_path = UUID_RE.sub("{study_id}", path) duration_ms = int((time.perf_counter() - started_at) * 1000) - key = f"{request.method} {normalized_path} 5xx" + status_bucket = f"{status_code // 100}xx" + key = f"{request.method} {normalized_path} {status_bucket}" setup_config_stats[key] += 1 - logger.exception( - "setup_config_request_error method=%s path=%s status=%s duration_ms=%s", - request.method, - normalized_path, - 500, - duration_ms, - ) + if status_code >= 500: + logger.error( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) + elif status_code >= 400: + logger.warning( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) + else: + logger.info( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) if should_security_log: _enqueue_security_access_log(request, path, status_code, started_at) - raise - - status_code = int(response.status_code) - if is_setup_config_path: - normalized_path = UUID_RE.sub("{study_id}", path) - duration_ms = int((time.perf_counter() - started_at) * 1000) - status_bucket = f"{status_code // 100}xx" - key = f"{request.method} {normalized_path} {status_bucket}" - setup_config_stats[key] += 1 - if status_code >= 500: - logger.error( - "setup_config_request method=%s path=%s status=%s duration_ms=%s", - request.method, - normalized_path, - status_code, - duration_ms, - ) - elif status_code >= 400: - logger.warning( - "setup_config_request method=%s path=%s status=%s duration_ms=%s", - request.method, - normalized_path, - status_code, - duration_ms, - ) - else: - logger.info( - "setup_config_request method=%s path=%s status=%s duration_ms=%s", - request.method, - normalized_path, - status_code, - duration_ms, - ) - if should_security_log: - _enqueue_security_access_log(request, path, status_code, started_at) - return response + return response + finally: + reset_request_audit_context(audit_context_token) register_exception_handlers(app) @@ -240,16 +251,6 @@ def create_app() -> FastAPI: app = create_app() -def _resolve_client_ip(request) -> str | None: - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - real_ip = request.headers.get("x-real-ip") - if real_ip: - return real_ip.strip() - return request.client.host if request.client else None - - def _resolve_auth_context(request) -> tuple[str, str | None]: authorization = request.headers.get("authorization") or "" if not authorization.lower().startswith("bearer "): @@ -276,7 +277,7 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a "path": path, "status_code": status_code, "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), - "client_ip": _resolve_client_ip(request), + "client_ip": resolve_client_ip(request), "user_agent": request.headers.get("user-agent"), "client_type": request.headers.get("x-ctms-client-type"), "client_version": request.headers.get("x-ctms-client-version"), diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index cc9a7c00..3c3c6678 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -13,7 +13,12 @@ from app.db.base_class import Base class AuditLog(Base): __tablename__ = "audit_logs" - __table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),) + __table_args__ = ( + Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"), + Index("ix_audit_logs_operator_created", "operator_id", "created_at"), + Index("ix_audit_logs_client_ip_created", "client_ip", "created_at"), + Index("ix_audit_logs_client_source_created", "client_type", "created_at"), + ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True) @@ -23,4 +28,11 @@ class AuditLog(Base): detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) operator_role: Mapped[str] = mapped_column(String(50), nullable=False) + client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py index 563317ef..4aefbc77 100644 --- a/backend/app/schemas/audit.py +++ b/backend/app/schemas/audit.py @@ -7,10 +7,27 @@ from pydantic import BaseModel, ConfigDict class AuditLogRead(BaseModel): id: uuid.UUID + study_id: Optional[uuid.UUID] = None + entity_type: str + entity_id: Optional[uuid.UUID] = None action: str detail: Optional[str] operator_id: uuid.UUID + operator_name: Optional[str] = None + operator_email: Optional[str] = None operator_role: str + client_ip: Optional[str] = None + ip_location: str = "" + ip_country: str = "" + ip_province: str = "" + ip_city: str = "" + ip_isp: str = "" + user_agent: Optional[str] = None + client_type: Optional[str] = None + client_version: Optional[str] = None + client_platform: Optional[str] = None + build_channel: Optional[str] = None + build_commit: Optional[str] = None created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/test_audit_access_context.py b/backend/tests/test_audit_access_context.py new file mode 100644 index 00000000..98a3a751 --- /dev/null +++ b/backend/tests/test_audit_access_context.py @@ -0,0 +1,62 @@ +import uuid + +import pytest + +from app.core.request_context import ( + RequestAuditContext, + reset_request_audit_context, + set_request_audit_context, +) +from app.crud.audit import log_action + + +class FakeAsyncSession: + def __init__(self) -> None: + self.added = None + self.flushed = False + + def add(self, value) -> None: + self.added = value + + async def flush(self) -> None: + self.flushed = True + + +@pytest.mark.asyncio +async def test_audit_log_action_captures_request_access_context(): + context_token = set_request_audit_context( + RequestAuditContext( + client_ip="203.0.113.10", + user_agent="curl/8.1.2", + client_type="desktop", + client_version="1.2.3", + client_platform="macos", + build_channel="release", + build_commit="abcdef1", + ) + ) + db = FakeAsyncSession() + try: + log = await log_action( + db, + study_id=uuid.uuid4(), + entity_type="subject", + entity_id=uuid.uuid4(), + action="UPDATE_SUBJECT", + detail=None, + operator_id=uuid.uuid4(), + operator_role="PM", + auto_commit=False, + ) + finally: + reset_request_audit_context(context_token) + + assert db.added is log + assert db.flushed is True + assert log.client_ip == "203.0.113.10" + assert log.user_agent == "curl/8.1.2" + assert log.client_type == "desktop" + assert log.client_version == "1.2.3" + assert log.client_platform == "macos" + assert log.build_channel == "release" + assert log.build_commit == "abcdef1" diff --git a/database/init.sql b/database/init.sql index e8ba0149..addbde88 100644 --- a/database/init.sql +++ b/database/init.sql @@ -455,6 +455,13 @@ CREATE TABLE IF NOT EXISTS public.audit_logs ( detail text, operator_id uuid NOT NULL, operator_role character varying(50) NOT NULL, + client_ip character varying(45), + user_agent character varying(500), + client_type character varying(16), + client_version character varying(32), + client_platform character varying(16), + build_channel character varying(16), + build_commit character varying(64), created_at timestamp with time zone NOT NULL DEFAULT now(), CONSTRAINT fk_audit_logs_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, CONSTRAINT fk_audit_logs_operator_id FOREIGN KEY (operator_id) REFERENCES public.users(id) ON DELETE RESTRICT @@ -950,6 +957,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_pending ON public.documen CREATE INDEX IF NOT EXISTS ix_distributions_version_target ON public.distributions (version_id, target_type, target_id); CREATE INDEX IF NOT EXISTS ix_acknowledgements_distribution_type ON public.acknowledgements (distribution_id, ack_type); CREATE INDEX IF NOT EXISTS ix_audit_logs_entity_at ON public.audit_logs (entity_type, entity_id, created_at); +CREATE INDEX IF NOT EXISTS ix_audit_logs_operator_created ON public.audit_logs (operator_id, created_at); +CREATE INDEX IF NOT EXISTS ix_audit_logs_client_ip_created ON public.audit_logs (client_ip, created_at); +CREATE INDEX IF NOT EXISTS ix_audit_logs_client_source_created ON public.audit_logs (client_type, created_at); DO $$ BEGIN diff --git a/frontend/src/api/auditLogs.ts b/frontend/src/api/auditLogs.ts index 411c04b2..d9646a3a 100644 --- a/frontend/src/api/auditLogs.ts +++ b/frontend/src/api/auditLogs.ts @@ -1,8 +1,8 @@ import { apiGet, apiPost } from "./axios"; -import type { ApiListResponse } from "../types/api"; +import type { ApiListResponse, AuditLogItem } from "../types/api"; export const fetchAuditLogs = (studyId: string, params?: Record) => - apiGet>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); + apiGet>(`/api/v1/studies/${studyId}/audit-logs/`, { params }); export const createAuditEvent = (studyId: string, payload: Record) => apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload); diff --git a/frontend/src/audit/auditNormalize.test.ts b/frontend/src/audit/auditNormalize.test.ts index 0bf889a0..eabf85c6 100644 --- a/frontend/src/audit/auditNormalize.test.ts +++ b/frontend/src/audit/auditNormalize.test.ts @@ -1,7 +1,49 @@ import { describe, expect, it } from "vitest"; -import { normalizeAuditEvent } from "."; +import { normalizeAuditEvent, resolveAuditClientSourceLabel } from "."; describe("normalizeAuditEvent", () => { + it("normalizes request source and IP context for access audit troubleshooting", () => { + const event = normalizeAuditEvent( + { + action: "UPDATE_SUBJECT", + detail: JSON.stringify({ targetName: "S001" }), + operator_id: "operator-1", + operator_name: "张三", + operator_email: "pm@example.com", + operator_role: "PM", + entity_type: "subject", + entity_id: "307e9a37-530c-4894-8d19-f22222222222", + client_ip: "203.0.113.10", + ip_country: "中国", + ip_province: "重庆", + ip_city: "渝中", + ip_isp: "电信", + client_type: "desktop", + client_version: "1.2.3", + client_platform: "macos", + build_channel: "release", + build_commit: "abcdef1", + user_agent: "CTMS Desktop", + created_at: "2026-07-09T10:00:00Z", + }, + {} + ); + + expect(event.actorName).toBe("张三"); + expect(event.actorAccount).toBe("pm@example.com"); + expect(event.clientIp).toBe("203.0.113.10"); + expect(event.ipLocation).toBe("重庆 / 渝中 / 电信"); + expect(event.clientSourceLabel).toBe("桌面端 / macos"); + expect(event.clientType).toBe("desktop"); + expect(event.clientVersion).toBe("1.2.3"); + expect(event.buildCommit).toBe("abcdef1"); + }); + + it("labels unmarked automation clients as script or command-line sources", () => { + expect(resolveAuditClientSourceLabel({ user_agent: "curl/8.1.2" })).toBe("脚本/命令行"); + expect(resolveAuditClientSourceLabel({ user_agent: "" })).toBe("未知来源"); + }); + it("removes UUID tokens from legacy audit detail text", () => { const event = normalizeAuditEvent( { diff --git a/frontend/src/audit/export/auditExportColumns.ts b/frontend/src/audit/export/auditExportColumns.ts index 27d0f4e6..601ed36f 100644 --- a/frontend/src/audit/export/auditExportColumns.ts +++ b/frontend/src/audit/export/auditExportColumns.ts @@ -19,5 +19,9 @@ export const auditExportColumns: AuditExportColumn[] = [ { key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel }, { key: "reason", label: TEXT.audit.exportColumns.reason }, { key: "ip", label: TEXT.audit.exportColumns.ip }, + { key: "ipLocation", label: TEXT.audit.exportColumns.ipLocation }, + { key: "clientSource", label: TEXT.audit.exportColumns.clientSource }, + { key: "clientType", label: TEXT.audit.exportColumns.clientType }, + { key: "userAgent", label: TEXT.audit.exportColumns.userAgent }, { key: "remark", label: TEXT.audit.exportColumns.remark }, ]; diff --git a/frontend/src/audit/export/auditExportFormatter.ts b/frontend/src/audit/export/auditExportFormatter.ts index 50de3ca9..d0788bde 100644 --- a/frontend/src/audit/export/auditExportFormatter.ts +++ b/frontend/src/audit/export/auditExportFormatter.ts @@ -43,7 +43,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => { afterStatus, resultLabel: event.resultLabel || "", reason: event.reason || "", - ip: "", + ip: event.clientIp || "", + ipLocation: event.ipLocation || "", + clientSource: event.clientSourceLabel || "", + clientType: [event.clientType, event.clientVersion, event.clientPlatform].filter(Boolean).join("/") || "", + userAgent: event.userAgent || "", remark: event.diffText?.join(";") || "", }; }; diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index a7755c8f..12a41e2d 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -7,6 +7,7 @@ export interface AuditEvent { eventLabel: string; actorId: string; actorName: string; + actorAccount?: string; actorRole: string; actorRoleLabel: string; targetType: string; @@ -21,6 +22,19 @@ export interface AuditEvent { result: "SUCCESS" | "FAIL"; resultLabel: string; timestamp: string; + clientIp?: string; + ipLocation?: string; + ipCountry?: string; + ipProvince?: string; + ipCity?: string; + ipIsp?: string; + clientType?: string; + clientVersion?: string; + clientPlatform?: string; + buildChannel?: string; + buildCommit?: string; + userAgent?: string; + clientSourceLabel?: string; } const entityTypeLabelMap: Record = { @@ -619,6 +633,57 @@ const buildDiffText = ( return lines; }; +const fallbackIpLocation = (ip: string | null | undefined): string => { + if (!ip) return ""; + if (ip === "127.0.0.1" || ip === "::1") return "本机访问"; + if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址"; + return ""; +}; + +export const formatAuditIpLocation = (raw: { + ip_country?: string | null; + ip_province?: string | null; + ip_city?: string | null; + ip_isp?: string | null; + ip_location?: string | null; + client_ip?: string | null; + clientIp?: string | null; +}): string => { + const parts = [ + raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "", + raw.ip_province, + raw.ip_city, + raw.ip_isp, + ].filter(Boolean); + if (parts.length) return parts.join(" / "); + return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || ""; +}; + +const isAutomationUserAgent = (userAgent = ""): boolean => + /curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent); + +const isBrowserUserAgent = (userAgent = ""): boolean => + /mozilla|chrome|safari|firefox|edg\//i.test(userAgent); + +export const resolveAuditClientSourceLabel = (raw: { + client_type?: string | null; + client_platform?: string | null; + user_agent?: string | null; + clientType?: string | null; + clientPlatform?: string | null; + userAgent?: string | null; +}): string => { + const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase(); + const platform = String(raw.client_platform || raw.clientPlatform || "").trim(); + const userAgent = String(raw.user_agent || raw.userAgent || "").trim(); + if (clientType === "web") return "网页端"; + if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端"; + if (clientType) return `未知客户端 / ${clientType}`; + if (isAutomationUserAgent(userAgent)) return "脚本/命令行"; + if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)"; + return "未知来源"; +}; + export const normalizeAuditEvent = (raw: any, userMap: Record): AuditEvent => { const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type); let detailObj: any = {}; @@ -668,7 +733,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): eventType: raw.action, eventLabel: dict.label, actorId: raw.operator_id, - actorName: userMap[raw.operator_id] || raw.operator_id, + actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id, + actorAccount: raw.operator_email || raw.operator_id, actorRole: raw.operator_role, actorRoleLabel: getDictLabel(roleDict, raw.operator_role), targetType: raw.entity_type || "", @@ -683,6 +749,19 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): result: detailObj.result || "SUCCESS", resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess, timestamp: raw.created_at, + clientIp: raw.client_ip || "", + ipLocation: formatAuditIpLocation(raw), + ipCountry: raw.ip_country || "", + ipProvince: raw.ip_province || "", + ipCity: raw.ip_city || "", + ipIsp: raw.ip_isp || "", + clientType: raw.client_type || "", + clientVersion: raw.client_version || "", + clientPlatform: raw.client_platform || "", + buildChannel: raw.build_channel || "", + buildCommit: raw.build_commit || "", + userAgent: raw.user_agent || "", + clientSourceLabel: resolveAuditClientSourceLabel(raw), }; }; diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index 255cfb41..3827cc1e 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -70,9 +70,11 @@ describe("desktop layout shell", () => { it("keeps web project switching routed through the workbench entry", () => { const webLayout = readWebLayoutSource(); - expect(webLayout).toContain('class="workbench-return-button"'); + expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"'); expect(webLayout).toContain('await router.push("/workbench")'); - expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value))"); + expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);"); + expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"'); + expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)"); expect(webLayout).not.toContain("hasDropdown: true"); expect(webLayout).not.toContain("type: 'study'"); expect(webLayout).not.toContain('type: "study"'); diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue index e2dc18ba..74e2d06f 100644 --- a/frontend/src/components/WebLayout.vue +++ b/frontend/src/components/WebLayout.vue @@ -59,7 +59,7 @@ - + @@ -204,7 +204,7 @@
- @@ -468,6 +468,8 @@ const headerReminderStats = ref({ const headerClockNow = ref(new Date()); let headerClockTimer: number | undefined; let desktopMenuUnlisten: (() => void) | undefined; +const isAdminContext = computed(() => route.path.startsWith("/admin")); +const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value); /* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */ const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy); @@ -489,10 +491,9 @@ const userDisplayInitial = computed(() => { return source.charAt(0).toUpperCase(); }); -const isAdminContext = computed(() => route.path.startsWith("/admin")); -const showAdminNavigation = computed(() => Boolean(auth.user) && (!study.currentStudy || isAdminContext.value)); -const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae")); -const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits")); +const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value); +const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae")); +const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits")); const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value); const toHeaderNumber = (value: unknown) => { @@ -518,7 +519,7 @@ const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value const loadHeaderOverviewStats = async () => { const studyId = study.currentStudy?.id; - if (!studyId || isAdminContext.value) { + if (!studyId || !hasProjectContext.value) { headerOverviewStats.value = null; return; } @@ -541,7 +542,7 @@ const loadHeaderOverviewStats = async () => { const loadHeaderReminders = async () => { const studyId = study.currentStudy?.id; - if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) { + if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) { headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 }; return; } @@ -602,7 +603,7 @@ const headerReminderBadgeValue = computed(() => // 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间) const headerProjectInfo = computed(() => { - if (isAdminContext.value || !study.currentStudy) return null; + if (!hasProjectContext.value || !study.currentStudy) return null; const s = study.currentStudy; const status = (s.status || "").toUpperCase(); const statusLabel = (TEXT.enums.projectStatus as Record)[status] || s.status || ""; @@ -640,7 +641,7 @@ const headerProjectInfo = computed(() => { }); const accountProjectRoleLabel = computed(() => { - if (isAdminContext.value || !study.currentStudy) return ""; + if (!hasProjectContext.value || !study.currentStudy) return ""; const roleCode = (projectRole.value || "").toUpperCase(); return roleCode ? ((TEXT.enums.userRole as Record)[roleCode] || projectRole.value) : ""; }); @@ -673,7 +674,7 @@ const desktopNavigationItems = computed(() => { } } - if (study.currentStudy && hasAnyProjectModuleAccess.value) { + if (hasProjectContext.value && hasAnyProjectModuleAccess.value) { const projectItems: DesktopNavigationItem[] = [ { label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] }, { label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] }, @@ -896,7 +897,7 @@ const breadcrumbs = computed(() => { }); return items; } - } else if (study.currentStudy) { + } else if (hasProjectContext.value && study.currentStudy) { // 项目内不提供横向切换项目;需要切换时先回工作台重新选择。 items.push({ label: (study.currentStudy.name || "").replace(/^示例项目[::]/, ''), diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 265b1d45..63c85c31 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -242,6 +242,10 @@ export const TEXT = { resultLabel: "操作结果", reason: "拒绝原因", ip: "IP 地址", + ipLocation: "IP 位置", + clientSource: "请求来源", + clientType: "客户端标识", + userAgent: "User-Agent", remark: "备注", }, eventDict: { @@ -926,6 +930,9 @@ export const TEXT = { filterEntityType: "对象类型", filterEvent: "操作类型", filterOperator: "操作人", + filterSource: "来源", + filterIpLocation: "IP / 位置", + filterKeyword: "账号、IP、位置或来源", filterResult: "结果", rangeStart: "开始", rangeEnd: "结束", @@ -941,6 +948,7 @@ export const TEXT = { columns: { time: "时间", actor: "操作人", + source: "请求来源", event: "操作类型", action: "内容", target: "对象", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index af82bd2f..fd66906d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -402,6 +402,32 @@ export interface SecurityAccessLogsResponse { items: SecurityAccessLogItem[]; } +export interface AuditLogItem { + id: string; + study_id: string | null; + entity_type: string; + entity_id: string | null; + action: string; + detail: string | null; + operator_id: string; + operator_name: string | null; + operator_email: string | null; + operator_role: string; + client_ip: string | null; + ip_location: string; + ip_country: string; + ip_province: string; + ip_city: string; + ip_isp: string; + user_agent: string | null; + client_type: string | null; + client_version: string | null; + client_platform: string | null; + build_channel: string | null; + build_commit: string | null; + created_at: string; +} + // 系统监测 - 趋势数据 export interface TrendDataPoint { bucket_time: string; diff --git a/frontend/src/views/admin/AuditLogs.test.ts b/frontend/src/views/admin/AuditLogs.test.ts index 261351ef..bc2f0b2e 100644 --- a/frontend/src/views/admin/AuditLogs.test.ts +++ b/frontend/src/views/admin/AuditLogs.test.ts @@ -67,6 +67,30 @@ describe("audit logs access", () => { expect(source).not.toContain("limit: 2000"); }); + it("surfaces request source, IP and location filters for audit troubleshooting", () => { + const source = readAuditLogsView(); + + expect(source).toContain("filters.clientType"); + expect(source).toContain("filters.ipKeyword"); + expect(source).toContain("TEXT.modules.adminAuditLogs.filterSource"); + expect(source).toContain("TEXT.modules.adminAuditLogs.filterIpLocation"); + expect(source).toContain("TEXT.modules.adminAuditLogs.columns.source"); + expect(source).toContain("formatClientIpLine(scope.row)"); + expect(source).toContain("client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined"); + }); + + it("includes request source context in audit exports", () => { + const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8"); + const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8"); + + expect(columns).toContain('key: "ipLocation"'); + expect(columns).toContain('key: "clientSource"'); + expect(columns).toContain('key: "userAgent"'); + expect(formatter).toContain("ip: event.clientIp || \"\""); + expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\""); + expect(formatter).toContain("userAgent: event.userAgent || \"\""); + }); + it("keeps the audit table compact by removing the duplicate content column", () => { const source = readAuditLogsView(); diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 4e46c7b8..8f15373a 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -35,8 +35,8 @@
- {{ operatorCount }} - 操作人数 + {{ uniqueIpCount }} + 来源 IP
@@ -59,6 +59,22 @@ +
+ + + +
+
+ +
@@ -105,6 +121,16 @@ + + + @@ -211,6 +237,34 @@ {{ selectedLog.actionText || TEXT.common.fallback }}
+
+
+ 请求来源 + {{ selectedLog.clientSourceLabel || TEXT.common.fallback }} +
+
+ 来源 IP + {{ selectedLog.clientIp || "未知 IP" }} +
+
+ IP 位置 + {{ selectedLog.ipLocation || TEXT.common.fallback }} +
+
+ 客户端 + {{ formatClientMeta(selectedLog) }} +
+
+
+
+ + 访问上下文 +
+
+
User-Agent{{ selectedLog.userAgent || TEXT.common.fallback }}
+
构建信息{{ formatBuildMeta(selectedLog) }}
+
+
@@ -265,7 +319,6 @@ import { listMembers } from "../../api/members"; import { auditDict, normalizeAuditEvent } from "../../audit"; import { useStudyStore } from "../../store/study"; import { useAuthStore } from "../../store/auth"; -import { roleDict, getDictLabel } from "../../dictionaries"; import { exportAuditCsv } from "../../audit/export/auditExportService"; import { displayDateTime } from "../../utils/display"; import { getProjectRole, isSystemAdmin } from "../../utils/roles"; @@ -296,13 +349,15 @@ const DIFF_PREVIEW_LIMIT = 2; const filters = ref({ eventType: "", operatorId: "", + clientType: "", + ipKeyword: "", result: "", range: [], }); const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length); const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length); -const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size); +const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size); const formatDiffLine = (line: any): string => { if (typeof line === 'string') return line; @@ -375,6 +430,11 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({ value, label: cfg.label, })); +const sourceTypeOptions = [ + { value: "web", label: "网页端" }, + { value: "desktop", label: "桌面端" }, + { value: "unknown", label: "未知/异常" }, +]; const resolveUserDisplayName = (u: any): string => { return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback; }; @@ -470,6 +530,34 @@ const fetchAuditLogPages = async (baseParams: Record = {}) => { return allItems; }; +const isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim()); + +const buildAuditServerParams = () => { + const ipKeyword = String(filters.value.ipKeyword || "").trim(); + return { + action: filters.value.eventType || undefined, + operator_id: filters.value.operatorId || undefined, + client_type: filters.value.clientType || undefined, + client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined, + }; +}; + +const formatClientMeta = (log: any) => { + const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/"); + if (meta) return meta; + return log.userAgent ? "未标识客户端" : TEXT.common.fallback; +}; + +const formatBuildMeta = (log: any) => { + const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/"); + return meta || TEXT.common.fallback; +}; + +const formatClientIpLine = (log: any) => { + const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean); + return parts.join(" / "); +}; + const loadLogs = async () => { if (!selectedStudyId.value) return; loading.value = true; @@ -477,10 +565,7 @@ const loadLogs = async () => { if (!permissionMatrix.value) { await loadPermissionMatrix(); } - const allItems = await fetchAuditLogPages({ - action: filters.value.eventType || undefined, - operator_id: filters.value.operatorId || undefined, - }); + const allItems = await fetchAuditLogPages(buildAuditServerParams()); enrichLogs(allItems); refreshPagedLogs(); } catch (e: any) { @@ -498,20 +583,36 @@ const enrichLogs = (items: any[]) => { allLogs.value = items .map((log) => normalizeAuditEvent(log, userMap)) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - if (users.value.length === 0) { - const byActor = new Map(); - allLogs.value.forEach((log) => { - if (!byActor.has(log.actorId)) { - byActor.set(log.actorId, log.actorName || log.actorId); - } - }); - users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name })); - } + const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)])); + allLogs.value.forEach((log) => { + if (!byActor.has(log.actorId)) { + byActor.set(log.actorId, log.actorName || log.actorAccount || log.actorId); + } + }); + users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name })); }; const filterLogs = (items: any[]) => items.filter((log) => { if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false; + const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase(); + if (ipKeyword) { + const haystack = [ + log.clientIp, + log.ipLocation, + log.ipCountry, + log.ipProvince, + log.ipCity, + log.ipIsp, + log.clientSourceLabel, + log.clientType, + log.clientPlatform, + log.userAgent, + log.actorName, + log.actorAccount, + ]; + if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) return false; + } if (filters.value.range?.length === 2) { const ts = new Date(log.timestamp); const start = new Date(filters.value.range[0]); @@ -582,24 +683,12 @@ const fetchAllForExport = async () => { if (!selectedStudyId.value) return []; exportLoading.value = true; try { - const items = await fetchAuditLogPages({ - action: filters.value.eventType || undefined, - operator_id: filters.value.operatorId || undefined, - }); + const items = await fetchAuditLogPages(buildAuditServerParams()); const userMap = users.value.reduce>((acc, cur) => { acc[cur.id] = resolveUserDisplayName(cur); return acc; }, {}); - const filtered = items.filter((log: any) => { - if (filters.value.range?.length === 2) { - const ts = new Date(log.created_at); - const start = new Date(filters.value.range[0]); - const end = new Date(filters.value.range[1]); - if (ts < start || ts > end) return false; - } - return true; - }); - return filtered.map((log: any) => normalizeAuditEvent(log, userMap)); + return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap))); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed); return []; @@ -701,7 +790,7 @@ onMounted(async () => { width: 100%; gap: 8px; align-items: center; - flex-wrap: nowrap; + flex-wrap: wrap; } .filter-item-form { @@ -712,9 +801,11 @@ onMounted(async () => { .filter-select-comp { width: 132px; } .filter-select-project { width: 180px; } +.filter-select-source { width: 112px; } +.filter-input-ip { width: 170px; } .filter-select-result { width: 96px; } .date-range-picker-comp { width: 248px !important; } -.filter-spacer { flex: 1; } +.filter-spacer { flex: 1; min-width: 16px; } .audit-export-dropdown { flex-shrink: 0; } .audit-export-btn { @@ -748,12 +839,56 @@ onMounted(async () => { } .actor-cell { + display: flex; + flex-direction: column; + gap: 2px; min-width: 0; } -.actor-name { +.actor-name, +.actor-account { display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actor-name { font-size: 13px; +} + +.actor-account { + font-size: 11px; + color: var(--ctms-text-secondary); +} + +.source-cell { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.source-main { + display: inline-flex; + align-self: flex-start; + max-width: 100%; + padding: 1px 7px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + color: #155e75; + background: #ecfeff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-meta, +.source-ip { + display: block; + font-size: 11px; + color: var(--ctms-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -923,6 +1058,35 @@ onMounted(async () => { color: var(--ctms-primary); } +.context-lines { + display: flex; + flex-direction: column; + gap: 8px; +} + +.context-lines div { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.context-lines span { + color: var(--ctms-text-secondary); + font-size: 12px; +} + +.context-lines code { + display: block; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--ctms-border-color); + background: var(--ctms-neutral-100); + color: var(--ctms-text-main); + white-space: pre-wrap; + word-break: break-all; +} + .diff-count { font-size: 11px; font-weight: 600;