479 lines
12 KiB
TypeScript
479 lines
12 KiB
TypeScript
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 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<string, unknown>;
|
|
enabled: boolean;
|
|
sortOrder: number;
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
htmlFor,
|
|
helper,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
htmlFor: string;
|
|
helper?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={htmlFor}>{label}</Label>
|
|
{children}
|
|
{helper ? (
|
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string, unknown>;
|
|
onChange: (config: Record<string, unknown>) => 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 (
|
|
<Field label="Saved task" htmlFor="widget-task-id">
|
|
<Select
|
|
value={String(config.task_id ?? "")}
|
|
onValueChange={(v) => onChange({ ...config, task_id: v })}
|
|
>
|
|
<SelectTrigger id="widget-task-id">
|
|
<SelectValue placeholder="Select a task" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tasks
|
|
.filter((t) => t.enabled)
|
|
.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
const properties = binding
|
|
? Object.entries(
|
|
(
|
|
binding.configSchema as
|
|
| { properties?: Record<string, unknown> }
|
|
| undefined
|
|
)?.properties ?? {},
|
|
)
|
|
: [];
|
|
|
|
if (properties.length === 0) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{properties.map(([key, schema]) => {
|
|
const isNumber =
|
|
(schema as { type?: string }).type === "integer" ||
|
|
(schema as { type?: string }).type === "number";
|
|
return (
|
|
<Field
|
|
key={key}
|
|
label={key}
|
|
htmlFor={`widget-cfg-${key}`}
|
|
helper={(schema as { description?: string }).description}
|
|
>
|
|
<Input
|
|
id={`widget-cfg-${key}`}
|
|
type={isNumber ? "number" : "text"}
|
|
value={String(config[key] ?? "")}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...config,
|
|
[key]: isNumber
|
|
? e.target.value === ""
|
|
? undefined
|
|
: Number(e.target.value)
|
|
: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</Field>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<Draft | null>(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 isTaskOutput =
|
|
draft?.serviceId !== null &&
|
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
|
"ssh_tasks";
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{draft
|
|
? draft.id
|
|
? "Edit widget"
|
|
: "Add widget"
|
|
: "Dashboard widgets"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{draft ? (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<Field label="Title" htmlFor="widget-title">
|
|
<Input
|
|
id="widget-title"
|
|
value={draft.title}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, title: e.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
|
<Input
|
|
id="widget-sort-order"
|
|
type="number"
|
|
value={String(draft.sortOrder)}
|
|
onChange={(e) =>
|
|
setDraft({
|
|
...draft,
|
|
sortOrder:
|
|
e.target.value === "" ? 0 : Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="widget-enabled"
|
|
checked={draft.enabled}
|
|
onCheckedChange={(checked) =>
|
|
setDraft({ ...draft, enabled: checked })
|
|
}
|
|
/>
|
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
|
</div>
|
|
<WidgetConfigEditor
|
|
binding={draftBinding}
|
|
isTaskOutput={!!isTaskOutput}
|
|
config={draft.config}
|
|
onChange={(config) => setDraft({ ...draft, config })}
|
|
tasks={tasks}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={reset}>
|
|
Back
|
|
</Button>
|
|
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
|
Save widget
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
{sortedInstances.length === 0 ? (
|
|
<Alert>
|
|
<AlertDescription>
|
|
No widgets yet. Add one below.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{sortedInstances.map((instance, index) => {
|
|
const serviceName = instance.service_id
|
|
? services.find((s) => s.id === instance.service_id)?.name
|
|
: "Built-in";
|
|
return (
|
|
<div
|
|
key={instance.id}
|
|
className="flex items-center gap-2 rounded border p-2"
|
|
>
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{instance.title}</span>
|
|
<Badge variant="outline">
|
|
{bindingLabel(
|
|
instance.service_id,
|
|
instance.widget_kind,
|
|
)}
|
|
</Badge>
|
|
{serviceName ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{serviceName}
|
|
</span>
|
|
) : null}
|
|
{!instance.enabled ? (
|
|
<Badge variant="secondary">disabled</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === 0}
|
|
onClick={() => moveInstance(index, -1)}
|
|
>
|
|
<ChevronUp className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === sortedInstances.length - 1}
|
|
onClick={() => moveInstance(index, 1)}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
<Switch
|
|
checked={instance.enabled}
|
|
onCheckedChange={() => toggleEnabled(instance)}
|
|
aria-label={`Toggle ${instance.title}`}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => startEdit(instance)}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => removeInstance(instance)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm font-medium">Add widget</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
|
<Button
|
|
key={b.kind}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => startAddBuiltIn(b.kind)}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
{b.name}
|
|
</Button>
|
|
))}
|
|
{services
|
|
.filter((s) => s.enabled)
|
|
.flatMap((s) =>
|
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
|
(w) => (
|
|
<Button
|
|
key={`${s.id}:${w.kind}`}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => startAddService(s.id, w.kind)}
|
|
>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
{w.name} · {s.name}
|
|
</Button>
|
|
),
|
|
),
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Configure services on their service pages to unlock more
|
|
widgets.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|