Add missing frontend and backend files

This commit is contained in:
2026-05-06 23:46:08 +02:00
parent b789034bbe
commit 016e3255f5
20 changed files with 5226 additions and 0 deletions
+641
View File
@@ -0,0 +1,641 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import type { MonitoringMachine, SavedTaskInput } from "../types";
import {
useDeleteTask,
useMonitoringSettings,
useRunTask,
useSaveTask,
useTaskRuns,
useTasks,
} from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SelectionRailCard } from "../components/SelectionRailCard";
type ActionTab = "new" | string;
function emptyTask(): SavedTaskInput {
return {
id: null,
name: "",
task_type: "shell",
content: "",
enabled: true,
default_machine_id: "",
notes: "",
};
}
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
return (
a.id === b.id &&
a.name === b.name &&
a.task_type === b.task_type &&
a.content === b.content &&
a.enabled === b.enabled &&
a.default_machine_id === b.default_machine_id &&
a.notes === b.notes
);
}
function TaskEditor({
task,
machines,
onChange,
}: {
task: SavedTaskInput;
machines: MonitoringMachine[];
onChange: (task: SavedTaskInput) => void;
}) {
const selectedMachine = machines.find(
(machine) => machine.id === task.default_machine_id,
);
return (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{task.id ? "Edit action" : "New action"}
</Typography>
<Chip size="small" variant="outlined" label={task.task_type} />
<Chip
size="small"
variant="outlined"
label={task.enabled ? "enabled" : "disabled"}
/>
{selectedMachine && (
<Chip
size="small"
variant="outlined"
label={`default: ${selectedMachine.name}`}
/>
)}
</Stack>
<Stack spacing={1.25}>
<TextField
fullWidth
size="small"
label="Name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={task.task_type}
onChange={(e) =>
onChange({
...task,
task_type: e.target.value as SavedTaskInput["task_type"],
})
}
>
<MenuItem value="shell">Shell</MenuItem>
<MenuItem value="python">Python</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
<InputLabel>Default machine</InputLabel>
<Select
label="Default machine"
value={task.default_machine_id}
onChange={(e) =>
onChange({
...task,
default_machine_id: String(e.target.value),
})
}
>
<MenuItem value="">None</MenuItem>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<TextField
fullWidth
size="small"
label="Notes"
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
<TextField
fullWidth
multiline
minRows={9}
size="small"
label={
task.task_type === "python" ? "Python script" : "Shell command"
}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
helperText={
task.task_type === "python"
? "Python is run as `python3 -c`."
: "Shell commands are run through `/bin/sh -c`."
}
/>
</Stack>
</Stack>
);
}
function TaskDialog({
open,
task,
baseline,
machines,
onClose,
onChange,
onSave,
onDelete,
}: {
open: boolean;
task: SavedTaskInput;
baseline: SavedTaskInput;
machines: MonitoringMachine[];
onClose: () => void;
onChange: (task: SavedTaskInput) => void;
onSave: () => void;
onDelete?: () => void;
}) {
const requestClose = () => {
if (
!sameTask(task, baseline) &&
!window.confirm("Discard unsaved changes?")
) {
return;
}
onClose();
};
return (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogContent dividers>
<TaskEditor task={task} machines={machines} onChange={onChange} />
</DialogContent>
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="outlined" color="error" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</Dialog>
);
}
export function Actions() {
const { data: machines = [] } = useMonitoringSettings();
const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask();
const deleteTask = useDeleteTask();
const runTask = useRunTask();
const [tab, setTab] = useState<ActionTab>("new");
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
emptyTask(),
);
const [runMachineId, setRunMachineId] = useState("");
const [editOpen, setEditOpen] = useState(false);
const selectedTask = useMemo(
() => tasks.find((task) => task.id === tab) ?? null,
[tasks, tab],
);
const selectedRuns = useTaskRuns(selectedTask?.id);
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
setDraftBaseline(initial);
setRunMachineId(machines[0]?.id || "");
setEditOpen(true);
};
const saveDraft = async () => {
const saved = await saveTask.mutateAsync(draft);
setTab(saved.id);
setEditOpen(false);
const nextDraft = {
id: saved.id,
name: saved.name,
task_type: saved.task_type,
content: saved.content,
enabled: saved.enabled,
default_machine_id: saved.default_machine_id,
notes: saved.notes,
};
setDraft(nextDraft);
setDraftBaseline(nextDraft);
};
const editingTask = selectedTask;
return (
<Stack spacing={2.25}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Actions
</Typography>
<Typography variant="body2" color="text.secondary">
Save reusable server tasks and switch between them with tabs.
</Typography>
</Box>
<Chip label={`${tasks.length} saved`} variant="outlined" />
</Stack>
{saveTask.error && (
<Alert severity="error">{String(saveTask.error)}</Alert>
)}
{deleteTask.error && (
<Alert severity="error">{String(deleteTask.error)}</Alert>
)}
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
gap: 2,
}}
>
<SelectionRailCard
title="Saved actions"
description="Pick a saved task, then edit or run it from the detail pane."
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
footer={
<Button fullWidth variant="contained" onClick={createNew}>
+ New action
</Button>
}
>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
orientation="vertical"
variant="scrollable"
sx={{ borderRight: 1, borderColor: "divider" }}
>
{tasks.map((task) => (
<Box
key={task.id}
sx={{
position: "relative",
width: "100%",
"&:hover .rail-edit": { opacity: 1 },
}}
>
<Tab
value={task.id}
label={task.name}
sx={{
alignItems: "flex-start",
justifyContent: "flex-start",
width: 1,
pr: 5,
}}
onClick={() => setTab(task.id)}
onDoubleClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
<Box
sx={{
position: "absolute",
right: 4,
top: "50%",
transform: "translateY(-50%)",
}}
>
<HoverEditButton
onClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
</Box>
</Box>
))}
</Tabs>
</SelectionRailCard>
<Stack spacing={2}>
{editingTask ? (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{editingTask.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Open the editor popup to modify this action.
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button
variant="outlined"
onClick={() => {
const initial = {
id: editingTask.id,
name: editingTask.name,
task_type: editingTask.task_type,
content: editingTask.content,
enabled: editingTask.enabled,
default_machine_id: editingTask.default_machine_id,
notes: editingTask.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
>
Edit
</Button>
<Button
variant="contained"
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel>Run on machine</InputLabel>
<Select
label="Run on machine"
value={runMachineId}
onChange={(e) =>
setRunMachineId(String(e.target.value))
}
>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Recent runs
</Typography>
{selectedRuns.data?.items?.length ? (
<Stack spacing={1.25}>
{selectedRuns.data.items.map((run) => (
<Card key={run.id} variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Chip
size="small"
variant="outlined"
label={run.status}
/>
<Typography
variant="body2"
color="text.secondary"
>
{run.machine_name} ·{" "}
{new Date(
run.created_at * 1000,
).toLocaleString()}
</Typography>
</Stack>
{run.stdout_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stdout
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stdout_tail}
</Typography>
</Box>
)}
{run.stderr_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stderr
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stderr_tail}
</Typography>
</Box>
)}
{run.error && (
<Alert severity="error">{run.error}</Alert>
)}
</Stack>
</CardContent>
</Card>
))}
</Stack>
) : (
<Alert severity="info">No runs yet.</Alert>
)}
</Stack>
</CardContent>
</Card>
) : (
<Stack spacing={2}>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1.25}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No action selected
</Typography>
<Typography variant="body2" color="text.secondary">
Select a saved action from the list on the left to view
its details, run it, or open the editor popup. Use the
button at the bottom to add a new action.
</Typography>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button variant="contained" onClick={createNew}>
+ New action
</Button>
{tasks[0] && (
<Button
variant="outlined"
onClick={() => setTab(tasks[0].id)}
>
Select first action
</Button>
)}
</Stack>
</Stack>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
What this panel shows
</Typography>
<Typography variant="body2" color="text.secondary">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
)}
</Stack>
</Box>
<TaskDialog
open={editOpen}
task={draft}
baseline={draftBaseline}
machines={machines}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
onDelete={
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
}
/>
</Stack>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
Chip,
Grid,
Stack,
Tab,
Typography,
} from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
return (
<SectionCard
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Chip
label={selectedMachineId ? "Selected machine" : "Default machine"}
variant="outlined"
size="small"
/>
}
>
<Stack spacing={1.25}>
{counts ? (
<Grid container spacing={1}>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Movies
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Series
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Episodes
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.episodes.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
) : null}
{libraries?.length ? (
<Grid container spacing={1}>
{libraries.map((library) => (
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.1, px: 1.5 }}>
<Stack spacing={0.5}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700 }}
noWrap
>
{library.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : null}
</Stack>
</SectionCard>
);
}
export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<Stack spacing={2.25}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Applications
</Typography>
<Typography variant="body2" color="text.secondary">
Browse application-specific tools from a compact tabbed workspace.
</Typography>
</Box>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
]}
>
{tab === "jellyfin" ? (
<Stack spacing={2}>
<JellyfinLibraryStats />
<Media />
</Stack>
) : (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Alert severity="info">
Nextcloud support will be added in a future update.
</Alert>
</CardContent>
</Card>
)}
</TabbedCard>
</Stack>
);
}
File diff suppressed because it is too large Load Diff