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
+19
View File
@@ -63,6 +63,9 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented.
- The frontend shell should use a polished two-row header with branding on the left, user/logout controls on the right, and primary navigation in a dedicated tab row beneath.
- The frontend shell and primary pages should remain responsive and mobile-safe, with compact navigation, stacked controls on narrow screens, and reduced table column density where needed.
- Dense management surfaces such as Actions and Settings should prefer compact tabbed or split-pane layouts over long single-column forms when that improves scanning and editing speed.
- Tab rails and subtabs should feel like a polished admin console: compact, clearly selected, and visually consistent across the app.
- When a setting subsection is not applicable to the currently selected mode or service, the UI should hide those fields rather than leaving them visible but disabled.
- The frontend should hydrate the API bearer token from persisted OIDC user storage immediately on reload so early requests do not race the auth provider lifecycle.
- The Media tab should persist its search/filter/sort/pagination state across reloads and tab switches.
- The File Browser should persist its current directory and selected file across reloads and tab switches.
@@ -229,4 +232,20 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- 2026-05-06: Saved SSH keys now surface a derived public key, fingerprint, and per-key machine usage count in the Settings UI for easier auditing.
- 2026-05-06: The dev Compose stack now starts without any SSH key material at all unless a user later configures remote SSH machines.
- 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases.
- 2026-05-06: The Actions page was redesigned into a compact tabbed workspace with a left tab rail of saved actions, and both new-action creation and editing now open in popups instead of inline forms.
- 2026-05-06: Closing an edited Action popup now warns before discarding unsaved changes.
- 2026-05-06: The Settings page was reworked into a compact tabbed layout with separate Machines, SSH Keys, and Danger Zone sections, and irrelevant machine/service fields now hide when that mode or service is not selected.
- 2026-05-06: The Settings machine list was converted into a compact table with popup editing so the page no longer repeats the full machine form for every entry.
- 2026-05-06: The Monitoring page was reworked into an overview tab plus one tab per configured machine, so each machine gets its own monitoring page.
- 2026-05-06: The Actions page now shows a visible empty-state container when no action is selected, and the Monitoring tabs were returned to the app's standard tab styling.
- 2026-05-06: The Monitoring tab spacing was tightened and the Applications page was updated to match the app's standard card-and-tab design.
- 2026-05-06: The dashboard library stats were moved into the Jellyfin Applications tab above the media table, and the dashboard activity panel was relabeled as Jellyfin activity.
- 2026-05-06: The dashboard, Monitoring overview, Applications, and File Browser layouts were normalized around shared titled containers, with Monitoring showing the fleet overview before machine tabs and File Browser using machine tabs with Browser/Media info/Jobs stacked in each machine view.
- 2026-05-06: The Applications and Monitoring tab containers were refactored into a reusable bordered tab-card pattern so the active tab's content sits inside the same card as the tab rail.
- 2026-05-06: The Settings Machines tab was changed from a repeated form layout to a left-hand machine list with a right-hand details panel and popup editing.
- 2026-05-06: The File Browser was reworked into Browser / Media info / Jobs subtabs.
- 2026-05-06: The app shell received a small density pass that tightened container padding and tab widths to make the whole site feel more compact.
- 2026-05-06: The tab rails across Actions, Monitoring, Settings, and Files were restyled to be more enterprise-console-like with compact pills, clearer active states, and reduced visual noise.
- 2026-05-06: Added an Actions tab for saved server tasks, with backend persistence, per-task run history, and support for shell/Python task types on either local or SSH machines.
- 2026-05-06: Reusable dialog footers now keep cancel on the left and confirm on the right, and hover edit buttons now appear on the right edge of editable list rows in Actions and Settings.
- 2026-05-06: Library stats, Jellyfin activity, and Monitoring overview now use shared section-container patterns so subcontainers stay consistent across the app.
+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>
);
}