fixes and improvements
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user