import { useMemo, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react"; import { useDeleteWidgetInstance, useSaveWidgetInstance, useWidgetInstances, } from "../hooks/useWidgets"; import { useServiceInstances } from "../hooks/useServices"; import { useTasks } from "../hooks/useSettings"; import { useIsMobile } from "../hooks/useIsMobile"; import { SheetForm } from "@/components/ui/sheet-form"; import type { WidgetInstance, WidgetInstanceInput } from "../types"; import { BUILTIN_WIDGETS, SERVICE_REGISTRY, type ServiceWidgetBinding, } from "../integrations/registry"; interface Props { open: boolean; onClose: () => void; } interface Draft { id?: string; serviceId: string | null; widgetKind: string; title: string; config: Record; enabled: boolean; sortOrder: number; } function Field({ label, htmlFor, helper, children, }: { label: string; htmlFor: string; helper?: string; children: React.ReactNode; }) { return (
{children} {helper ? (

{helper}

) : null}
); } function bindingLabel(serviceId: string | null, widgetKind: string): string { if (serviceId === null) return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind; return widgetKind; } function WidgetConfigEditor({ binding, isTaskOutput, config, onChange, tasks, }: { binding: ServiceWidgetBinding | undefined; isTaskOutput: boolean; config: Record; onChange: (config: Record) => void; tasks: { id: string; name: string; enabled: boolean }[]; }) { // SSH task output gets a dedicated task picker; everything else gets a // generic text field per top-level schema property. if (isTaskOutput) { return ( ); } const properties = binding ? Object.entries( ( binding.configSchema as | { properties?: Record } | undefined )?.properties ?? {}, ) : []; if (properties.length === 0) return null; return (
{properties.map(([key, schema]) => { const isNumber = (schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "number"; return ( onChange({ ...config, [key]: isNumber ? e.target.value === "" ? undefined : Number(e.target.value) : e.target.value, }) } /> ); })}
); } export function WidgetConfigDialog({ open, onClose }: Props) { const { data: instances = [] } = useWidgetInstances(); const { data: services = [] } = useServiceInstances(); const { data: tasks = [] } = useTasks(); const saveWidget = useSaveWidgetInstance(); const deleteWidget = useDeleteWidgetInstance(); const [draft, setDraft] = useState(null); const sortedInstances = useMemo( () => [...instances].sort( (a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, ), [instances], ); function startAddBuiltIn(kind: string) { const binding = BUILTIN_WIDGETS[kind]; setDraft({ serviceId: null, widgetKind: kind, title: binding?.name ?? kind, config: { ...(binding?.defaultConfig ?? {}) }, enabled: true, sortOrder: 0, }); } function startAddService(serviceId: string, kind: string) { const binding = SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find((w) => w.kind === kind); setDraft({ serviceId, widgetKind: kind, title: binding?.name ?? kind, config: { ...(binding?.defaultConfig ?? {}) }, enabled: true, sortOrder: 0, }); } function startEdit(instance: WidgetInstance) { setDraft({ id: instance.id, serviceId: instance.service_id, widgetKind: instance.widget_kind, title: instance.title, config: instance.config, enabled: instance.enabled, sortOrder: instance.sort_order, }); } function reset() { setDraft(null); } async function saveDraft() { if (!draft) return; const input: WidgetInstanceInput = { id: draft.id ?? null, service_id: draft.serviceId, widget_kind: draft.widgetKind, title: draft.title, config: draft.config, enabled: draft.enabled, sort_order: draft.sortOrder, }; await saveWidget.mutateAsync(input); reset(); } async function toggleEnabled(instance: WidgetInstance) { await saveWidget.mutateAsync({ id: instance.id, service_id: instance.service_id, widget_kind: instance.widget_kind, title: instance.title, config: instance.config, enabled: !instance.enabled, sort_order: instance.sort_order, }); } async function moveInstance(index: number, direction: -1 | 1) { const targetIndex = index + direction; if (targetIndex < 0 || targetIndex >= sortedInstances.length) return; const a = sortedInstances[index]; const b = sortedInstances[targetIndex]; await Promise.all([ saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }), saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }), ]); } async function removeInstance(instance: WidgetInstance) { await deleteWidget.mutateAsync(instance.id); } function handleClose(next: boolean) { if (!next) { reset(); onClose(); } } const draftBinding = draft ? draft.serviceId ? SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find((w) => w.kind === draft.widgetKind) : BUILTIN_WIDGETS[draft.widgetKind] : undefined; const isMobile = useIsMobile(); const isTaskOutput = draft?.serviceId !== null && services.find((s) => s.id === draft?.serviceId)?.service_type === "ssh_tasks"; // The draft body (Title/SortOrder/Enabled/config editor) is shared between // the Dialog (desktop) and SheetForm (mobile). On mobile the inline // Back/Save buttons are omitted because the SheetForm footer provides them. const draftBody = draft ? (
setDraft({ ...draft, title: e.target.value })} /> setDraft({ ...draft, sortOrder: e.target.value === "" ? 0 : Number(e.target.value), }) } />
setDraft({ ...draft, enabled: checked }) } />
setDraft({ ...draft, config })} tasks={tasks} /> {!isMobile ? (
) : null}
) : (
{sortedInstances.length === 0 ? ( No widgets yet. Add one below. ) : (
{sortedInstances.map((instance, index) => { const serviceName = instance.service_id ? services.find((s) => s.id === instance.service_id)?.name : "Built-in"; return (
{instance.title} {bindingLabel(instance.service_id, instance.widget_kind)} {serviceName ? ( {serviceName} ) : null} {!instance.enabled ? ( disabled ) : null}
toggleEnabled(instance)} aria-label={`Toggle ${instance.title}`} />
); })}
)}

Add widget

{Object.values(BUILTIN_WIDGETS).map((b) => ( ))} {services .filter((s) => s.enabled) .flatMap((s) => (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( )), )}

Configure services on their service pages to unlock more widgets.

); const dialogTitle = draft ? draft.id ? "Edit widget" : "Add widget" : "Dashboard widgets"; if (isMobile) { return ( { if (!next) handleClose(next); }} title={dialogTitle} onSave={draft ? saveDraft : () => handleClose(false)} onCancel={draft ? reset : () => handleClose(false)} saveLabel={draft ? "Save widget" : "Done"} isPending={draft ? saveWidget.isPending : false} >
{draftBody}
); } return ( {dialogTitle} {draftBody} ); }