feat(widgets): dashboard loop, widget config UI, and addon pages
PR 4 of 4 for configurable dashboard widgets. - Replace hard-coded Jellyfin/Backups dashboard sections with a loop that renders enabled widget instances by sort_order. - Add WidgetInstance renderer and WidgetConfigDialog for adding, editing, enabling/disabling, deleting, and reordering widgets. - Add addon pages for grafana, prometheus, and ssh-tasks at /addons/:addonId. - Register /addons/:addonId route in App.tsx. - Update docs/REQUIREMENTS.md with the widget system design and API. Verification: - backend ruff clean; pytest 200 passed - frontend npm run lint: 0 errors - frontend npm run build: success - frontend npm run test -- src/widgets/registry.test.ts: 3 passed
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
GrafanaAddonPage,
|
||||
PrometheusAddonPage,
|
||||
SshTasksAddonPage,
|
||||
} from "../addons";
|
||||
|
||||
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||
grafana: GrafanaAddonPage,
|
||||
prometheus: PrometheusAddonPage,
|
||||
"ssh-tasks": SshTasksAddonPage,
|
||||
};
|
||||
|
||||
export function AddonPage() {
|
||||
const { addonId } = useParams<{ addonId: string }>();
|
||||
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||
|
||||
if (!Page) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Addon "{addonId}" is not installed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <Page />;
|
||||
}
|
||||
@@ -21,18 +21,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useActivity,
|
||||
useDashboardShortcuts,
|
||||
useDeleteDashboardShortcut,
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||
import { WidgetInstance } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
function emptyShortcut(): DashboardShortcutInput {
|
||||
return {
|
||||
@@ -140,7 +139,9 @@ function ShortcutDialog({
|
||||
<Input
|
||||
id="shortcut-icon"
|
||||
value={draft.icon}
|
||||
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, icon: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -182,7 +183,9 @@ function ShortcutDialog({
|
||||
<Input
|
||||
id="shortcut-url"
|
||||
value={draft.url}
|
||||
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : draft.shortcut_type === "action" ? (
|
||||
@@ -234,7 +237,9 @@ function ShortcutDialog({
|
||||
<Input
|
||||
id="shortcut-notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, notes: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -327,19 +332,6 @@ function ShortcutCard({
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
machines.filter(
|
||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
|
||||
useState<string>("");
|
||||
const selectedJellyfinId =
|
||||
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
|
||||
const { data: activity } = useActivity(selectedJellyfinId || undefined);
|
||||
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||
const saveShortcut = useSaveDashboardShortcut();
|
||||
const deleteShortcut = useDeleteDashboardShortcut();
|
||||
@@ -348,6 +340,16 @@ export function Dashboard() {
|
||||
emptyShortcut(),
|
||||
);
|
||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgetInstances
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgetInstances],
|
||||
);
|
||||
|
||||
const openCreateShortcut = () => {
|
||||
setShortcutDraft(emptyShortcut());
|
||||
@@ -382,9 +384,17 @@ export function Dashboard() {
|
||||
title="Shortcuts"
|
||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||
action={
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWidgetDialogOpen(true)}
|
||||
>
|
||||
Edit dashboard
|
||||
</Button>
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{shortcuts.length ? (
|
||||
@@ -416,42 +426,9 @@ export function Dashboard() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jellyfin activity"
|
||||
description="Live sessions and idle users from Jellyfin."
|
||||
action={
|
||||
jellyfinMachines.length > 1 ? (
|
||||
<Select
|
||||
value={selectedJellyfinId}
|
||||
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinMachines.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : jellyfinMachines.length === 1 ? (
|
||||
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{activity ? (
|
||||
<NowPlaying
|
||||
sessions={activity}
|
||||
onSelectSession={(session) =>
|
||||
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
|
||||
<BackupDashboardWidget />
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstance key={widget.id} widget={widget} />
|
||||
))}
|
||||
|
||||
<ShortcutDialog
|
||||
open={shortcutDialogOpen}
|
||||
@@ -473,6 +450,10 @@ export function Dashboard() {
|
||||
setDeleteShortcutId(null);
|
||||
}}
|
||||
/>
|
||||
<WidgetConfigDialog
|
||||
open={widgetDialogOpen}
|
||||
onClose={() => setWidgetDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user