Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
344 lines
9.2 KiB
TypeScript
344 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
|
import type { ActiveJob } from "@/lib/api/dashboard";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Internal display types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
|
|
|
type BadgeVariant =
|
|
| "badge-teal"
|
|
| "badge-amber"
|
|
| "badge-red"
|
|
| "badge-blue"
|
|
| "badge-muted"
|
|
| "badge-purple"
|
|
| "badge-pulse";
|
|
|
|
interface AgentNode {
|
|
id: string;
|
|
initials: string;
|
|
avatarColor: string;
|
|
name: string;
|
|
task: string;
|
|
status: DotVariant;
|
|
statusLabel: string;
|
|
}
|
|
|
|
interface OrchestratorSession {
|
|
id: string;
|
|
orchId: string;
|
|
name: string;
|
|
badge: string;
|
|
badgeVariant: BadgeVariant;
|
|
duration: string;
|
|
progress: number;
|
|
agents: AgentNode[];
|
|
}
|
|
|
|
export interface OrchestratorSessionsProps {
|
|
jobs?: ActiveJob[] | undefined;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Mapping helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const STEP_COLORS: string[] = [
|
|
"rgba(47,128,255,0.15)",
|
|
"rgba(20,184,166,0.15)",
|
|
"rgba(245,158,11,0.15)",
|
|
"rgba(139,92,246,0.15)",
|
|
"rgba(229,72,77,0.15)",
|
|
];
|
|
|
|
function statusToDotVariant(status: string): DotVariant {
|
|
const lower = status.toLowerCase();
|
|
if (lower === "running" || lower === "active" || lower === "completed") return "teal";
|
|
if (lower === "pending" || lower === "queued") return "blue";
|
|
if (lower === "waiting" || lower === "paused") return "amber";
|
|
if (lower === "failed" || lower === "error") return "red";
|
|
return "muted";
|
|
}
|
|
|
|
function statusToBadgeVariant(status: string): BadgeVariant {
|
|
const lower = status.toLowerCase();
|
|
if (lower === "running" || lower === "active") return "badge-teal";
|
|
if (lower === "pending" || lower === "queued") return "badge-blue";
|
|
if (lower === "waiting" || lower === "paused") return "badge-amber";
|
|
if (lower === "failed" || lower === "error") return "badge-red";
|
|
if (lower === "completed") return "badge-purple";
|
|
return "badge-muted";
|
|
}
|
|
|
|
function formatDuration(isoDate: string): string {
|
|
const now = Date.now();
|
|
const start = new Date(isoDate).getTime();
|
|
const diffMs = now - start;
|
|
|
|
if (Number.isNaN(diffMs) || diffMs < 0) return "0m";
|
|
|
|
const totalMinutes = Math.floor(diffMs / 60_000);
|
|
if (totalMinutes < 60) return `${String(totalMinutes)}m`;
|
|
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
return `${String(hours)}h ${String(minutes)}m`;
|
|
}
|
|
|
|
function initials(name: string): string {
|
|
return name
|
|
.split(/[\s\-_]+/)
|
|
.slice(0, 2)
|
|
.map((w) => w.charAt(0).toUpperCase())
|
|
.join("");
|
|
}
|
|
|
|
function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
|
const agents: AgentNode[] = job.steps.map((step, idx) => ({
|
|
id: step.id,
|
|
initials: initials(step.name),
|
|
avatarColor: STEP_COLORS[idx % STEP_COLORS.length] ?? "rgba(100,116,139,0.15)",
|
|
name: step.name,
|
|
task: `Phase: ${step.phase}`,
|
|
status: statusToDotVariant(step.status),
|
|
statusLabel: step.status.toLowerCase(),
|
|
}));
|
|
|
|
return {
|
|
id: job.id,
|
|
orchId: job.id.length > 10 ? job.id.slice(0, 10).toUpperCase() : job.id.toUpperCase(),
|
|
name: job.type,
|
|
badge: job.status,
|
|
badgeVariant: statusToBadgeVariant(job.status),
|
|
duration: formatDuration(job.createdAt),
|
|
progress: job.progressPercent,
|
|
agents,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Sub-components */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface AgentNodeItemProps {
|
|
agent: AgentNode;
|
|
}
|
|
|
|
function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<div
|
|
onMouseEnter={(): void => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={(): void => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
padding: "7px 10px",
|
|
borderRadius: "var(--r-sm)",
|
|
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
|
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
|
transition: "border-color 0.15s, background 0.15s",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 6,
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontWeight: 700,
|
|
fontFamily: "var(--mono)",
|
|
fontSize: "0.7rem",
|
|
background: agent.avatarColor,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{agent.initials}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "0.8rem",
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{agent.name}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--muted)",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{agent.task}
|
|
</div>
|
|
</div>
|
|
<Dot variant={agent.status} />
|
|
<span
|
|
style={{
|
|
fontSize: "0.65rem",
|
|
fontFamily: "var(--mono)",
|
|
color: "var(--muted)",
|
|
textTransform: "uppercase",
|
|
}}
|
|
>
|
|
{agent.statusLabel}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface OrchCardProps {
|
|
session: OrchestratorSession;
|
|
}
|
|
|
|
function OrchCard({ session }: OrchCardProps): ReactElement {
|
|
return (
|
|
<div
|
|
style={{
|
|
background: "var(--bg-mid)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r)",
|
|
padding: "12px 14px",
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<Dot variant={statusToDotVariant(session.badge)} />
|
|
<span
|
|
style={{
|
|
fontFamily: "var(--mono)",
|
|
fontSize: "0.75rem",
|
|
color: "var(--ms-purple-400)",
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
{session.orchId}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.8rem",
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{session.name}
|
|
</span>
|
|
<Badge variant={session.badgeVariant}>{session.badge}</Badge>
|
|
<span
|
|
style={{
|
|
marginLeft: "auto",
|
|
fontSize: "0.72rem",
|
|
fontFamily: "var(--mono)",
|
|
color: "var(--muted)",
|
|
}}
|
|
>
|
|
{session.duration}
|
|
</span>
|
|
</div>
|
|
{session.progress > 0 && (
|
|
<div
|
|
style={{
|
|
height: 4,
|
|
borderRadius: 2,
|
|
background: "var(--border)",
|
|
marginBottom: 10,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: `${String(session.progress)}%`,
|
|
background: "var(--primary)",
|
|
borderRadius: 2,
|
|
transition: "width 0.3s ease",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
{session.agents.map((agent) => (
|
|
<AgentNodeItem key={agent.id} agent={agent} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main export */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function OrchestratorSessions({ jobs }: OrchestratorSessionsProps): ReactElement {
|
|
const sessions = jobs ? jobs.map(mapJobToSession) : [];
|
|
const activeCount = jobs
|
|
? jobs.filter(
|
|
(j) => j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
|
|
).length
|
|
: 0;
|
|
|
|
return (
|
|
<Card>
|
|
<SectionHeader
|
|
title="Active Orchestrator Sessions"
|
|
subtitle={
|
|
sessions.length > 0
|
|
? `${String(activeCount)} of ${String(sessions.length)} jobs running`
|
|
: "No active sessions"
|
|
}
|
|
actions={
|
|
sessions.length > 0 ? (
|
|
<Badge variant="badge-teal">{String(activeCount)} active</Badge>
|
|
) : undefined
|
|
}
|
|
/>
|
|
<div>
|
|
{sessions.length > 0 ? (
|
|
sessions.map((session) => <OrchCard key={session.id} session={session} />)
|
|
) : (
|
|
<div
|
|
style={{
|
|
padding: "24px 0",
|
|
textAlign: "center",
|
|
fontSize: "0.8rem",
|
|
color: "var(--muted)",
|
|
}}
|
|
>
|
|
No active sessions
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|