feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #497.
This commit is contained in:
2026-02-24 00:50:24 +00:00
committed by jason.woltje
parent f9cccd6965
commit cc56f2cbe1
8 changed files with 399 additions and 176 deletions

View File

@@ -1,22 +1,24 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { ReactElement } from "react";
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
import { QuickActions } from "@/components/dashboard/QuickActions";
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
import { TokenBudget } from "@/components/dashboard/TokenBudget";
import { fetchDashboardSummary } from "@/lib/api/dashboard";
import type { DashboardSummaryResponse } from "@/lib/api/dashboard";
import type { WidgetPlacement } from "@mosaic/shared";
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
import { useWorkspaceId } from "@/lib/hooks";
export default function DashboardPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
const [layoutId, setLayoutId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Debounce timer for auto-saving layout changes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load the user's default layout (or create one)
useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
@@ -24,70 +26,86 @@ export default function DashboardPage(): ReactElement {
}
const wsId = workspaceId;
let cancelled = false;
setError(null);
setIsLoading(true);
const ac = new AbortController();
async function loadSummary(): Promise<void> {
async function loadLayout(): Promise<void> {
try {
const summary = await fetchDashboardSummary(wsId);
if (!cancelled) {
setData(summary);
const existing = await fetchDefaultLayout(wsId);
if (ac.signal.aborted) return;
if (existing) {
setLayout(existing.layout);
setLayoutId(existing.id);
} else {
const created = await createLayout(wsId, {
name: "Default",
isDefault: true,
layout: DEFAULT_LAYOUT,
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
if (ac.signal.aborted) return;
setLayout(created.layout);
setLayoutId(created.id);
}
} catch (err: unknown) {
console.error("[Dashboard] Failed to fetch summary:", err);
if (!cancelled) {
setError("Failed to load dashboard data");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
console.error("[Dashboard] Failed to load layout:", err);
}
setIsLoading(false);
}
void loadSummary();
void loadLayout();
return (): void => {
cancelled = true;
ac.abort();
};
}, [workspaceId]);
useEffect(() => {
if (!workspaceId) return;
// Save layout changes with debounce
const saveLayout = useCallback(
(newLayout: WidgetPlacement[]) => {
if (!workspaceId || !layoutId) return;
let cancelled = false;
const wsId = workspaceId;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
const interval = setInterval(() => {
fetchDashboardSummary(wsId)
.then((summary) => {
if (!cancelled) setData(summary);
})
.catch((err: unknown) => {
console.error("[Dashboard] Refresh failed:", err);
saveTimerRef.current = setTimeout(() => {
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
console.error("[Dashboard] Failed to save layout:", err);
});
}, 30_000);
}, 800);
},
[workspaceId, layoutId]
);
return (): void => {
cancelled = true;
clearInterval(interval);
};
}, [workspaceId]);
const handleLayoutChange = useCallback(
(newLayout: WidgetPlacement[]) => {
setLayout(newLayout);
saveLayout(newLayout);
},
[saveLayout]
);
const handleRemoveWidget = useCallback(
(widgetId: string) => {
const updated = layout.filter((item) => item.i !== widgetId);
setLayout(updated);
saveLayout(updated);
},
[layout, saveLayout]
);
if (isLoading) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DashboardMetrics />
<div className="dash-grid">
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions />
<QuickActions />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<ActivityFeed />
<TokenBudget />
</div>
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
<div className="flex flex-col items-center gap-2">
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading dashboard...
</span>
</div>
</div>
);
@@ -95,32 +113,45 @@ export default function DashboardPage(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{error && (
<div
{/* Dashboard header with edit toggle */}
<div className="flex items-center justify-between">
<h1
style={{
padding: "12px 16px",
marginBottom: 16,
background: "rgba(229,72,77,0.1)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--text)",
fontSize: "0.85rem",
margin: 0,
}}
>
{error}
</div>
)}
<DashboardMetrics metrics={data?.metrics} />
<div className="dash-grid">
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions jobs={data?.activeJobs} />
<QuickActions />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<ActivityFeed items={data?.recentActivity} />
<TokenBudget budgets={data?.tokenBudget} />
</div>
Dashboard
</h1>
<button
onClick={() => {
setIsEditing((prev) => !prev);
}}
style={{
padding: "6px 14px",
borderRadius: "var(--r)",
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
background: isEditing ? "var(--primary)" : "transparent",
color: isEditing ? "#fff" : "var(--text-2)",
fontSize: "0.83rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
{isEditing ? "Done" : "Edit Layout"}
</button>
</div>
{/* Widget grid */}
<WidgetGrid
layout={layout}
onLayoutChange={handleLayoutChange}
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
isEditing={isEditing}
/>
</div>
);
}