import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Activity, DatabaseBackup, LayoutDashboard, Monitor, } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { useDashboardShortcuts, useDeleteDashboardShortcut, useSaveDashboardShortcut, } from "../hooks/useDashboard"; import { useDetachWidgetReference, useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets"; import { useServiceInstances } from "../hooks/useServices"; import { useIsMobile } from "../hooks/useIsMobile"; import type { DashboardShortcut, DashboardShortcutInput, ServiceInstance, WidgetInstance, } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { WidgetInstanceCard } from "../components/WidgetInstance"; import { WidgetConfigDialog } from "../components/WidgetConfigDialog"; // --- Mobile section grouping (mobile-parity) --- const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const; type SectionId = (typeof SECTION_ORDER)[number]; const SECTION_META: Record< SectionId, { label: string; icon: typeof Activity } > = { observability: { label: "Observability", icon: Activity }, media: { label: "Media", icon: Monitor }, backups: { label: "Backups", icon: DatabaseBackup }, custom: { label: "Custom", icon: LayoutDashboard }, }; const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]); function widgetSection( widget: WidgetInstance, services: ServiceInstance[], ): SectionId { if (!widget.service_id) { return widget.widget_kind === "backups" ? "backups" : "custom"; } const service = services.find((s) => s.id === widget.service_id); const serviceType = service?.service_type ?? ""; if (OBSERVABILITY_TYPES.has(serviceType)) return "observability"; if (serviceType === "jellyfin") return "media"; return "custom"; } function groupWidgetsBySection( widgets: WidgetInstance[], services: ServiceInstance[], ): { id: SectionId; widgets: WidgetInstance[] }[] { const groups: Record = { observability: [], media: [], backups: [], custom: [], }; for (const w of widgets) { groups[widgetSection(w, services)].push(w); } return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter( (s) => s.widgets.length > 0, ); } function MobileWidgetSections({ sections, onEditWidget, }: { sections: { id: SectionId; widgets: WidgetInstance[] }[]; onEditWidget?: (widgetId: string) => void; }) { return ( <>
{sections.map((section) => { const meta = SECTION_META[section.id]; const Icon = meta.icon; return ( ); })}
{sections.map((section) => (

{SECTION_META[section.id].label}

{section.widgets.map((widget) => ( onEditWidget(id) : undefined} /> ))}
))}
); } function emptyShortcut(): DashboardShortcutInput { return { id: null, label: "", shortcut_type: "website", enabled: true, icon: "", url: "", task_id: "", machine_id: "", user_id: "", notes: "", }; } function normalizeWebsiteUrl(url: string): string { const trimmed = url.trim(); if (!trimmed) return ""; if (/^https?:\/\//i.test(trimmed)) return trimmed; return `https://${trimmed}`; } function shortcutHref(shortcut: DashboardShortcut): string { if (shortcut.shortcut_type === "website") { return normalizeWebsiteUrl(shortcut.url); } if (shortcut.shortcut_type === "action") { if (!shortcut.task_id) return ""; const params = new URLSearchParams({ task: shortcut.task_id }); if (shortcut.machine_id) params.set("machine_id", shortcut.machine_id); return `/actions?${params.toString()}`; } if (!shortcut.user_id) return ""; return `/users?user=${encodeURIComponent(shortcut.user_id)}`; } function Field({ label, htmlFor, helper, children, }: { label: string; htmlFor: string; helper?: string; children: React.ReactNode; }) { return (
{children} {helper ? (

{helper}

) : null}
); } function ShortcutDialog({ open, draft, onChange, onClose, onSave, }: { open: boolean; draft: DashboardShortcutInput; onChange: (shortcut: DashboardShortcutInput) => void; onClose: () => void; onSave: () => void; }) { return ( { if (!next) onClose(); }} > {draft.id ? "Edit shortcut" : "New shortcut"}
onChange({ ...draft, label: e.target.value }) } />
onChange({ ...draft, icon: e.target.value })} />
{draft.shortcut_type === "website" ? ( onChange({ ...draft, url: e.target.value })} /> ) : draft.shortcut_type === "action" ? (
onChange({ ...draft, task_id: e.target.value }) } /> onChange({ ...draft, machine_id: e.target.value }) } />
) : ( onChange({ ...draft, user_id: e.target.value }) } /> )} onChange({ ...draft, notes: e.target.value })} />
onChange({ ...draft, enabled: checked }) } />
); } function ShortcutCard({ shortcut, onOpen, onEdit, onDelete, }: { shortcut: DashboardShortcut; onOpen: () => void; onEdit: () => void; onDelete: () => void; }) { const href = shortcutHref(shortcut); const subtitle = shortcut.shortcut_type === "website" ? shortcut.url || "No URL configured" : shortcut.shortcut_type === "action" ? [ shortcut.task_id || "task pending", shortcut.machine_id ? `machine ${shortcut.machine_id}` : "any machine", ].join(" ยท ") : shortcut.user_id || "No user configured"; return (
{shortcut.label}
{subtitle}
{shortcut.icon ? (
{shortcut.icon}
) : null} {shortcut.shortcut_type}
{shortcut.notes ? (

{shortcut.notes}

) : null}
); } export function Dashboard() { const navigate = useNavigate(); const { data: shortcuts = [] } = useDashboardShortcuts(); const saveShortcut = useSaveDashboardShortcut(); const deleteShortcut = useDeleteDashboardShortcut(); const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false); const [shortcutDraft, setShortcutDraft] = useState( emptyShortcut(), ); const [deleteShortcutId, setDeleteShortcutId] = useState(null); const [widgetDialogOpen, setWidgetDialogOpen] = useState(false); const [editWidgetId, setEditWidgetId] = useState(); const { data: widgetInstances = [] } = useWidgetInstances( undefined, "dashboard", ); const { data: widgetReferences = [] } = useWidgetReferences("main"); const { data: services = [] } = useServiceInstances(); const isMobile = useIsMobile(); const visibleWidgets = useMemo(() => { const refs = widgetReferences .filter((r) => r.widget.enabled) .map((r) => r.widget); return [...widgetInstances, ...refs] .filter((w) => w.enabled) .sort((a, b) => a.sort_order - b.sort_order); }, [widgetInstances, widgetReferences]); // Track which visible widgets are references (for the copy/detach button). const referencedWidgetIds = useMemo( () => new Set(widgetReferences.map((r) => r.widget.id)), [widgetReferences], ); const detachRef = useDetachWidgetReference(); const mobileSections = useMemo( () => groupWidgetsBySection(visibleWidgets, services), [visibleWidgets, services], ); const openCreateShortcut = () => { setShortcutDraft(emptyShortcut()); setShortcutDialogOpen(true); }; const openEditShortcut = (shortcut: DashboardShortcut) => { setShortcutDraft({ id: shortcut.id, label: shortcut.label, shortcut_type: shortcut.shortcut_type, enabled: shortcut.enabled, icon: shortcut.icon, url: shortcut.url, task_id: shortcut.task_id, machine_id: shortcut.machine_id, user_id: shortcut.user_id, notes: shortcut.notes, }); setShortcutDialogOpen(true); }; const saveShortcutDraft = async () => { await saveShortcut.mutateAsync(shortcutDraft); setShortcutDialogOpen(false); setShortcutDraft(emptyShortcut()); }; return (
{services.length === 0 ? (

No services configured yet. Add a Jellyfin, SSH target, Authentik, or observability service to populate the navigation and dashboards.

) : null}
} > {shortcuts.length ? (
{shortcuts.map((shortcut) => ( { const href = shortcutHref(shortcut); if (shortcut.shortcut_type === "website") { window.open(href, "_blank", "noopener,noreferrer"); } else if (href) { navigate(href); } }} onEdit={() => openEditShortcut(shortcut)} onDelete={() => setDeleteShortcutId(shortcut.id)} /> ))}
) : ( No shortcuts yet. Add a website now, then add action or user shortcuts later. )} {isMobile && mobileSections.length > 0 ? ( { setEditWidgetId(id); setWidgetDialogOpen(true); }} /> ) : ( visibleWidgets.map((widget) => ( { setEditWidgetId(id); setWidgetDialogOpen(true); }} onCopy={ referencedWidgetIds.has(widget.id) ? () => { // Detach: find the reference and clone it. const ref = widgetReferences.find((r) => r.widget.id === widget.id); if (ref) detachRef.mutate(ref.id); } : undefined } /> )) )} setShortcutDialogOpen(false)} onSave={saveShortcutDraft} /> setDeleteShortcutId(null)} onConfirm={() => { if (deleteShortcutId) { deleteShortcut.mutate(deleteShortcutId); } setDeleteShortcutId(null); }} /> { setWidgetDialogOpen(false); setEditWidgetId(undefined); }} dashboardScope="main" editWidgetId={editWidgetId} /> ); }