fixes and improvements

This commit is contained in:
2026-05-06 23:37:19 +02:00
parent b0d84399ab
commit b789034bbe
6 changed files with 349 additions and 311 deletions
+2 -2
View File
@@ -161,7 +161,7 @@ function Shell({
minHeight: 44,
py: 1,
px: 1.5,
minWidth: { xs: 96, md: 120 },
minWidth: { xs: 88, md: 108 },
textTransform: "none",
fontWeight: 600,
},
@@ -199,7 +199,7 @@ function Shell({
</AppBar>
<Container
maxWidth={false}
sx={{ py: { xs: 2, md: 3 }, px: { xs: 1.5, sm: 2.5, md: 3 } }}
sx={{ py: { xs: 1.5, md: 2 }, px: { xs: 1.25, sm: 2, md: 2.5 } }}
>
<Routes>
<Route path="/" element={<Dashboard />} />
+5 -10
View File
@@ -1,4 +1,3 @@
import { Card, CardContent } from "@mui/material";
import type { NowPlayingSession } from "../types";
import { SessionActivityPanel } from "./SessionActivityPanel";
@@ -9,14 +8,10 @@ interface Props {
export function NowPlaying({ sessions, onSelectSession }: Props) {
return (
<Card variant="outlined">
<CardContent>
<SessionActivityPanel
sessions={sessions}
onSelectSession={onSelectSession}
emptyMessage="No recent user activity sessions right now."
/>
</CardContent>
</Card>
<SessionActivityPanel
sessions={sessions}
onSelectSession={onSelectSession}
emptyMessage="No recent user activity sessions right now."
/>
);
}
+17 -68
View File
@@ -1,88 +1,37 @@
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
import { Stack } from "@mui/material";
import { useNavigate } from "react-router-dom";
import {
useCounts,
useLibraries,
useActivity,
useMonitoringOverview,
} from "../hooks/useDashboard";
import { useActivity, useMonitoringOverview } from "../hooks/useDashboard";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { LibraryOverview } from "../components/LibraryOverview";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: activity } = useActivity();
const { data: monitoringOverview } = useMonitoringOverview();
return (
<Stack spacing={3}>
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Activity
</Typography>
{activity && (
<Stack spacing={2.25}>
<SectionCard
title="Jellyfin activity"
description="Live sessions and idle users from Jellyfin."
>
{activity ? (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
)}
</Box>
) : null}
</SectionCard>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Monitoring Overview
</Typography>
<MonitoringOverviewTable overview={monitoringOverview} />
</Box>
<Divider />
<Box>
<Typography variant="h5" sx={{ mb: 1.5 }}>
Library Stats
</Typography>
{counts && (
<Grid container spacing={2} sx={{ mb: 2, alignItems: "stretch" }}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Movies"
value={counts.movies.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Series"
value={counts.series.toLocaleString()}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</Grid>
</Grid>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</Box>
<SectionCard
title="Monitoring overview"
description="10-minute averages and fleet status across all configured machines."
>
<MonitoringOverviewTable overview={monitoringOverview} embedded />
</SectionCard>
</Stack>
);
}
+219 -172
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
@@ -15,6 +15,7 @@ import {
MenuItem,
Select,
Stack,
Tab,
TextField,
Typography,
useMediaQuery,
@@ -27,6 +28,8 @@ import {
} from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState";
import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
interface DisplayRow {
id: string;
@@ -596,6 +599,7 @@ export function FileBrowser() {
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
const navigateToSettings = useNavigate();
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
@@ -685,194 +689,237 @@ export function FileBrowser() {
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<Stack spacing={2}>
<Stack spacing={2.25}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">File Browser</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) => setMachine(String(e.target.value))}
>
{fileMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name} · {machine.mode}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => updateBrowserState({ pathInput: e.target.value })}
onKeyDown={handlePathSubmit}
/>
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => refetch()}
>
Refresh
</Button>
</Stack>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <Alert severity="error">{String(error)}</Alert>}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
rowSelectionModel={rowSelectionModel}
columnVisibilityModel={
isMobile ? { ext: false, modified: false } : undefined
<Chip
label={
fileMachines.length
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
: "No file machines"
}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else
updateBrowserState({
selectedPath: row.path,
currentDir,
pathInput: row.path,
});
}}
variant="outlined"
size="small"
/>
</Box>
</Stack>
{selectedPath && isVideoFile(selectedPath) && (
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{selectedPath && templates && templates.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) =>
updateBrowserState({ selectedJob: e.target.value })
}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<TabbedCard
value={fileMachines.length > 0 ? selectedMachineId : ""}
onChange={setMachine}
tabs={fileMachines.map((machine) => (
<Tab key={machine.id} value={machine.id} label={`${machine.name} · ${machine.mode}`} />
))}
>
{fileMachines.length > 0 ? (
<Stack spacing={2}>
<SectionCard
title="Browser"
description="Read-only listing with explicit open/select actions."
>
<Stack spacing={1.5}>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) =>
updateBrowserState({ pathInput: e.target.value })
}
onKeyDown={handlePathSubmit}
/>
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => navigate(pathInput || "/")}
>
Run job
Open
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => refetch()}
>
Refresh
</Button>
</Stack>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <Alert severity="error">{String(error)}</Alert>}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
rowSelectionModel={rowSelectionModel}
columnVisibilityModel={
isMobile ? { ext: false, modified: false } : undefined
}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up")
navigate(row.path);
else
updateBrowserState({
selectedPath: row.path,
currentDir,
pathInput: row.path,
});
}}
/>
</Box>
</Stack>
</SectionCard>
<SectionCard
title="Media info"
description="ffprobe metadata for the selected media file."
>
{selectedPath ? (
isVideoFile(selectedPath) ? (
ffprobeError ? (
<Alert severity="error">{String(ffprobeError)}</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Alert severity="info">Loading ffprobe data...</Alert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Alert severity="info">No ffprobe data available.</Alert>
)
) : (
<Alert severity="info">
Select a video file to view ffprobe details.
</Alert>
)
) : (
<Alert severity="info">
Select a file in Browser to view ffprobe details.
</Alert>
)}
</SectionCard>
<SectionCard
title="Jobs"
description="Run predefined safe jobs against the selected file."
>
{selectedPath && templates && templates.length > 0 ? (
<Stack spacing={1.5}>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) =>
updateBrowserState({ selectedJob: e.target.value })
}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack
direction={isMobile ? "column" : "row"}
spacing={1}
>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({
jobKey: selectedJob,
path: selectedPath,
})
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
borderRadius: 1,
}}
>
{selectedTemplate.description}
</Typography>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
) : (
<Alert severity="info">
Select a file in Browser to run jobs.
</Alert>
)}
</SectionCard>
</Stack>
) : (
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
onClick={() => navigateToSettings("/settings")}
>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
Open Settings
</Button>
}
>
No file-capable machines are configured yet.
</Alert>
)}
</TabbedCard>
</Stack>
);
}
+87 -59
View File
@@ -1,80 +1,108 @@
import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
import { useMemo, useState } from "react";
import { Alert, Button, Stack, Tab, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import {
useMonitoringMachines,
useMonitoringPoller,
} from "../hooks/useMonitoring";
import { useMonitoringOverview } from "../hooks/useDashboard";
import { MachineMonitoringSection } from "../components/MachineMonitoringSection";
import { useNavigate } from "react-router-dom";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
type MonitoringTab = "overview" | string;
export function Monitoring() {
const { data: machines, isLoading, error } = useMonitoringMachines();
const { data: machines = [], isLoading, error } = useMonitoringMachines();
const { data: poller } = useMonitoringPoller();
const { data: overview } = useMonitoringOverview();
const navigate = useNavigate();
const [tab, setTab] = useState<MonitoringTab>("overview");
const selectedMachine = useMemo(
() => machines.find((machine) => machine.id === tab) ?? null,
[machines, tab],
);
return (
<Stack spacing={3}>
<Stack spacing={2.25}>
<Stack spacing={0.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Monitoring</Typography>
{poller && (
<Chip
size="small"
variant="outlined"
color={poller.worker_running ? "success" : "default"}
label={
poller.worker_running
? `Poller running · ${poller.interval_seconds}s`
: "Poller stopped"
}
/>
)}
</Stack>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Monitoring
</Typography>
<Typography variant="body2" color="text.secondary">
Each machine below is monitored with its own settings and collector
state, and recent activity is gathered automatically by the backend.
Review fleet health first, then switch to a machine-specific tab.
</Typography>
</Stack>
{error && <Alert severity="error">{String(error)}</Alert>}
{isLoading && (
{error ? <Alert severity="error">{String(error)}</Alert> : null}
{isLoading ? (
<Alert severity="info">Loading monitoring machines...</Alert>
)}
{!isLoading && (machines?.length ?? 0) === 0 && (
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
onClick={() => navigate("/settings")}
>
Open Settings
</Button>
}
>
No monitoring machines are configured yet.
</Alert>
)}
) : null}
{machines?.map((machine) => (
<Stack
key={machine.id}
spacing={2}
sx={{
p: 2,
border: 1,
borderColor: "divider",
borderRadius: 2,
bgcolor: "background.paper",
}}
>
<MachineMonitoringSection machine={machine} />
</Stack>
))}
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="overview" value="overview" label="Overview" />,
...machines.map((machine) => (
<Tab key={machine.id} value={machine.id} label={machine.name} />
)),
]}
contentSx={{ p: 1.5 }}
>
{tab === "overview" ? (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Fleet overview
</Typography>
<Typography variant="caption" color="text.secondary">
{poller?.worker_running
? `Poller running · ${poller.interval_seconds}s`
: "Poller stopped"}
</Typography>
</Stack>
{!isLoading && machines.length === 0 ? (
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
onClick={() => navigate("/settings")}
>
Open Settings
</Button>
}
>
No monitoring machines are configured yet.
</Alert>
) : overview ? (
<MonitoringOverviewTable overview={overview} embedded />
) : (
<Alert severity="info">Loading fleet overview...</Alert>
)}
</Stack>
) : selectedMachine ? (
<SectionCard
title={selectedMachine.name}
description={
selectedMachine.mode === "local"
? "Local API host"
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
}
>
<MachineMonitoringSection machine={selectedMachine} />
</SectionCard>
) : null}
</TabbedCard>
</Stack>
);
}