Files
manage/frontend/src/pages/Dashboard.tsx
T
2026-05-07 22:24:08 +02:00

482 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import {
useActivity,
useDashboardShortcuts,
useDeleteDashboardShortcut,
useMonitoringOverview,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
import { DialogFooter } from "../components/DialogFooter";
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 ShortcutDialog({
open,
draft,
onChange,
onClose,
onSave,
}: {
open: boolean;
draft: DashboardShortcutInput;
onChange: (shortcut: DashboardShortcutInput) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
<Grid container spacing={1}>
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
<TextField
fullWidth
size="small"
label="Label"
value={draft.label}
onChange={(e) => onChange({ ...draft, label: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
<TextField
fullWidth
size="small"
label="Icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
helperText="Emoji or glyph"
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
<FormControl fullWidth size="small">
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={draft.shortcut_type}
onChange={(e) =>
onChange({
...draft,
shortcut_type: e.target
.value as DashboardShortcutInput["shortcut_type"],
})
}
>
<MenuItem value="website">Website</MenuItem>
<MenuItem value="action">Saved action</MenuItem>
<MenuItem value="user">User</MenuItem>
</Select>
<FormHelperText>
Website opens a URL. Saved actions jump to a task. Users
deep-link.
</FormHelperText>
</FormControl>
</Grid>
</Grid>
{draft.shortcut_type === "website" ? (
<TextField
fullWidth
size="small"
label="Website URL"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
helperText="https:// is added if missing."
/>
) : draft.shortcut_type === "action" ? (
<Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Task ID"
value={draft.task_id}
onChange={(e) =>
onChange({ ...draft, task_id: e.target.value })
}
helperText="Saved action ID."
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Machine ID"
value={draft.machine_id}
onChange={(e) =>
onChange({ ...draft, machine_id: e.target.value })
}
helperText="Optional machine target."
/>
</Grid>
</Grid>
) : (
<TextField
fullWidth
size="small"
label="User ID"
value={draft.user_id}
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
helperText="Jellyfin user ID."
/>
)}
<TextField
fullWidth
size="small"
label="Notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={draft.enabled}
onChange={(e) =>
onChange({ ...draft, enabled: e.target.checked })
}
/>
}
label="Enabled"
/>
</Stack>
</DialogContent>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</Dialog>
);
}
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 (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: 1.25 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>
{shortcut.label}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{subtitle}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{shortcut.icon ? (
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
bgcolor: "action.hover",
fontSize: 18,
}}
>
{shortcut.icon}
</Box>
) : null}
<Chip
size="small"
variant="outlined"
label={shortcut.shortcut_type}
/>
</Stack>
</Stack>
{shortcut.notes ? (
<Typography variant="caption" color="text.secondary">
{shortcut.notes}
</Typography>
) : null}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="contained"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
Open
</Button>
<Button size="small" variant="outlined" onClick={onEdit}>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={onDelete}
>
Delete
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
}
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: monitoringOverview } = useMonitoringOverview();
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
const [shortcutDialogOpen, setShortcutDialogOpen] = useState(false);
const [shortcutDraft, setShortcutDraft] = useState<DashboardShortcutInput>(
emptyShortcut(),
);
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
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 (
<Stack spacing={2.25}>
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<Button variant="outlined" onClick={openCreateShortcut}>
Add shortcut
</Button>
}
>
{shortcuts.length ? (
<Grid container spacing={1.25}>
{shortcuts.map((shortcut) => (
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
<ShortcutCard
shortcut={shortcut}
onOpen={() => {
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)}
/>
</Grid>
))}
</Grid>
) : (
<Alert severity="info">
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
</Alert>
)}
</SectionCard>
<SectionCard
title="Jellyfin activity"
description="Live sessions and idle users from Jellyfin."
action={
jellyfinMachines.length > 1 ? (
<FormControl size="small" sx={{ minWidth: 180 }}>
<Select
value={selectedJellyfinId}
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
sx={{ fontSize: "0.8rem" }}
>
{jellyfinMachines.map((m) => (
<MenuItem key={m.id} value={m.id}>
{m.name}
</MenuItem>
))}
</Select>
</FormControl>
) : jellyfinMachines.length === 1 ? (
<Chip
label={jellyfinMachines[0].name}
size="small"
variant="outlined"
/>
) : null
}
>
{activity ? (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
) : null}
</SectionCard>
<SectionCard
title="Monitoring overview"
description="10-minute averages and fleet status across all configured machines."
>
<MonitoringOverviewTable overview={monitoringOverview} embedded />
</SectionCard>
<ShortcutDialog
open={shortcutDialogOpen}
draft={shortcutDraft}
onChange={setShortcutDraft}
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
<Dialog
open={Boolean(deleteShortcutId)}
onClose={() => setDeleteShortcutId(null)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Delete shortcut?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
This cannot be undone. The shortcut will be removed from the
dashboard.
</Typography>
</DialogContent>
<DialogFooter
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
confirmLabel="Delete"
confirmColor="error"
/>
</Dialog>
</Stack>
);
}