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:
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { AddonPage } from "./pages/AddonPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -449,6 +450,7 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -480,6 +482,7 @@ function AppInner() {
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function GrafanaAddonPage() {
|
||||
const grafanaUrl =
|
||||
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
|
||||
"http://localhost:3000";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Grafana</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics & logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open the full Grafana instance for dashboards, metrics, and
|
||||
log exploration.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={grafanaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function PrometheusAddonPage() {
|
||||
const prometheusUrl =
|
||||
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
|
||||
"http://localhost:9090";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Prometheus</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics explorer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a
|
||||
href={prometheusUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Open Prometheus
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function SshTasksAddonPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">SSH tasks</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Saved actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create, edit, and run saved shell or Python tasks against local
|
||||
or remote machines.
|
||||
</p>
|
||||
<Button onClick={() => navigate("/actions")}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Open Actions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
||||
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
||||
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
||||
@@ -0,0 +1,502 @@
|
||||
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,
|
||||
useWidgetTypes,
|
||||
} from "../hooks/useWidgets";
|
||||
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
|
||||
import type {
|
||||
MonitoringMachine,
|
||||
SavedTask,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
} from "../types";
|
||||
import {
|
||||
getWidgetDefinition,
|
||||
listWidgetTypes,
|
||||
type WidgetDefinition,
|
||||
} from "../widgets/registry";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function emptyDraft(widgetType: string): WidgetInstanceInput {
|
||||
const def = getWidgetDefinition(widgetType);
|
||||
return {
|
||||
addon_id: def?.addonId ?? "",
|
||||
widget_type: widgetType,
|
||||
title: def?.name ?? "",
|
||||
config: { ...(def?.defaultConfig ?? {}) },
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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 WidgetConfigFields({
|
||||
definition,
|
||||
config,
|
||||
onChange,
|
||||
machines,
|
||||
tasks,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
machines: MonitoringMachine[];
|
||||
tasks: SavedTask[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{definition.configFields.map((field) => {
|
||||
const value = config[field.key] ?? "";
|
||||
|
||||
if (
|
||||
definition.widgetType === "jellyfin" &&
|
||||
field.key === "machine_id"
|
||||
) {
|
||||
return (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={field.key}
|
||||
helper={field.helper}
|
||||
>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...config, [field.key]: v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={field.key}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Default</SelectItem>
|
||||
{machines
|
||||
.filter(
|
||||
(m) =>
|
||||
m.enabled &&
|
||||
m.services.includes("jellyfin"),
|
||||
)
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
definition.widgetType === "ssh-task" &&
|
||||
field.key === "task_id"
|
||||
) {
|
||||
return (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={field.key}
|
||||
helper={field.helper}
|
||||
>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...config, [field.key]: v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={field.key}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tasks
|
||||
.filter((t) => t.enabled)
|
||||
.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={field.key}
|
||||
helper={field.helper}
|
||||
>
|
||||
<Input
|
||||
id={field.key}
|
||||
type="number"
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[field.key]:
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
htmlFor={field.key}
|
||||
helper={field.helper}
|
||||
>
|
||||
<Input
|
||||
id={field.key}
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[field.key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
const { data: instances = [] } = useWidgetInstances();
|
||||
const { data: types = [] } = useWidgetTypes();
|
||||
const { data: machines = [] } = useMonitoringSettings();
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveWidget = useSaveWidgetInstance();
|
||||
const deleteWidget = useDeleteWidgetInstance();
|
||||
|
||||
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
|
||||
|
||||
const sortedInstances = useMemo(
|
||||
() =>
|
||||
[...instances].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||
),
|
||||
[instances],
|
||||
);
|
||||
|
||||
function startAdd(widgetType: string) {
|
||||
setDraft(emptyDraft(widgetType));
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
function startEdit(instance: WidgetInstance) {
|
||||
setDraft({
|
||||
id: instance.id,
|
||||
addon_id: instance.addon_id,
|
||||
widget_type: instance.widget_type,
|
||||
title: instance.title,
|
||||
config: instance.config,
|
||||
enabled: instance.enabled,
|
||||
sort_order: instance.sort_order,
|
||||
});
|
||||
setEditingId(instance.id);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft(null);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return;
|
||||
await saveWidget.mutateAsync(draft);
|
||||
reset();
|
||||
}
|
||||
|
||||
async function toggleEnabled(instance: WidgetInstance) {
|
||||
await saveWidget.mutateAsync({
|
||||
...instance,
|
||||
enabled: !instance.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
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 definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft
|
||||
? editingId
|
||||
? "Edit widget"
|
||||
: "Add widget"
|
||||
: "Dashboard widgets"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{draft && definition ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{definition.description}
|
||||
</p>
|
||||
<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.sort_order)}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
sort_order:
|
||||
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>
|
||||
<WidgetConfigFields
|
||||
definition={definition}
|
||||
config={draft.config}
|
||||
onChange={(config) => setDraft({ ...draft, config })}
|
||||
machines={machines}
|
||||
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 typeDef = getWidgetDefinition(
|
||||
instance.widget_type,
|
||||
);
|
||||
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">
|
||||
{typeDef?.name ?? instance.widget_type}
|
||||
</Badge>
|
||||
{!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">
|
||||
{registryDefinitions.map((def) => (
|
||||
<Button
|
||||
key={def.widgetType}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startAdd(def.widgetType)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
{def.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Widget registry is empty. Backend may not be running.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { getWidgetDefinition } from "../widgets/registry";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
}
|
||||
|
||||
export function WidgetInstance({ widget }: Props) {
|
||||
const def = getWidgetDefinition(widget.widget_type);
|
||||
if (!def) {
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Unknown widget type: {widget.widget_type}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = def.component;
|
||||
return <Component widget={widget} />;
|
||||
}
|
||||
@@ -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