From ed7a7a5ce0d27d5c27b4a6b0afa1cf25edf5f0f9 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 21 Jun 2026 20:45:42 +0000 Subject: [PATCH 1/2] 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 --- docs/REQUIREMENTS.md | 48 ++ frontend/src/App.tsx | 3 + frontend/src/addons/GrafanaAddonPage.tsx | 37 ++ frontend/src/addons/PrometheusAddonPage.tsx | 36 ++ frontend/src/addons/SshTasksAddonPage.tsx | 29 + frontend/src/addons/index.ts | 3 + .../src/components/WidgetConfigDialog.tsx | 502 ++++++++++++++++++ frontend/src/components/WidgetInstance.tsx | 26 + frontend/src/pages/AddonPage.tsx | 30 ++ frontend/src/pages/Dashboard.tsx | 99 ++-- .../apply-progress.md | 48 +- 11 files changed, 801 insertions(+), 60 deletions(-) create mode 100644 frontend/src/addons/GrafanaAddonPage.tsx create mode 100644 frontend/src/addons/PrometheusAddonPage.tsx create mode 100644 frontend/src/addons/SshTasksAddonPage.tsx create mode 100644 frontend/src/addons/index.ts create mode 100644 frontend/src/components/WidgetConfigDialog.tsx create mode 100644 frontend/src/components/WidgetInstance.tsx create mode 100644 frontend/src/pages/AddonPage.tsx diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index cdad3ec..2509bcd 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -256,6 +256,54 @@ fully removed (web-ui-rework; see decision log 2026-06-17). - Job templates should remain centralized in `jobs.py` for future extension. - Remote job template values must be shell-quoted before execution. +## Configurable Dashboard Widgets + +### Overview + +The dashboard is composed of persisted widget instances stored in the backend SQLite +settings database. Each widget has a type, title, configuration, enabled flag, and +sort order. The frontend renders enabled widgets in sort order and fetches data +independently through the backend source adapters. + +### Widget types + +- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine. +- **Backups** — backup job summary and active alerts. +- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding). +- **Prometheus metric** — result of a PromQL instant query. +- **SSH task output** — output of a saved task run on a machine. +- **Static text** — plain text or markdown note. + +### Security + +- Widget `config` may not contain credential keys such as `password`, `token`, + `secret`, `api_key`, `private_key`, or `passphrase`, or values that look like + secrets (e.g., base64 blobs, `sk-` prefixes). +- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for + Grafana/Prometheus URLs; no secrets are stored in widget configuration. +- SSH task widgets only run tasks from the saved-task registry; arbitrary commands + are not accepted. + +### Addon pages + +Each non-core addon gets a dedicated page at `/addons/:addonId`: + +- `/addons/grafana` +- `/addons/prometheus` +- `/addons/ssh-tasks` + +Unknown addons render a "not installed" alert. + +### API + +- `GET /api/widgets/sources` — list source types. +- `GET /api/widgets/types` — list widget type metadata. +- `GET /api/widgets/instances` — list widget instances. +- `POST /api/widgets/instances` — create instance. +- `PUT /api/widgets/instances/{id}` — update instance. +- `DELETE /api/widgets/instances/{id}` — delete instance. +- `GET /api/widgets/instances/{id}/data` — fetch widget data. + ## Decision Log - 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e77c6b4..ca7000e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> @@ -480,6 +482,7 @@ function AppInner() { } /> } /> } /> + } /> diff --git a/frontend/src/addons/GrafanaAddonPage.tsx b/frontend/src/addons/GrafanaAddonPage.tsx new file mode 100644 index 0000000..c3adce4 --- /dev/null +++ b/frontend/src/addons/GrafanaAddonPage.tsx @@ -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 ( +
+

Grafana

+ + + Metrics & logs + + +

+ Open the full Grafana instance for dashboards, metrics, and + log exploration. +

+ +
+
+
+ ); +} diff --git a/frontend/src/addons/PrometheusAddonPage.tsx b/frontend/src/addons/PrometheusAddonPage.tsx new file mode 100644 index 0000000..f1cd2a2 --- /dev/null +++ b/frontend/src/addons/PrometheusAddonPage.tsx @@ -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 ( +
+

Prometheus

+ + + Metrics explorer + + +

+ Open Prometheus to run ad-hoc PromQL queries and inspect targets. +

+ +
+
+
+ ); +} diff --git a/frontend/src/addons/SshTasksAddonPage.tsx b/frontend/src/addons/SshTasksAddonPage.tsx new file mode 100644 index 0000000..f5c9de6 --- /dev/null +++ b/frontend/src/addons/SshTasksAddonPage.tsx @@ -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 ( +
+

SSH tasks

+ + + Saved actions + + +

+ Create, edit, and run saved shell or Python tasks against local + or remote machines. +

+ +
+
+
+ ); +} diff --git a/frontend/src/addons/index.ts b/frontend/src/addons/index.ts new file mode 100644 index 0000000..b0578ef --- /dev/null +++ b/frontend/src/addons/index.ts @@ -0,0 +1,3 @@ +export { GrafanaAddonPage } from "./GrafanaAddonPage"; +export { PrometheusAddonPage } from "./PrometheusAddonPage"; +export { SshTasksAddonPage } from "./SshTasksAddonPage"; diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx new file mode 100644 index 0000000..9a89a25 --- /dev/null +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -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 ( +
+ + {children} + {helper ? ( +

{helper}

+ ) : null} +
+ ); +} + +function WidgetConfigFields({ + definition, + config, + onChange, + machines, + tasks, +}: { + definition: WidgetDefinition; + config: Record; + onChange: (config: Record) => void; + machines: MonitoringMachine[]; + tasks: SavedTask[]; +}) { + return ( +
+ {definition.configFields.map((field) => { + const value = config[field.key] ?? ""; + + if ( + definition.widgetType === "jellyfin" && + field.key === "machine_id" + ) { + return ( + + + + ); + } + + if ( + definition.widgetType === "ssh-task" && + field.key === "task_id" + ) { + return ( + + + + ); + } + + if (field.type === "number") { + return ( + + + onChange({ + ...config, + [field.key]: + e.target.value === "" + ? undefined + : Number(e.target.value), + }) + } + /> + + ); + } + + return ( + + + onChange({ + ...config, + [field.key]: e.target.value, + }) + } + /> + + ); + })} +
+ ); +} + +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(null); + const [editingId, setEditingId] = useState(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 ( + + + + + {draft + ? editingId + ? "Edit widget" + : "Add widget" + : "Dashboard widgets"} + + + + {draft && definition ? ( +
+

+ {definition.description} +

+
+ + + setDraft({ ...draft, title: e.target.value }) + } + /> + + + + setDraft({ + ...draft, + sort_order: + e.target.value === "" + ? 0 + : Number(e.target.value), + }) + } + /> + +
+
+ + setDraft({ ...draft, enabled: checked }) + } + /> + +
+ setDraft({ ...draft, config })} + machines={machines} + tasks={tasks} + /> +
+ + +
+
+ ) : ( +
+ {sortedInstances.length === 0 ? ( + + + No widgets yet. Add one below. + + + ) : ( +
+ {sortedInstances.map((instance, index) => { + const typeDef = getWidgetDefinition( + instance.widget_type, + ); + return ( +
+
+
+ + {instance.title} + + + {typeDef?.name ?? instance.widget_type} + + {!instance.enabled ? ( + + disabled + + ) : null} +
+
+
+ + + + toggleEnabled(instance) + } + aria-label={`Toggle ${instance.title}`} + /> + + +
+
+ ); + })} +
+ )} + +
+

Add widget

+
+ {registryDefinitions.map((def) => ( + + ))} +
+
+ + {types.length === 0 ? ( + + + Widget registry is empty. Backend may not be running. + + + ) : null} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/WidgetInstance.tsx b/frontend/src/components/WidgetInstance.tsx new file mode 100644 index 0000000..cf692ae --- /dev/null +++ b/frontend/src/components/WidgetInstance.tsx @@ -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 ( + + + + Unknown widget type: {widget.widget_type} + + + + ); + } + + const Component = def.component; + return ; +} diff --git a/frontend/src/pages/AddonPage.tsx b/frontend/src/pages/AddonPage.tsx new file mode 100644 index 0000000..3dfe759 --- /dev/null +++ b/frontend/src/pages/AddonPage.tsx @@ -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 = { + 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 ( + + + Addon "{addonId}" is not installed. + + + ); + } + + return ; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c08ae7f..5c20f5e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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({ onChange({ ...draft, icon: e.target.value })} + onChange={(e) => + onChange({ ...draft, icon: e.target.value }) + } /> @@ -182,7 +183,9 @@ function ShortcutDialog({ onChange({ ...draft, url: e.target.value })} + onChange={(e) => + onChange({ ...draft, url: e.target.value }) + } /> ) : draft.shortcut_type === "action" ? ( @@ -234,7 +237,9 @@ function ShortcutDialog({ onChange({ ...draft, notes: e.target.value })} + onChange={(e) => + onChange({ ...draft, notes: e.target.value }) + } />
@@ -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(""); - 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(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={ - +
+ + +
} > {shortcuts.length ? ( @@ -416,42 +426,9 @@ export function Dashboard() { )} - 1 ? ( - - ) : jellyfinMachines.length === 1 ? ( - {jellyfinMachines[0].name} - ) : null - } - > - {activity ? ( - - navigate(`/users?user=${encodeURIComponent(session.user)}`) - } - /> - ) : null} - - - + {visibleWidgets.map((widget) => ( + + ))} + setWidgetDialogOpen(false)} + />
); } diff --git a/openspec/changes/configurable-dashboard-widgets/apply-progress.md b/openspec/changes/configurable-dashboard-widgets/apply-progress.md index 2361c81..f82108d 100644 --- a/openspec/changes/configurable-dashboard-widgets/apply-progress.md +++ b/openspec/changes/configurable-dashboard-widgets/apply-progress.md @@ -141,9 +141,55 @@ npm run test -- src/widgets/registry.test.ts # 3 passed - Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`. - `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop. +## Completed tasks (Slice 4) + +All Slice 4 tasks are marked `- [x]` in `tasks.md`: + +- [x] 4.1 Refactor `Dashboard.tsx` to render enabled widget instances in sort order +- [x] 4.2 Create `WidgetInstance` renderer component +- [x] 4.3 Create `WidgetConfigDialog` for add/edit/reorder/delete widgets +- [x] 4.4 Create addon pages (`AddonPage`, `GrafanaAddonPage`, `PrometheusAddonPage`, `SshTasksAddonPage`) +- [x] 4.5 Register `/addons/:addonId` route in `App.tsx` +- [x] 4.6 Update `docs/REQUIREMENTS.md` with widget system documentation + +## Files changed (Slice 4) + +### New files + +- `frontend/src/components/WidgetInstance.tsx` — Renders a widget instance by looking up its definition and dispatching to the registered component. +- `frontend/src/components/WidgetConfigDialog.tsx` — Dashboard widget configuration UI: list, add, edit, delete, reorder, enable/disable. +- `frontend/src/pages/AddonPage.tsx` — Route mapper for `/addons/:addonId`. +- `frontend/src/addons/GrafanaAddonPage.tsx` — Grafana addon landing page (deep-link only). +- `frontend/src/addons/PrometheusAddonPage.tsx` — Prometheus addon landing page. +- `frontend/src/addons/SshTasksAddonPage.tsx` — SSH tasks addon landing page. +- `frontend/src/addons/index.ts` — Barrel exports. + +### Modified files + +- `frontend/src/pages/Dashboard.tsx` — Replaced hard-coded Jellyfin/Backups sections with widget instance loop; kept Shortcuts section; added "Edit dashboard" button. +- `frontend/src/App.tsx` — Registered `/addons/:addonId` route in both OIDC and non-OIDC route trees. +- `docs/REQUIREMENTS.md` — Added Configurable Dashboard Widgets section. + +## Verification (Slice 4) + +```bash +cd backend +.venv/bin/python -m ruff check . # All checks passed +PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings +cd ../frontend +npm run lint # 2 pre-existing warnings, 0 errors +npm run build # Built successfully +npm run test -- src/widgets/registry.test.ts # 3 passed +``` + +## Deviations from design (Slice 4) + +- The "Edit dashboard" button lives in the Shortcuts section action area for now. A future UI pass can move it to a dedicated dashboard header. +- Machine/task selectors in the config dialog filter to enabled Jellyfin machines / enabled tasks, which is slightly stricter than the design's generic string field. + ## Remaining work -- Slice 4: Dashboard loop + configuration UI + addon pages +- Phase 1 widget system is complete. Future work could include widget grid layout, drag-and-drop reorder, richer Prometheus visualizations, or migrating shortcuts into the widget system. ## PR boundary From 09eb76bf0fb9210f643c507414d8ec399f4d41cb Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 22 Jun 2026 08:05:44 +0000 Subject: [PATCH 2/2] style(widgets): apply formatter to dashboard and addon files --- frontend/src/addons/GrafanaAddonPage.tsx | 4 +- frontend/src/addons/SshTasksAddonPage.tsx | 4 +- .../src/components/WidgetConfigDialog.tsx | 68 ++++--------------- frontend/src/pages/AddonPage.tsx | 4 +- frontend/src/pages/Dashboard.tsx | 17 ++--- 5 files changed, 24 insertions(+), 73 deletions(-) diff --git a/frontend/src/addons/GrafanaAddonPage.tsx b/frontend/src/addons/GrafanaAddonPage.tsx index c3adce4..894b41b 100644 --- a/frontend/src/addons/GrafanaAddonPage.tsx +++ b/frontend/src/addons/GrafanaAddonPage.tsx @@ -16,8 +16,8 @@ export function GrafanaAddonPage() {

- Open the full Grafana instance for dashboards, metrics, and - log exploration. + Open the full Grafana instance for dashboards, metrics, and log + exploration.

@@ -426,30 +399,21 @@ export function WidgetConfigDialog({ open, onClose }: Props) { variant="ghost" size="icon" className="h-8 w-8" - disabled={ - index === - sortedInstances.length - 1 - } - onClick={() => - moveInstance(index, 1) - } + disabled={index === sortedInstances.length - 1} + onClick={() => moveInstance(index, 1)} > - toggleEnabled(instance) - } + onCheckedChange={() => toggleEnabled(instance)} aria-label={`Toggle ${instance.title}`} /> @@ -457,9 +421,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) { variant="ghost" size="icon" className="h-8 w-8 text-destructive" - onClick={() => - removeInstance(instance) - } + onClick={() => removeInstance(instance)} > diff --git a/frontend/src/pages/AddonPage.tsx b/frontend/src/pages/AddonPage.tsx index 3dfe759..a094b0a 100644 --- a/frontend/src/pages/AddonPage.tsx +++ b/frontend/src/pages/AddonPage.tsx @@ -19,9 +19,7 @@ export function AddonPage() { if (!Page) { return ( - - Addon "{addonId}" is not installed. - + Addon "{addonId}" is not installed. ); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5c20f5e..0192daa 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -139,9 +139,7 @@ function ShortcutDialog({ - onChange({ ...draft, icon: e.target.value }) - } + onChange={(e) => onChange({ ...draft, icon: e.target.value })} /> @@ -183,9 +181,7 @@ function ShortcutDialog({ - onChange({ ...draft, url: e.target.value }) - } + onChange={(e) => onChange({ ...draft, url: e.target.value })} /> ) : draft.shortcut_type === "action" ? ( @@ -237,9 +233,7 @@ function ShortcutDialog({ - onChange({ ...draft, notes: e.target.value }) - } + onChange={(e) => onChange({ ...draft, notes: e.target.value })} />
@@ -385,10 +379,7 @@ export function Dashboard() { description="Quick links to websites today, with room for action and user shortcuts later." action={
-