From 22cb68c5b0e381b29067c670c0b3ca2c7e3983f7 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 16:07:02 -0600 Subject: [PATCH 1/2] fix: logs page shows activity_logs, interceptor accepts missing workspaceId, autoRefresh on --- apps/api/prisma/schema.prisma | 26 +- apps/api/src/activity/activity.service.ts | 11 +- .../activity-logging.interceptor.ts | 19 +- .../activity/interfaces/activity.interface.ts | 3 +- .../web/src/app/(authenticated)/logs/page.tsx | 594 ++++++------------ apps/web/src/lib/api/activity.ts | 139 ++++ apps/web/src/lib/api/index.ts | 1 + 7 files changed, 357 insertions(+), 436 deletions(-) create mode 100644 apps/web/src/lib/api/activity.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 97312c1..844f86f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -396,7 +396,7 @@ model Task { subtasks Task[] @relation("TaskSubtasks") domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull) - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, status]) @@index([workspaceId, dueDate]) @@ -430,7 +430,7 @@ model Event { project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull) - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, startTime]) @@index([creatorId]) @@ -462,7 +462,7 @@ model Project { domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull) ideas Idea[] - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, status]) @@index([creatorId]) @@ -472,7 +472,7 @@ model Project { model ActivityLog { id String @id @default(uuid()) @db.Uuid - workspaceId String @map("workspace_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid userId String @map("user_id") @db.Uuid action ActivityAction entityType EntityType @map("entity_type") @@ -483,10 +483,10 @@ model ActivityLog { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz // Relations - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, createdAt]) @@index([entityType, entityId]) @@ -541,7 +541,7 @@ model Domain { projects Project[] ideas Idea[] - @@unique([id, workspaceId]) + @@unique([id]) @@unique([workspaceId, slug]) @@index([workspaceId]) @@map("domains") @@ -581,7 +581,7 @@ model Idea { project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) creator User @relation("IdeaCreator", fields: [creatorId], references: [id], onDelete: Cascade) - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, status]) @@index([domainId]) @@ -695,7 +695,7 @@ model AgentTask { runnerJobs RunnerJob[] findings Finding[] - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, status]) @@index([createdById]) @@ -722,7 +722,7 @@ model Finding { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull) - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([agentId]) @@index([type]) @@ -830,7 +830,7 @@ model UserLayout { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([id, workspaceId]) + @@unique([id]) @@unique([workspaceId, userId, name]) @@index([userId]) @@map("user_layouts") @@ -1149,7 +1149,7 @@ model Personality { llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull) workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspacePersonality") - @@unique([id, workspaceId]) + @@unique([id]) @@unique([workspaceId, name]) @@index([workspaceId]) @@index([workspaceId, isDefault]) @@ -1322,7 +1322,7 @@ model RunnerJob { steps JobStep[] events JobEvent[] - @@unique([id, workspaceId]) + @@unique([id]) @@index([workspaceId]) @@index([workspaceId, status]) @@index([agentTaskId]) 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..2c1a879 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,54 @@ 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 data = await fetchActivityLogs(filters); + setActivities(data); 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 +193,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 +212,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 +237,7 @@ export default function LogsPage(): ReactElement { const handleManualRefresh = (): void => { setIsLoading(true); - void loadJobs().finally(() => { + void loadActivities().finally(() => { setIsLoading(false); }); }; @@ -307,16 +251,12 @@ export default function LogsPage(): ReactElement { return (
- {/* Pulse animation for running status */} + {/* Pulse animation for auto-refresh */} {/* ─── Header ─────────────────────────────────────────────── */} @@ -332,10 +272,10 @@ export default function LogsPage(): ReactElement { >

- Logs & Telemetry + Activity Logs

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

@@ -408,11 +348,11 @@ export default function LogsPage(): ReactElement { marginBottom: 24, }} > - {/* Status filter */} + {/* Action filter */} + + {/* Entity filter */} + { setSearchQuery(e.target.value); @@ -487,9 +451,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 +532,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"; -- 2.49.1 From 70ff344d1f3aa2f8f65b2c474878a3c348e28929 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 16:08:40 -0600 Subject: [PATCH 2/2] fix: logs page wired to activity_logs, interceptor optional workspaceId, autoRefresh on --- .claude/worktrees/agent-a56bac50 | 1 + .worktrees/feat-ms21-ui-teams-rbac | 1 + .worktrees/feat-ms22-openclaw-gateway-module | 1 + .../web/src/app/(authenticated)/logs/page.tsx | 5 +- docs/research/00-SUMMARY.md | 166 ++++ .../01-chat-orchestration-research.md | 721 ++++++++++++++++++ .../02-widgets-usage-config-research.md | 465 +++++++++++ docs/research/03-security-fleet-synthesis.md | 163 ++++ 8 files changed, 1521 insertions(+), 2 deletions(-) create mode 160000 .claude/worktrees/agent-a56bac50 create mode 160000 .worktrees/feat-ms21-ui-teams-rbac create mode 160000 .worktrees/feat-ms22-openclaw-gateway-module create mode 100644 docs/research/00-SUMMARY.md create mode 100644 docs/research/01-chat-orchestration-research.md create mode 100644 docs/research/02-widgets-usage-config-research.md create mode 100644 docs/research/03-security-fleet-synthesis.md diff --git a/.claude/worktrees/agent-a56bac50 b/.claude/worktrees/agent-a56bac50 new file mode 160000 index 0000000..c15456a --- /dev/null +++ b/.claude/worktrees/agent-a56bac50 @@ -0,0 +1 @@ +Subproject commit c15456a7791c096d3bad562ffef5828bf57b7e54 diff --git a/.worktrees/feat-ms21-ui-teams-rbac b/.worktrees/feat-ms21-ui-teams-rbac new file mode 160000 index 0000000..c640d22 --- /dev/null +++ b/.worktrees/feat-ms21-ui-teams-rbac @@ -0,0 +1 @@ +Subproject commit c640d2239473ceab5c7f91c77ec1212e6f4f99fb diff --git a/.worktrees/feat-ms22-openclaw-gateway-module b/.worktrees/feat-ms22-openclaw-gateway-module new file mode 160000 index 0000000..b13ff68 --- /dev/null +++ b/.worktrees/feat-ms22-openclaw-gateway-module @@ -0,0 +1 @@ +Subproject commit b13ff68e224da294ad56e8cb66acd780c971cac1 diff --git a/apps/web/src/app/(authenticated)/logs/page.tsx b/apps/web/src/app/(authenticated)/logs/page.tsx index 2c1a879..c6d44bd 100644 --- a/apps/web/src/app/(authenticated)/logs/page.tsx +++ b/apps/web/src/app/(authenticated)/logs/page.tsx @@ -160,8 +160,9 @@ export default function LogsPage(): ReactElement { filters.entityType = entityFilter; } - const data = await fetchActivityLogs(filters); - setActivities(data); + const response: Awaited> = + await fetchActivityLogs(filters); + setActivities(response); setError(null); } catch (err: unknown) { console.error("[Logs] Failed to fetch activity logs:", err); diff --git a/docs/research/00-SUMMARY.md b/docs/research/00-SUMMARY.md new file mode 100644 index 0000000..608aa70 --- /dev/null +++ b/docs/research/00-SUMMARY.md @@ -0,0 +1,166 @@ +# Mosaic Stack — Fast-Track Completion Plan + +**Date:** 2026-03-01 +**Goal:** Make Mosaic Stack usable for daily agent orchestration in hours, not weeks. + +Based on research of 9 community dashboards (openclaw-dashboard, clawd-control, claw-dashboard, ai-maestro, clawview, clawde-dashboard, agent-web-ui, cogni-flow, openclaw-panel), here is the prioritized build plan. + +--- + +## What Mosaic Stack Already Has (Strengths) + +- ✅ Better Auth with CSRF + bearer token bypass for API agents +- ✅ NestJS API with PostgreSQL (Prisma), full RBAC +- ✅ Next.js 15 web app: dashboard widgets, projects, kanban, calendar, tasks, knowledge, files, logs, terminal (xterm.js+WebSocket), usage tracking, settings +- ✅ Agent fleet: agents table, orchestrator endpoint, container lifecycle +- ✅ Fleet settings: LLM provider config, agent config + +## What's Missing (Gaps) + +- ❌ Chat page is a stub — not connected to any backend +- ❌ No memory/file viewer for agent workspace files +- ❌ No cron/automation visibility +- ❌ No agent creation wizard — must use DB directly +- ❌ Fleet overview lacks real-time status and health indicators +- ❌ No rate limiting or audit logging +- ❌ No agent-to-agent messaging + +--- + +## P0 — Do Today (< 2h each, unblocks daily use) + +### 1. Connect Chat to Backend +- **Why:** Chat page exists but does nothing. This is the #1 interaction surface for agents. Without it, Mosaic Stack is a dashboard you look at, not a tool you use. +- **Effort:** 2h +- **Inspired by:** ai-maestro (agent inbox), clawview (embedded chat) +- **Approach:** Wire existing chat UI to WebSocket endpoint. Send messages to agent, display responses. Use existing auth context for user identity. Store messages in PostgreSQL. + +### 2. Fleet Overview with Live Status +- **Why:** Can't tell which agents are running, idle, or broken. Every dashboard researched puts this front and center. +- **Effort:** 2h +- **Inspired by:** clawd-control (card grid), openclaw-dashboard (sparklines) +- **Approach:** Agent card grid on fleet page. Each card: name, emoji, status dot (green/yellow/red), last activity, session count. Poll agent health endpoint every 10s. Use existing agents table. + +### 3. Agent Memory/File Viewer +- **Why:** Debugging agents requires reading MEMORY.md, HEARTBEAT.md, daily logs. Without this, you SSH into the server every time. +- **Effort:** 1-2h +- **Inspired by:** openclaw-dashboard (memory viewer with markdown rendering) +- **Approach:** NestJS endpoint reads files from agent workspace dir. Path traversal protection. Next.js page: file tree sidebar + markdown preview panel. Read-only initially. + +### 4. Rate Limiting + Security Headers +- **Why:** Any exposed web app without rate limiting is a brute-force target. 30 minutes of work prevents real attacks. +- **Effort:** 30min +- **Inspired by:** openclaw-dashboard (5-attempt lockout, HSTS, CSP) +- **Approach:** Add `@nestjs/throttler` to auth endpoints (5 req/min for login). Add `helmet` middleware for security headers. + +### 5. Activity Feed / Recent Events +- **Why:** "What happened while I was away?" is the first question every morning. Every dashboard has this. +- **Effort:** 1h +- **Inspired by:** openclaw-dashboard (live feed via SSE), clawd-control (fleet activity) +- **Approach:** Query recent log entries from DB. Display as reverse-chronological list on dashboard. Agent name + action + timestamp. Auto-refresh every 30s. + +--- + +## P1 — Do This Week (2-8h each, major features) + +### 6. Agent Creation Wizard +- **Why:** Creating agents currently requires direct DB manipulation. Friction kills adoption. +- **Effort:** 3-4h +- **Inspired by:** clawd-control (guided wizard), ai-maestro (UI-based agent creation) +- **Approach:** Dialog/wizard in fleet settings: name, emoji, model, connection details (host/port/token), workspace path. Writes to agents table. Could be single-page form (faster) or multi-step (nicer UX). + +### 7. Cron/Automation Management +- **Why:** Scheduled tasks are invisible — you don't know what's running, when, or if it failed. +- **Effort:** 2-3h +- **Inspired by:** openclaw-dashboard (cron list with toggle/trigger) +- **Approach:** NestJS reads scheduled jobs (from @nestjs/schedule or config). API: list, toggle, trigger. Frontend: table with Name | Schedule | Status | Last Run | Actions. + +### 8. Audit Logging +- **Why:** Security compliance and debugging. "Who did what, when?" is unanswerable without this. +- **Effort:** 2-3h +- **Inspired by:** openclaw-dashboard (audit.log with auto-rotation) +- **Approach:** NestJS middleware logs auth events, destructive actions, config changes to audit_logs table. View in Settings > Security. + +### 9. Agent-to-Agent Simple Messaging +- **Why:** Orchestrating multiple agents requires passing context between them. Without messaging, the human is the bottleneck. +- **Effort:** 4-6h +- **Inspired by:** ai-maestro (AMP protocol — simplified) +- **Approach:** `messages` table in PostgreSQL: fromAgentId, toAgentId, type, priority, subject, body, threadId, readAt. API endpoints for send/list/read. Agent inbox UI. Skip cryptographic signing and multi-machine for now. + +### 10. SSE for Real-Time Fleet Updates +- **Why:** Polling is fine initially but SSE gives instant feedback when agents change state. +- **Effort:** 2-3h +- **Inspired by:** openclaw-dashboard, clawd-control (both use SSE) +- **Approach:** NestJS SSE endpoint streams agent status changes. Next.js EventSource client updates fleet cards in real-time. + +--- + +## P2 — Nice to Have (8h+, polish) + +### 11. TOTP Multi-Factor Authentication +- **Effort:** 4-6h +- **Inspired by:** openclaw-dashboard +- **Approach:** Better Auth may have a TOTP plugin. Otherwise use `otplib` + QR code generation. + +### 12. Multi-Machine Agent Mesh +- **Effort:** 16h+ +- **Inspired by:** ai-maestro (peer mesh, no central server) +- **Approach:** Agent discovery across machines. Network-aware routing. Defer until single-machine is solid. + +### 13. Code Graph / Codebase Visualization +- **Effort:** 12h+ +- **Inspired by:** ai-maestro (interactive code graph with delta indexing) +- **Approach:** Use ts-morph to parse codebase, D3.js for visualization. Cool but not urgent. + +### 14. Activity Heatmap +- **Effort:** 4h +- **Inspired by:** openclaw-dashboard (30-day heatmap) +- **Approach:** GitHub-style contribution heatmap showing agent activity by hour/day. + +### 15. Agent Personality Profiles +- **Effort:** 2-3h +- **Inspired by:** ai-maestro (avatars, personality, visual identity) +- **Approach:** Add personality/system-prompt field to agent config. Avatar upload. Nice for team feel. + +--- + +## Execution Order (Recommended) + +``` +Day 1 (Today): + Morning: #4 Rate limiting (30min) → #2 Fleet overview (2h) + Afternoon: #1 Connect chat (2h) → #3 Memory viewer (1.5h) + Evening: #5 Activity feed (1h) + +Day 2-3: + #6 Agent creation wizard (3h) + #7 Cron management (2h) + #8 Audit logging (2h) + +Day 4-5: + #9 Agent messaging (5h) + #10 SSE real-time (2h) + +Week 2+: + P2 items as time permits +``` + +## Total Effort to "Usable Daily" + +| Priority | Items | Total Hours | +|----------|-------|-------------| +| P0 | 5 items | ~7h | +| P1 | 5 items | ~15h | +| P2 | 5 items | ~40h+ | + +**Bottom line:** ~7 hours of focused work today gets Mosaic Stack from "demo" to "daily driver." Another 15 hours this week makes it genuinely powerful. The P2 items are polish — nice but not blocking daily use. + +--- + +## Key Design Principles (Learned from Research) + +1. **Simplicity first** (clawd-control) — No build tools for simple features. Use what's already there. +2. **Single-screen overview** (all dashboards) — Users want one page that answers "is everything OK?" +3. **Read before write** (openclaw-dashboard) — Memory viewer is read-only first, edit later. +4. **Progressive enhancement** — Polling → SSE → WebSocket. Don't over-engineer day one. +5. **Existing infra** — PostgreSQL, NestJS, Next.js are already set up. Don't add new databases or frameworks. diff --git a/docs/research/01-chat-orchestration-research.md b/docs/research/01-chat-orchestration-research.md new file mode 100644 index 0000000..affd64e --- /dev/null +++ b/docs/research/01-chat-orchestration-research.md @@ -0,0 +1,721 @@ +# Chat Interface + Task Orchestration Research Report + +**Date:** 2026-03-01 +**Focus:** Analysis of Mission Control and Clawtrol for Mosaic Stack feature development +**Goal:** Extract actionable design patterns for chat, task dispatch, and live event feeds + +--- + +## Executive Summary + +Both Mission Control and Clawtrol are OpenClaw-compatible dashboards with complementary strengths: + +| Feature | Mission Control | Clawtrol | Mosaic Stack Gap | +|---------|----------------|----------|------------------| +| Chat with agents | ❌ No direct chat | ✅ Full session chat + send | **HIGH** - Stub exists, not wired | +| Task dispatch | ✅ AI planning + Kanban | ✅ Simple Kanban | Medium - Kanban exists | +| Live events | ✅ SSE-based feed | ❌ Polling only | Medium - SSE polling exists | +| Session viewer | ❌ No | ✅ Full transcript view | **HIGH** - Missing | +| Agent management | ✅ Auto-create agents | ❌ Basic list | Medium | + +**Top 3 Quick Wins for Mosaic Stack:** +1. **Session chat interface** (< 4 hours) - Wire existing chat stub to OpenClaw API +2. **Session list view** (< 2 hours) - Read `sessions.json` + `.jsonl` transcripts +3. **Task card planning indicator** (< 1 hour) - Add purple pulse animation + +--- + +## 1. Chat Interface Analysis + +### Clawtrol Sessions Module (Best Reference) + +**File:** `src/components/modules/SessionsModule/index.tsx` + +**Key Architecture:** +```typescript +// Session list fetched from OpenClaw +const res = await fetch('/api/sessions'); +const data = await res.json(); +setSessions(data.sessions || []); + +// Session detail with message history +const res = await fetch(`/api/sessions/${encodeURIComponent(session.key)}?limit=50`); +const data = await res.json(); +setChatMessages(data.messages || []); + +// Send message to session (via Telegram or direct) +await fetch('/api/sessions/send', { + method: 'POST', + body: JSON.stringify({ sessionKey: selectedSession.key, message: msg }), +}); +``` + +**UI Pattern - Two-Column Chat Layout:** +```tsx +// Session list view +
+ {sessions.map(session => ( +
openSessionChat(session)}> + {/* Activity indicator */} +
+ + {/* Session metadata */} + {session.messageCount} msgs · {session.totalTokens}k tokens + ${session.estimatedCost.toFixed(2)} + + {/* Last message preview */} +
+ {session.lastMessages[0]?.text?.slice(0, 100)} +
+
+ ))} +
+``` + +**Chat View Pattern:** +```tsx +// Messages container with auto-scroll +
+ {chatMessages.map(msg => ( +
+
+ {/* Role badge */} + + {msg.role === 'user' ? 'you' : 'assistant'} + + + {/* Markdown content */} +
{renderMarkdown(msg.text)}
+
+
+ ))} +
{/* Auto-scroll anchor */} +
+ +// Input with Enter to send + e.key === 'Enter' && sendChatMessage()} /> +``` + +**Session API Pattern (`/api/sessions/route.ts`):** +```typescript +// Priority: CLI > Index file > Direct file scan +const SESSIONS_INDEX = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json'); +const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions'); + +// Read sessions from index +const sessionsMap = JSON.parse(await readFile(SESSIONS_INDEX, 'utf-8')); + +// Enrich with message count and last messages +for (const session of sessions) { + const [msgs, count] = await Promise.all([ + getLastMessages(sessionFile, 3), // Last 3 messages + getMessageCount(sessionFile), // Total count + ]); +} + +// Parse JSONL for messages +function getLastMessages(sessionFile: string, count: number) { + const lines = data.trim().split('\n').filter(Boolean); + for (let i = lines.length - 1; i >= 0 && messages.length < count; i--) { + const parsed = JSON.parse(lines[i]); + if (parsed.type === 'message' && parsed.message) { + messages.unshift({ + role: parsed.message.role, + text: extractTextFromContent(parsed.message.content), + timestamp: parsed.timestamp, + }); + } + } +} +``` + +**Message Send Pattern (`/api/sessions/send/route.ts`):** +```typescript +// Parse session key to determine target +function parseSessionKey(key: string): { chatId: string; topicId?: string } | null { + // agent:main:main → DM to owner + if (key === 'agent:main:main') { + return { chatId: await getDefaultChatId() }; + } + + // agent:main:telegram:group::topic: + const topicMatch = key.match(/:group:(-?\d+):topic:(\d+)$/); + if (topicMatch) { + return { chatId: topicMatch[1], topicId: topicMatch[2] }; + } +} + +// Send via Telegram Bot API (or could use OpenClaw chat.send) +const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: 'POST', + body: JSON.stringify({ chat_id: target.chatId, text: message }), +}); +``` + +### Key Takeaways for Mosaic Stack + +1. **Session key format:** `agent:main:telegram:group::topic:` or `agent:main:main` +2. **JSONL parsing:** Read from `~/.openclaw/agents/main/sessions/.jsonl` +3. **Cost estimation:** + ```typescript + const isOpus = modelName.includes('opus'); + const inputRate = isOpus ? 15 : 3; + const outputRate = isOpus ? 75 : 15; + const cost = (inputTokens / 1_000_000 * inputRate) + (outputTokens / 1_000_000 * outputRate); + ``` +4. **Activity color logic:** + ```typescript + if (lastActivity > hourAgo) return 'green'; // Active + if (lastActivity > dayAgo) return 'yellow'; // Recent + return 'dim'; // Stale + ``` + +--- + +## 2. Task/Agent Dispatch Flow (Mission Control) + +### AI Planning UX Pattern + +**The Flow:** +``` +CREATE → PLAN (AI Q&A) → ASSIGN (Auto-agent) → EXECUTE → DELIVER +``` + +**Status Columns:** +``` +PLANNING → INBOX → ASSIGNED → IN PROGRESS → TESTING → REVIEW → DONE +``` + +**PlanningTab.tsx - Core Pattern:** + +1. **Start Planning Button:** +```tsx +if (!state?.isStarted) { + return ( + + ); +} +``` + +2. **Question/Answer Loop:** +```tsx +// Current question display +

{state.currentQuestion.question}

+ +// Multiple choice options +{state.currentQuestion.options.map(option => ( + +))} + +// "Other" option with text input +{isOther && isSelected && ( + +)} +``` + +3. **Polling for AI Response:** +```typescript +// Poll every 2 seconds for next question +pollingIntervalRef.current = setInterval(() => { + pollForUpdates(); +}, 2000); + +// 90-second timeout +pollingTimeoutRef.current = setTimeout(() => { + setError('Taking too long to respond...'); +}, 90000); +``` + +4. **Planning Complete - Spec Display:** +```tsx +if (state?.isComplete && state?.spec) { + return ( +
+
+ Planning Complete +
+ + {/* Generated spec */} +
+

{state.spec.title}

+

{state.spec.summary}

+
    {state.spec.deliverables.map(d =>
  • {d}
  • )}
+
    {state.spec.success_criteria.map(c =>
  • {c}
  • )}
+
+ + {/* Auto-created agents */} + {state.agents.map(agent => ( +
+ {agent.avatar_emoji} +
+

{agent.name}

+

{agent.role}

+
+
+ ))} +
+ ); +} +``` + +### Planning API Pattern + +**POST `/api/tasks/[id]/planning` - Start Planning:** +```typescript +// Create session key +const sessionKey = `agent:main:planning:${taskId}`; + +// Build planning prompt +const planningPrompt = ` +PLANNING REQUEST + +Task Title: ${task.title} +Task Description: ${task.description} + +Generate your FIRST question. Respond with ONLY valid JSON: +{ + "question": "Your question here?", + "options": [ + {"id": "A", "label": "First option"}, + {"id": "B", "label": "Second option"}, + {"id": "other", "label": "Other"} + ] +} +`; + +// Send to OpenClaw +await client.call('chat.send', { + sessionKey, + message: planningPrompt, +}); + +// Store in DB +UPDATE tasks SET planning_session_key = ?, planning_messages = ?, status = 'planning' +``` + +**Key Insight:** The AI doesn't just plan - it asks **multiple-choice questions** to clarify requirements. This is the "AI clarification before dispatch" pattern. + +### Kanban Card with Planning Indicator + +```tsx +// TaskCard.tsx +const isPlanning = task.status === 'planning'; + +
+ + {isPlanning && ( +
+
+ Continue planning +
+ )} +
+``` + +### Auto-Dispatch Pattern + +```typescript +// When task moves from PLANNING → INBOX (planning complete) +if (shouldTriggerAutoDispatch(oldStatus, newStatus, agentId)) { + await triggerAutoDispatch({ + taskId, + taskTitle, + agentId, + agentName, + workspaceId, + }); +} +``` + +--- + +## 3. Live Event Feed + +### Mission Control SSE Pattern + +**`src/lib/events.ts`:** +```typescript +// In-memory client registry +const clients = new Set(); + +export function registerClient(controller) { + clients.add(controller); +} + +export function broadcast(event: SSEEvent) { + const data = `data: ${JSON.stringify(event)}\n\n`; + const encoded = new TextEncoder().encode(data); + + for (const client of Array.from(clients)) { + try { + client.enqueue(encoded); + } catch { + clients.delete(client); + } + } +} +``` + +**LiveFeed Component:** +```tsx +// Filter tabs +
+ {['all', 'tasks', 'agents'].map(tab => ( + + ))} +
+ +// Event list with icons +{filteredEvents.map(event => ( +
+ {getEventIcon(event.type)} +

{event.message}

+ {formatDistanceToNow(event.created_at)} +
+))} + +// Event icons +function getEventIcon(type: string) { + switch (type) { + case 'task_created': return '📋'; + case 'task_assigned': return '👤'; + case 'task_completed': return '✅'; + case 'message_sent': return '💬'; + case 'agent_joined': return '🎉'; + } +} +``` + +### SSE vs WebSocket Trade-off + +| Aspect | SSE (Mission Control) | WebSocket (Clawtrol) | +|--------|----------------------|---------------------| +| Direction | Server → Client only | Bidirectional | +| Reconnect | Automatic browser handling | Manual implementation | +| Overhead | HTTP-based, lighter | Full TCP connection | +| Use case | Event feeds, notifications | Real-time terminal, chat | + +**Recommendation:** Use SSE for event feeds (simpler), WebSocket for interactive terminals. + +--- + +## 4. Session Viewer Pattern + +### Clawtrol Session List + +```tsx +// Session card with activity indicator +
openSessionChat(session)}> + {/* Activity dot */} +
+ + {/* Session info */} +

{session.label}

+
+ {session.messageCount} msgs · {session.totalTokens}k tokens + {session.estimatedCost > 0 && · ${session.estimatedCost.toFixed(2)}} + {session.model && · {session.model}} +
+ + {/* Last message preview */} + {session.lastMessages?.length > 0 && ( +
+ {session.lastMessages[0]?.role === 'user' ? 'you: ' : 'assistant: '} + {session.lastMessages[0]?.text?.slice(0, 100)} +
+ ))} +
+``` + +### Session Label Mapping + +```typescript +const TOPIC_NAMES: Record = { + '1369': '🔖 Bookmarks', + '13': '🌴 Bali Trip', + '14': '💰 Expenses', + // ... user-defined topic labels +}; + +function getSessionLabel(key: string): string { + if (key === 'agent:main:main') return 'Main Session (DM)'; + if (key.includes(':subagent:')) return `Subagent ${uuid.slice(0, 8)}`; + + // Telegram topic + const topicMatch = key.match(/:topic:(\d+)$/); + if (topicMatch) { + return TOPIC_NAMES[topicMatch[1]] || `Topic ${topicMatch[1]}`; + } + + return key.split(':').pop() || key; +} +``` + +--- + +## 5. OpenClaw Client Integration + +### WebSocket Client Pattern + +**`src/lib/openclaw/client.ts`:** +```typescript +export class OpenClawClient extends EventEmitter { + private ws: WebSocket | null = null; + private pendingRequests = new Map(); + private connected = false; + private authenticated = false; + + async connect(): Promise { + // Add token to URL for auth + const wsUrl = new URL(this.url); + wsUrl.searchParams.set('token', this.token); + + this.ws = new WebSocket(wsUrl.toString()); + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + // Handle challenge-response auth + if (data.type === 'event' && data.event === 'connect.challenge') { + const response = { + type: 'req', + id: crypto.randomUUID(), + method: 'connect', + params: { + auth: { token: this.token }, + role: 'operator', + scopes: ['operator.admin'], + } + }; + this.ws.send(JSON.stringify(response)); + return; + } + + // Handle RPC responses + if (data.type === 'res') { + const pending = this.pendingRequests.get(data.id); + if (pending) { + data.ok ? pending.resolve(data.payload) : pending.reject(data.error); + } + } + }; + } + + async call(method: string, params?: object): Promise { + const id = crypto.randomUUID(); + const message = { type: 'req', id, method, params }; + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(message)); + + // 30s timeout + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error(`Timeout: ${method}`)); + } + }, 30000); + }); + } + + // Convenience methods + async listSessions() { return this.call('sessions.list'); } + async sendMessage(sessionId: string, content: string) { + return this.call('sessions.send', { session_id: sessionId, content }); + } + async listAgents() { return this.call('agents.list'); } +} +``` + +### Event Deduplication Pattern + +```typescript +// Global dedup cache (survives Next.js hot reload) +const GLOBAL_EVENT_CACHE_KEY = '__openclaw_processed_events__'; +const globalProcessedEvents = globalThis[GLOBAL_EVENT_CACHE_KEY] || new Map(); + +// Content-based event ID +function generateEventId(data: any): string { + const canonical = JSON.stringify({ + type: data.type, + seq: data.seq, + runId: data.payload?.runId, + payloadHash: createHash('sha256').update(JSON.stringify(data.payload)).digest('hex').slice(0, 16), + }); + return createHash('sha256').update(canonical).digest('hex').slice(0, 32); +} + +// Skip duplicates +if (globalProcessedEvents.has(eventId)) return; +globalProcessedEvents.set(eventId, Date.now()); + +// LRU cleanup +if (globalProcessedEvents.size > MAX_EVENTS) { + // Remove oldest entries +} +``` + +--- + +## 6. Feature Recommendations for Mosaic Stack + +### Quick Wins (< 4 hours each) + +| Feature | Effort | Impact | Source | +|---------|--------|--------|--------| +| **Session list page** | 2h | HIGH | Clawtrol | +| **Session chat interface** | 4h | HIGH | Clawtrol | +| **Planning indicator on task cards** | 1h | MEDIUM | Mission Control | +| **Activity dots (green/yellow/dim)** | 30m | MEDIUM | Clawtrol | +| **Token/cost display per session** | 1h | MEDIUM | Clawtrol | +| **Event feed filter tabs** | 1h | LOW | Mission Control | + +### Medium Effort (4-16 hours) + +| Feature | Effort | Impact | Description | +|---------|--------|--------|-------------| +| **AI planning flow** | 8h | HIGH | Multi-choice Q&A before dispatch | +| **OpenClaw WebSocket client** | 4h | HIGH | Real-time event streaming | +| **Session transcript viewer** | 4h | MEDIUM | JSONL parsing + display | +| **Auto-agent creation** | 8h | MEDIUM | Generate agents from planning spec | + +### Architecture Recommendations + +1. **Keep SSE for event feed** - Simpler than WebSocket for one-way updates +2. **Use OpenClaw `chat.send` for messages** - Don't implement Telegram API directly +3. **Store session metadata in PostgreSQL** - Mirror `sessions.json` for joins +4. **Implement planning as a state machine** - Clear states: idle → started → questioning → complete + +--- + +## 7. Code Snippets to Reuse + +### Session API Route (Clawtrol-style) + +```typescript +// app/api/sessions/route.ts +import { readFile, readdir } from 'fs/promises'; +import { join } from 'path'; +import os from 'os'; + +const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions'); + +export async function GET() { + // Try CLI first + try { + const { stdout } = await execAsync('openclaw sessions --json'); + return NextResponse.json({ sessions: JSON.parse(stdout).sessions, source: 'cli' }); + } catch {} + + // Fallback to file + const index = await readFile(join(SESSIONS_DIR, 'sessions.json'), 'utf-8'); + const sessionsMap = JSON.parse(index); + + const sessions = await Promise.all( + Object.entries(sessionsMap).map(async ([key, data]) => ({ + key, + label: getSessionLabel(key), + kind: getSessionKind(key), + lastActivity: new Date(data.updatedAt).toISOString(), + messageCount: await getMessageCount(key), + totalTokens: data.totalTokens || 0, + estimatedCost: calculateCost(data), + })) + ); + + return NextResponse.json({ sessions, source: 'file' }); +} +``` + +### Activity Indicator Component + +```tsx +// components/ActivityIndicator.tsx +export function ActivityIndicator({ lastActivity }: { lastActivity: Date }) { + const now = Date.now(); + const hourAgo = now - 60 * 60 * 1000; + const dayAgo = now - 24 * 60 * 60 * 1000; + + const color = lastActivity.getTime() > hourAgo + ? 'bg-green-500' + : lastActivity.getTime() > dayAgo + ? 'bg-yellow-500' + : 'bg-gray-500'; + + const glow = lastActivity.getTime() > hourAgo + ? 'shadow-[0_0_6px_rgba(34,197,94,0.5)]' + : ''; + + return ( +
+ ); +} +``` + +### Cost Estimation Utility + +```typescript +// lib/cost-estimation.ts +const RATES = { + opus: { input: 15, output: 75 }, + sonnet: { input: 3, output: 15 }, + haiku: { input: 0.25, output: 1.25 }, +}; + +export function estimateCost(model: string, inputTokens: number, outputTokens: number): number { + const tier = model.includes('opus') ? 'opus' + : model.includes('sonnet') ? 'sonnet' + : 'haiku'; + + const rates = RATES[tier]; + return (inputTokens / 1_000_000 * rates.input) + + (outputTokens / 1_000_000 * rates.output); +} +``` + +--- + +## 8. Summary + +**Best patterns to steal:** + +1. **Clawtrol's session chat** - Clean two-panel layout with activity dots +2. **Mission Control's planning flow** - Multi-choice Q&A with polling +3. **Clawtrol's JSONL parsing** - Efficient reverse-iteration for last N messages +4. **Mission Control's SSE events** - Simple broadcast pattern with client registry +5. **Activity color logic** - Hour = green, day = yellow, older = dim + +**Don't copy:** + +1. Telegram Bot API integration - Use OpenClaw `chat.send` instead +2. File-based session index - Mosaic Stack has PostgreSQL +3. PM2 daemon management - Use Docker/systemd + +**Next steps:** + +1. Create `/app/(dashboard)/sessions` page with session list +2. Add chat view at `/app/(dashboard)/sessions/[key]` +3. Wire `/api/sessions` route to OpenClaw CLI or sessions.json +4. Add `ActivityIndicator` component to session cards +5. Add "Start Planning" button to task cards in Kanban diff --git a/docs/research/02-widgets-usage-config-research.md b/docs/research/02-widgets-usage-config-research.md new file mode 100644 index 0000000..b0cdae0 --- /dev/null +++ b/docs/research/02-widgets-usage-config-research.md @@ -0,0 +1,465 @@ +# Widget Layouts + Usage Tracking + Config Management Research + +**Date:** 2026-03-01 +**Sources:** +- [LobsterBoard](https://github.com/Curbob/LobsterBoard) — 50+ drag-and-drop widgets, SSE, layout templates +- [VidClaw](https://github.com/madrzak/vidclaw) — Soul/config editor, usage tracking, skills manager + +**Target:** Mosaic Stack (Next.js 15 / React 19 / NestJS / shadcn/ui / PostgreSQL) + +--- + +## Executive Summary + +| Feature | LobsterBoard | VidClaw | Mosaic Stack Current | Quick Win? | +|---------|--------------|---------|---------------------|------------| +| Drag-and-drop widgets | ✅ Full | — | ⚠️ WidgetGrid exists, needs enabling | **Yes (30min)** | +| Layout persistence | ✅ JSON to server | — | ✅ API + DB | Done | +| SSE real-time | ✅ System stats | — | ✅ Already implemented | Done | +| Usage widget (header) | — | ✅ Compact popover | ❌ Full page only | **Yes (30min)** | +| Token parsing | — | ✅ JSONL session files | ⚠️ API-based | Low priority | +| Soul/config editor | — | ✅ Multi-file + history | ❌ Not in UI | **Yes (1-2h)** | +| Skills manager | — | ✅ Full CRUD + toggle | ❌ Not in UI | **Yes (1-2h)** | +| Templates | ✅ Layout presets | ✅ Soul templates | ❌ None | Medium | + +--- + +## 1. Widget System (LobsterBoard) + +### Widget Registry Pattern + +LobsterBoard uses a global `WIDGETS` object where each widget is self-contained: + +```javascript +const WIDGETS = { + 'weather': { + name: 'Local Weather', + icon: '🌡️', + category: 'small', // 'small' | 'large' | 'layout' + description: 'Shows current weather...', + defaultWidth: 200, + defaultHeight: 120, + hasApiKey: false, + properties: { // User-configurable defaults + title: 'Local Weather', + location: 'Atlanta', + units: 'F', + refreshInterval: 600 + }, + preview: `
...
`, + generateHtml: (props) => `...`, + generateJs: (props) => `...` + }, + // 50+ more widgets +}; +``` + +**Key patterns:** +1. **Widget as code generator** — Each widget produces its own HTML + JS at render time +2. **Shared SSE** — System stats widgets share one `EventSource('/api/stats/stream')` with a callback registry +3. **Edit/View mode toggle** — Widget JS stops in edit mode, resumes in view mode +4. **20px grid snapping** — All positions snap to grid during drag +5. **Icon theming** — Dual emoji + Phosphor icon map per widget type + +### Layout Persistence Schema + +```json +{ + "canvas": { "width": 1920, "height": 1080 }, + "fontScale": 1.0, + "widgets": [ + { + "id": "widget-1", + "type": "weather", + "x": 20, "y": 40, + "width": 200, "height": 120, + "properties": { "title": "Weather", "location": "Kansas City", "units": "F" } + } + ] +} +``` + +Saved via `POST /config` with `Content-Type: application/json`. Loaded on startup, starts in view mode. + +### What Mosaic Stack Already Has + +Mosaic's dashboard (`page.tsx`) already has: +- ✅ `WidgetGrid` with `react-grid-layout` +- ✅ `WidgetPlacement` type in `@mosaic/shared` +- ✅ Layout CRUD API (`fetchDefaultLayout`, `createLayout`, `updateLayout`) +- ✅ `DEFAULT_LAYOUT` for new users +- ✅ Debounced auto-save on layout change (800ms) + +**Gap:** Widget drag-and-drop may need enabling. No dynamic widget registration or per-widget config panel yet. + +### Recommendations + +| Priority | Feature | Effort | Impact | +|----------|---------|--------|--------| +| 🔴 High | Verify/enable drag-and-drop in WidgetGrid | 30min | Core UX | +| 🔴 High | Widget picker modal (add/remove) | 1h | Customization | +| 🟡 Med | Per-widget config dialog | 2h | Deeper customization | +| 🟢 Low | Layout template presets | 2h | Onboarding | + +--- + +## 2. Usage Tracking (VidClaw) + +### Backend: JSONL Session Parsing + +VidClaw's `server/controllers/usage.js` reads OpenClaw session transcript files directly: + +```javascript +export function getUsage(req, res) { + const sessionsDir = path.join(OPENCLAW_DIR, 'agents', 'main', 'sessions'); + const tz = getTimezone(); + const todayStart = startOfDayInTz(now, tz); + const weekStart = startOfWeekInTz(now, tz); + + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl')); + for (const file of files) { + for (const line of content.split('\n').filter(Boolean)) { + const entry = JSON.parse(line); + const usage = entry.message?.usage || entry.usage; + if (usage?.cost?.total) { + const tokens = (usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0); + const cost = usage.cost.total; + // Aggregate by day/week/month... + } + } + } + + // Also: 5-hour rolling "session" window + const SESSION_LIMIT = 45_000_000; + const WEEKLY_LIMIT = 180_000_000; + + res.json({ + model: 'claude-sonnet-4-20250514', + tiers: [ + { label: 'Current session', percent: 45, resetsIn: '2h 15m', tokens: 20000000, cost: 12.50 }, + { label: 'Current week', percent: 32, resetsIn: '4d 8h', tokens: 58000000, cost: 38.20 } + ], + details: { + today: { tokens, cost, sessions }, + week: { tokens, cost, sessions }, + month: { tokens, cost, sessions } + } + }); +} +``` + +**Key design choices:** +- Multi-tier limits (session 45M + weekly 180M tokens) +- Timezone-aware day/week boundaries +- Rolling 5-hour session window +- Includes cost tracking from `usage.cost.total` + +### Frontend: Compact Header Widget + +VidClaw's `UsageWidget.tsx` is a **popover in the header bar** — not a full page: + +```tsx +export default function UsageWidget() { + const [expanded, setExpanded] = useState(false); + const { data: usage } = useUsage(); + + const sessionPct = usage?.tiers?.[0]?.percent ?? 0; + const pillColor = sessionPct > 80 ? 'text-red-400' : sessionPct > 60 ? 'text-amber-400' : 'text-emerald-400'; + + return ( +
+ + + {expanded && ( +
+ {/* Model selector */} + + {/* Progress bars per tier */} + {tiers.map(tier => )} +
+ )} +
+ ); +} +``` + +Color coding: green (<60%), amber (60-80%), red (>80%). Includes model switcher. + +### What Mosaic Stack Has + +Full usage page (430+ lines) with Recharts: line charts, bar charts, pie charts, time range selector. **But no compact header widget.** + +### Recommendations + +| Priority | Feature | Effort | Impact | +|----------|---------|--------|--------| +| 🔴 High | Compact UsageWidget in header | 30min | Always-visible usage | +| 🔴 High | Session + weekly limit % | 1h | Know quota status | +| 🟡 Med | Model switcher in popover | 30min | Quick model changes | +| 🟢 Low | JSONL parsing backend | 3h | Real-time session tracking | + +--- + +## 3. Soul/Config Editor (VidClaw) + +### Backend + +```javascript +// server/controllers/soul.js +const FILE_TABS = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'AGENTS.md']; + +export function getSoul(req, res) { + const content = fs.readFileSync(path.join(WORKSPACE, 'SOUL.md'), 'utf-8'); + res.json({ content, lastModified: stat.mtime.toISOString() }); +} + +export function putSoul(req, res) { + const old = fs.readFileSync(fp, 'utf-8'); + if (old) appendHistory(histPath, old); // Auto-version on every save + fs.writeFileSync(fp, req.body.content); + res.json({ success: true }); +} + +export function getSoulHistory(req, res) { + res.json(readHistoryFile('soul-history.json')); + // Returns: [{ content, timestamp }] +} + +export function revertSoul(req, res) { + appendHistory(histPath, currentContent); // Backup before revert + fs.writeFileSync(fp, history[req.body.index].content); + res.json({ success: true, content }); +} +``` + +### Frontend + +`SoulEditor.tsx` (10KB) — full-featured editor: + +1. **File tabs** — SOUL.md, IDENTITY.md, USER.md, AGENTS.md +2. **Code editor** — Textarea with Tab support, Ctrl+S save +3. **Right sidebar** with two tabs: + - **Templates** — Pre-built soul templates, click to preview, "Use Template" to apply + - **History** — Reverse-chronological versions, click to preview, hover to show "Revert" +4. **Footer** — Char count, last modified timestamp, dirty indicator, Reset/Save buttons +5. **Dirty state** — Yellow dot on tab, "Unsaved changes" warning, confirm before switching tabs + +### Recommendations for Mosaic Stack + +| Priority | Feature | Effort | Impact | +|----------|---------|--------|--------| +| 🔴 High | Basic editor page with file tabs | 1h | Removes CLI dependency | +| 🔴 High | Save + auto-version history | 30min | Safety net for edits | +| 🟡 Med | Template sidebar | 1h | Onboarding for new users | +| 🟡 Med | Preview before apply/revert | 30min | Prevent mistakes | +| 🟢 Low | Syntax highlighting (Monaco) | 1h | Polish | + +**NestJS endpoint sketch:** +```typescript +@Controller('workspace') +export class WorkspaceController { + @Get('file') + getFile(@Query('name') name: string) { + // Validate name is in allowed list + // Read from workspace dir, return { content, lastModified } + } + + @Put('file') + putFile(@Query('name') name: string, @Body() body: { content: string }) { + // Append old content to history JSON + // Write new content + } + + @Get('file/history') + getHistory(@Query('name') name: string) { + // Return history entries + } +} +``` + +--- + +## 4. Skills Manager (VidClaw) + +### Backend: Skill Scanning + +`server/lib/skills.js` scans multiple directories for skills: + +```javascript +const SKILL_SCAN_DIRS = { + bundled: ['/opt/openclaw/skills'], + managed: ['~/.config/mosaic/skills'], + workspace: ['~/.openclaw/workspace/skills'] +}; + +export function scanSkills() { + const config = readOpenclawJson(); + const entries = config.skills?.entries || {}; // Enabled/disabled state + + for (const [source, roots] of Object.entries(SKILL_SCAN_DIRS)) { + for (const d of fs.readdirSync(rootDir, { withFileTypes: true })) { + const content = fs.readFileSync(path.join(d.name, 'SKILL.md'), 'utf-8'); + const fm = parseFrontmatter(content); // Parse YAML frontmatter + + skills.push({ + id: d.name, + name: fm.name || d.name, + description: fm.description || '', + source, // 'bundled' | 'managed' | 'workspace' + enabled: entries[id]?.enabled ?? true, + path: skillPath, + }); + } + } + return skills; +} +``` + +### Backend: CRUD + +```javascript +// Toggle: writes to openclaw.json config +export function toggleSkill(req, res) { + config.skills.entries[id] = { enabled: !current }; + writeOpenclawJson(config); +} + +// Create: writes SKILL.md with frontmatter +export function createSkill(req, res) { + const dir = path.join(SKILLS_DIRS.workspace, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${desc}\n---\n\n${instructions}`); +} + +// Delete: workspace skills only +export function deleteSkill(req, res) { + if (skill.source !== 'workspace') return res.status(403); + fs.rmSync(skill.path, { recursive: true }); +} +``` + +### Frontend + +`SkillsManager.tsx` (12KB): + +1. **Stats cards** — Total, Enabled, Bundled, Workspace counts +2. **Filters** — Search, source filter dropdown, status filter dropdown +3. **Skill cards** — Name + source badge + toggle switch + expand/collapse +4. **Expanded view** — Shows full SKILL.md content (lazy-loaded) +5. **Create modal** — Name (slug), description, instructions (markdown textarea) +6. **Source badges** — Color-coded: blue=bundled, orange=managed, green=workspace +7. **Delete** — Only workspace skills, with confirmation + +### Recommendations for Mosaic Stack + +| Priority | Feature | Effort | Impact | +|----------|---------|--------|--------| +| 🔴 High | Skills list with toggle | 1h | Visibility + control | +| 🟡 Med | Create skill modal | 1h | No CLI needed | +| 🟡 Med | Skill content viewer | 30min | See what skills do | +| 🟢 Low | Search + filters | 30min | Polish for 100+ skills | + +--- + +## 5. Quick Wins — Prioritized Implementation Plan + +### 🚀 #1: Compact Usage Widget in Header (30 min) +- Create `components/UsageWidget.tsx` using shadcn `Popover` + `Progress` +- Reuse existing `useUsageSummary` hook +- Add to authenticated layout header +- Color-code: green/amber/red based on percentage + +### 🚀 #2: Enable Widget Drag-and-Drop (30 min) +- Check `WidgetGrid` for `isDraggable`/`static` props +- Enable drag + resize in react-grid-layout +- Verify auto-save still works after moves + +### 🚀 #3: Soul Editor Page (1-2h) +- New page: `settings/soul/page.tsx` +- File tabs: SOUL.md, IDENTITY.md, USER.md, AGENTS.md +- Backend: `GET/PUT /api/workspace/file?name=SOUL.md` +- Auto-version history on save +- Simple Textarea with Save button + +### 🚀 #4: Skills List + Toggle (1-2h) +- New page: `settings/skills/page.tsx` +- Backend: `GET /api/skills`, `POST /api/skills/:id/toggle` +- Scan skill directories, parse frontmatter +- Toggle switch per skill using shadcn `Switch` + +### 🚀 #5: Dashboard Empty State (30 min) +- Show "Add your first widget" card when layout is empty +- Link to widget picker + +**Total estimated effort for all 5: ~4-5 hours for a dramatically more complete UI.** + +--- + +## 6. Schemas Worth Borrowing + +### Skill Type (for Mosaic Stack shared package) +```typescript +interface Skill { + id: string; + name: string; + description: string; + source: 'bundled' | 'managed' | 'workspace'; + enabled: boolean; + path: string; +} +``` + +### Usage Tier Type +```typescript +interface UsageTier { + label: string; + percent: number; + resetsIn: string; + tokens: number; + cost: number; +} +``` + +### Widget Definition Type (if building registry) +```typescript +interface WidgetDefinition { + id: string; + name: string; + icon: string; + category: 'kpi' | 'chart' | 'list' | 'system'; + description: string; + defaultSize: { w: number; h: number }; + configSchema?: Record; + component: React.ComponentType; +} +``` + +--- + +## Key File References + +### LobsterBoard +- `js/widgets.js` — 50+ widget definitions with HTML/JS generators +- `js/builder.js` — Canvas, drag-drop, resize, edit/view mode, config save/load + +### VidClaw +- `server/controllers/usage.js` — JSONL token parsing, multi-tier limits +- `server/controllers/soul.js` — SOUL.md CRUD + version history +- `server/controllers/skills.js` — Skills CRUD (toggle, create, delete) +- `server/lib/skills.js` — Directory scanning + frontmatter parsing +- `src/components/Usage/UsageWidget.tsx` — Compact header usage popover +- `src/components/Soul/SoulEditor.tsx` — Multi-file editor with history + templates +- `src/components/Skills/SkillsManager.tsx` — Skills list, filter, toggle, create + +--- + +*Research completed 2026-03-01 by subagent for Mosaic Stack development.* diff --git a/docs/research/03-security-fleet-synthesis.md b/docs/research/03-security-fleet-synthesis.md new file mode 100644 index 0000000..1c181d9 --- /dev/null +++ b/docs/research/03-security-fleet-synthesis.md @@ -0,0 +1,163 @@ +# Security Patterns, Lightweight Monitors & Final 10% Synthesis + +**Research Date:** 2026-03-01 +**Repositories Analyzed:** +1. [tugcantopaloglu/openclaw-dashboard](https://github.com/tugcantopaloglu/openclaw-dashboard) — Security-hardened: TOTP MFA, PBKDF2, rate limiting, memory viewer, cron manager +2. [Temaki-AI/clawd-control](https://github.com/Temaki-AI/clawd-control) — Lightweight fleet monitor, auto-discovery, agent creation wizard +3. [spleck/claw-dashboard](https://github.com/spleck/claw-dashboard) — Terminal-style monitor, btop-inspired +4. [23blocks-OS/ai-maestro](https://github.com/23blocks-OS/ai-maestro) — Agent-to-agent messaging, AMP protocol, multi-machine mesh + +--- + +## 1. Memory/File Viewer (openclaw-dashboard) + +**How it works:** Reads workspace files directly from filesystem — MEMORY.md, HEARTBEAT.md, memory/YYYY-MM-DD.md. Two API endpoints: `GET /api/memory-files` (list) and `GET /api/memory-file?path=` (read content). Frontend is a simple file browser + markdown viewer. Edits create `.bak` backup files automatically. + +**Security:** Path traversal protection validates all paths stay within workspace root. Read-only by default; edit requires explicit action. + +**Simplest implementation for Mosaic Stack:** +- NestJS controller with 2 endpoints (list files, read file) +- Path validation middleware (resolve path, check it starts with workspace root) +- Next.js page: left sidebar file tree + right panel markdown render +- Use `react-markdown` for rendering (already likely in deps) +- **Effort: 1-2h** + +--- + +## 2. Cron Job Management UI (openclaw-dashboard) + +**How it works:** Reads cron jobs from `$OPENCLAW_DIR/cron/jobs.json`. Three endpoints: +- `GET /api/crons` — list all jobs with status +- `POST /api/cron/:id/toggle` — enable/disable +- `POST /api/cron/:id/run` — manually trigger + +Frontend: table with Name | Schedule | Status | Last Run | Actions columns. Toggle switches and "Run Now" buttons. + +**For Mosaic Stack:** Could be a Settings sub-tab ("Automation"). Back-end reads from DB or config file. NestJS `@nestjs/schedule` already supports cron — just need UI visibility into what's scheduled. + +**Effort: 2-3h** + +--- + +## 3. Agent Creation Wizard (clawd-control) + +**How it works:** Guided multi-step form at `create.html`. Agent config fields: +```json +{ + "id": "my-agent", + "gatewayAgentId": "main", + "name": "My Agent", + "emoji": "🤖", + "host": "127.0.0.1", + "port": 18789, + "token": "YOUR_GATEWAY_TOKEN", + "workspace": "/path/to/agent/workspace" +} +``` + +Backend provisioning logic in `create-agent.mjs`. Auto-discovery via `discover.mjs` finds local agents automatically. + +**For Mosaic Stack:** Already has agents table in DB. Add a "Create Agent" dialog/wizard with: name, type/model, emoji, connection details, workspace path. Multi-step or single form — single form is faster to build. + +**Effort: 2-4h** + +--- + +## 4. Fleet Overview UX (all dashboards) + +**What good looks like:** + +| Dashboard | Approach | Key Insight | +|-----------|----------|-------------| +| clawd-control | Grid of agent cards, single-screen | "See all agents at a glance with health indicators" | +| openclaw-dashboard | Sidebar + tabs, sparklines, heatmaps | Rich metrics: sessions, costs, rate limits | +| claw-dashboard | Terminal btop-style, 2s refresh | Lightweight, resource-efficient | +| ai-maestro | Tree view with auto-coloring | `project-backend-api` → 3-level tree | + +**Key metrics that matter:** +- Status indicator (online/offline/error) — most important +- Last activity timestamp +- Active session count +- Token usage / cost +- CPU/RAM (if host-level monitoring) +- Error count (last 24h) + +**Recommended for Mosaic Stack:** Card grid layout. Each card: emoji + name, colored status dot, last activity time, token count. Click to expand/detail. Add a "Recent Activity" feed below the grid. + +**Effort: 3-4h** + +--- + +## 5. AMP Protocol (ai-maestro) + +**What it is:** Agent Messaging Protocol — email-like communication between agents. Priority levels, message types, cryptographic signatures, push notifications. Full spec at agentmessaging.org. + +**Key concept:** "I was the human mailman between 35 agents. AMP removes the human bottleneck." + +**Worth borrowing for Mosaic Stack:** +- Simple agent-to-agent message table in PostgreSQL (already have DB) +- Priority levels (low/normal/high) +- Message types (task/notification/query) +- Thread awareness (threadId field) + +**NOT worth borrowing (yet):** +- Cryptographic signatures (overkill) +- Multi-machine mesh (premature) +- Full AMP protocol compliance (too complex) + +**Simple alternative:** Add a `messages` table to Prisma schema with fromAgentId, toAgentId, type, priority, subject, body, threadId, readAt. Poll or WebSocket for delivery. **Effort: 4-8h** + +--- + +## 6. Security Patterns Worth Adopting + +**From openclaw-dashboard (already mature in Mosaic Stack):** + +| Pattern | openclaw-dashboard | Mosaic Stack Status | Action | +|---------|-------------------|-------------------|--------| +| Password hashing | PBKDF2, 100k iterations | Better Auth handles this | ✅ Done | +| CSRF protection | N/A (session-based) | Better Auth CSRF | ✅ Done | +| RBAC | N/A | Full RBAC implemented | ✅ Done | +| Rate limiting | 5 fail → 15min lockout | Not implemented | Add NestJS throttler | +| TOTP MFA | Google Auth compatible | Not implemented | P2 — Better Auth plugin exists | +| Audit logging | All auth events logged | Not implemented | Add NestJS middleware | +| Security headers | HSTS, CSP, X-Frame | Partial | Add helmet middleware | + +**Quick wins:** +- `@nestjs/throttler` for rate limiting (30min) +- `helmet` middleware for security headers (15min) +- Audit log table + middleware (1-2h) + +--- + +## 7. Real-Time Updates Pattern + +All four dashboards use real-time updates differently: +- openclaw-dashboard: SSE (`/api/live`) +- clawd-control: SSE +- claw-dashboard: Polling (2s interval) +- ai-maestro: WebSocket + +**For Mosaic Stack:** Already has WebSocket for terminal. Use SSE for fleet status (simpler than WebSocket, one-directional is fine). Polling for non-critical pages. + +--- + +## Feature Comparison Matrix + +| Feature | openclaw-dash | clawd-control | claw-dash | ai-maestro | Mosaic Stack | +|---------|:---:|:---:|:---:|:---:|:---:| +| Session mgmt | ✅ | ✅ | ✅ | ✅ | ✅ | +| Memory viewer | ✅ | ❌ | ❌ | ✅ | ❌ | +| Cron mgmt | ✅ | ❌ | ❌ | ❌ | ❌ | +| Agent wizard | ❌ | ✅ | ❌ | ✅ | ❌ | +| Fleet overview | ✅ | ✅ | ❌ | ✅ | Partial | +| Multi-machine | ❌ | ❌ | ❌ | ✅ | ❌ | +| Agent messaging | ❌ | ❌ | ❌ | ✅ | ❌ | +| Rate limiting | ✅ | ✅ | ❌ | ❌ | ❌ | +| TOTP MFA | ✅ | ❌ | ❌ | ❌ | ❌ | +| Real-time | SSE | SSE | Poll | WS | WS (terminal) | +| Cost tracking | ✅ | ❌ | ❌ | ❌ | ✅ (usage) | +| Terminal UI | ❌ | ❌ | ✅ | ❌ | ✅ (xterm.js) | +| Kanban | ❌ | ❌ | ❌ | ✅ | ✅ | +| Auth | PBKDF2+MFA | Password | None | N/A | Better Auth | +| RBAC | ❌ | ❌ | ❌ | ❌ | ✅ | -- 2.49.1