diff --git a/apps/api/src/activity/activity.service.ts b/apps/api/src/activity/activity.service.ts index ce11d50..ba0af7a 100644 --- a/apps/api/src/activity/activity.service.ts +++ b/apps/api/src/activity/activity.service.ts @@ -117,12 +117,13 @@ export class ActivityService { /** * Get a single activity log by ID */ - async findOne(id: string, workspaceId: string): Promise { + async findOne(id: string, workspaceId?: string): Promise { + const where: Prisma.ActivityLogWhereUniqueInput = { id }; + if (workspaceId) { + where.workspaceId = workspaceId; + } return await this.prisma.activityLog.findUnique({ - where: { - id, - workspaceId, - }, + where, include: { user: { select: { diff --git a/apps/api/src/activity/interceptors/activity-logging.interceptor.ts b/apps/api/src/activity/interceptors/activity-logging.interceptor.ts index 45821cb..491c8ae 100644 --- a/apps/api/src/activity/interceptors/activity-logging.interceptor.ts +++ b/apps/api/src/activity/interceptors/activity-logging.interceptor.ts @@ -4,6 +4,7 @@ import { tap } from "rxjs/operators"; import { ActivityService } from "../activity.service"; import { ActivityAction, EntityType } from "@prisma/client"; import type { Prisma } from "@prisma/client"; +import type { CreateActivityLogInput } from "../interfaces/activity.interface"; import type { AuthenticatedRequest } from "../../common/types/user.types"; /** @@ -61,10 +62,13 @@ export class ActivityLoggingInterceptor implements NestInterceptor { // Extract entity information const resultObj = result as Record | undefined; const entityId = params.id ?? (resultObj?.id as string | undefined); + + // workspaceId is now optional - log events even when missing const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined); - if (!entityId || !workspaceId) { - this.logger.warn("Cannot log activity: missing entityId or workspaceId"); + // Log with warning if entityId is missing, but still proceed with logging if workspaceId exists + if (!entityId) { + this.logger.warn("Cannot log activity: missing entityId"); return; } @@ -92,9 +96,8 @@ export class ActivityLoggingInterceptor implements NestInterceptor { const userAgent = typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0]; - // Log the activity - await this.activityService.logActivity({ - workspaceId, + // Log the activity — workspaceId is optional + const activityInput: CreateActivityLogInput = { userId: user.id, action, entityType, @@ -102,7 +105,11 @@ export class ActivityLoggingInterceptor implements NestInterceptor { details, ipAddress: ip ?? undefined, userAgent: userAgent ?? undefined, - }); + }; + if (workspaceId) { + activityInput.workspaceId = workspaceId; + } + await this.activityService.logActivity(activityInput); } catch (error) { // Don't fail the request if activity logging fails this.logger.error( diff --git a/apps/api/src/activity/interfaces/activity.interface.ts b/apps/api/src/activity/interfaces/activity.interface.ts index d0ef668..88c8b17 100644 --- a/apps/api/src/activity/interfaces/activity.interface.ts +++ b/apps/api/src/activity/interfaces/activity.interface.ts @@ -2,9 +2,10 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client"; /** * Interface for creating a new activity log entry + * workspaceId is optional - allows logging events without workspace context */ export interface CreateActivityLogInput { - workspaceId: string; + workspaceId?: string | null; userId: string; action: ActivityAction; entityType: EntityType; diff --git a/apps/web/src/app/(authenticated)/logs/page.tsx b/apps/web/src/app/(authenticated)/logs/page.tsx index ee6e9e5..c6d44bd 100644 --- a/apps/web/src/app/(authenticated)/logs/page.tsx +++ b/apps/web/src/app/(authenticated)/logs/page.tsx @@ -4,21 +4,39 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { ReactElement } from "react"; import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; -import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs"; -import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs"; +import { + fetchActivityLogs, + ActivityAction, + EntityType, + type ActivityLog, + type ActivityLogFilters, +} from "@/lib/api/activity"; import { useWorkspaceId } from "@/lib/hooks"; // ─── Constants ──────────────────────────────────────────────────────── -type StatusFilter = "all" | "running" | "completed" | "failed" | "queued"; +type ActionFilter = "all" | ActivityAction; +type EntityFilter = "all" | EntityType; type DateRange = "24h" | "7d" | "30d" | "all"; -const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "all", label: "All statuses" }, - { value: "running", label: "Running" }, - { value: "completed", label: "Completed" }, - { value: "failed", label: "Failed" }, - { value: "queued", label: "Queued" }, +const ACTION_OPTIONS: { value: ActionFilter; label: string }[] = [ + { value: "all", label: "All actions" }, + { value: ActivityAction.CREATED, label: "Created" }, + { value: ActivityAction.UPDATED, label: "Updated" }, + { value: ActivityAction.DELETED, label: "Deleted" }, + { value: ActivityAction.COMPLETED, label: "Completed" }, + { value: ActivityAction.ASSIGNED, label: "Assigned" }, +]; + +const ENTITY_OPTIONS: { value: EntityFilter; label: string }[] = [ + { value: "all", label: "All entities" }, + { value: EntityType.TASK, label: "Tasks" }, + { value: EntityType.EVENT, label: "Events" }, + { value: EntityType.PROJECT, label: "Projects" }, + { value: EntityType.WORKSPACE, label: "Workspaces" }, + { value: EntityType.USER, label: "Users" }, + { value: EntityType.DOMAIN, label: "Domains" }, + { value: EntityType.IDEA, label: "Ideas" }, ]; const DATE_RANGES: { value: DateRange; label: string }[] = [ @@ -28,37 +46,37 @@ const DATE_RANGES: { value: DateRange; label: string }[] = [ { value: "all", label: "All" }, ]; -const STATUS_FILTER_TO_ENUM: Record = { - all: undefined, - running: [RunnerJobStatus.RUNNING], - completed: [RunnerJobStatus.COMPLETED], - failed: [RunnerJobStatus.FAILED], - queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING], -}; - const POLL_INTERVAL_MS = 5_000; // ─── Helpers ────────────────────────────────────────────────────────── -function getStatusColor(status: string): string { - switch (status) { - case "RUNNING": - return "var(--ms-amber-400)"; - case "COMPLETED": - return "var(--ms-teal-400)"; - case "FAILED": - case "CANCELLED": - return "var(--danger)"; - case "QUEUED": - case "PENDING": - return "var(--ms-blue-400)"; - default: - return "var(--muted)"; - } +const ACTION_COLORS: Record = { + [ActivityAction.CREATED]: "var(--ms-teal-400)", + [ActivityAction.UPDATED]: "var(--ms-blue-400)", + [ActivityAction.DELETED]: "var(--danger)", + [ActivityAction.COMPLETED]: "var(--ms-emerald-400)", + [ActivityAction.ASSIGNED]: "var(--ms-amber-400)", +}; + +function getActionColor(action: string): string { + return ACTION_COLORS[action] ?? "var(--muted)"; } -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return "\u2014"; +const ENTITY_LABELS: Record = { + [EntityType.TASK]: "Task", + [EntityType.EVENT]: "Event", + [EntityType.PROJECT]: "Project", + [EntityType.WORKSPACE]: "Workspace", + [EntityType.USER]: "User", + [EntityType.DOMAIN]: "Domain", + [EntityType.IDEA]: "Idea", +}; + +function getEntityTypeLabel(entityType: string): string { + return ENTITY_LABELS[entityType] ?? entityType; +} + +function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr); const now = Date.now(); const diffMs = now - date.getTime(); @@ -74,29 +92,6 @@ function formatRelativeTime(dateStr: string | null): string { return date.toLocaleDateString(); } -function formatDuration(startedAt: string | null, completedAt: string | null): string { - if (!startedAt) return "\u2014"; - const start = new Date(startedAt).getTime(); - const end = completedAt ? new Date(completedAt).getTime() : Date.now(); - const ms = end - start; - if (ms < 1_000) return `${String(ms)}ms`; - const sec = Math.floor(ms / 1_000); - if (sec < 60) return `${String(sec)}s`; - const min = Math.floor(sec / 60); - const remainSec = sec % 60; - return `${String(min)}m ${String(remainSec)}s`; -} - -function formatStepDuration(durationMs: number | null): string { - if (durationMs === null) return "\u2014"; - if (durationMs < 1_000) return `${String(durationMs)}ms`; - const sec = Math.floor(durationMs / 1_000); - if (sec < 60) return `${String(sec)}s`; - const min = Math.floor(sec / 60); - const remainSec = sec % 60; - return `${String(min)}m ${String(remainSec)}s`; -} - function isWithinDateRange(dateStr: string, range: DateRange): boolean { if (range === "all") return true; const date = new Date(dateStr); @@ -105,18 +100,16 @@ function isWithinDateRange(dateStr: string, range: DateRange): boolean { return now - date.getTime() < hours * 60 * 60 * 1_000; } -// ─── Status Badge ───────────────────────────────────────────────────── +// ─── Action Badge ───────────────────────────────────────────────────── -function StatusBadge({ status }: { status: string }): ReactElement { - const color = getStatusColor(status); - const isRunning = status === "RUNNING"; +function ActionBadge({ action }: { action: string }): ReactElement { + const color = getActionColor(action); return ( - {isRunning && ( - - )} - {status.toLowerCase()} + {action.toLowerCase()} ); } @@ -149,59 +131,55 @@ export default function LogsPage(): ReactElement { const workspaceId = useWorkspaceId(); // Data state - const [jobs, setJobs] = useState([]); + const [activities, setActivities] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Expanded job and steps - const [expandedJobId, setExpandedJobId] = useState(null); - const [jobStepsMap, setJobStepsMap] = useState>({}); - const [stepsLoading, setStepsLoading] = useState>(new Set()); - // Filters - const [statusFilter, setStatusFilter] = useState("all"); + const [actionFilter, setActionFilter] = useState("all"); + const [entityFilter, setEntityFilter] = useState("all"); const [dateRange, setDateRange] = useState("7d"); const [searchQuery, setSearchQuery] = useState(""); // Auto-refresh - const [autoRefresh, setAutoRefresh] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); const intervalRef = useRef | null>(null); - // Hover state - const [hoveredRowId, setHoveredRowId] = useState(null); - // ─── Data Loading ───────────────────────────────────────────────── - const loadJobs = useCallback(async (): Promise => { + const loadActivities = useCallback(async (): Promise => { try { - const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter]; - const filters: Parameters[0] = {}; + const filters: ActivityLogFilters = {}; if (workspaceId) { filters.workspaceId = workspaceId; } - if (statusEnums) { - filters.status = statusEnums; + if (actionFilter !== "all") { + filters.action = actionFilter; + } + if (entityFilter !== "all") { + filters.entityType = entityFilter; } - const data = await fetchRunnerJobs(filters); - setJobs(data); + const response: Awaited> = + await fetchActivityLogs(filters); + setActivities(response); setError(null); } catch (err: unknown) { - console.error("[Logs] Failed to fetch runner jobs:", err); + console.error("[Logs] Failed to fetch activity logs:", err); setError( err instanceof Error ? err.message - : "We had trouble loading jobs. Please try again when you're ready." + : "We had trouble loading activity logs. Please try again when you're ready." ); } - }, [workspaceId, statusFilter]); + }, [workspaceId, actionFilter, entityFilter]); // Initial load useEffect(() => { let cancelled = false; setIsLoading(true); - loadJobs() + loadActivities() .then(() => { if (!cancelled) { setIsLoading(false); @@ -216,13 +194,13 @@ export default function LogsPage(): ReactElement { return (): void => { cancelled = true; }; - }, [loadJobs]); + }, [loadActivities]); // Auto-refresh polling useEffect(() => { if (autoRefresh) { intervalRef.current = setInterval(() => { - void loadJobs(); + void loadActivities(); }, POLL_INTERVAL_MS); } else if (intervalRef.current) { clearInterval(intervalRef.current); @@ -235,55 +213,22 @@ export default function LogsPage(): ReactElement { intervalRef.current = null; } }; - }, [autoRefresh, loadJobs]); - - // ─── Steps Loading ──────────────────────────────────────────────── - - const toggleExpand = useCallback( - (jobId: string) => { - if (expandedJobId === jobId) { - setExpandedJobId(null); - return; - } - - setExpandedJobId(jobId); - - // Load steps if not already loaded - if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) { - setStepsLoading((prev) => new Set(prev).add(jobId)); - - fetchJobSteps(jobId, workspaceId ?? undefined) - .then((steps) => { - setJobStepsMap((prev) => ({ ...prev, [jobId]: steps })); - }) - .catch((err: unknown) => { - console.error("[Logs] Failed to fetch steps for job:", jobId, err); - setJobStepsMap((prev) => ({ ...prev, [jobId]: [] })); - }) - .finally(() => { - setStepsLoading((prev) => { - const next = new Set(prev); - next.delete(jobId); - return next; - }); - }); - } - }, - [expandedJobId, jobStepsMap, stepsLoading, workspaceId] - ); + }, [autoRefresh, loadActivities]); // ─── Filtering ──────────────────────────────────────────────────── - const filteredJobs = jobs.filter((job) => { + const filteredActivities = activities.filter((activity) => { // Date range filter - if (!isWithinDateRange(job.createdAt, dateRange)) return false; + if (!isWithinDateRange(activity.createdAt, dateRange)) return false; // Search filter if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); - const matchesType = job.type.toLowerCase().includes(q); - const matchesId = job.id.toLowerCase().includes(q); - if (!matchesType && !matchesId) return false; + const matchesEntity = getEntityTypeLabel(activity.entityType).toLowerCase().includes(q); + const matchesId = activity.entityId.toLowerCase().includes(q); + const matchesUser = activity.user?.name?.toLowerCase().includes(q); + const matchesEmail = activity.user?.email.toLowerCase().includes(q); + if (!matchesEntity && !matchesId && !matchesUser && !matchesEmail) return false; } return true; @@ -293,7 +238,7 @@ export default function LogsPage(): ReactElement { const handleManualRefresh = (): void => { setIsLoading(true); - void loadJobs().finally(() => { + void loadActivities().finally(() => { setIsLoading(false); }); }; @@ -307,16 +252,12 @@ export default function LogsPage(): ReactElement { return (
- {/* Pulse animation for running status */} + {/* Pulse animation for auto-refresh */} {/* ─── Header ─────────────────────────────────────────────── */} @@ -332,10 +273,10 @@ export default function LogsPage(): ReactElement { >

- Logs & Telemetry + Activity Logs

- Runner job history and step-level detail + Audit trail and activity history

@@ -408,11 +349,11 @@ export default function LogsPage(): ReactElement { marginBottom: 24, }} > - {/* Status filter */} + {/* Action filter */} + + {/* Entity filter */} + { setSearchQuery(e.target.value); @@ -487,9 +452,9 @@ export default function LogsPage(): ReactElement { {/* ─── Content ────────────────────────────────────────────── */} - {isLoading && jobs.length === 0 ? ( + {isLoading && activities.length === 0 ? (
- +
) : error !== null ? (
- ) : filteredJobs.length === 0 ? ( + ) : filteredActivities.length === 0 ? (
-

No jobs found

+

No activity logs found

) : ( - /* ─── Job Table ──────────────────────────────────────────── */ + /* ─── Activity Table ──────────────────────────────────────── */
- {["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => ( + {["Action", "Entity", "User", "Details", "Time"].map((header) => ( - {filteredJobs.map((job) => { - const isExpanded = expandedJobId === job.id; - const isHovered = hoveredRowId === job.id; - const steps = jobStepsMap[job.id]; - const isStepsLoading = stepsLoading.has(job.id); - - return ( - { - toggleExpand(job.id); - }} - onMouseEnter={() => { - setHoveredRowId(job.id); - }} - onMouseLeave={() => { - setHoveredRowId(null); - }} - /> - ); - })} + {filteredActivities.map((activity) => ( + + ))}
@@ -591,260 +533,91 @@ export default function LogsPage(): ReactElement { ); } -// ─── Job Row Component ──────────────────────────────────────────────── - -function JobRow({ - job, - isExpanded, - isHovered, - steps, - isStepsLoading, - onToggle, - onMouseEnter, - onMouseLeave, -}: { - job: RunnerJob; - isExpanded: boolean; - isHovered: boolean; - steps: JobStep[] | undefined; - isStepsLoading: boolean; - onToggle: () => void; - onMouseEnter: () => void; - onMouseLeave: () => void; -}): ReactElement { - return ( - <> - - - - - ▶ - - {job.type} - - - - - - - {formatRelativeTime(job.startedAt ?? job.createdAt)} - - - {formatDuration(job.startedAt, job.completedAt)} - - - {steps ? String(steps.length) : "\u2014"} - - - - {/* Expanded Steps Section */} - {isExpanded && ( - - -
- {isStepsLoading ? ( -
- -
- ) : !steps || steps.length === 0 ? ( -

- No steps recorded for this job -

- ) : ( - - - - {["#", "Name", "Phase", "Status", "Duration"].map((header) => ( - - ))} - - - - {steps - .sort((a, b) => a.ordinal - b.ordinal) - .map((step) => ( - - ))} - -
- {header} -
- )} - - {/* Job error message if failed */} - {job.error && ( -
- {job.error} -
- )} -
- - - )} - - ); -} - -// ─── Step Row Component ─────────────────────────────────────────────── - -function StepRow({ step }: { step: JobStep }): ReactElement { - const [hovered, setHovered] = useState(false); +// ─── Activity Row Component ─────────────────────────────────────────── +function ActivityRow({ activity }: { activity: ActivityLog }): ReactElement { return ( { - setHovered(true); - }} - onMouseLeave={() => { - setHovered(false); - }} style={{ - background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent", - borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)", + background: "var(--surface)", + borderBottom: "1px solid var(--border)", transition: "background 100ms ease", }} > - - {String(step.ordinal)} + + - {step.name} +
+ {getEntityTypeLabel(activity.entityType)} + + {activity.entityId} + +
- {step.phase} - - - + {activity.user ? ( +
+ {activity.user.name ?? activity.user.email} + {activity.user.name && ( + + {activity.user.email} + + )} +
+ ) : ( + + )} + {activity.details ? JSON.stringify(activity.details) : "—"} + + - {formatStepDuration(step.durationMs)} + {formatRelativeTime(activity.createdAt)} ); diff --git a/apps/web/src/lib/api/activity.ts b/apps/web/src/lib/api/activity.ts new file mode 100644 index 0000000..85114a5 --- /dev/null +++ b/apps/web/src/lib/api/activity.ts @@ -0,0 +1,139 @@ +/** + * Activity API Client + * Handles activity-log-related API requests + */ + +import { apiGet, type ApiResponse } from "./client"; + +/** + * Activity action enum (matches backend ActivityAction) + */ +export enum ActivityAction { + CREATED = "CREATED", + UPDATED = "UPDATED", + DELETED = "DELETED", + COMPLETED = "COMPLETED", + ASSIGNED = "ASSIGNED", +} + +/** + * Entity type enum (matches backend EntityType) + */ +export enum EntityType { + TASK = "TASK", + EVENT = "EVENT", + PROJECT = "PROJECT", + WORKSPACE = "WORKSPACE", + USER = "USER", + DOMAIN = "DOMAIN", + IDEA = "IDEA", +} + +/** + * Activity log response interface (matches Prisma ActivityLog model) + */ +export interface ActivityLog { + id: string; + workspaceId: string; + userId: string; + action: ActivityAction; + entityType: EntityType; + entityId: string; + details: Record | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; + user?: { + id: string; + name: string | null; + email: string; + }; +} + +/** + * Filters for querying activity logs + */ +export interface ActivityLogFilters { + workspaceId?: string; + userId?: string; + action?: ActivityAction; + entityType?: EntityType; + entityId?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} + +/** + * Paginated activity logs response + */ +export interface PaginatedActivityLogs { + data: ActivityLog[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +/** + * Fetch activity logs with optional filters + */ +export async function fetchActivityLogs(filters?: ActivityLogFilters): Promise { + const params = new URLSearchParams(); + + if (filters?.userId) { + params.append("userId", filters.userId); + } + if (filters?.action) { + params.append("action", filters.action); + } + if (filters?.entityType) { + params.append("entityType", filters.entityType); + } + if (filters?.entityId) { + params.append("entityId", filters.entityId); + } + if (filters?.startDate) { + params.append("startDate", filters.startDate); + } + if (filters?.endDate) { + params.append("endDate", filters.endDate); + } + if (filters?.page !== undefined) { + params.append("page", String(filters.page)); + } + if (filters?.limit !== undefined) { + params.append("limit", String(filters.limit)); + } + + const queryString = params.toString(); + const endpoint = queryString ? `/api/activity?${queryString}` : "/api/activity"; + + const response = await apiGet(endpoint, filters?.workspaceId); + return response.data; +} + +/** + * Fetch a single activity log by ID + */ +export async function fetchActivityLog(id: string, workspaceId?: string): Promise { + return apiGet(`/api/activity/${id}`, workspaceId); +} + +/** + * Fetch audit trail for a specific entity + */ +export async function fetchAuditTrail( + entityType: EntityType, + entityId: string, + workspaceId?: string +): Promise { + const response = await apiGet>( + `/api/activity/audit/${entityType}/${entityId}`, + workspaceId + ); + return response.data; +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index f7b0ec6..6b5273c 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -18,3 +18,4 @@ export * from "./projects"; export * from "./workspaces"; export * from "./admin"; export * from "./fleet-settings"; +export * from "./activity";