2026-04-24 13:47:22 -05:00
|
|
|
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
|
|
|
|
import type { components } from "@/api/schema.d.ts";
|
|
|
|
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|
|
|
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
2026-04-24 16:52:40 -05:00
|
|
|
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
2026-04-24 13:47:22 -05:00
|
|
|
import { COLOR } from "@/lib/constants";
|
|
|
|
|
import { formatCount } from "@/lib/utils";
|
2026-04-24 21:30:48 -05:00
|
|
|
import { Link } from "@tanstack/react-router";
|
|
|
|
|
import { motion } from "framer-motion";
|
2026-04-24 13:47:22 -05:00
|
|
|
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
|
|
|
|
import { useState } from "react";
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
|
|
|
|
const { data, isLoading } = useQueueStatus(workspaceId);
|
|
|
|
|
|
|
|
|
|
const pending = data?.pending_work_units ?? 0;
|
|
|
|
|
const active = data?.in_progress_work_units ?? 0;
|
|
|
|
|
const done = data?.completed_work_units ?? 0;
|
|
|
|
|
const total = data?.total_work_units ?? 0;
|
|
|
|
|
const isActive = active > 0 || pending > 0;
|
2026-04-24 21:30:48 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-04-24 13:47:22 -05:00
|
|
|
<tr
|
|
|
|
|
style={{
|
|
|
|
|
borderTop: "1px solid var(--border)",
|
|
|
|
|
background: isActive ? COLOR.warningDim : undefined,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<td className="py-2 px-4">
|
|
|
|
|
<Link
|
|
|
|
|
to="/workspaces/$workspaceId"
|
|
|
|
|
params={{ workspaceId } as never}
|
|
|
|
|
className="flex items-center gap-2 group"
|
2026-04-24 21:30:48 -05:00
|
|
|
>
|
2026-04-24 13:47:22 -05:00
|
|
|
<span
|
|
|
|
|
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
|
|
|
|
style={{ color: "var(--accent-text)" }}
|
|
|
|
|
>
|
|
|
|
|
{workspaceId}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronRight
|
|
|
|
|
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
|
|
|
|
style={{ color: "var(--accent)" }}
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
<td className="py-2 px-4 text-right">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
|
|
|
|
…
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
|
|
|
{isActive ? (
|
|
|
|
|
<motion.div
|
|
|
|
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
|
|
|
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
|
|
|
|
>
|
|
|
|
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
|
|
|
|
</motion.div>
|
|
|
|
|
) : (
|
|
|
|
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
|
|
|
|
)}
|
|
|
|
|
<span
|
|
|
|
|
className="text-xs font-medium"
|
|
|
|
|
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
|
|
|
|
>
|
|
|
|
|
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
|
2026-04-24 21:30:48 -05:00
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-04-24 13:47:22 -05:00
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ val: total, color: "var(--text-2)" },
|
|
|
|
|
{ val: done, color: COLOR.success },
|
|
|
|
|
{ val: active, color: COLOR.warning },
|
|
|
|
|
{ val: pending, color: "var(--text-3)" },
|
|
|
|
|
] as Array<{ val: number; color: string }>
|
|
|
|
|
).map(({ val, color }, i) => (
|
|
|
|
|
<td
|
|
|
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: static positional columns
|
|
|
|
|
key={i}
|
|
|
|
|
className="py-2 px-4 text-right font-mono text-xs"
|
|
|
|
|
style={{ color: isLoading ? "var(--text-4)" : color }}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? "—" : formatCount(val)}
|
|
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Aggregate banner ─────────────────────────────────────────────────────────
|
|
|
|
|
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
|
|
|
|
|
// the fetches so calling the same hooks here just reads from cache.
|
|
|
|
|
|
|
|
|
|
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
|
|
|
|
const statuses = workspaces.map((ws) => {
|
|
|
|
|
const { data } = useQueueStatus(ws.id);
|
|
|
|
|
return data as QueueStatus | undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
|
|
|
|
|
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
|
|
|
|
|
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
|
|
|
|
|
const allLoaded = statuses.every((d) => d !== undefined);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
|
2026-04-24 16:52:40 -05:00
|
|
|
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
|
|
|
|
|
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
|
|
|
|
|
{
|
|
|
|
|
label: "Pending",
|
|
|
|
|
value: totalPending,
|
|
|
|
|
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
|
|
|
|
|
always: false,
|
|
|
|
|
},
|
2026-04-24 13:47:22 -05:00
|
|
|
] as Array<{ label: string; value: number; color: string; always: boolean }>
|
|
|
|
|
).map(({ label, value, color, always }) => (
|
|
|
|
|
<div key={label} className="rounded-xl p-4 theme-card">
|
|
|
|
|
<div
|
|
|
|
|
className="text-2xl font-semibold font-mono"
|
|
|
|
|
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
|
|
|
|
|
>
|
|
|
|
|
{allLoaded || always ? formatCount(value) : "—"}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
|
|
|
|
{label}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-04-24 21:30:48 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
// ─── Main dashboard ───────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 21:30:48 -05:00
|
|
|
export function Dashboard() {
|
|
|
|
|
const [page] = useState(1);
|
2026-04-24 13:47:22 -05:00
|
|
|
const { data, isLoading, error } = useWorkspaces(page, 50);
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 16:52:40 -05:00
|
|
|
const workspaces =
|
|
|
|
|
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
2026-04-24 21:30:48 -05:00
|
|
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-24 16:52:40 -05:00
|
|
|
<div className="page-container page-container--xl">
|
|
|
|
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
2026-04-24 21:30:48 -05:00
|
|
|
<div className="flex items-center gap-2 mb-1">
|
2026-04-24 13:47:22 -05:00
|
|
|
<LayoutDashboard
|
|
|
|
|
className="w-5 h-5"
|
|
|
|
|
style={{ color: "var(--accent)" }}
|
|
|
|
|
strokeWidth={1.5}
|
|
|
|
|
/>
|
2026-04-24 16:52:40 -05:00
|
|
|
<PageTitle>Dashboard</PageTitle>
|
2026-04-24 13:47:22 -05:00
|
|
|
{total > 0 && (
|
|
|
|
|
<span
|
|
|
|
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
|
|
|
|
style={{
|
|
|
|
|
background: COLOR.accentSubtle,
|
|
|
|
|
color: COLOR.accentText,
|
|
|
|
|
border: `1px solid ${COLOR.accentBorder}`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{total} workspace{total !== 1 ? "s" : ""}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-04-24 21:30:48 -05:00
|
|
|
</div>
|
2026-04-24 16:52:40 -05:00
|
|
|
<Body className="leading-none">Overview of your Honcho instance</Body>
|
2026-04-24 21:30:48 -05:00
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
|
|
|
|
{isLoading && <PageLoader />}
|
|
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
{!isLoading && workspaces.length > 0 && (
|
2026-04-24 21:30:48 -05:00
|
|
|
<div className="space-y-4">
|
2026-04-24 13:47:22 -05:00
|
|
|
{/* Aggregate stat row */}
|
2026-04-24 21:30:48 -05:00
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 8 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.05 }}
|
|
|
|
|
>
|
2026-04-24 13:47:22 -05:00
|
|
|
<GlobalQueueBanner workspaces={workspaces} />
|
2026-04-24 21:30:48 -05:00
|
|
|
</motion.div>
|
|
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
{/* Per-workspace queue table */}
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 8 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.12 }}
|
|
|
|
|
className="rounded-xl theme-card overflow-hidden"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-2 px-4 py-3"
|
|
|
|
|
style={{ borderBottom: "1px solid var(--border)" }}
|
2026-04-24 21:30:48 -05:00
|
|
|
>
|
2026-04-24 16:52:40 -05:00
|
|
|
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
|
|
|
|
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
2026-04-24 13:47:22 -05:00
|
|
|
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
|
|
|
|
all workspaces · updates every 10s
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ background: "var(--bg-3)" }}>
|
|
|
|
|
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
|
|
|
|
|
<th
|
|
|
|
|
key={h}
|
|
|
|
|
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
|
|
|
|
|
style={{ color: "var(--text-3)" }}
|
2026-04-24 21:30:48 -05:00
|
|
|
>
|
2026-04-24 13:47:22 -05:00
|
|
|
{h}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{workspaces.map((ws) => (
|
|
|
|
|
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
2026-04-24 21:30:48 -05:00
|
|
|
))}
|
2026-04-24 13:47:22 -05:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 13:47:22 -05:00
|
|
|
{total > workspaces.length && (
|
|
|
|
|
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
|
|
|
|
|
Showing {workspaces.length} of {total} workspaces.{" "}
|
|
|
|
|
<Link
|
|
|
|
|
to="/workspaces"
|
|
|
|
|
className="hover:underline"
|
|
|
|
|
style={{ color: "var(--accent-text)" }}
|
2026-04-24 21:30:48 -05:00
|
|
|
>
|
2026-04-24 13:47:22 -05:00
|
|
|
View all →
|
|
|
|
|
</Link>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isLoading && workspaces.length === 0 && (
|
|
|
|
|
<div className="rounded-xl p-10 text-center theme-card">
|
|
|
|
|
<Boxes
|
|
|
|
|
className="w-8 h-8 mx-auto mb-3"
|
|
|
|
|
style={{ color: "var(--text-4)" }}
|
|
|
|
|
strokeWidth={1}
|
|
|
|
|
/>
|
2026-04-24 16:52:40 -05:00
|
|
|
<Muted>No workspaces found.</Muted>
|
2026-04-24 21:30:48 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|