322b3d1a3b
- Add /backups route and navigation tab in App.tsx - Add BackupDashboardWidget to Dashboard page - Document backup monitoring feature in REQUIREMENTS.md
485 lines
12 KiB
TypeScript
485 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";
|
|
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
|
|
|
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>
|
|
|
|
<BackupDashboardWidget />
|
|
|
|
<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>
|
|
);
|
|
}
|