fix: Logs page empty — wrong table + interceptor + autoRefresh #636
1
.claude/worktrees/agent-a56bac50
Submodule
1
.claude/worktrees/agent-a56bac50
Submodule
Submodule .claude/worktrees/agent-a56bac50 added at c15456a779
1
.worktrees/feat-ms21-ui-teams-rbac
Submodule
1
.worktrees/feat-ms21-ui-teams-rbac
Submodule
Submodule .worktrees/feat-ms21-ui-teams-rbac added at c640d22394
1
.worktrees/feat-ms22-openclaw-gateway-module
Submodule
1
.worktrees/feat-ms22-openclaw-gateway-module
Submodule
Submodule .worktrees/feat-ms22-openclaw-gateway-module added at b13ff68e22
@@ -396,7 +396,7 @@ model Task {
|
|||||||
subtasks Task[] @relation("TaskSubtasks")
|
subtasks Task[] @relation("TaskSubtasks")
|
||||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@index([workspaceId, dueDate])
|
@@index([workspaceId, dueDate])
|
||||||
@@ -430,7 +430,7 @@ model Event {
|
|||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, startTime])
|
@@index([workspaceId, startTime])
|
||||||
@@index([creatorId])
|
@@index([creatorId])
|
||||||
@@ -462,7 +462,7 @@ model Project {
|
|||||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||||
ideas Idea[]
|
ideas Idea[]
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@index([creatorId])
|
@@index([creatorId])
|
||||||
@@ -472,7 +472,7 @@ model Project {
|
|||||||
|
|
||||||
model ActivityLog {
|
model ActivityLog {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
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
|
userId String @map("user_id") @db.Uuid
|
||||||
action ActivityAction
|
action ActivityAction
|
||||||
entityType EntityType @map("entity_type")
|
entityType EntityType @map("entity_type")
|
||||||
@@ -483,10 +483,10 @@ model ActivityLog {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
|
|
||||||
// Relations
|
// 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)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, createdAt])
|
@@index([workspaceId, createdAt])
|
||||||
@@index([entityType, entityId])
|
@@index([entityType, entityId])
|
||||||
@@ -541,7 +541,7 @@ model Domain {
|
|||||||
projects Project[]
|
projects Project[]
|
||||||
ideas Idea[]
|
ideas Idea[]
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@unique([workspaceId, slug])
|
@@unique([workspaceId, slug])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@map("domains")
|
@@map("domains")
|
||||||
@@ -581,7 +581,7 @@ model Idea {
|
|||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
creator User @relation("IdeaCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation("IdeaCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@index([domainId])
|
@@index([domainId])
|
||||||
@@ -695,7 +695,7 @@ model AgentTask {
|
|||||||
runnerJobs RunnerJob[]
|
runnerJobs RunnerJob[]
|
||||||
findings Finding[]
|
findings Finding[]
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@index([createdById])
|
@@index([createdById])
|
||||||
@@ -722,7 +722,7 @@ model Finding {
|
|||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
task AgentTask? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([agentId])
|
@@index([agentId])
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@ -830,7 +830,7 @@ model UserLayout {
|
|||||||
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)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@unique([workspaceId, userId, name])
|
@@unique([workspaceId, userId, name])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("user_layouts")
|
@@map("user_layouts")
|
||||||
@@ -1149,7 +1149,7 @@ model Personality {
|
|||||||
llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull)
|
llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull)
|
||||||
workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspacePersonality")
|
workspaceLlmSettings WorkspaceLlmSettings[] @relation("WorkspacePersonality")
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@unique([workspaceId, name])
|
@@unique([workspaceId, name])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, isDefault])
|
@@index([workspaceId, isDefault])
|
||||||
@@ -1322,7 +1322,7 @@ model RunnerJob {
|
|||||||
steps JobStep[]
|
steps JobStep[]
|
||||||
events JobEvent[]
|
events JobEvent[]
|
||||||
|
|
||||||
@@unique([id, workspaceId])
|
@@unique([id])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([workspaceId, status])
|
@@index([workspaceId, status])
|
||||||
@@index([agentTaskId])
|
@@index([agentTaskId])
|
||||||
|
|||||||
@@ -117,12 +117,13 @@ export class ActivityService {
|
|||||||
/**
|
/**
|
||||||
* Get a single activity log by ID
|
* Get a single activity log by ID
|
||||||
*/
|
*/
|
||||||
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
|
async findOne(id: string, workspaceId?: string): Promise<ActivityLogResult | null> {
|
||||||
|
const where: Prisma.ActivityLogWhereUniqueInput = { id };
|
||||||
|
if (workspaceId) {
|
||||||
|
where.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
return await this.prisma.activityLog.findUnique({
|
return await this.prisma.activityLog.findUnique({
|
||||||
where: {
|
where,
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tap } from "rxjs/operators";
|
|||||||
import { ActivityService } from "../activity.service";
|
import { ActivityService } from "../activity.service";
|
||||||
import { ActivityAction, EntityType } from "@prisma/client";
|
import { ActivityAction, EntityType } from "@prisma/client";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
|
||||||
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
import type { AuthenticatedRequest } from "../../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,10 +62,13 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
// Extract entity information
|
// Extract entity information
|
||||||
const resultObj = result as Record<string, unknown> | undefined;
|
const resultObj = result as Record<string, unknown> | undefined;
|
||||||
const entityId = params.id ?? (resultObj?.id as string | 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);
|
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
||||||
|
|
||||||
if (!entityId || !workspaceId) {
|
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
|
||||||
this.logger.warn("Cannot log activity: missing entityId or workspaceId");
|
if (!entityId) {
|
||||||
|
this.logger.warn("Cannot log activity: missing entityId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +96,8 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
const userAgent =
|
const userAgent =
|
||||||
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
|
||||||
|
|
||||||
// Log the activity
|
// Log the activity — workspaceId is optional
|
||||||
await this.activityService.logActivity({
|
const activityInput: CreateActivityLogInput = {
|
||||||
workspaceId,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
action,
|
action,
|
||||||
entityType,
|
entityType,
|
||||||
@@ -102,7 +105,11 @@ export class ActivityLoggingInterceptor implements NestInterceptor {
|
|||||||
details,
|
details,
|
||||||
ipAddress: ip ?? undefined,
|
ipAddress: ip ?? undefined,
|
||||||
userAgent: userAgent ?? undefined,
|
userAgent: userAgent ?? undefined,
|
||||||
});
|
};
|
||||||
|
if (workspaceId) {
|
||||||
|
activityInput.workspaceId = workspaceId;
|
||||||
|
}
|
||||||
|
await this.activityService.logActivity(activityInput);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't fail the request if activity logging fails
|
// Don't fail the request if activity logging fails
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { ActivityAction, EntityType, Prisma } from "@prisma/client";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for creating a new activity log entry
|
* Interface for creating a new activity log entry
|
||||||
|
* workspaceId is optional - allows logging events without workspace context
|
||||||
*/
|
*/
|
||||||
export interface CreateActivityLogInput {
|
export interface CreateActivityLogInput {
|
||||||
workspaceId: string;
|
workspaceId?: string | null;
|
||||||
userId: string;
|
userId: string;
|
||||||
action: ActivityAction;
|
action: ActivityAction;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
|
|||||||
@@ -4,21 +4,39 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
import {
|
||||||
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
fetchActivityLogs,
|
||||||
|
ActivityAction,
|
||||||
|
EntityType,
|
||||||
|
type ActivityLog,
|
||||||
|
type ActivityLogFilters,
|
||||||
|
} from "@/lib/api/activity";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
type ActionFilter = "all" | ActivityAction;
|
||||||
|
type EntityFilter = "all" | EntityType;
|
||||||
type DateRange = "24h" | "7d" | "30d" | "all";
|
type DateRange = "24h" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
const ACTION_OPTIONS: { value: ActionFilter; label: string }[] = [
|
||||||
{ value: "all", label: "All statuses" },
|
{ value: "all", label: "All actions" },
|
||||||
{ value: "running", label: "Running" },
|
{ value: ActivityAction.CREATED, label: "Created" },
|
||||||
{ value: "completed", label: "Completed" },
|
{ value: ActivityAction.UPDATED, label: "Updated" },
|
||||||
{ value: "failed", label: "Failed" },
|
{ value: ActivityAction.DELETED, label: "Deleted" },
|
||||||
{ value: "queued", label: "Queued" },
|
{ 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 }[] = [
|
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
||||||
@@ -28,37 +46,37 @@ const DATE_RANGES: { value: DateRange; label: string }[] = [
|
|||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
|
||||||
all: undefined,
|
|
||||||
running: [RunnerJobStatus.RUNNING],
|
|
||||||
completed: [RunnerJobStatus.COMPLETED],
|
|
||||||
failed: [RunnerJobStatus.FAILED],
|
|
||||||
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
switch (status) {
|
[ActivityAction.CREATED]: "var(--ms-teal-400)",
|
||||||
case "RUNNING":
|
[ActivityAction.UPDATED]: "var(--ms-blue-400)",
|
||||||
return "var(--ms-amber-400)";
|
[ActivityAction.DELETED]: "var(--danger)",
|
||||||
case "COMPLETED":
|
[ActivityAction.COMPLETED]: "var(--ms-emerald-400)",
|
||||||
return "var(--ms-teal-400)";
|
[ActivityAction.ASSIGNED]: "var(--ms-amber-400)",
|
||||||
case "FAILED":
|
};
|
||||||
case "CANCELLED":
|
|
||||||
return "var(--danger)";
|
function getActionColor(action: string): string {
|
||||||
case "QUEUED":
|
return ACTION_COLORS[action] ?? "var(--muted)";
|
||||||
case "PENDING":
|
|
||||||
return "var(--ms-blue-400)";
|
|
||||||
default:
|
|
||||||
return "var(--muted)";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
if (!dateStr) return "\u2014";
|
[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 date = new Date(dateStr);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diffMs = now - date.getTime();
|
const diffMs = now - date.getTime();
|
||||||
@@ -74,29 +92,6 @@ function formatRelativeTime(dateStr: string | null): string {
|
|||||||
return date.toLocaleDateString();
|
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 {
|
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
||||||
if (range === "all") return true;
|
if (range === "all") return true;
|
||||||
const date = new Date(dateStr);
|
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;
|
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Status Badge ─────────────────────────────────────────────────────
|
// ─── Action Badge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }): ReactElement {
|
function ActionBadge({ action }: { action: string }): ReactElement {
|
||||||
const color = getStatusColor(status);
|
const color = getActionColor(action);
|
||||||
const isRunning = status === "RUNNING";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
|
||||||
padding: "2px 10px",
|
padding: "2px 10px",
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
@@ -127,18 +120,7 @@ function StatusBadge({ status }: { status: string }): ReactElement {
|
|||||||
textTransform: "capitalize",
|
textTransform: "capitalize",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isRunning && (
|
{action.toLowerCase()}
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: color,
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{status.toLowerCase()}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,59 +131,55 @@ export default function LogsPage(): ReactElement {
|
|||||||
const workspaceId = useWorkspaceId();
|
const workspaceId = useWorkspaceId();
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
const [activities, setActivities] = useState<ActivityLog[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Expanded job and steps
|
|
||||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
|
||||||
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
|
||||||
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [actionFilter, setActionFilter] = useState<ActionFilter>("all");
|
||||||
|
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Auto-refresh
|
// Auto-refresh
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Hover state
|
|
||||||
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ─── Data Loading ─────────────────────────────────────────────────
|
// ─── Data Loading ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const loadJobs = useCallback(async (): Promise<void> => {
|
const loadActivities = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
const filters: ActivityLogFilters = {};
|
||||||
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
filters.workspaceId = workspaceId;
|
filters.workspaceId = workspaceId;
|
||||||
}
|
}
|
||||||
if (statusEnums) {
|
if (actionFilter !== "all") {
|
||||||
filters.status = statusEnums;
|
filters.action = actionFilter;
|
||||||
|
}
|
||||||
|
if (entityFilter !== "all") {
|
||||||
|
filters.entityType = entityFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchRunnerJobs(filters);
|
const response: Awaited<ReturnType<typeof fetchActivityLogs>> =
|
||||||
setJobs(data);
|
await fetchActivityLogs(filters);
|
||||||
|
setActivities(response);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[Logs] Failed to fetch runner jobs:", err);
|
console.error("[Logs] Failed to fetch activity logs:", err);
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? 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
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
loadJobs()
|
loadActivities()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -216,13 +194,13 @@ export default function LogsPage(): ReactElement {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadJobs]);
|
}, [loadActivities]);
|
||||||
|
|
||||||
// Auto-refresh polling
|
// Auto-refresh polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void loadJobs();
|
void loadActivities();
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} else if (intervalRef.current) {
|
} else if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
@@ -235,55 +213,22 @@ export default function LogsPage(): ReactElement {
|
|||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [autoRefresh, loadJobs]);
|
}, [autoRefresh, loadActivities]);
|
||||||
|
|
||||||
// ─── 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]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Filtering ────────────────────────────────────────────────────
|
// ─── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filteredJobs = jobs.filter((job) => {
|
const filteredActivities = activities.filter((activity) => {
|
||||||
// Date range filter
|
// Date range filter
|
||||||
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
if (!isWithinDateRange(activity.createdAt, dateRange)) return false;
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const matchesType = job.type.toLowerCase().includes(q);
|
const matchesEntity = getEntityTypeLabel(activity.entityType).toLowerCase().includes(q);
|
||||||
const matchesId = job.id.toLowerCase().includes(q);
|
const matchesId = activity.entityId.toLowerCase().includes(q);
|
||||||
if (!matchesType && !matchesId) return false;
|
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;
|
return true;
|
||||||
@@ -293,7 +238,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
const handleManualRefresh = (): void => {
|
const handleManualRefresh = (): void => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
void loadJobs().finally(() => {
|
void loadActivities().finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -307,16 +252,12 @@ export default function LogsPage(): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
{/* Pulse animation for running status */}
|
{/* Pulse animation for auto-refresh */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
@keyframes auto-refresh-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* ─── Header ─────────────────────────────────────────────── */}
|
{/* ─── Header ─────────────────────────────────────────────── */}
|
||||||
@@ -332,10 +273,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
Logs & Telemetry
|
Activity Logs
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
Runner job history and step-level detail
|
Audit trail and activity history
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,11 +349,11 @@ export default function LogsPage(): ReactElement {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Status filter */}
|
{/* Action filter */}
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setStatusFilter(e.target.value as StatusFilter);
|
setActionFilter(e.target.value as ActionFilter);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
@@ -425,7 +366,31 @@ export default function LogsPage(): ReactElement {
|
|||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
{ACTION_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Entity filter */}
|
||||||
|
<select
|
||||||
|
value={entityFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEntityFilter(e.target.value as EntityFilter);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--surface)",
|
||||||
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: 140,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ENTITY_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
@@ -467,7 +432,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by job type..."
|
placeholder="Search by entity or user..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -487,9 +452,9 @@ export default function LogsPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Content ────────────────────────────────────────────── */}
|
{/* ─── Content ────────────────────────────────────────────── */}
|
||||||
{isLoading && jobs.length === 0 ? (
|
{isLoading && activities.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
<MosaicSpinner label="Loading jobs..." />
|
<MosaicSpinner label="Loading activity logs..." />
|
||||||
</div>
|
</div>
|
||||||
) : error !== null ? (
|
) : error !== null ? (
|
||||||
<div
|
<div
|
||||||
@@ -508,7 +473,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredJobs.length === 0 ? (
|
) : filteredActivities.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-8 text-center"
|
className="rounded-lg p-8 text-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -516,10 +481,10 @@ export default function LogsPage(): ReactElement {
|
|||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
<p style={{ color: "var(--text-muted)" }}>No activity logs found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ─── Job Table ──────────────────────────────────────────── */
|
/* ─── Activity Table ──────────────────────────────────────── */
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -535,7 +500,7 @@ export default function LogsPage(): ReactElement {
|
|||||||
background: "var(--bg-mid)",
|
background: "var(--bg-mid)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
{["Action", "Entity", "User", "Details", "Time"].map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header}
|
key={header}
|
||||||
style={{
|
style={{
|
||||||
@@ -556,32 +521,9 @@ export default function LogsPage(): ReactElement {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredJobs.map((job) => {
|
{filteredActivities.map((activity) => (
|
||||||
const isExpanded = expandedJobId === job.id;
|
<ActivityRow key={activity.id} activity={activity} />
|
||||||
const isHovered = hoveredRowId === job.id;
|
))}
|
||||||
const steps = jobStepsMap[job.id];
|
|
||||||
const isStepsLoading = stepsLoading.has(job.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobRow
|
|
||||||
key={job.id}
|
|
||||||
job={job}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isHovered={isHovered}
|
|
||||||
steps={steps}
|
|
||||||
isStepsLoading={isStepsLoading}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleExpand(job.id);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHoveredRowId(job.id);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHoveredRowId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,260 +533,91 @@ export default function LogsPage(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Job Row Component ────────────────────────────────────────────────
|
// ─── Activity 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 (
|
|
||||||
<>
|
|
||||||
<tr
|
|
||||||
onClick={onToggle}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
style={{
|
|
||||||
background: isExpanded
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: isHovered
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: "var(--surface)",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
|
||||||
transition: "background 100ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
transition: "transform 150ms ease",
|
|
||||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
{job.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px 16px" }}>
|
|
||||||
<StatusBadge status={job.status} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDuration(job.startedAt, job.completedAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{steps ? String(steps.length) : "\u2014"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Expanded Steps Section */}
|
|
||||||
{isExpanded && (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
padding: "12px 16px 12px 48px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isStepsLoading ? (
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
|
||||||
<MosaicSpinner size={24} label="Loading steps..." />
|
|
||||||
</div>
|
|
||||||
) : !steps || steps.length === 0 ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No steps recorded for this job
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
|
||||||
<th
|
|
||||||
key={header}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{steps
|
|
||||||
.sort((a, b) => a.ordinal - b.ordinal)
|
|
||||||
.map((step) => (
|
|
||||||
<StepRow key={step.id} step={step} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Job error message if failed */}
|
|
||||||
{job.error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
|
||||||
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{job.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step Row Component ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StepRow({ step }: { step: JobStep }): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
|
function ActivityRow({ activity }: { activity: ActivityLog }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
background: "var(--surface)",
|
||||||
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
borderBottom: "1px solid var(--border)",
|
||||||
transition: "background 100ms ease",
|
transition: "background 100ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td style={{ padding: "12px 16px" }}>
|
||||||
style={{
|
<ActionBadge action={activity.action} />
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{String(step.ordinal)}
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step.name}
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<span>{getEntityTypeLabel(activity.entityType)}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.entityId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.82rem",
|
||||||
fontFamily: "var(--mono)",
|
color: "var(--text)",
|
||||||
color: "var(--text-muted)",
|
|
||||||
textTransform: "lowercase",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step.phase}
|
{activity.user ? (
|
||||||
</td>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<td style={{ padding: "6px 12px" }}>
|
<span>{activity.user.name ?? activity.user.email}</span>
|
||||||
<StatusBadge status={step.status} />
|
{activity.user.name && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--muted)" }}>—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 12px",
|
padding: "12px 16px",
|
||||||
fontSize: "0.78rem",
|
fontSize: "0.78rem",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
maxWidth: 300,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={activity.details ? JSON.stringify(activity.details) : undefined}
|
||||||
|
>
|
||||||
|
{activity.details ? JSON.stringify(activity.details) : "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "0.82rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatStepDuration(step.durationMs)}
|
{formatRelativeTime(activity.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
139
apps/web/src/lib/api/activity.ts
Normal file
139
apps/web/src/lib/api/activity.ts
Normal file
@@ -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<string, unknown> | 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<ActivityLog[]> {
|
||||||
|
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<PaginatedActivityLogs>(endpoint, filters?.workspaceId);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single activity log by ID
|
||||||
|
*/
|
||||||
|
export async function fetchActivityLog(id: string, workspaceId?: string): Promise<ActivityLog> {
|
||||||
|
return apiGet<ActivityLog>(`/api/activity/${id}`, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch audit trail for a specific entity
|
||||||
|
*/
|
||||||
|
export async function fetchAuditTrail(
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
workspaceId?: string
|
||||||
|
): Promise<ActivityLog[]> {
|
||||||
|
const response = await apiGet<ApiResponse<ActivityLog[]>>(
|
||||||
|
`/api/activity/audit/${entityType}/${entityId}`,
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from "./projects";
|
|||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./fleet-settings";
|
export * from "./fleet-settings";
|
||||||
|
export * from "./activity";
|
||||||
|
|||||||
166
docs/research/00-SUMMARY.md
Normal file
166
docs/research/00-SUMMARY.md
Normal file
@@ -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.
|
||||||
721
docs/research/01-chat-orchestration-research.md
Normal file
721
docs/research/01-chat-orchestration-research.md
Normal file
@@ -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
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{sessions.map(session => (
|
||||||
|
<div onClick={() => openSessionChat(session)}>
|
||||||
|
{/* Activity indicator */}
|
||||||
|
<div className="w-2 h-2 rounded-full"
|
||||||
|
style={{ background: activityColor, boxShadow: '0 0 6px ...' }} />
|
||||||
|
|
||||||
|
{/* Session metadata */}
|
||||||
|
<span>{session.messageCount} msgs · {session.totalTokens}k tokens</span>
|
||||||
|
<span>${session.estimatedCost.toFixed(2)}</span>
|
||||||
|
|
||||||
|
{/* Last message preview */}
|
||||||
|
<div className="truncate">
|
||||||
|
{session.lastMessages[0]?.text?.slice(0, 100)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chat View Pattern:**
|
||||||
|
```tsx
|
||||||
|
// Messages container with auto-scroll
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-3">
|
||||||
|
{chatMessages.map(msg => (
|
||||||
|
<div className={msg.role === 'user' ? 'justify-end' : 'justify-start'}>
|
||||||
|
<div className="max-w-[85%] rounded-lg px-3 py-2">
|
||||||
|
{/* Role badge */}
|
||||||
|
<span className="text-[9px] uppercase">
|
||||||
|
{msg.role === 'user' ? 'you' : 'assistant'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Markdown content */}
|
||||||
|
<div>{renderMarkdown(msg.text)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={chatEndRef} /> {/* Auto-scroll anchor */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Input with Enter to send
|
||||||
|
<input onKeyDown={e => 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:<id>:topic:<id>
|
||||||
|
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:<id>:topic:<id>` or `agent:main:main`
|
||||||
|
2. **JSONL parsing:** Read from `~/.openclaw/agents/main/sessions/<session-id>.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 (
|
||||||
|
<button onClick={startPlanning} className="px-6 py-3 bg-mc-accent">
|
||||||
|
📋 Start Planning
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Question/Answer Loop:**
|
||||||
|
```tsx
|
||||||
|
// Current question display
|
||||||
|
<h3>{state.currentQuestion.question}</h3>
|
||||||
|
|
||||||
|
// Multiple choice options
|
||||||
|
{state.currentQuestion.options.map(option => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedOption(option.label)}
|
||||||
|
className={isSelected ? 'border-mc-accent bg-mc-accent/10' : 'border-mc-border'}
|
||||||
|
>
|
||||||
|
<span className="w-8 h-8">{option.id.toUpperCase()}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{isSelected && <CheckCircle />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
// "Other" option with text input
|
||||||
|
{isOther && isSelected && (
|
||||||
|
<input placeholder="Please specify..." value={otherText} />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<Lock /> Planning Complete
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generated spec */}
|
||||||
|
<div className="bg-mc-bg border rounded-lg p-4">
|
||||||
|
<h3>{state.spec.title}</h3>
|
||||||
|
<p>{state.spec.summary}</p>
|
||||||
|
<ul>{state.spec.deliverables.map(d => <li>{d}</li>)}</ul>
|
||||||
|
<ul>{state.spec.success_criteria.map(c => <li>{c}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-created agents */}
|
||||||
|
{state.agents.map(agent => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{agent.avatar_emoji}</span>
|
||||||
|
<div>
|
||||||
|
<p>{agent.name}</p>
|
||||||
|
<p className="text-sm">{agent.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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';
|
||||||
|
|
||||||
|
<div className={isPlanning
|
||||||
|
? 'border-purple-500/40 hover:border-purple-500'
|
||||||
|
: 'border-mc-border/50 hover:border-mc-accent/40'}>
|
||||||
|
|
||||||
|
{isPlanning && (
|
||||||
|
<div className="flex items-center gap-2 py-2 px-3 bg-purple-500/10">
|
||||||
|
<div className="w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-xs text-purple-400">Continue planning</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<ReadableStreamDefaultController>();
|
||||||
|
|
||||||
|
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
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['all', 'tasks', 'agents'].map(tab => (
|
||||||
|
<button className={filter === tab ? 'bg-mc-accent' : ''}>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Event list with icons
|
||||||
|
{filteredEvents.map(event => (
|
||||||
|
<div className={`p-2 rounded border-l-2 ${
|
||||||
|
isHighlight ? 'bg-mc-bg-tertiary border-mc-accent-pink' : 'hover:bg-mc-bg-tertiary'
|
||||||
|
}`}>
|
||||||
|
<span>{getEventIcon(event.type)}</span>
|
||||||
|
<p>{event.message}</p>
|
||||||
|
<span className="text-xs">{formatDistanceToNow(event.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
<div className="card-base cursor-pointer" onClick={() => openSessionChat(session)}>
|
||||||
|
{/* Activity dot */}
|
||||||
|
<div className="w-2 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: activityColor,
|
||||||
|
boxShadow: activityColor === 'green' ? '0 0 6px rgba(0,255,106,0.5)' : undefined
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<h3 className="truncate">{session.label}</h3>
|
||||||
|
<div className="text-[9px]">
|
||||||
|
{session.messageCount} msgs · {session.totalTokens}k tokens
|
||||||
|
{session.estimatedCost > 0 && <span> · ${session.estimatedCost.toFixed(2)}</span>}
|
||||||
|
{session.model && <span> · {session.model}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last message preview */}
|
||||||
|
{session.lastMessages?.length > 0 && (
|
||||||
|
<div className="mt-2 p-2 rounded bg-secondary">
|
||||||
|
<span>{session.lastMessages[0]?.role === 'user' ? 'you: ' : 'assistant: '}</span>
|
||||||
|
<span className="truncate">{session.lastMessages[0]?.text?.slice(0, 100)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Label Mapping
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const TOPIC_NAMES: Record<string, string> = {
|
||||||
|
'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<string, PromiseHandlers>();
|
||||||
|
private connected = false;
|
||||||
|
private authenticated = false;
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// 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<T>(method: string, params?: object): Promise<T> {
|
||||||
|
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 (
|
||||||
|
<div className={`w-2 h-2 rounded-full ${color} ${glow}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
465
docs/research/02-widgets-usage-config-research.md
Normal file
465
docs/research/02-widgets-usage-config-research.md
Normal file
@@ -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: `<div>...</div>`,
|
||||||
|
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 (
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-2 bg-secondary/50 rounded-full px-4 py-1.5 text-xs">
|
||||||
|
<Zap size={12} className="text-orange-400" />
|
||||||
|
<span className="text-muted-foreground">{model}</span>
|
||||||
|
<div className="w-16 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div className={barColor} style={{ width: `${sessionPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={pillColor}>{sessionPct}%</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-card border rounded-lg shadow-xl p-4">
|
||||||
|
{/* Model selector */}
|
||||||
|
<select value={model} onChange={switchModel}>...</select>
|
||||||
|
{/* Progress bars per tier */}
|
||||||
|
{tiers.map(tier => <ProgressBar key={tier.label} {...tier} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, { type: string; label: string; default: unknown }>;
|
||||||
|
component: React.ComponentType<WidgetProps>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.*
|
||||||
163
docs/research/03-security-fleet-synthesis.md
Normal file
163
docs/research/03-security-fleet-synthesis.md
Normal file
@@ -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=<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 | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||||
Reference in New Issue
Block a user