diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 1f38c253b8..68e52e8a0c 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -321,6 +321,7 @@ const ChatPage = lazy(() => import("./v2/ChatPage.js").then(m => ({ default const TasksPage = lazy(() => import("./v2/TasksPage.js").then(m => ({ default: m.TasksPage }))); const AgentsPage = lazy(() => import("./v2/AgentsPage.js").then(m => ({ default: m.AgentsPage }))); const NodesPage = lazy(() => import("./v2/NodesPage.js").then(m => ({ default: m.NodesPage }))); +const CustomDashboardsPage = lazy(() => import("./v2/CustomDashboardsPage.js").then(m => ({ default: m.CustomDashboardsPage }))); const StatsPage = lazy(() => import("./v2/StatsPage.js").then(m => ({ default: m.StatsPage }))); const SchedulerPage = lazy(() => import("./v2/SchedulerPage.js").then(m => ({ default: m.SchedulerPage }))); const SettingsPage = lazy(() => import("./v2/SettingsPage.js").then(m => ({ default: m.SettingsPage }))); @@ -388,6 +389,12 @@ const nodesRoute = createRoute({ component: NodesPage, }); +const customDashboardsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/custom-dashboards", + component: CustomDashboardsPage, +}); + const statsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/stats", @@ -455,7 +462,7 @@ const notFoundRoute = createRoute({ component: ErrorPage, }); -const routeTree = rootRoute.addChildren([indexRoute, sprintsRoute, tasksRoute, projectsRoute, chatRoute, agentsRoute, nodesRoute, statsRoute, schedulerRoute, configRoute, memoryRoute, knowledgeRoute, browserRoute, fileBrowserRoute, docsRoute, docsDocumentRoute, liveRoute, notFoundRoute]); +const routeTree = rootRoute.addChildren([indexRoute, sprintsRoute, tasksRoute, projectsRoute, chatRoute, agentsRoute, nodesRoute, customDashboardsRoute, statsRoute, schedulerRoute, configRoute, memoryRoute, knowledgeRoute, browserRoute, fileBrowserRoute, docsRoute, docsDocumentRoute, liveRoute, notFoundRoute]); // `defaultPreload: "intent"` warms route matching on hover/focus; the page chunks themselves are // prefetched explicitly by the nav components via prefetchRoute() since they are Preact-lazy. const router = createRouter({ routeTree, defaultPreload: "intent", defaultPreloadDelay: 50 }); diff --git a/dashboard/src/v2/CustomDashboardsPage.tsx b/dashboard/src/v2/CustomDashboardsPage.tsx new file mode 100644 index 0000000000..fa3810d4a4 --- /dev/null +++ b/dashboard/src/v2/CustomDashboardsPage.tsx @@ -0,0 +1,524 @@ +import type { FunctionComponent } from "preact"; +import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { AlertTriangle, LayoutDashboard, RefreshCw, Save } from "lucide-preact"; +import { PageContainer } from "./components/layout/PageContainer.js"; +import { PageHeader } from "./components/layout/PageHeader.js"; +import { Button } from "./components/ui/Button.js"; +import { EmptyState } from "./components/ui/EmptyState.js"; +import { ActionFeedbackRegion } from "./components/ui/ActionFeedbackRegion.js"; +import { ConfirmDialog } from "./components/ui/ConfirmDialog.js"; +import { useConfirmDialog } from "./hooks/use-confirm-dialog.js"; +import { useActionFeedback } from "./hooks/use-action-feedback.js"; +import { useProjectData } from "./context/project-data.js"; +import { + archiveCustomDashboard, + createCustomDashboard, + createCustomDashboardRevision, + fetchCustomDashboard, + fetchCustomDashboardDataCatalog, + fetchCustomDashboardValidationLogs, + fetchCustomDashboardValidationSession, + fetchCustomDashboards, + publishCustomDashboardRevision, + startCustomDashboardValidation, + updateCustomDashboardDraft, + type CustomDashboardDataCatalogResponse, +} from "./lib/custom-dashboard-api.js"; +import { + createDefaultCustomDashboardDraft, + hasDraftChanged, + parseJsonDraft, + selectLatestRevision, + stableJsonStringify, +} from "./lib/custom-dashboard-view-models.js"; +import { CustomDashboardList } from "./components/custom-dashboards/CustomDashboardList.js"; +import { + CustomDashboardEditorPanel, + type CustomDashboardDraftState, + type CustomDashboardEditorTab, +} from "./components/custom-dashboards/CustomDashboardEditorPanel.js"; +import { CustomDashboardValidationPanel } from "./components/custom-dashboards/CustomDashboardValidationPanel.js"; +import type { + CreateCustomDashboardRevisionInput, + CustomDashboardDataSourceNodeGraph, + CustomDashboardFileBundle, + CustomDashboardJsonObject, + CustomDashboardManifest, + CustomDashboardRecord, + CustomDashboardRevisionRecord, + CustomDashboardValidationSessionRecord, + UpdateCustomDashboardDraftInput, +} from "./types.js"; + +const terminalValidationStatuses = new Set(["passed", "failed", "cancelled"]); + +function dashboardToDraft(dashboard: CustomDashboardRecord): CustomDashboardDraftState { + return { + title: dashboard.title, + description: dashboard.description, + manifestText: stableJsonStringify(dashboard.manifest), + fileBundleText: stableJsonStringify(dashboard.fileBundle), + sourceGraphText: stableJsonStringify(dashboard.sourceNodeGraph), + styleguideText: stableJsonStringify(dashboard.styleguide), + }; +} + +export const CustomDashboardsPage: FunctionComponent = () => { + const { selectedProject, loading: projectLoading } = useProjectData(); + const projectId = selectedProject?.id ?? null; + const [dashboards, setDashboards] = useState([]); + const [selectedDashboardId, setSelectedDashboardId] = useState(null); + const [selectedDashboard, setSelectedDashboard] = useState(null); + const [revisions, setRevisions] = useState([]); + const [selectedRevisionId, setSelectedRevisionId] = useState(null); + const [catalog, setCatalog] = useState(null); + const [draft, setDraft] = useState(null); + const [activeTab, setActiveTab] = useState("manifest"); + const [selectedFilePath, setSelectedFilePath] = useState("src/dashboard.tsx"); + const [validationSession, setValidationSession] = useState(null); + const [logs, setLogs] = useState(""); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [saving, setSaving] = useState(false); + const [creatingRevision, setCreatingRevision] = useState(false); + const [validating, setValidating] = useState(false); + const [refreshingLogs, setRefreshingLogs] = useState(false); + const [publishing, setPublishing] = useState(false); + const [archiving, setArchiving] = useState(false); + const { + feedback, + setError, + setSuccess, + clearFeedback, + clearError, + } = useActionFeedback(); + const archiveConfirm = useConfirmDialog(); + + const selectedRevision = useMemo( + () => revisions.find((revision) => revision.id === selectedRevisionId) ?? null, + [revisions, selectedRevisionId], + ); + const dirty = useMemo(() => draft ? hasDraftChanged(selectedDashboard, draft) : false, [draft, selectedDashboard]); + + const loadProjectDashboards = useCallback(async (nextProjectId: string, signal?: AbortSignal): Promise => { + setLoading(true); + try { + const [listResponse, nextCatalog] = await Promise.all([ + fetchCustomDashboards(nextProjectId, signal), + fetchCustomDashboardDataCatalog(nextProjectId, signal), + ]); + if (signal?.aborted) { + return; + } + setDashboards(listResponse.dashboards); + setCatalog(nextCatalog); + setSelectedDashboardId((current) => ( + current && listResponse.dashboards.some((dashboard) => dashboard.id === current) + ? current + : listResponse.dashboards[0]?.id ?? null + )); + if (listResponse.dashboards.length === 0) { + setSelectedDashboard(null); + setRevisions([]); + setSelectedRevisionId(null); + setDraft(null); + } + } catch (error) { + if (!signal?.aborted) { + setError(error instanceof Error ? error.message : "Failed to load custom dashboards."); + } + } finally { + if (!signal?.aborted) { + setLoading(false); + } + } + }, [setError]); + + const loadDashboardDetail = useCallback(async (dashboardId: string, signal?: AbortSignal): Promise => { + try { + const detail = await fetchCustomDashboard(dashboardId, signal); + if (signal?.aborted) { + return; + } + setSelectedDashboard(detail.dashboard); + setDashboards((current) => current.map((dashboard) => dashboard.id === detail.dashboard.id ? detail.dashboard : dashboard)); + setRevisions(detail.revisions); + const nextRevision = detail.revisions.find((revision) => revision.id === selectedRevisionId) + ?? selectLatestRevision(detail.revisions); + setSelectedRevisionId(nextRevision?.id ?? null); + setDraft(dashboardToDraft(detail.dashboard)); + setSelectedFilePath(detail.dashboard.fileBundle.files[0]?.path ?? "src/dashboard.tsx"); + } catch (error) { + if (!signal?.aborted) { + setError(error instanceof Error ? error.message : "Failed to load custom dashboard details."); + } + } + }, [selectedRevisionId, setError]); + + useEffect(() => { + if (!projectId) { + setDashboards([]); + setSelectedDashboardId(null); + setSelectedDashboard(null); + setRevisions([]); + setDraft(null); + return; + } + const controller = new AbortController(); + void loadProjectDashboards(projectId, controller.signal); + return () => controller.abort(); + }, [loadProjectDashboards, projectId]); + + useEffect(() => { + if (!selectedDashboardId) { + return; + } + const controller = new AbortController(); + void loadDashboardDetail(selectedDashboardId, controller.signal); + return () => controller.abort(); + }, [loadDashboardDetail, selectedDashboardId]); + + const refreshSelectedDashboard = useCallback(async (): Promise => { + if (!selectedDashboardId) { + return; + } + await loadDashboardDetail(selectedDashboardId); + }, [loadDashboardDetail, selectedDashboardId]); + + const buildDraftInput = useCallback((): UpdateCustomDashboardDraftInput & CreateCustomDashboardRevisionInput => { + if (!draft) { + throw new Error("No dashboard draft is selected."); + } + const manifest = parseJsonDraft(draft.manifestText, "Manifest"); + if (!manifest.ok) { + throw new Error(manifest.message); + } + const fileBundle = parseJsonDraft(draft.fileBundleText, "File bundle"); + if (!fileBundle.ok) { + throw new Error(fileBundle.message); + } + const sourceNodeGraph = parseJsonDraft(draft.sourceGraphText, "Source graph"); + if (!sourceNodeGraph.ok) { + throw new Error(sourceNodeGraph.message); + } + const styleguide = parseJsonDraft(draft.styleguideText, "Styleguide"); + if (!styleguide.ok) { + throw new Error(styleguide.message); + } + return { + title: draft.title.trim() || manifest.value.title || "Untitled Dashboard", + description: draft.description, + manifest: manifest.value, + fileBundle: fileBundle.value, + sourceNodeGraph: sourceNodeGraph.value, + styleguide: styleguide.value, + }; + }, [draft]); + + const saveDraft = useCallback(async (): Promise => { + if (!selectedDashboard) { + throw new Error("No dashboard is selected."); + } + const input = buildDraftInput(); + const updated = await updateCustomDashboardDraft(selectedDashboard.id, input); + setSelectedDashboard(updated); + setDashboards((current) => current.map((dashboard) => dashboard.id === updated.id ? updated : dashboard)); + setDraft(dashboardToDraft(updated)); + return updated; + }, [buildDraftInput, selectedDashboard]); + + const handleSaveDraft = async (): Promise => { + setSaving(true); + clearFeedback(); + try { + await saveDraft(); + setSuccess("Custom dashboard draft saved."); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to save custom dashboard."); + } finally { + setSaving(false); + } + }; + + const handleCreateDashboard = async (): Promise => { + if (!projectId) { + return; + } + setCreating(true); + clearFeedback(); + try { + const created = await createCustomDashboard(projectId, createDefaultCustomDashboardDraft()); + setDashboards((current) => [created, ...current]); + setSelectedDashboardId(created.id); + setSuccess("Custom dashboard created."); + await loadProjectDashboards(projectId); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to create custom dashboard."); + } finally { + setCreating(false); + } + }; + + const handleCreateRevision = async (): Promise => { + if (!selectedDashboard) { + return; + } + setCreatingRevision(true); + clearFeedback(); + try { + const input = buildDraftInput(); + if (dirty) { + await saveDraft(); + } + const revision = await createCustomDashboardRevision(selectedDashboard.id, input); + setRevisions((current) => [revision, ...current.filter((item) => item.id !== revision.id)]); + setSelectedRevisionId(revision.id); + setValidationSession(null); + setLogs(""); + setSuccess(`Revision ${revision.revisionNumber} created.`); + await refreshSelectedDashboard(); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to create dashboard revision."); + } finally { + setCreatingRevision(false); + } + }; + + const refreshLogs = useCallback(async (sessionId: string): Promise => { + setRefreshingLogs(true); + try { + const response = await fetchCustomDashboardValidationLogs(sessionId, 300); + setLogs(response.logs); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to load validation logs."); + } finally { + setRefreshingLogs(false); + } + }, [setError]); + + const handleStartValidation = async (): Promise => { + if (!projectId || !selectedDashboard || !selectedRevision) { + return; + } + setValidating(true); + setLogs(""); + clearFeedback(); + try { + const session = await startCustomDashboardValidation(selectedDashboard.id, selectedRevision.id, projectId); + setValidationSession(session); + await refreshLogs(session.id); + setSuccess(`Validation ${session.status}.`); + if (terminalValidationStatuses.has(session.status)) { + await refreshSelectedDashboard(); + } + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to start validation."); + } finally { + setValidating(false); + } + }; + + useEffect(() => { + if (!validationSession || terminalValidationStatuses.has(validationSession.status)) { + return; + } + let cancelled = false; + const interval = window.setInterval(() => { + void fetchCustomDashboardValidationSession(validationSession.id) + .then((session) => { + if (cancelled) { + return; + } + setValidationSession(session); + void refreshLogs(session.id); + if (terminalValidationStatuses.has(session.status)) { + void refreshSelectedDashboard(); + } + }) + .catch((error) => { + if (!cancelled) { + setError(error instanceof Error ? error.message : "Failed to poll validation status."); + } + }); + }, 2500); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [refreshLogs, refreshSelectedDashboard, setError, validationSession]); + + const handleRefreshLogs = async (): Promise => { + if (validationSession) { + await refreshLogs(validationSession.id); + } + }; + + const handlePublish = async (): Promise => { + if (!selectedDashboard || !selectedRevision) { + return; + } + setPublishing(true); + clearFeedback(); + try { + const validationSessionId = validationSession?.revisionId === selectedRevision.id ? validationSession.id : undefined; + const published = await publishCustomDashboardRevision(selectedDashboard.id, selectedRevision.id, validationSessionId); + setSelectedDashboard(published); + setDashboards((current) => current.map((dashboard) => dashboard.id === published.id ? published : dashboard)); + setSuccess("Custom dashboard revision published."); + await refreshSelectedDashboard(); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to publish revision."); + } finally { + setPublishing(false); + } + }; + + const handleArchive = async (): Promise => { + if (!selectedDashboard) { + return; + } + const confirmed = await archiveConfirm.requestConfirm({ + title: "Archive custom dashboard?", + body: "Archiving clears the active publication while preserving revision and validation history.", + confirmLabel: "Archive", + destructive: true, + tone: "danger", + }); + if (!confirmed) { + return; + } + setArchiving(true); + clearFeedback(); + try { + const archived = await archiveCustomDashboard(selectedDashboard.id); + setSelectedDashboard(archived); + setDashboards((current) => current.map((dashboard) => dashboard.id === archived.id ? archived : dashboard)); + setDraft(dashboardToDraft(archived)); + setSuccess("Custom dashboard archived."); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to archive custom dashboard."); + } finally { + setArchiving(false); + } + }; + + const noProject = !projectLoading && !projectId; + const showEmpty = !loading && projectId && dashboards.length === 0; + + return ( + + + + + + )} + /> + + + + {noProject ? ( + + ); +}; diff --git a/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx b/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx new file mode 100644 index 0000000000..6fb8aaf59a --- /dev/null +++ b/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx @@ -0,0 +1,282 @@ +import type { FunctionComponent } from "preact"; +import { Database, FileCode2, Layers3, Palette, ScrollText } from "lucide-preact"; +import { Button } from "../ui/Button.js"; +import type { + CustomDashboardDataSourceNodeGraph, + CustomDashboardFileBundle, + CustomDashboardFileBundleEntry, + CustomDashboardJsonObject, + CustomDashboardManifest, +} from "../../types.js"; +import type { CustomDashboardDataCatalogResponse, CustomDashboardCatalogSource } from "../../lib/custom-dashboard-api.js"; +import { parseJsonDraft, stableJsonStringify } from "../../lib/custom-dashboard-view-models.js"; + +export type CustomDashboardEditorTab = "manifest" | "files" | "sources" | "styleguide" | "catalog"; + +export interface CustomDashboardDraftState { + title: string; + description: string; + manifestText: string; + fileBundleText: string; + sourceGraphText: string; + styleguideText: string; +} + +interface CustomDashboardEditorPanelProps { + draft: CustomDashboardDraftState; + onDraftChange: (draft: CustomDashboardDraftState) => void; + activeTab: CustomDashboardEditorTab; + onActiveTabChange: (tab: CustomDashboardEditorTab) => void; + selectedFilePath: string; + onSelectedFilePathChange: (path: string) => void; + catalog: CustomDashboardDataCatalogResponse | null; +} + +const tabs: Array<{ id: CustomDashboardEditorTab; label: string; icon: typeof ScrollText }> = [ + { id: "manifest", label: "Manifest", icon: ScrollText }, + { id: "files", label: "Files", icon: FileCode2 }, + { id: "sources", label: "Sources", icon: Layers3 }, + { id: "styleguide", label: "Styleguide", icon: Palette }, + { id: "catalog", label: "Catalog", icon: Database }, +]; + +export const CustomDashboardEditorPanel: FunctionComponent = ({ + draft, + onDraftChange, + activeTab, + onActiveTabChange, + selectedFilePath, + onSelectedFilePathChange, + catalog, +}) => { + const parsedBundle = parseJsonDraft(draft.fileBundleText, "File bundle"); + const files = parsedBundle.ok && Array.isArray(parsedBundle.value.files) ? parsedBundle.value.files : []; + const selectedFile = files.find((file) => file.path === selectedFilePath) ?? files[0] ?? null; + + const setDraftField = (field: keyof CustomDashboardDraftState, value: string) => { + onDraftChange({ ...draft, [field]: value }); + }; + + const updateFileBundle = (nextFiles: CustomDashboardFileBundleEntry[]) => { + const metadata = parsedBundle.ok ? parsedBundle.value.metadata : undefined; + onDraftChange({ + ...draft, + fileBundleText: stableJsonStringify({ files: nextFiles, ...(metadata ? { metadata } : {}) }), + }); + }; + + const updateSelectedFile = (patch: Partial) => { + if (!selectedFile) { + return; + } + const nextFile = { ...selectedFile, ...patch }; + const nextFiles = files.map((file) => file.path === selectedFile.path ? nextFile : file); + updateFileBundle(nextFiles); + if (patch.path) { + onSelectedFilePathChange(patch.path); + } + }; + + const addFile = () => { + const path = `src/custom-${files.length + 1}.tsx`; + updateFileBundle([...files, { path, content: "export const value = null;\n", contentType: "text/typescript-jsx" }]); + onSelectedFilePathChange(path); + }; + + const removeSelectedFile = () => { + if (!selectedFile || files.length <= 1) { + return; + } + const nextFiles = files.filter((file) => file.path !== selectedFile.path); + updateFileBundle(nextFiles); + onSelectedFilePathChange(nextFiles[0]?.path ?? ""); + }; + + const addCatalogSource = (source: CustomDashboardCatalogSource) => { + const parsedGraph = parseJsonDraft(draft.sourceGraphText, "Source graph"); + if (!parsedGraph.ok) { + return; + } + const exists = parsedGraph.value.nodes.some((node) => node.id === source.id); + const nextGraph: CustomDashboardDataSourceNodeGraph = { + ...parsedGraph.value, + nodes: exists ? parsedGraph.value.nodes : [ + ...parsedGraph.value.nodes, + { id: source.id, type: source.type, title: source.title, config: source.config as CustomDashboardJsonObject | undefined }, + ], + }; + onDraftChange({ ...draft, sourceGraphText: stableJsonStringify(nextGraph) }); + onActiveTabChange("sources"); + }; + + return ( +
+
+ + +
+ +
+ {tabs.map((tab) => { + const Icon = tab.icon; + const selected = activeTab === tab.id; + return ( + + ); + })} +
+ +
+ {activeTab === "manifest" ? ( + setDraftField("manifestText", value)} + rows={18} + /> + ) : null} + + {activeTab === "files" ? ( +
+
+
+ Bundle + +
+
+ {files.map((file) => ( + + ))} +
+
+ {selectedFile ? ( +
+
+ updateSelectedFile({ path: event.currentTarget.value })} + className="min-h-[2.5rem] min-w-0 rounded-[0.85rem] border border-black/[0.08] bg-white/80 px-3 text-sm font-semibold text-slate-900 outline-none focus:border-signal-500 focus:ring-2 focus:ring-signal-500/20 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-white" + /> + +
+