From 4792f7b70a4e95767a79608d8d219f40473c6af3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 14:37:52 -0600 Subject: [PATCH] feat: MS23-P2-007 AuditLogDrawer + audit log endpoint --- ...get-mission-control-audit-log-query.dto.ts | 21 ++ .../mission-control.controller.ts | 11 +- .../mission-control.service.ts | 47 +++ .../mission-control/AuditLogDrawer.tsx | 322 ++++++++++++++++++ .../mission-control/MissionControlLayout.tsx | 18 +- apps/web/src/components/ui/sheet.tsx | 137 ++++++++ 6 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 apps/orchestrator/src/api/mission-control/dto/get-mission-control-audit-log-query.dto.ts create mode 100644 apps/web/src/components/mission-control/AuditLogDrawer.tsx create mode 100644 apps/web/src/components/ui/sheet.tsx diff --git a/apps/orchestrator/src/api/mission-control/dto/get-mission-control-audit-log-query.dto.ts b/apps/orchestrator/src/api/mission-control/dto/get-mission-control-audit-log-query.dto.ts new file mode 100644 index 0000000..f7a4d97 --- /dev/null +++ b/apps/orchestrator/src/api/mission-control/dto/get-mission-control-audit-log-query.dto.ts @@ -0,0 +1,21 @@ +import { Type } from "class-transformer"; +import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +export class GetMissionControlAuditLogQueryDto { + @IsOptional() + @IsString() + sessionId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit = 50; +} diff --git a/apps/orchestrator/src/api/mission-control/mission-control.controller.ts b/apps/orchestrator/src/api/mission-control/mission-control.controller.ts index f7f3798..5504200 100644 --- a/apps/orchestrator/src/api/mission-control/mission-control.controller.ts +++ b/apps/orchestrator/src/api/mission-control/mission-control.controller.ts @@ -18,9 +18,10 @@ import type { AgentMessage, AgentSession, InjectResult } from "@mosaic/shared"; import { Observable } from "rxjs"; import { AuthGuard } from "../../auth/guards/auth.guard"; import { InjectAgentDto } from "../agents/dto/inject-agent.dto"; +import { GetMissionControlAuditLogQueryDto } from "./dto/get-mission-control-audit-log-query.dto"; import { GetMissionControlMessagesQueryDto } from "./dto/get-mission-control-messages-query.dto"; import { KillSessionDto } from "./dto/kill-session.dto"; -import { MissionControlService } from "./mission-control.service"; +import { MissionControlService, type MissionControlAuditLogPage } from "./mission-control.service"; const DEFAULT_OPERATOR_ID = "mission-control"; @@ -61,6 +62,14 @@ export class MissionControlController { return { messages }; } + @Get("audit-log") + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + getAuditLog( + @Query() query: GetMissionControlAuditLogQueryDto + ): Promise { + return this.missionControlService.getAuditLog(query.sessionId, query.page, query.limit); + } + @Post("sessions/:sessionId/inject") @HttpCode(200) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) diff --git a/apps/orchestrator/src/api/mission-control/mission-control.service.ts b/apps/orchestrator/src/api/mission-control/mission-control.service.ts index 2e8060f..219e415 100644 --- a/apps/orchestrator/src/api/mission-control/mission-control.service.ts +++ b/apps/orchestrator/src/api/mission-control/mission-control.service.ts @@ -8,6 +8,24 @@ type MissionControlAction = "inject" | "pause" | "resume" | "kill"; const DEFAULT_OPERATOR_ID = "mission-control"; +export interface AuditLogEntry { + id: string; + userId: string; + sessionId: string; + provider: string; + action: string; + content: string | null; + metadata: Prisma.JsonValue; + createdAt: Date; +} + +export interface MissionControlAuditLogPage { + items: AuditLogEntry[]; + total: number; + page: number; + pages: number; +} + @Injectable() export class MissionControlService { constructor( @@ -33,6 +51,35 @@ export class MissionControlService { return provider.getMessages(sessionId, limit, before); } + async getAuditLog( + sessionId: string | undefined, + page: number, + limit: number + ): Promise { + const normalizedSessionId = sessionId?.trim(); + const where: Prisma.OperatorAuditLogWhereInput = + normalizedSessionId && normalizedSessionId.length > 0 + ? { sessionId: normalizedSessionId } + : {}; + + const [total, items] = await this.prisma.$transaction([ + this.prisma.operatorAuditLog.count({ where }), + this.prisma.operatorAuditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + }), + ]); + + return { + items, + total, + page, + pages: total === 0 ? 0 : Math.ceil(total / limit), + }; + } + async injectMessage( sessionId: string, message: string, diff --git a/apps/web/src/components/mission-control/AuditLogDrawer.tsx b/apps/web/src/components/mission-control/AuditLogDrawer.tsx new file mode 100644 index 0000000..4477b91 --- /dev/null +++ b/apps/web/src/components/mission-control/AuditLogDrawer.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { isValidElement, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { format } from "date-fns"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import type { BadgeVariant } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { apiGet } from "@/lib/api/client"; + +const AUDIT_LOG_REFRESH_INTERVAL_MS = 10_000; +const AUDIT_LOG_PAGE_SIZE = 50; +const SUMMARY_MAX_LENGTH = 120; + +interface AuditLogDrawerProps { + sessionId?: string; + trigger: ReactNode; +} + +interface AuditLogEntry { + id: string; + userId: string; + sessionId: string; + provider: string; + action: string; + content: string | null; + metadata: unknown; + createdAt: string; +} + +interface AuditLogResponse { + items: AuditLogEntry[]; + total: number; + page: number; + pages: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function truncateText(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength - 1)}…`; +} + +function truncateSessionId(sessionId: string): string { + return sessionId.slice(0, 8); +} + +function formatTimestamp(value: string): string { + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return "Unknown"; + } + + return format(parsed, "yyyy-MM-dd HH:mm:ss"); +} + +function stringifyPayloadValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return "[unserializable]"; + } +} + +function getPayloadSummary(entry: AuditLogEntry): string { + const metadata = isRecord(entry.metadata) ? entry.metadata : undefined; + const payload = metadata && isRecord(metadata.payload) ? metadata.payload : undefined; + + if (typeof entry.content === "string" && entry.content.trim().length > 0) { + return truncateText(entry.content.trim(), SUMMARY_MAX_LENGTH); + } + + if (payload) { + const summary = Object.entries(payload) + .map(([key, value]) => `${key}=${stringifyPayloadValue(value)}`) + .join(", "); + + if (summary.length > 0) { + return truncateText(summary, SUMMARY_MAX_LENGTH); + } + } + + return "—"; +} + +function getActionVariant(action: string): BadgeVariant { + switch (action) { + case "inject": + return "badge-blue"; + case "pause": + return "status-warning"; + case "resume": + return "status-success"; + case "kill": + return "status-error"; + default: + return "status-neutral"; + } +} + +async function fetchAuditLog( + sessionId: string | undefined, + page: number +): Promise { + const params = new URLSearchParams({ + page: String(page), + limit: String(AUDIT_LOG_PAGE_SIZE), + }); + + const normalizedSessionId = sessionId?.trim(); + if (normalizedSessionId && normalizedSessionId.length > 0) { + params.set("sessionId", normalizedSessionId); + } + + return apiGet(`/api/mission-control/audit-log?${params.toString()}`); +} + +export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element { + const [open, setOpen] = useState(false); + const [page, setPage] = useState(1); + + const triggerElement = useMemo( + () => + isValidElement(trigger) ? ( + trigger + ) : ( + + ), + [trigger] + ); + + const auditLogQuery = useQuery({ + queryKey: ["mission-control", "audit-log", sessionId ?? "all", page], + queryFn: async (): Promise => fetchAuditLog(sessionId, page), + enabled: open, + refetchInterval: open ? AUDIT_LOG_REFRESH_INTERVAL_MS : false, + }); + + useEffect(() => { + if (open) { + setPage(1); + } + }, [open, sessionId]); + + useEffect(() => { + const pages = auditLogQuery.data?.pages; + if (pages !== undefined && pages > 0 && page > pages) { + setPage(pages); + } + }, [auditLogQuery.data?.pages, page]); + + const totalItems = auditLogQuery.data?.total ?? 0; + const totalPages = auditLogQuery.data?.pages ?? 0; + const items = auditLogQuery.data?.items ?? []; + + const canGoPrevious = page > 1; + const canGoNext = totalPages > 0 && page < totalPages; + const errorMessage = + auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log"; + + return ( + + {triggerElement} + + +
+ Audit Log + {auditLogQuery.isFetching ? ( +
+ + {sessionId + ? `Showing actions for session ${sessionId}.` + : "Showing operator actions across all mission control sessions."} + +
+ +
+
+ + + + + + + + + + + + + {auditLogQuery.isLoading ? ( + + + + ) : auditLogQuery.error ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((entry) => { + const payloadSummary = getPayloadSummary(entry); + + return ( + + + + + + + + ); + }) + )} + +
+ Timestamp + + Action + + Session + + Operator + + Payload +
+ Loading audit log... +
+ {errorMessage} +
+ No audit entries found. +
+ {formatTimestamp(entry.createdAt)} + + + {entry.action} + + + {truncateSessionId(entry.sessionId)} + + {entry.userId} + + {payloadSummary} +
+
+
+ +
+

{totalItems} total entries

+
+ + + Page {page} of {Math.max(totalPages, 1)} + + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/mission-control/MissionControlLayout.tsx b/apps/web/src/components/mission-control/MissionControlLayout.tsx index 8e93150..046c5d6 100644 --- a/apps/web/src/components/mission-control/MissionControlLayout.tsx +++ b/apps/web/src/components/mission-control/MissionControlLayout.tsx @@ -1,8 +1,10 @@ "use client"; import { useState } from "react"; +import { AuditLogDrawer } from "@/components/mission-control/AuditLogDrawer"; import { GlobalAgentRoster } from "@/components/mission-control/GlobalAgentRoster"; import { MissionControlPanel } from "@/components/mission-control/MissionControlPanel"; +import { Button } from "@/components/ui/button"; import { useSessions } from "@/hooks/useMissionControl"; const DEFAULT_PANEL_SLOTS = ["panel-1", "panel-2", "panel-3", "panel-4"] as const; @@ -16,12 +18,22 @@ export function MissionControlLayout(): React.JSX.Element { const panelSessionIds = [firstPanelSessionId, undefined, undefined, undefined] as const; return ( -
-
+
+
+ + Audit Log + + } + /> +
+ +
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx new file mode 100644 index 0000000..3bba558 --- /dev/null +++ b/apps/web/src/components/ui/sheet.tsx @@ -0,0 +1,137 @@ +import * as React from "react"; +import { X } from "lucide-react"; + +export interface SheetProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; +} + +export interface SheetTriggerProps { + children?: React.ReactNode; + asChild?: boolean; +} + +export interface SheetContentProps { + children?: React.ReactNode; + className?: string; +} + +export interface SheetHeaderProps { + children?: React.ReactNode; + className?: string; +} + +export interface SheetTitleProps { + children?: React.ReactNode; + className?: string; +} + +export interface SheetDescriptionProps { + children?: React.ReactNode; + className?: string; +} + +const SheetContext = React.createContext<{ + open?: boolean; + onOpenChange?: (open: boolean) => void; +}>({}); + +export function Sheet({ open, onOpenChange, children }: SheetProps): React.JSX.Element { + const contextValue: { open?: boolean; onOpenChange?: (open: boolean) => void } = {}; + + if (open !== undefined) { + contextValue.open = open; + } + + if (onOpenChange !== undefined) { + contextValue.onOpenChange = onOpenChange; + } + + return {children}; +} + +export function SheetTrigger({ children, asChild }: SheetTriggerProps): React.JSX.Element { + const { onOpenChange } = React.useContext(SheetContext); + + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + onClick: () => onOpenChange?.(true), + } as React.HTMLAttributes); + } + + return ( + + ); +} + +export function SheetContent({ + children, + className = "", +}: SheetContentProps): React.JSX.Element | null { + const { open, onOpenChange } = React.useContext(SheetContext); + + React.useEffect(() => { + if (!open) { + return; + } + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + onOpenChange?.(false); + } + }; + + window.addEventListener("keydown", onKeyDown); + return (): void => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [onOpenChange, open]); + + if (!open) { + return null; + } + + return ( +
+ + + {children} +
+
+ ); +} + +export function SheetHeader({ children, className = "" }: SheetHeaderProps): React.JSX.Element { + return
{children}
; +} + +export function SheetTitle({ children, className = "" }: SheetTitleProps): React.JSX.Element { + return

{children}

; +} + +export function SheetDescription({ + children, + className = "", +}: SheetDescriptionProps): React.JSX.Element { + return

{children}

; +}