94bf830955
Two fixes for the Grafana chart widget: 1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the CSS variable is named '--color-chart-1' and already contains a hex color (#4f8cff). The hsl() wrapper produced invalid CSS, making every stroke invisible. Fixed to var(--color-chart-1). 2. Multiple series collision: the backend labeled all Prometheus series with the value field name (often just 'Value'), so multiple time series collided on the same recharts dataKey and overwrote each other. Now extracts meaningful labels from the Grafana frame metadata: prefers displayName, then Prometheus metric labels (e.g. 'instance=server1:9100 mode=iowait'), then falls back to the field name. Duplicate labels get a numeric suffix for uniqueness. Multi-series queries now render correctly: each Prometheus time series gets its own colored line with a unique label in the legend/tooltip. 280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/ eslint clean.
527 lines
14 KiB
TypeScript
527 lines
14 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 { Textarea } from "@/components/ui/textarea";
|
|
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;
|
|
/** When set, scope the dialog to a specific service instance's widgets. */
|
|
serviceId?: string;
|
|
}
|
|
|
|
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";
|
|
// Use a multi-line textarea for fields that tend to hold complex
|
|
// multi-line values (PromQL, text blocks, etc.). The widget kind's
|
|
// config schema can opt in via `format: "textarea"`; the well-known
|
|
// `query` field is treated as textarea by default.
|
|
const schemaFormat = (schema as { format?: string }).format;
|
|
const isTextarea = schemaFormat === "textarea" || key === "query";
|
|
return (
|
|
<Field
|
|
key={key}
|
|
label={key}
|
|
htmlFor={`widget-cfg-${key}`}
|
|
helper={(schema as { description?: string }).description}
|
|
>
|
|
{isTextarea ? (
|
|
<Textarea
|
|
id={`widget-cfg-${key}`}
|
|
rows={4}
|
|
className="resize-y font-mono text-xs"
|
|
value={String(config[key] ?? "")}
|
|
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
|
/>
|
|
) : (
|
|
<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, serviceId }: Props) {
|
|
const { data: instances = [] } = useWidgetInstances(serviceId);
|
|
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: 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];
|
|
// Sequential (not Promise.all) to avoid a race where the first mutation's
|
|
// cache invalidation refetches before the second completes, reverting the swap.
|
|
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
|
|
await 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 ? (
|
|
<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"
|
|
className="mobile-touch-target"
|
|
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}
|
|
/>
|
|
{!isMobile ? (
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={reset}
|
|
className="mobile-touch-target"
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={saveDraft}
|
|
disabled={saveWidget.isPending}
|
|
className="mobile-touch-target"
|
|
>
|
|
Save widget
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</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="mobile-touch-target h-8 w-8"
|
|
disabled={index === 0}
|
|
onClick={() => moveInstance(index, -1)}
|
|
>
|
|
<ChevronUp className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mobile-touch-target h-8 w-8"
|
|
disabled={index === sortedInstances.length - 1}
|
|
onClick={() => moveInstance(index, 1)}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
<Switch
|
|
className="mobile-touch-target"
|
|
checked={instance.enabled}
|
|
onCheckedChange={() => toggleEnabled(instance)}
|
|
aria-label={`Toggle ${instance.title}`}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mobile-touch-target h-8 w-8"
|
|
onClick={() => startEdit(instance)}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mobile-touch-target 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"
|
|
className="mobile-touch-target"
|
|
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"
|
|
className="mobile-touch-target"
|
|
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>
|
|
);
|
|
|
|
const dialogTitle = draft
|
|
? draft.id
|
|
? "Edit widget"
|
|
: "Add widget"
|
|
: "Dashboard widgets";
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<SheetForm
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
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}
|
|
isDirty={draft !== null}
|
|
>
|
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
|
</SheetForm>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
</DialogHeader>
|
|
{draftBody}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|