Phase 2: Docker and OIDC auth
This commit is contained in:
+220
-46
@@ -1,62 +1,236 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
NavLink,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Container,
|
||||
CssBaseline,
|
||||
Chip,
|
||||
useMediaQuery,
|
||||
Button,
|
||||
Stack,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Monitoring } from "./pages/Monitoring";
|
||||
import { Media } from "./pages/Media";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { getAppTheme } from "./theme";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/monitoring", label: "Monitoring" },
|
||||
{ to: "/media", label: "Media" },
|
||||
{ to: "/files", label: "File Browser" },
|
||||
];
|
||||
function Shell({
|
||||
darkMode,
|
||||
authLabel,
|
||||
onSignOut,
|
||||
}: {
|
||||
darkMode: boolean;
|
||||
authLabel?: string;
|
||||
onSignOut?: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const current = location.pathname;
|
||||
|
||||
function NavBar() {
|
||||
return (
|
||||
<nav className="border-b px-6 py-3 flex gap-6 items-center bg-white sticky top-0 z-10">
|
||||
<span className="font-bold text-lg mr-4">Media Library Viewer</span>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
end={link.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<>
|
||||
<CssBaseline />
|
||||
<AppBar position="sticky" color="inherit" elevation={0}>
|
||||
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
|
||||
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
|
||||
Media Library Viewer
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={current}
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
|
||||
<Tab
|
||||
value="/monitoring"
|
||||
label="Monitoring"
|
||||
component={NavLink}
|
||||
to="/monitoring"
|
||||
/>
|
||||
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
||||
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
||||
<Tab
|
||||
value="/files"
|
||||
label="File Browser"
|
||||
component={NavLink}
|
||||
to="/files"
|
||||
/>
|
||||
</Tabs>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
{authLabel && (
|
||||
<Chip size="small" variant="outlined" label={authLabel} />
|
||||
)}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={darkMode ? "Dark" : "Light"}
|
||||
/>
|
||||
{onSignOut && (
|
||||
<Button size="small" variant="text" onClick={onSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth={false} sx={{ py: 3 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Monitoring />} />
|
||||
<Route path="/media" element={<Media />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen({ label }: { label: string }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Card variant="outlined" sx={{ maxWidth: 420, width: "100%" }}>
|
||||
<CardContent>
|
||||
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="h6">{label}</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
|
||||
<CardContent>
|
||||
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
|
||||
<Typography variant="h5">Sign in required</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Use your Authentik account to access the media library viewer.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={onSignIn}>
|
||||
Sign in with OIDC
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
|
||||
const auth = useAuth();
|
||||
useEffect(() => {
|
||||
setAccessToken(auth.user?.access_token ?? null);
|
||||
}, [auth.user?.access_token]);
|
||||
|
||||
const authLabel = useMemo(() => {
|
||||
const profile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
return String(
|
||||
profile?.name ??
|
||||
profile?.preferred_username ??
|
||||
profile?.email ??
|
||||
auth.user?.profile?.sub ??
|
||||
"Authenticated",
|
||||
);
|
||||
}, [auth.user]);
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator) {
|
||||
return <LoadingScreen label="Checking sign-in…" />;
|
||||
}
|
||||
|
||||
if (auth.error) {
|
||||
return (
|
||||
<LoadingScreen
|
||||
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<BrowserRouter>
|
||||
<Shell
|
||||
darkMode={darkMode}
|
||||
authLabel={authLabel}
|
||||
onSignOut={() => void auth.signoutRedirect()}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const theme = useMemo(
|
||||
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
|
||||
[prefersDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{isOidcConfigured() ? (
|
||||
<AuthProvider {...getOidcConfig()}>
|
||||
<AuthenticatedApp darkMode={prefersDarkMode} />
|
||||
</AuthProvider>
|
||||
) : (
|
||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<BrowserRouter>
|
||||
<Shell darkMode={prefersDarkMode} />
|
||||
</BrowserRouter>
|
||||
</Box>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<NavBar />
|
||||
<main className="max-w-screen-2xl mx-auto px-6 py-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Monitoring />} />
|
||||
<Route path="/media" element={<Media />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return <AppInner />;
|
||||
}
|
||||
|
||||
+98
-18
@@ -2,14 +2,20 @@
|
||||
* Typed API client for the FastAPI backend.
|
||||
*/
|
||||
|
||||
import { getAccessToken } from "../auth";
|
||||
import type {
|
||||
MediaCounts,
|
||||
LibraryCount,
|
||||
UserDirectoryResponse,
|
||||
UserMessageResponse,
|
||||
UserMessageQueueStatus,
|
||||
SmtpTestResponse,
|
||||
NowPlayingSession,
|
||||
MonitoringStatus,
|
||||
MonitoringMetrics,
|
||||
DiskSpace,
|
||||
MediaIndexStatus,
|
||||
MediaIndexActionResponse,
|
||||
MediaQueryResponse,
|
||||
DirectoryListing,
|
||||
JobTemplate,
|
||||
@@ -17,36 +23,89 @@ import type {
|
||||
ResolvedPath,
|
||||
} from "../types";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
|
||||
|
||||
function isAbsoluteUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || value.startsWith("//");
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string>): string {
|
||||
if (!isAbsoluteUrl(BASE_URL)) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "")
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function get<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, BASE_URL);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "") url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
const response = await fetch(url.toString());
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function readErrorDetail(response: Response): Promise<string> {
|
||||
const text = await response.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
|
||||
const detail = parsed.detail ?? parsed.message;
|
||||
if (typeof detail === "string" && detail.trim()) {
|
||||
return detail;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the raw response body below.
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function buildHeaders(isJsonBody: boolean): Headers {
|
||||
const headers = new Headers();
|
||||
const token = getAccessToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (isJsonBody) headers.set("Content-Type", "application/json");
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function get<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(buildUrl(path, params), {
|
||||
headers: buildHeaders(false),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`${response.status}: ${detail}`);
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: buildHeaders(true),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`${response.status}: ${detail}`);
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function postForm<T>(path: string, body: FormData): Promise<T> {
|
||||
const headers = buildHeaders(false);
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -55,15 +114,23 @@ async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
||||
export const fetchLibraries = () =>
|
||||
get<LibraryCount[]>("/api/dashboard/libraries");
|
||||
export const fetchNowPlaying = () =>
|
||||
get<NowPlayingSession[]>("/api/dashboard/now-playing");
|
||||
export const fetchActivity = () =>
|
||||
get<NowPlayingSession[]>("/api/dashboard/activity");
|
||||
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
|
||||
|
||||
// Backward-compatible alias used by older hooks/components.
|
||||
export const fetchNowPlaying = fetchActivity;
|
||||
|
||||
// Monitoring
|
||||
export const fetchMonitoringStatus = () =>
|
||||
get<MonitoringStatus>("/api/monitoring/status");
|
||||
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
|
||||
export const fetchMonitoringMetrics = (
|
||||
lastSeconds?: number | null,
|
||||
maxLines = 70_000,
|
||||
) =>
|
||||
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
||||
last_seconds: String(lastSeconds),
|
||||
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
|
||||
max_lines: String(maxLines),
|
||||
});
|
||||
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
|
||||
export const startCollector = () =>
|
||||
@@ -77,7 +144,11 @@ export const restartCollector = () =>
|
||||
export const fetchMediaStatus = () =>
|
||||
get<MediaIndexStatus>("/api/media/status");
|
||||
export const buildMediaIndex = () =>
|
||||
post<{ indexed_items: number }>("/api/media/build");
|
||||
post<MediaIndexActionResponse>("/api/media/build");
|
||||
export const stopMediaIndexBuild = () =>
|
||||
post<MediaIndexActionResponse>("/api/media/stop");
|
||||
export const forceStopMediaIndexBuild = () =>
|
||||
post<MediaIndexActionResponse>("/api/media/force-stop");
|
||||
export const queryMedia = (params: {
|
||||
libraries?: string;
|
||||
types?: string;
|
||||
@@ -114,3 +185,12 @@ export const fetchJobTemplates = () =>
|
||||
get<JobTemplate[]>("/api/jobs/templates");
|
||||
export const runJob = (jobKey: string, path: string) =>
|
||||
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
|
||||
|
||||
export const fetchUserMessageQueueStatus = () =>
|
||||
get<UserMessageQueueStatus>("/api/users/message/status");
|
||||
|
||||
export const testUserSmtpConnection = () =>
|
||||
post<SmtpTestResponse>("/api/users/message/test-smtp");
|
||||
|
||||
export const sendUserMessage = (formData: FormData) =>
|
||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function isOidcConfigured(): boolean {
|
||||
const enabled =
|
||||
(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase() !== "false";
|
||||
return Boolean(
|
||||
enabled &&
|
||||
import.meta.env.VITE_OIDC_ISSUER &&
|
||||
import.meta.env.VITE_OIDC_CLIENT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export function getOidcConfig() {
|
||||
return {
|
||||
authority: import.meta.env.VITE_OIDC_ISSUER as string,
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID as string,
|
||||
redirect_uri:
|
||||
import.meta.env.VITE_OIDC_REDIRECT_URI || window.location.origin,
|
||||
post_logout_redirect_uri:
|
||||
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ||
|
||||
window.location.origin,
|
||||
scope: import.meta.env.VITE_OIDC_SCOPE || "openid profile email",
|
||||
response_type: "code" as const,
|
||||
automaticSilentRenew: false,
|
||||
loadUserInfo: true,
|
||||
onSigninCallback: () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null | undefined) {
|
||||
accessToken = token ?? null;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
|
||||
import type { LibraryCount } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) {
|
||||
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{movieLibs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||
Movie libraries
|
||||
</p>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Movie libraries
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{movieLibs.map((lib) => (
|
||||
<div key={lib.library} className="rounded-lg border p-4 mb-2">
|
||||
<p className="font-semibold">{lib.library}</p>
|
||||
<div className="flex gap-6 mt-2 text-sm">
|
||||
<span>
|
||||
Total: <strong>{lib.total.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Movies: <strong>{lib.movies.toLocaleString()}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card key={lib.library} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{lib.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
||||
{lib.movies.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tvLibs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||
TV libraries
|
||||
</p>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
TV libraries
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{tvLibs.map((lib) => (
|
||||
<div key={lib.library} className="rounded-lg border p-4 mb-2">
|
||||
<p className="font-semibold">{lib.library}</p>
|
||||
<div className="flex gap-6 mt-2 text-sm">
|
||||
<span>
|
||||
Total: <strong>{lib.total.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Series: <strong>{lib.series.toLocaleString()}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card key={lib.library} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{lib.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total: {lib.total.toLocaleString()} | Series:{" "}
|
||||
{lib.series.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Card, CardContent, Typography } from "@mui/material";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -6,14 +8,28 @@ interface Props {
|
||||
|
||||
export function MetricCard({ label, value, subtext }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
{subtext && (
|
||||
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textTransform: "uppercase" }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{subtext && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{subtext}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as d3 from "d3";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { MonitoringSample } from "../types";
|
||||
|
||||
interface Props {
|
||||
samples: MonitoringSample[];
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
type MetricKey =
|
||||
| "cpu"
|
||||
| "iowait"
|
||||
| "mem"
|
||||
| "netDown"
|
||||
| "netUp"
|
||||
| "diskRead"
|
||||
| "diskWrite";
|
||||
|
||||
interface DataPoint {
|
||||
ts: number;
|
||||
cpu: number;
|
||||
iowait: number;
|
||||
mem: number;
|
||||
netDown: number;
|
||||
netUp: number;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
}
|
||||
|
||||
interface MetricConfig {
|
||||
key: MetricKey;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChartProps {
|
||||
title: string;
|
||||
data: DataPoint[];
|
||||
metrics: MetricConfig[];
|
||||
showAverages: boolean;
|
||||
averages: Record<MetricKey, number[]>;
|
||||
yFormatter?: (v: number) => string;
|
||||
}
|
||||
|
||||
interface BrushProps {
|
||||
data: DataPoint[];
|
||||
selectionRange: [number, number] | null;
|
||||
onBrush: (range: [number, number] | null) => void;
|
||||
}
|
||||
|
||||
const MOVING_AVG_WINDOW = 10;
|
||||
const CHART_HEIGHT = 280;
|
||||
const BRUSH_HEIGHT = 84;
|
||||
const BRUSH_LABEL_HEIGHT = 24;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B/s";
|
||||
if (!bytes) return "0 B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
@@ -33,147 +75,741 @@ function formatBytes(bytes: number): string {
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function movingAverage(values: number[]): number[] {
|
||||
if (values.length === 0) return [];
|
||||
return values.map((_, index) => {
|
||||
const start = Math.max(0, index - MOVING_AVG_WINDOW + 1);
|
||||
const slice = values.slice(start, index + 1);
|
||||
return slice.reduce((sum, value) => sum + value, 0) / slice.length;
|
||||
});
|
||||
}
|
||||
|
||||
function buildAverages(samples: DataPoint[]): Record<string, number[]> {
|
||||
return {
|
||||
cpu: movingAverage(samples.map((sample) => sample.cpu)),
|
||||
iowait: movingAverage(samples.map((sample) => sample.iowait)),
|
||||
mem: movingAverage(samples.map((sample) => sample.mem)),
|
||||
netDown: movingAverage(samples.map((sample) => sample.netDown)),
|
||||
netUp: movingAverage(samples.map((sample) => sample.netUp)),
|
||||
diskRead: movingAverage(samples.map((sample) => sample.diskRead)),
|
||||
diskWrite: movingAverage(samples.map((sample) => sample.diskWrite)),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRangeLabel(range: [number, number] | null) {
|
||||
if (!range) return "Full range";
|
||||
return `${formatTime(range[0])} – ${formatTime(range[1])}`;
|
||||
}
|
||||
|
||||
function metricsKey(metrics: MetricConfig[]) {
|
||||
return metrics.map((m) => `${m.key}:${m.label}:${m.color}`).join("|");
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// Shared brush slider (<MonitoringBrush>)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const brushGroupRef = useRef<d3.Selection<
|
||||
SVGGElement,
|
||||
unknown,
|
||||
null,
|
||||
unknown
|
||||
> | null>(null);
|
||||
const brushRef = useRef<d3.BrushBehavior<unknown> | null>(null);
|
||||
const brushXRef = useRef<d3.ScaleTime<number, number> | null>(null);
|
||||
const isUserBrushingRef = useRef(false);
|
||||
const isProgrammaticMoveRef = useRef(false);
|
||||
|
||||
const margin = { top: 18, right: 24, bottom: 22, left: 48 };
|
||||
const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
|
||||
const brushedColor = "rgba(99, 102, 241, 0.25)";
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) setWidth(entry.contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Build the brush UI when the available data or layout width changes.
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || width === 0 || data.length === 0) return;
|
||||
|
||||
const innerWidth = Math.max(0, width - margin.left - margin.right);
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const root = svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
const minTs = d3.min(data, (d) => d.ts) ?? 0;
|
||||
const maxTs = d3.max(data, (d) => d.ts) ?? 0;
|
||||
const x = d3
|
||||
.scaleTime()
|
||||
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
|
||||
.range([0, innerWidth]);
|
||||
brushXRef.current = x;
|
||||
|
||||
const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1);
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([0, yMax * 1.1])
|
||||
.range([innerHeight, 0])
|
||||
.nice();
|
||||
|
||||
root
|
||||
.append("g")
|
||||
.call(d3.axisLeft(y).ticks(3))
|
||||
.selectAll("text")
|
||||
.style("font-size", "9px");
|
||||
root
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${innerHeight})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(Math.min(data.length || 1, 12))
|
||||
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
|
||||
)
|
||||
.selectAll("text")
|
||||
.style("font-size", "8.5px");
|
||||
|
||||
root
|
||||
.append("g")
|
||||
.attr("stroke", "currentColor")
|
||||
.attr("stroke-opacity", 0.08)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(3)
|
||||
.tickSize(-innerWidth)
|
||||
.tickFormat(() => ""),
|
||||
);
|
||||
|
||||
const overviewMetrics: Array<{ key: MetricKey; color: string }> = [
|
||||
{ key: "cpu", color: "#2563eb" },
|
||||
{ key: "mem", color: "#16a34a" },
|
||||
];
|
||||
|
||||
overviewMetrics.forEach(({ key, color }) => {
|
||||
const line = d3
|
||||
.line<DataPoint>()
|
||||
.x((d) => x(new Date(d.ts * 1000)))
|
||||
.y((d) => y((d[key] as number) || 0))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
root
|
||||
.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 1.2)
|
||||
.attr("opacity", 0.6)
|
||||
.attr("d", line);
|
||||
});
|
||||
|
||||
const brush = d3
|
||||
.brushX()
|
||||
.handleSize(14)
|
||||
.extent([
|
||||
[0, 0],
|
||||
[innerWidth, innerHeight],
|
||||
])
|
||||
.on("start", () => {
|
||||
isUserBrushingRef.current = true;
|
||||
})
|
||||
.on("brush", (event: d3.D3BrushEvent<unknown>) => {
|
||||
if (isProgrammaticMoveRef.current) return;
|
||||
if (!event.selection) return;
|
||||
const sel = event.selection as [number, number];
|
||||
const start = Math.floor(x.invert(sel[0]).getTime() / 1000);
|
||||
const end = Math.floor(x.invert(sel[1]).getTime() / 1000);
|
||||
onBrush([start, end]);
|
||||
})
|
||||
.on("end", (event: d3.D3BrushEvent<unknown>) => {
|
||||
isUserBrushingRef.current = false;
|
||||
if (isProgrammaticMoveRef.current) return;
|
||||
if (!event.selection) onBrush(null);
|
||||
});
|
||||
|
||||
const brushG = root.append("g").call(brush);
|
||||
brushGroupRef.current = brushG;
|
||||
brushRef.current = brush;
|
||||
|
||||
brushG
|
||||
.selectAll("rect.selection")
|
||||
.attr("fill", brushedColor)
|
||||
.attr("stroke", "#6366f1")
|
||||
.attr("stroke-width", 1);
|
||||
brushG
|
||||
.selectAll("rect.handle")
|
||||
.attr("fill", "#6366f1")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("rx", 2)
|
||||
.attr("ry", 2)
|
||||
.style("cursor", "ew-resize");
|
||||
}, [data, width, margin.left, margin.top, innerHeight, onBrush]);
|
||||
|
||||
// Keep the brush selection in sync with external changes (zoom buttons / reset)
|
||||
useEffect(() => {
|
||||
if (!brushGroupRef.current || !brushRef.current || !brushXRef.current)
|
||||
return;
|
||||
if (isUserBrushingRef.current) return;
|
||||
|
||||
const x = brushXRef.current;
|
||||
const brush = brushRef.current;
|
||||
const brushG = brushGroupRef.current;
|
||||
|
||||
const selection = selectionRange
|
||||
? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number])
|
||||
: (x.range() as unknown as [number, number]);
|
||||
|
||||
isProgrammaticMoveRef.current = true;
|
||||
const moveBrush = brush.move as unknown as (
|
||||
group: d3.Selection<SVGGElement, unknown, null, unknown>,
|
||||
selection: d3.BrushSelection,
|
||||
) => void;
|
||||
moveBrush(brushG, selection as d3.BrushSelection);
|
||||
window.setTimeout(() => {
|
||||
isProgrammaticMoveRef.current = false;
|
||||
}, 0);
|
||||
}, [selectionRange, width, margin.left, margin.right]);
|
||||
|
||||
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 0.5, pl: 0.5 }}
|
||||
>
|
||||
Time range — drag the left/right ends or the middle
|
||||
</Typography>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{ width: "100%", height: totalHeight, px: 2 }}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={totalHeight}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// Parent: MonitoringCharts
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function MonitoringCharts({ samples }: Props) {
|
||||
if (samples.length === 0) {
|
||||
const [showAverages, setShowAverages] = useState(false);
|
||||
const [selectionRange, setSelectionRange] = useState<[number, number] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const baseData = useMemo<DataPoint[]>(
|
||||
() =>
|
||||
samples.map((sample) => ({
|
||||
ts: sample.ts,
|
||||
cpu: sample.cpu_pct,
|
||||
iowait: sample.iowait_pct ?? 0,
|
||||
mem: sample.mem_pct,
|
||||
netDown: sample.net_rx_bytes_per_sec,
|
||||
netUp: sample.net_tx_bytes_per_sec,
|
||||
diskRead: sample.disk_read_bps,
|
||||
diskWrite: sample.disk_write_bps,
|
||||
})),
|
||||
[samples],
|
||||
);
|
||||
|
||||
const averages = useMemo(() => buildAverages(baseData), [baseData]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!selectionRange) return baseData;
|
||||
const [start, end] = selectionRange;
|
||||
return baseData.filter((sample) => sample.ts >= start && sample.ts <= end);
|
||||
}, [baseData, selectionRange]);
|
||||
|
||||
const zoomOptions = useMemo(
|
||||
() => [
|
||||
{ label: "1h", seconds: 60 * 60 },
|
||||
{ label: "8h", seconds: 8 * 60 * 60 },
|
||||
{ label: "1 day", seconds: 24 * 60 * 60 },
|
||||
{ label: "7 days", seconds: 7 * 24 * 60 * 60 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const zoomTo = useCallback(
|
||||
(seconds: number) => {
|
||||
if (!baseData.length) return;
|
||||
const start = Math.max(
|
||||
baseData[0].ts,
|
||||
baseData[baseData.length - 1].ts - seconds,
|
||||
);
|
||||
setSelectionRange([start, baseData[baseData.length - 1].ts]);
|
||||
},
|
||||
[baseData],
|
||||
);
|
||||
|
||||
const selectionLabel = useMemo(
|
||||
() =>
|
||||
selectionRange
|
||||
? `${formatRangeLabel(selectionRange)} · ${displayData.length} samples`
|
||||
: `All ${baseData.length} samples`,
|
||||
[selectionRange, displayData.length, baseData.length],
|
||||
);
|
||||
|
||||
if (!samples.length) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">No monitoring samples available.</p>
|
||||
<Typography color="text.secondary">
|
||||
No monitoring samples available.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const data = samples.map((s) => ({
|
||||
time: formatTime(s.ts),
|
||||
ts: s.ts,
|
||||
cpu: s.cpu_pct,
|
||||
iowait: s.iowait_pct ?? 0,
|
||||
mem: s.mem_pct,
|
||||
net_down: s.net_rx_bytes_per_sec,
|
||||
net_up: s.net_tx_bytes_per_sec,
|
||||
disk_read: s.disk_read_bps,
|
||||
disk_write: s.disk_write_bps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">
|
||||
CPU, IO Wait, and RAM - last hour
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis unit="%" domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
name="CPU %"
|
||||
stroke="#2563eb"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
{/* Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showAverages}
|
||||
onChange={(event) => setShowAverages(event.target.checked)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="iowait"
|
||||
name="IO Wait %"
|
||||
stroke="#dc2626"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="mem"
|
||||
name="RAM %"
|
||||
stroke="#16a34a"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
}
|
||||
label={`Show ${MOVING_AVG_WINDOW}-point moving average`}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Chip size="small" label={selectionLabel} variant="outlined" />
|
||||
{zoomOptions.map((option) => (
|
||||
<Button
|
||||
key={option.label}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => zoomTo(option.seconds)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!selectionRange}
|
||||
onClick={() => setSelectionRange(null)}
|
||||
>
|
||||
Reset zoom
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Network download</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="net_down"
|
||||
name="Download"
|
||||
stroke="#2563eb"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Network upload</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="net_up"
|
||||
name="Upload"
|
||||
stroke="#9333ea"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
{/* Shared brush slider above all graphs */}
|
||||
<MonitoringBrush
|
||||
data={baseData}
|
||||
selectionRange={selectionRange}
|
||||
onBrush={setSelectionRange}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="disk_read"
|
||||
name="Read"
|
||||
stroke="#ea580c"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => formatBytes(v)} />
|
||||
<Tooltip formatter={(v) => formatBytes(Number(v))} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="disk_write"
|
||||
name="Write"
|
||||
stroke="#0891b2"
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chart grid */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={12}>
|
||||
<MonitoringD3Chart
|
||||
title="CPU, IO Wait, and RAM"
|
||||
data={displayData}
|
||||
metrics={[
|
||||
{ key: "cpu", label: "CPU %", color: "#2563eb" },
|
||||
{ key: "iowait", label: "IO Wait %", color: "#dc2626" },
|
||||
{ key: "mem", label: "RAM %", color: "#16a34a" },
|
||||
]}
|
||||
showAverages={showAverages}
|
||||
averages={averages}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MonitoringD3Chart
|
||||
title="Network traffic"
|
||||
data={displayData}
|
||||
metrics={[
|
||||
{ key: "netDown", label: "Download", color: "#2563eb" },
|
||||
{ key: "netUp", label: "Upload", color: "#9333ea" },
|
||||
]}
|
||||
showAverages={showAverages}
|
||||
averages={averages}
|
||||
yFormatter={formatBytes}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MonitoringD3Chart
|
||||
title="Disk I/O"
|
||||
data={displayData}
|
||||
metrics={[
|
||||
{ key: "diskRead", label: "Read", color: "#ea580c" },
|
||||
{ key: "diskWrite", label: "Write", color: "#0891b2" },
|
||||
]}
|
||||
showAverages={showAverages}
|
||||
averages={averages}
|
||||
yFormatter={formatBytes}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// MonitoringD3Chart – single chart (lines + hover, no brush)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function MonitoringD3Chart({
|
||||
title,
|
||||
data,
|
||||
metrics,
|
||||
showAverages,
|
||||
averages,
|
||||
yFormatter,
|
||||
}: ChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const mk = metricsKey(metrics);
|
||||
|
||||
const margin = useMemo(
|
||||
() => ({ top: 18, right: 24, bottom: 26, left: 56 }),
|
||||
[],
|
||||
);
|
||||
const innerHeight = CHART_HEIGHT - margin.top - margin.bottom;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const values = metrics.flatMap((metric) =>
|
||||
data.map((sample) => (sample[metric.key] as number) || 0),
|
||||
);
|
||||
const avg = values.length
|
||||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
: 0;
|
||||
return { min: d3.min(values) ?? 0, avg, max: d3.max(values) ?? 0 };
|
||||
}, [data, metrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) setWidth(entry.contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// One effect – rebuild chart layer only
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || width === 0) return;
|
||||
|
||||
const innerWidth = Math.max(0, width - margin.left - margin.right);
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const root = svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
if (data.length === 0) return;
|
||||
|
||||
const x = d3
|
||||
.scaleTime()
|
||||
.domain(d3.extent(data, (d) => new Date(d.ts * 1000)) as [Date, Date])
|
||||
.range([0, innerWidth]);
|
||||
|
||||
const yMax =
|
||||
d3.max(data, (d) =>
|
||||
Math.max(...metrics.map((m) => (d[m.key] as number) || 0)),
|
||||
) ?? 1;
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([0, yMax * 1.1])
|
||||
.nice()
|
||||
.range([innerHeight, 0]);
|
||||
|
||||
// X axis
|
||||
root
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${innerHeight})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(Math.min(data.length || 1, 10))
|
||||
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
|
||||
)
|
||||
.selectAll("text")
|
||||
.style("font-size", "10px");
|
||||
|
||||
// Y axis
|
||||
const yAxis = d3.axisLeft(y).ticks(5);
|
||||
if (yFormatter) yAxis.tickFormat((v) => yFormatter(Number(v)));
|
||||
root.append("g").call(yAxis).selectAll("text").style("font-size", "10px");
|
||||
|
||||
// Grid
|
||||
root
|
||||
.append("g")
|
||||
.attr("stroke", "currentColor")
|
||||
.attr("stroke-opacity", 0.1)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(5)
|
||||
.tickSize(-innerWidth)
|
||||
.tickFormat(() => ""),
|
||||
);
|
||||
|
||||
// ── Lines ───────────────────────────────────────────
|
||||
metrics.forEach((metric) => {
|
||||
const line = d3
|
||||
.line<DataPoint>()
|
||||
.x((d) => x(new Date(d.ts * 1000)))
|
||||
.y((d) => y((d[metric.key] as number) || 0))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
root
|
||||
.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", metric.color)
|
||||
.attr("stroke-width", 1.6)
|
||||
.attr("d", line);
|
||||
|
||||
if (showAverages && averages?.[metric.key]) {
|
||||
const avgLine = d3
|
||||
.line<DataPoint>()
|
||||
.x((d) => x(new Date(d.ts * 1000)))
|
||||
.y((_, i) =>
|
||||
y(averages[metric.key as keyof typeof averages]?.[i] || 0),
|
||||
)
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
root
|
||||
.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", metric.color)
|
||||
.attr("stroke-width", 1.4)
|
||||
.attr("stroke-dasharray", "5,3")
|
||||
.attr("opacity", 0.7)
|
||||
.attr("d", avgLine);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cursor line ─────────────────────────────────────
|
||||
const cursorLine = root
|
||||
.append("line")
|
||||
.attr("y1", 0)
|
||||
.attr("y2", innerHeight)
|
||||
.attr("stroke", "currentColor")
|
||||
.attr("stroke-opacity", 0.45)
|
||||
.attr("stroke-dasharray", "4,4")
|
||||
.style("display", "none");
|
||||
|
||||
// ── Cursor markers ──────────────────────────────────
|
||||
const cursorMarkers = root
|
||||
.append("g")
|
||||
.attr("pointer-events", "none")
|
||||
.style("display", "none");
|
||||
|
||||
cursorMarkers
|
||||
.selectAll<SVGCircleElement, MetricConfig>("circle")
|
||||
.data(metrics)
|
||||
.join("circle")
|
||||
.attr("r", 4.5)
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", 1.4);
|
||||
|
||||
// ── Tooltip ─────────────────────────────────────────
|
||||
const tooltip = root
|
||||
.append("g")
|
||||
.attr("pointer-events", "none")
|
||||
.style("display", "none");
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("rx", 6)
|
||||
.attr("ry", 6)
|
||||
.attr("fill", "rgba(15,23,42,0.92)");
|
||||
const tooltipText = tooltip
|
||||
.append("text")
|
||||
.attr("fill", "#fff")
|
||||
.attr("font-size", 11)
|
||||
.attr("font-family", "monospace");
|
||||
|
||||
// ── Hit area ────────────────────────────────────────
|
||||
const bisect = d3.bisector((d: DataPoint) => d.ts).center;
|
||||
|
||||
root
|
||||
.append("rect")
|
||||
.attr("width", innerWidth)
|
||||
.attr("height", innerHeight)
|
||||
.attr("fill", "transparent")
|
||||
.attr("pointer-events", "all")
|
||||
.on("mousemove", (event) => {
|
||||
const [mx, my] = d3.pointer(event, root.node() as SVGGElement);
|
||||
const ts = x.invert(mx).getTime() / 1000;
|
||||
const idx = bisect(data, ts);
|
||||
const sample = data[Math.max(0, Math.min(data.length - 1, idx))];
|
||||
if (!sample) return;
|
||||
|
||||
const xP = x(new Date(sample.ts * 1000));
|
||||
cursorLine.style("display", null).attr("x1", xP).attr("x2", xP);
|
||||
cursorMarkers
|
||||
.style("display", null)
|
||||
.attr("transform", `translate(${xP},0)`)
|
||||
.selectAll<SVGCircleElement, MetricConfig>("circle")
|
||||
.data(metrics)
|
||||
.attr("cx", 0)
|
||||
.attr("cy", (m) => y((sample[m.key] as number) || 0))
|
||||
.attr("fill", (m) => m.color);
|
||||
|
||||
const lines = [
|
||||
formatTime(sample.ts),
|
||||
...metrics.map((m) => {
|
||||
const raw = (sample[m.key] as number) || 0;
|
||||
const avgV =
|
||||
showAverages &&
|
||||
averages?.[m.key as keyof typeof averages]?.[idx] != null
|
||||
? averages[m.key as keyof typeof averages][idx]
|
||||
: null;
|
||||
const fmt = yFormatter ? yFormatter(raw) : `${raw.toFixed(1)}%`;
|
||||
return avgV == null
|
||||
? `${m.label}: ${fmt}`
|
||||
: `${m.label}: ${fmt} (avg ${yFormatter ? yFormatter(avgV) : avgV.toFixed(1)})`;
|
||||
}),
|
||||
];
|
||||
|
||||
const lh = 14,
|
||||
pad = 8;
|
||||
const bw = Math.min(
|
||||
Math.max(...lines.map((l) => l.length)) * 6.5 + pad * 2,
|
||||
260,
|
||||
);
|
||||
const bh = lines.length * lh + pad * 2;
|
||||
const px = Math.min(mx + 12, innerWidth - bw - 4);
|
||||
const py = Math.max(4, Math.min(my - bh - 12, innerHeight - bh - 4));
|
||||
|
||||
tooltip
|
||||
.style("display", null)
|
||||
.attr("transform", `translate(${px},${py})`);
|
||||
tooltip.select("rect").attr("width", bw).attr("height", bh);
|
||||
tooltipText.selectAll("tspan").remove();
|
||||
lines.forEach((line, i) =>
|
||||
tooltipText
|
||||
.append("tspan")
|
||||
.attr("x", pad)
|
||||
.attr("y", pad + 12 + i * lh)
|
||||
.text(line),
|
||||
);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
tooltip.style("display", "none");
|
||||
cursorLine.style("display", "none");
|
||||
cursorMarkers.style("display", "none");
|
||||
});
|
||||
}, [
|
||||
data,
|
||||
mk,
|
||||
showAverages,
|
||||
averages,
|
||||
width,
|
||||
yFormatter,
|
||||
margin.left,
|
||||
margin.top,
|
||||
innerHeight,
|
||||
]);
|
||||
|
||||
// ── JSX ───────────────────────────────────────────────
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Min ${yFormatter ? yFormatter(summary.min) : summary.min.toFixed(1)}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Avg ${yFormatter ? yFormatter(summary.avg) : summary.avg.toFixed(1)}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Max ${yFormatter ? yFormatter(summary.max) : summary.max.toFixed(1)}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Box ref={containerRef} sx={{ width: "100%", height: CHART_HEIGHT }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={CHART_HEIGHT}
|
||||
style={{ overflow: "visible" }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
{metrics.map((metric) => (
|
||||
<Box
|
||||
key={metric.key}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: metric.color,
|
||||
borderRadius: "2px",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption">{metric.label}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{showAverages ? (
|
||||
<Typography variant="caption">Dashed = moving average</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,22 @@
|
||||
import { Card, CardContent } from "@mui/material";
|
||||
import type { NowPlayingSession } from "../types";
|
||||
import { SessionActivityPanel } from "./SessionActivityPanel";
|
||||
|
||||
interface Props {
|
||||
sessions: NowPlayingSession[];
|
||||
onSelectSession?: (session: NowPlayingSession) => void;
|
||||
}
|
||||
|
||||
export function NowPlaying({ sessions }: Props) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
No active playback sessions right now.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function NowPlaying({ sessions, onSelectSession }: Props) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-600">
|
||||
<th className="py-2 pr-4">User</th>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Type</th>
|
||||
<th className="py-2 pr-4">State</th>
|
||||
<th className="py-2 pr-4">Transcoding</th>
|
||||
<th className="py-2 pr-4">Transcode type</th>
|
||||
<th className="py-2 pr-4">Device</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr key={s.session_id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 font-medium">{s.user}</td>
|
||||
<td className="py-2 pr-4">{s.title}</td>
|
||||
<td className="py-2 pr-4">{s.type}</td>
|
||||
<td className="py-2 pr-4">{s.state}</td>
|
||||
<td className="py-2 pr-4">{s.transcoding}</td>
|
||||
<td className="py-2 pr-4">{s.transcoding_type}</td>
|
||||
<td className="py-2 pr-4">{s.device}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<SessionActivityPanel
|
||||
sessions={sessions}
|
||||
onSelectSession={onSelectSession}
|
||||
emptyMessage="No recent user activity sessions right now."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { NowPlayingSession } from "../types";
|
||||
|
||||
interface Props {
|
||||
sessions: NowPlayingSession[];
|
||||
emptyMessage?: string;
|
||||
selectedUserLabel?: string;
|
||||
onSelectSession?: (session: NowPlayingSession) => void;
|
||||
}
|
||||
|
||||
function formatStateLabel(state: string): string {
|
||||
const normalized = String(state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "playing") {
|
||||
return "Playing";
|
||||
}
|
||||
if (normalized === "paused") {
|
||||
return "Paused";
|
||||
}
|
||||
if (normalized === "idle") {
|
||||
return "Idle";
|
||||
}
|
||||
return normalized
|
||||
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
: "Unknown";
|
||||
}
|
||||
|
||||
function buildStatusSummary(sessions: NowPlayingSession[]) {
|
||||
const playing = sessions.filter(
|
||||
(session) =>
|
||||
String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase() === "playing",
|
||||
).length;
|
||||
const paused = sessions.filter(
|
||||
(session) =>
|
||||
String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase() === "paused",
|
||||
).length;
|
||||
const idle = sessions.filter(
|
||||
(session) =>
|
||||
String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase() === "idle",
|
||||
).length;
|
||||
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${playing} playing · ${paused} paused · ${idle} idle`;
|
||||
}
|
||||
|
||||
export function SessionActivityPanel({
|
||||
sessions,
|
||||
emptyMessage = "No live sessions matched to this user.",
|
||||
selectedUserLabel,
|
||||
onSelectSession,
|
||||
}: Props) {
|
||||
const userFallback = selectedUserLabel || "Unknown user";
|
||||
|
||||
if (!sessions.length) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyMessage}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
variant="outlined"
|
||||
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }}
|
||||
>
|
||||
<Table size="small" stickyHeader aria-label="Session activity details">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
User
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
|
||||
>
|
||||
State
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
Title / Type
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
Device
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
width: 118,
|
||||
}}
|
||||
>
|
||||
Transcoding
|
||||
</TableCell>
|
||||
{onSelectSession ? (
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
width: 150,
|
||||
}}
|
||||
>
|
||||
Action
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={onSelectSession ? 6 : 5}
|
||||
sx={{ py: 0.75, bgcolor: "background.paper" }}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{buildStatusSummary(sessions)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{sessions.map((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const sessionLabel = formatStateLabel(session.state);
|
||||
return (
|
||||
<TableRow
|
||||
key={session.session_id}
|
||||
hover
|
||||
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
|
||||
onClick={
|
||||
onSelectSession ? () => onSelectSession(session) : undefined
|
||||
}
|
||||
>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
title={session.user || userFallback}
|
||||
>
|
||||
{session.user || userFallback}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
title={session.session_id}
|
||||
>
|
||||
{session.session_id}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={sessionLabel}
|
||||
color={
|
||||
state === "playing"
|
||||
? "primary"
|
||||
: state === "paused"
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
variant={
|
||||
state === "playing" || state === "paused"
|
||||
? "filled"
|
||||
: "outlined"
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
title={session.title || ""}
|
||||
>
|
||||
{session.title || "(idle)"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{session.type || "—"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{session.device || "Unknown device"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{session.transcoding === "yes"
|
||||
? session.transcoding_type
|
||||
? `yes (${session.transcoding_type})`
|
||||
: "yes"
|
||||
: "no"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
{onSelectSession ? (
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectSession(session);
|
||||
}}
|
||||
>
|
||||
Open in Users
|
||||
</Button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCounts, fetchLibraries, fetchNowPlaying } from "../api/client";
|
||||
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
|
||||
|
||||
export function useCounts() {
|
||||
return useQuery({
|
||||
@@ -17,10 +17,13 @@ export function useLibraries() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useNowPlaying() {
|
||||
export function useActivity() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "now-playing"],
|
||||
queryFn: fetchNowPlaying,
|
||||
queryKey: ["dashboard", "activity"],
|
||||
queryFn: fetchActivity,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Backward-compatible alias used by older code.
|
||||
export const useNowPlaying = useActivity;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchMediaStatus, buildMediaIndex, queryMedia } from "../api/client";
|
||||
import {
|
||||
fetchMediaStatus,
|
||||
buildMediaIndex,
|
||||
queryMedia,
|
||||
stopMediaIndexBuild,
|
||||
forceStopMediaIndexBuild,
|
||||
} from "../api/client";
|
||||
|
||||
export function useMediaStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["media", "status"],
|
||||
queryFn: fetchMediaStatus,
|
||||
staleTime: 60_000,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.build_running ? 1000 : false,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +30,8 @@ export function useMediaQuery(params: {
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { enabled = true, ...queryParams } = params;
|
||||
|
||||
// Feature: Sync file browser with selected media path
|
||||
return useQuery({
|
||||
queryKey: ["media", "query", queryParams],
|
||||
queryFn: () => queryMedia(queryParams),
|
||||
@@ -29,12 +40,36 @@ export function useMediaQuery(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
}
|
||||
|
||||
export function useBuildIndex() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: buildMediaIndex,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopBuildIndex() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: stopMediaIndexBuild,
|
||||
onSuccess: () => {
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useForceStopBuildIndex() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: forceStopMediaIndexBuild,
|
||||
onSuccess: () => {
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ export function useMonitoringStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMetrics(lastSeconds = 3600) {
|
||||
export function useMonitoringMetrics() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "metrics", lastSeconds],
|
||||
queryFn: () => fetchMonitoringMetrics(lastSeconds),
|
||||
queryKey: ["monitoring", "metrics"],
|
||||
queryFn: () => fetchMonitoringMetrics(),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { sendUserMessage } from "../api/client";
|
||||
|
||||
export function useSendUserMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: sendUserMessage,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "message-queue"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { testUserSmtpConnection } from "../api/client";
|
||||
|
||||
export function useTestUserSmtp() {
|
||||
return useMutation({
|
||||
mutationFn: testUserSmtpConnection,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUserMessageQueueStatus } from "../api/client";
|
||||
|
||||
export function useUserMessageQueueStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["users", "message-queue"],
|
||||
queryFn: fetchUserMessageQueueStatus,
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUsers } from "../api/client";
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: fetchUsers,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
|
||||
import { useMemo } from "react";
|
||||
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
|
||||
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
@@ -20,96 +23,223 @@ function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
function formatPct(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) return null;
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
avg: total / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: counts } = useCounts();
|
||||
const { data: libraries } = useLibraries();
|
||||
const { data: nowPlaying } = useNowPlaying();
|
||||
const { data: activity } = useActivity();
|
||||
const { data: metrics } = useMonitoringMetrics();
|
||||
const { data: disk } = useDiskSpace();
|
||||
|
||||
const latest = metrics?.samples?.at(-1);
|
||||
const monitoringWindow = useMemo(() => {
|
||||
const samples = metrics?.samples ?? [];
|
||||
if (samples.length === 0) return [];
|
||||
const latestTs = samples.at(-1)?.ts ?? 0;
|
||||
const windowStart = latestTs - 10 * 60;
|
||||
const windowed = samples.filter((sample) => sample.ts >= windowStart);
|
||||
return windowed.length > 0 ? windowed : samples;
|
||||
}, [metrics?.samples]);
|
||||
|
||||
const cpuSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.cpu_pct),
|
||||
);
|
||||
const iowaitSummary = summarize(
|
||||
monitoringWindow
|
||||
.map((sample) => sample.iowait_pct)
|
||||
.filter((value): value is number => value !== undefined),
|
||||
);
|
||||
const memSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.mem_pct),
|
||||
);
|
||||
const netRxSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec),
|
||||
);
|
||||
const netTxSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec),
|
||||
);
|
||||
const diskReadSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.disk_read_bps),
|
||||
);
|
||||
const diskWriteSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.disk_write_bps),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Now Playing */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
|
||||
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Server Overview */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||
Activity
|
||||
</Typography>
|
||||
{activity && (
|
||||
<NowPlaying
|
||||
sessions={activity}
|
||||
onSelectSession={(session) =>
|
||||
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="RAM"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
/>
|
||||
</div>
|
||||
{disk && (
|
||||
<div className="mt-3 grid grid-cols-4 gap-3">
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Box>
|
||||
|
||||
<hr />
|
||||
<Divider />
|
||||
|
||||
{/* Media Library Overview */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||
Monitoring Overview
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="CPU (10m avg)"
|
||||
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
|
||||
subtext={
|
||||
cpuSummary
|
||||
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="IO Wait (10m avg)"
|
||||
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
|
||||
subtext={
|
||||
iowaitSummary
|
||||
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="RAM (10m avg)"
|
||||
value={memSummary ? formatPct(memSummary.avg) : "-"}
|
||||
subtext={
|
||||
memSummary
|
||||
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net down (10m avg)"
|
||||
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
|
||||
subtext={
|
||||
netRxSummary
|
||||
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net up (10m avg)"
|
||||
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
|
||||
subtext={
|
||||
netTxSummary
|
||||
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk read (10m avg)"
|
||||
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
|
||||
subtext={
|
||||
diskReadSummary
|
||||
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk write (10m avg)"
|
||||
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
|
||||
subtext={
|
||||
diskWriteSummary
|
||||
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{disk && (
|
||||
<Grid container spacing={1.5} sx={{ mt: 0.5 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||
Library Stats
|
||||
</Typography>
|
||||
{counts && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<MetricCard
|
||||
label="Total"
|
||||
value={(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
/>
|
||||
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
|
||||
<MetricCard label="Series" value={counts.series.toLocaleString()} />
|
||||
<MetricCard
|
||||
label="Episodes"
|
||||
value={counts.episodes.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
<Grid container spacing={1.5} sx={{ mb: 2 }}>
|
||||
<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} />}
|
||||
</section>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+671
-113
@@ -1,5 +1,23 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
@@ -8,6 +26,7 @@ import {
|
||||
} from "../hooks/useFiles";
|
||||
|
||||
interface DisplayRow {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
@@ -16,6 +35,46 @@ interface DisplayRow {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FfprobeStream {
|
||||
index?: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
codec_long_name?: string;
|
||||
profile?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bit_rate?: string | number;
|
||||
duration?: string | number;
|
||||
channels?: number;
|
||||
sample_rate?: string | number;
|
||||
channel_layout?: string;
|
||||
pix_fmt?: string;
|
||||
sample_aspect_ratio?: string;
|
||||
display_aspect_ratio?: string;
|
||||
field_order?: string;
|
||||
level?: number | string;
|
||||
color_range?: string;
|
||||
color_space?: string;
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeFormat {
|
||||
filename?: string;
|
||||
format_name?: string;
|
||||
format_long_name?: string;
|
||||
duration?: string | number;
|
||||
size?: string | number;
|
||||
bit_rate?: string | number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FfprobeData {
|
||||
format?: FfprobeFormat;
|
||||
streams?: FfprobeStream[];
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -33,6 +92,45 @@ function formatTime(epoch: number): string {
|
||||
return new Date(epoch * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function humanBytes(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const bytes = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(bytes)) return "-";
|
||||
return formatSize(bytes);
|
||||
}
|
||||
|
||||
function humanRate(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const rate = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(rate)) return "-";
|
||||
const units = ["bps", "Kbps", "Mbps", "Gbps"];
|
||||
let v = rate;
|
||||
let unitIdx = 0;
|
||||
while (v >= 1000 && unitIdx < units.length - 1) {
|
||||
v /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function humanDuration(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
const seconds = typeof value === "string" ? Number(value) : value;
|
||||
if (!Number.isFinite(seconds)) return "-";
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0)
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fieldLabel(_key: string, value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
const exts = [
|
||||
".mkv",
|
||||
@@ -48,38 +146,446 @@ function isVideoFile(name: string): boolean {
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [currentDir, setCurrentDir] = useState("/");
|
||||
const [pathInput, setPathInput] = useState("/");
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
const format = data.format ?? {};
|
||||
const streams = data.streams ?? [];
|
||||
const videoStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "video",
|
||||
);
|
||||
const audioStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
const subtitleStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "subtitle",
|
||||
);
|
||||
|
||||
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
|
||||
const { data: ffprobeData } = useFfprobe(
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
|
||||
ffprobe details
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{path}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Container / format
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="body2">
|
||||
<b>Format:</b> {fieldLabel("format", format.format_name)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Long name:</b>{" "}
|
||||
{fieldLabel("format_long_name", format.format_long_name)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Duration:</b> {humanDuration(format.duration)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="body2">
|
||||
<b>Size:</b> {humanBytes(format.size)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Bitrate:</b> {humanRate(format.bit_rate)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Filename:</b> {fieldLabel("filename", format.filename)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Streams
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{videoStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Video streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{videoStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`video-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="primary"
|
||||
label={stream.codec_type ?? "video"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.codec_long_name && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_long_name}
|
||||
/>
|
||||
)}
|
||||
{stream.profile && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.profile}
|
||||
/>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanRate(stream.bit_rate)}
|
||||
/>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanDuration(stream.duration)}
|
||||
/>
|
||||
)}
|
||||
{stream.width && stream.height && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.width}×${stream.height}`}
|
||||
/>
|
||||
)}
|
||||
{stream.pix_fmt && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.pix_fmt}
|
||||
/>
|
||||
)}
|
||||
{stream.display_aspect_ratio && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`DAR ${stream.display_aspect_ratio}`}
|
||||
/>
|
||||
)}
|
||||
{stream.sample_aspect_ratio && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`SAR ${stream.sample_aspect_ratio}`}
|
||||
/>
|
||||
)}
|
||||
{stream.level !== undefined &&
|
||||
stream.level !== null && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`L${stream.level}`}
|
||||
/>
|
||||
)}
|
||||
{stream.field_order &&
|
||||
stream.field_order !== "unknown" && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.field_order}
|
||||
/>
|
||||
)}
|
||||
{(stream.color_range ||
|
||||
stream.color_space ||
|
||||
stream.color_transfer ||
|
||||
stream.color_primaries) && (
|
||||
<Chip
|
||||
size="small"
|
||||
color={
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("2084") ||
|
||||
(stream.color_transfer ?? "")
|
||||
.toLowerCase()
|
||||
.includes("b67") ||
|
||||
(stream.color_space ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020") ||
|
||||
(stream.color_primaries ?? "")
|
||||
.toLowerCase()
|
||||
.includes("bt2020")
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
variant="outlined"
|
||||
label={[
|
||||
stream.color_range,
|
||||
stream.color_space,
|
||||
stream.color_transfer,
|
||||
stream.color_primaries,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ mt: 0.75 }}>
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title
|
||||
? `Title: ${stream.tags.title}.`
|
||||
: ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{audioStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Audio streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{audioStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`audio-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="secondary"
|
||||
label={stream.codec_type ?? "audio"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.channels && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.channels} ch`}
|
||||
/>
|
||||
)}
|
||||
{stream.sample_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${stream.sample_rate} Hz`}
|
||||
/>
|
||||
)}
|
||||
{stream.bit_rate && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanRate(stream.bit_rate)}
|
||||
/>
|
||||
)}
|
||||
{stream.duration && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={humanDuration(stream.duration)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ mt: 0.75 }}>
|
||||
{stream.codec_long_name
|
||||
? `${stream.codec_long_name}. `
|
||||
: ""}
|
||||
{stream.channel_layout
|
||||
? `Layout: ${stream.channel_layout}. `
|
||||
: ""}
|
||||
{stream.tags?.language
|
||||
? `Language: ${stream.tags.language}. `
|
||||
: ""}
|
||||
{stream.tags?.title
|
||||
? `Title: ${stream.tags.title}.`
|
||||
: ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{subtitleStreams.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Subtitle streams
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 0.75 }}>
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<Box
|
||||
key={`subtitle-${stream.index ?? index}`}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ flexWrap: "wrap", alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${stream.index ?? index}`}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
color="info"
|
||||
label={stream.codec_type ?? "subtitle"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.codec_name ?? "unknown codec"}
|
||||
/>
|
||||
{stream.tags?.language && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.tags.language}
|
||||
/>
|
||||
)}
|
||||
{stream.tags?.title && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={stream.tags.title}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{streams.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No streams found.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Tags
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size="small"
|
||||
label={`${key}: ${value}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialRequestedPath = searchParams.get("path") ?? "/";
|
||||
const initialSelectedPath =
|
||||
initialRequestedPath !== "/" &&
|
||||
(isVideoFile(initialRequestedPath) || initialRequestedPath.includes("."))
|
||||
? initialRequestedPath.replace(/\/+$/, "")
|
||||
: null;
|
||||
const initialCurrentDir = initialSelectedPath
|
||||
? initialSelectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: initialRequestedPath.replace(/\/+$/, "") || "/";
|
||||
const [currentDir, setCurrentDir] = useState(initialCurrentDir);
|
||||
const [pathInput, setPathInput] = useState(initialCurrentDir);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(
|
||||
initialSelectedPath,
|
||||
);
|
||||
const [selectedJob, setSelectedJob] = useState<string>("");
|
||||
|
||||
const {
|
||||
data: listing,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
error: ffprobeError,
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob();
|
||||
|
||||
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
|
||||
|
||||
const navigate = useCallback((path: string) => {
|
||||
const navigate = (path: string) => {
|
||||
setCurrentDir(path);
|
||||
setPathInput(path);
|
||||
setSelectedPath(null);
|
||||
}, []);
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
navigate(pathInput || "/");
|
||||
}
|
||||
};
|
||||
|
||||
// Build display rows
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") navigate(pathInput || "/");
|
||||
};
|
||||
|
||||
const rows: DisplayRow[] = [];
|
||||
if (currentDir !== "/") {
|
||||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||||
rows.push({
|
||||
id: `up-${parent}`,
|
||||
type: "up",
|
||||
name: "..",
|
||||
ext: "",
|
||||
@@ -92,131 +598,183 @@ export function FileBrowser() {
|
||||
for (const entry of listing.entries) {
|
||||
const kind = entry.type === "d" ? "dir" : "file";
|
||||
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
|
||||
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
|
||||
rows.push({
|
||||
id: path,
|
||||
type: kind,
|
||||
name: entry.name,
|
||||
ext,
|
||||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||||
modified: formatTime(entry.mtime),
|
||||
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columnDefs = [
|
||||
{ field: "type" as const, headerName: "Type", width: 80 },
|
||||
{ field: "name" as const, headerName: "Name", flex: 2 },
|
||||
{ field: "ext" as const, headerName: "Ext", width: 80 },
|
||||
{ field: "size" as const, headerName: "Size", width: 110 },
|
||||
{ field: "modified" as const, headerName: "Modified", width: 180 },
|
||||
const columns: GridColDef<DisplayRow>[] = [
|
||||
{ field: "type", headerName: "Type", width: 90 },
|
||||
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
|
||||
{ field: "ext", headerName: "Ext", width: 90 },
|
||||
{ field: "size", headerName: "Size", width: 120 },
|
||||
{ field: "modified", headerName: "Modified", width: 190 },
|
||||
];
|
||||
|
||||
const onRowClicked = useCallback(
|
||||
(event: { data?: DisplayRow }) => {
|
||||
const row = event.data;
|
||||
if (!row) return;
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
} else {
|
||||
setSelectedPath(row.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
const rowSelectionModel: GridRowSelectionModel = selectedPath
|
||||
? { type: "include", ids: new Set([selectedPath]) }
|
||||
: { type: "include", ids: new Set() };
|
||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Path input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h5">File Browser</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Remote path"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathSubmit}
|
||||
className="border rounded px-3 py-1 text-sm flex-1"
|
||||
placeholder="Remote path (press Enter to navigate)"
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
>
|
||||
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}>
|
||||
Open
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => refetch()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex gap-6 text-xs text-gray-500">
|
||||
<span>
|
||||
Current: <code>{currentDir}</code>
|
||||
</span>
|
||||
{selectedPath && (
|
||||
<span>
|
||||
Selected: <code>{selectedPath}</code>
|
||||
</span>
|
||||
)}
|
||||
{listing && <span>Entries: {listing.count}</span>}
|
||||
</div>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Current: {currentDir}{" "}
|
||||
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</Typography>
|
||||
|
||||
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
|
||||
{/* File listing grid */}
|
||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact<DisplayRow>
|
||||
ref={gridRef}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
rowSelection="single"
|
||||
onRowClicked={onRowClicked}
|
||||
<Box
|
||||
sx={{
|
||||
height: 420,
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
suppressCellFocus
|
||||
animateRows={false}
|
||||
rowSelectionModel={rowSelectionModel}
|
||||
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 setSelectedPath(row.path);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* ffprobe preview */}
|
||||
{selectedPath && isVideoFile(selectedPath) && (
|
||||
<section className="border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">
|
||||
ffprobe preview: <code className="text-xs">{selectedPath}</code>
|
||||
</h3>
|
||||
{ffprobeData ? (
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
|
||||
{JSON.stringify(ffprobeData, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
|
||||
)}
|
||||
</section>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Jobs */}
|
||||
{selectedPath && templates && templates.length > 0 && (
|
||||
<section className="border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.key}
|
||||
onClick={() =>
|
||||
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
|
||||
}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
title={tpl.description}
|
||||
<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) => setSelectedJob(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="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={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
overflow: "auto",
|
||||
maxHeight: 260,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{tpl.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
|
||||
Exit: {runJob.data.exit_status}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
Exit: {runJob.data.exit_status}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+336
-142
@@ -1,15 +1,49 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import type { GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
LinearProgress,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useMediaStatus,
|
||||
useMediaQuery,
|
||||
useBuildIndex,
|
||||
useStopBuildIndex,
|
||||
useForceStopBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import type { MediaItem } from "../types";
|
||||
|
||||
function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
export function Media() {
|
||||
const navigate = useNavigate();
|
||||
const { data: status } = useMediaStatus();
|
||||
const buildIndex = useBuildIndex();
|
||||
const stopBuildIndex = useStopBuildIndex();
|
||||
const forceStopBuildIndex = useForceStopBuildIndex();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [types, setTypes] = useState("Movie,Episode");
|
||||
@@ -30,181 +64,341 @@ export function Media() {
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
const gridRef = useRef<AgGridReact<MediaItem>>(null);
|
||||
|
||||
const columnDefs = [
|
||||
{ field: "title" as const, headerName: "Title", minWidth: 150 },
|
||||
{ field: "series" as const, headerName: "Series", minWidth: 120 },
|
||||
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
|
||||
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
|
||||
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
|
||||
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
|
||||
{
|
||||
field: "runtime_min" as const,
|
||||
headerName: "Runtime (min)",
|
||||
maxWidth: 125,
|
||||
},
|
||||
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
|
||||
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
|
||||
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
|
||||
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
|
||||
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
|
||||
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
|
||||
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
|
||||
{ field: "path" as const, headerName: "Path", minWidth: 200 },
|
||||
const columns: GridColDef<MediaItem>[] = [
|
||||
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
|
||||
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
|
||||
{ field: "season", headerName: "Season", width: 90 },
|
||||
{ field: "episode", headerName: "Episode", width: 100 },
|
||||
{ field: "type", headerName: "Type", width: 100 },
|
||||
{ field: "year", headerName: "Year", width: 90 },
|
||||
{ field: "runtime_min", headerName: "Runtime", width: 110 },
|
||||
{ field: "size", headerName: "Size", width: 120 },
|
||||
{ field: "bitrate", headerName: "Bitrate", width: 130 },
|
||||
{ field: "hdr", headerName: "HDR", width: 80 },
|
||||
{ field: "video", headerName: "Video codec", width: 130 },
|
||||
{ field: "resolution", headerName: "Resolution", width: 120 },
|
||||
{ field: "date_added", headerName: "Date added", width: 120 },
|
||||
{ field: "library", headerName: "Library", width: 140 },
|
||||
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
|
||||
];
|
||||
|
||||
const onGridReady = useCallback(() => {
|
||||
gridRef.current?.api?.sizeColumnsToFit();
|
||||
}, []);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(queryResult?.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: item.id || item.path,
|
||||
})),
|
||||
[queryResult],
|
||||
);
|
||||
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
|
||||
const totalPages = queryResult
|
||||
? Math.max(1, Math.ceil(queryResult.total / limit))
|
||||
: 1;
|
||||
const buildRunning = status?.build_running ?? false;
|
||||
const buildProgress = status?.build_progress ?? null;
|
||||
const buildLibraryProgress = status?.build_library_progress ?? null;
|
||||
const buildCancelRequested = status?.build_cancel_requested ?? false;
|
||||
const buildLabel = buildRunning
|
||||
? status?.build_message || "Building media index..."
|
||||
: status?.build_error
|
||||
? `Build failed: ${status.build_error}`
|
||||
: "";
|
||||
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
|
||||
const etaLabel =
|
||||
buildRunning && status?.build_eta_seconds != null
|
||||
? formatDuration(status.build_eta_seconds)
|
||||
: "-";
|
||||
const libraryElapsedLabel = formatDuration(
|
||||
status?.build_library_elapsed_seconds,
|
||||
);
|
||||
const libraryEtaLabel =
|
||||
buildRunning && status?.build_library_eta_seconds != null
|
||||
? formatDuration(status.build_library_eta_seconds)
|
||||
: "-";
|
||||
const libraryLabel =
|
||||
status?.build_current_library ||
|
||||
(status?.build_library_index && status?.build_libraries_total
|
||||
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
|
||||
: "Current library");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="h5">Media</Typography>
|
||||
{status?.exists ? (
|
||||
<span className="text-sm text-gray-600">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
|
||||
</span>
|
||||
{status.updated_at_label
|
||||
? ` | updated ${status.updated_at_label}`
|
||||
: ""}
|
||||
</Typography>
|
||||
) : (
|
||||
<span className="text-sm text-amber-600">No index built yet.</span>
|
||||
<Alert severity="warning" sx={{ py: 0 }}>
|
||||
No index built yet.
|
||||
</Alert>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => buildIndex.mutate()}
|
||||
disabled={buildIndex.isPending}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={
|
||||
buildIndex.isPending || buildRunning || buildCancelRequested
|
||||
}
|
||||
>
|
||||
{buildIndex.isPending ? "Building..." : "Build index"}
|
||||
</button>
|
||||
</div>
|
||||
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
|
||||
</Button>
|
||||
{buildRunning && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => stopBuildIndex.mutate()}
|
||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||
>
|
||||
{buildCancelRequested || stopBuildIndex.isPending
|
||||
? "Stopping..."
|
||||
: "Stop build"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => forceStopBuildIndex.mutate()}
|
||||
disabled={forceStopBuildIndex.isPending}
|
||||
>
|
||||
{forceStopBuildIndex.isPending
|
||||
? "Force stopping..."
|
||||
: "Force stop"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(buildRunning || status?.build_error) && (
|
||||
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={status?.build_error ? "error" : "text.secondary"}
|
||||
>
|
||||
{buildLabel ||
|
||||
(buildRunning
|
||||
? "Building media index..."
|
||||
: status?.build_error || "")}
|
||||
</Typography>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm w-48"
|
||||
placeholder="Search title, series, path..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Types</label>
|
||||
<select
|
||||
value={types}
|
||||
onChange={(e) => {
|
||||
setTypes(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="Movie,Episode">Movies + Episodes</option>
|
||||
<option value="Movie">Movies only</option>
|
||||
<option value="Episode">Episodes only</option>
|
||||
<option value="Movie,Episode,Video">All video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">HDR</label>
|
||||
<select
|
||||
value={hdrFilter}
|
||||
onChange={(e) => {
|
||||
setHdrFilter(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="All">All</option>
|
||||
<option value="HDR only">HDR only</option>
|
||||
<option value="SDR/unknown only">SDR/unknown only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Sort</label>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="title">Title</option>
|
||||
<option value="series">Series</option>
|
||||
<option value="size">Size</option>
|
||||
<option value="bitrate">Bitrate</option>
|
||||
<option value="runtime">Runtime</option>
|
||||
<option value="year">Year</option>
|
||||
<option value="date_added">Date added</option>
|
||||
<option value="resolution">Resolution</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Order</label>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="Ascending">Ascending</option>
|
||||
<option value="Descending">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Stack spacing={0.35}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Overall:{" "}
|
||||
{buildProgress != null
|
||||
? `${Math.round(buildProgress * 100)}%`
|
||||
: "pending"}
|
||||
{buildRunning
|
||||
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
||||
: ""}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={
|
||||
buildProgress != null ? "determinate" : "indeterminate"
|
||||
}
|
||||
value={
|
||||
buildProgress != null
|
||||
? Math.max(0, Math.min(100, buildProgress * 100))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
||||
{status?.build_items_total?.toLocaleString() ?? 0} items
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={0.35}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Current: {libraryLabel}
|
||||
{buildRunning
|
||||
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
||||
: ""}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={
|
||||
buildLibraryProgress != null
|
||||
? "determinate"
|
||||
: "indeterminate"
|
||||
}
|
||||
value={
|
||||
buildLibraryProgress != null
|
||||
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status?.build_library_items_processed?.toLocaleString() ?? 0}
|
||||
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
|
||||
items
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Search"
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Types</InputLabel>
|
||||
<Select
|
||||
label="Types"
|
||||
value={types}
|
||||
onChange={(e) => {
|
||||
setTypes(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
|
||||
<MenuItem value="Movie">Movies only</MenuItem>
|
||||
<MenuItem value="Episode">Episodes only</MenuItem>
|
||||
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>HDR</InputLabel>
|
||||
<Select
|
||||
label="HDR"
|
||||
value={hdrFilter}
|
||||
onChange={(e) => {
|
||||
setHdrFilter(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="All">All</MenuItem>
|
||||
<MenuItem value="HDR only">HDR only</MenuItem>
|
||||
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Sort</InputLabel>
|
||||
<Select
|
||||
label="Sort"
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
>
|
||||
{[
|
||||
["title", "Title"],
|
||||
["series", "Series"],
|
||||
["size", "Size"],
|
||||
["bitrate", "Bitrate"],
|
||||
["runtime", "Runtime"],
|
||||
["year", "Year"],
|
||||
["date_added", "Date added"],
|
||||
["resolution", "Resolution"],
|
||||
].map(([k, l]) => (
|
||||
<MenuItem key={k} value={k}>
|
||||
{l}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 2 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Order</InputLabel>
|
||||
<Select
|
||||
label="Order"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
>
|
||||
<MenuItem value="Ascending">Ascending</MenuItem>
|
||||
<MenuItem value="Descending">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results info */}
|
||||
{queryResult && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Showing {queryResult.items.length} of{" "}
|
||||
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
||||
{totalPages}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* AG Grid table */}
|
||||
{status?.exists && (
|
||||
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
|
||||
<AgGridReact<MediaItem>
|
||||
ref={gridRef}
|
||||
rowData={queryResult?.items ?? []}
|
||||
columnDefs={columnDefs}
|
||||
rowSelection="single"
|
||||
onGridReady={onGridReady}
|
||||
<Box
|
||||
sx={{
|
||||
height: 640,
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
suppressCellFocus
|
||||
animateRows={false}
|
||||
checkboxSelection={false}
|
||||
disableRowSelectionOnClick
|
||||
onRowClick={(params) => {
|
||||
const row = params.row as MediaItem;
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
}}
|
||||
pageSizeOptions={[100]}
|
||||
hideFooter
|
||||
sx={{
|
||||
"& .MuiDataGrid-columnHeaders": {
|
||||
fontWeight: 700,
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{queryResult && totalPages > 1 && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm">
|
||||
</Button>
|
||||
<Typography variant="body2">
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useMonitoringStatus,
|
||||
useMonitoringMetrics,
|
||||
@@ -32,7 +41,6 @@ export function Monitoring() {
|
||||
const samples = metrics?.samples ?? [];
|
||||
const latest = samples.at(-1);
|
||||
|
||||
// Compute averages and peaks
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
|
||||
@@ -46,95 +54,120 @@ export function Monitoring() {
|
||||
const diskWriteArr = samples.map((s) => s.disk_write_bps);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Controls */}
|
||||
<section className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
Collector:{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">
|
||||
{status?.status ?? "unknown"}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
<Stack spacing={3}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="h5">Monitoring</Typography>
|
||||
<Chip
|
||||
label={status?.status ?? "unknown"}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => start.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
disabled={start.isPending}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => restart.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
disabled={restart.isPending}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => stop.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</section>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Metrics summary */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="CPU now"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="RAM now"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Disk space */}
|
||||
{disk && (
|
||||
<section>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</div>
|
||||
</section>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<section>
|
||||
<Divider />
|
||||
<Box>
|
||||
<MonitoringCharts samples={samples} />
|
||||
</section>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { createTheme, type PaletteMode } from "@mui/material/styles";
|
||||
|
||||
export function getAppTheme(mode: PaletteMode) {
|
||||
const isDark = mode === "dark";
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: { main: "#4f8cff" },
|
||||
background: {
|
||||
default: isDark ? "#0f172a" : "#f4f6fb",
|
||||
paper: isDark ? "#111827" : "#ffffff",
|
||||
},
|
||||
},
|
||||
shape: { borderRadius: 12 },
|
||||
typography: {
|
||||
fontFamily:
|
||||
'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
h5: { fontWeight: 700 },
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backdropFilter: "blur(8px)",
|
||||
backgroundImage: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: isDark
|
||||
? "rgba(148,163,184,0.25)"
|
||||
: "rgba(15,23,42,0.08)",
|
||||
boxShadow: isDark
|
||||
? "0 2px 12px rgba(2,6,23,0.35)"
|
||||
: "0 2px 8px rgba(15,23,42,0.04)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,99 @@ export interface LibraryCount {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserDirectoryItem {
|
||||
jellyfin_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_source: string;
|
||||
avatar: string;
|
||||
avatar_source: string;
|
||||
contactable: boolean;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
name_source: string;
|
||||
access_source: string;
|
||||
jellyseerr_user_id: number | null;
|
||||
jellyseerr_username: string;
|
||||
user_type: number | null;
|
||||
user_type_label: string;
|
||||
role: string;
|
||||
permissions: number;
|
||||
permissions_label: string;
|
||||
request_count: number | null;
|
||||
}
|
||||
|
||||
export interface UserDirectoryResponse {
|
||||
items: UserDirectoryItem[];
|
||||
total: number;
|
||||
jellyseerr_configured: boolean;
|
||||
jellyseerr_available: boolean;
|
||||
jellyseerr_error: string;
|
||||
jellyseerr_jellyfin_user_count: number;
|
||||
jellyseerr_user_count: number;
|
||||
enriched_count: number;
|
||||
}
|
||||
|
||||
export interface UserMessageResponse {
|
||||
status: string;
|
||||
request_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
recipient_count: number;
|
||||
attachment_count: number;
|
||||
recipient_labels: string[];
|
||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface UserMessageQueueStatus {
|
||||
state: "idle" | "busy" | "error" | "stopped";
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
pending_count: number;
|
||||
active_request_id: string | null;
|
||||
last_request_id: string | null;
|
||||
last_result: string | null;
|
||||
last_error: string;
|
||||
last_error_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_activity_at: number | null;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface SmtpTestAttempt {
|
||||
label: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
use_tls: boolean;
|
||||
use_ssl: boolean;
|
||||
status: "ok" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SmtpTestSelectedMode {
|
||||
label: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
use_tls: boolean;
|
||||
use_ssl: boolean;
|
||||
}
|
||||
|
||||
export interface SmtpTestResponse {
|
||||
status: "ok" | "error";
|
||||
message: string;
|
||||
from_address: string;
|
||||
from_name: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
use_tls: boolean;
|
||||
use_ssl: boolean;
|
||||
authenticated: boolean;
|
||||
selected_mode: SmtpTestSelectedMode | null;
|
||||
attempts: SmtpTestAttempt[];
|
||||
}
|
||||
|
||||
export interface NowPlayingSession {
|
||||
user: string;
|
||||
title: string;
|
||||
@@ -65,6 +158,48 @@ export interface MediaIndexStatus {
|
||||
updated_at: number | null;
|
||||
updated_at_label: string;
|
||||
build_duration_seconds: number | null;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaIndexActionResponse {
|
||||
status: string;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
import type { NowPlayingSession, UserDirectoryItem } from "./types";
|
||||
|
||||
export interface UserActivitySummary {
|
||||
sessions: NowPlayingSession[];
|
||||
active_count: number;
|
||||
idle_count: number;
|
||||
label: string;
|
||||
summary: string;
|
||||
primary_session: NowPlayingSession | null;
|
||||
}
|
||||
|
||||
export interface UserStateItem extends UserDirectoryItem {
|
||||
activity: UserActivitySummary;
|
||||
activity_label: string;
|
||||
activity_summary: string;
|
||||
activity_count: number;
|
||||
activity_active_count: number;
|
||||
activity_idle_count: number;
|
||||
}
|
||||
|
||||
export declare function mergeUsersWithActivity(
|
||||
users: UserDirectoryItem[],
|
||||
sessions: NowPlayingSession[],
|
||||
): UserStateItem[];
|
||||
|
||||
export declare function resolveUserSelection(
|
||||
users: Array<UserDirectoryItem | UserStateItem>,
|
||||
identifier: string,
|
||||
): UserStateItem | UserDirectoryItem | null;
|
||||
@@ -0,0 +1,100 @@
|
||||
function normalize(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function userKeys(user) {
|
||||
return [user.jellyfin_id, user.username, user.display_name]
|
||||
.map(normalize)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sessionMatchesUser(user, session) {
|
||||
const sessionUser = normalize(session.user);
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
return userKeys(user).some((key) => key === sessionUser);
|
||||
}
|
||||
|
||||
function buildActivitySummary(sessions) {
|
||||
const activeSessions = sessions.filter((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return state === "playing" || state === "paused";
|
||||
});
|
||||
const idleSessions = sessions.filter((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return state === "idle";
|
||||
});
|
||||
const primarySession = activeSessions[0] || sessions[0] || null;
|
||||
const hasPlaying = activeSessions.some((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return state === "playing";
|
||||
});
|
||||
const hasPaused = activeSessions.some((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return state === "paused";
|
||||
});
|
||||
const label = !sessions.length
|
||||
? "No sessions"
|
||||
: hasPlaying
|
||||
? "Playing"
|
||||
: hasPaused
|
||||
? "Paused"
|
||||
: "Idle";
|
||||
const summary =
|
||||
sessions.length === 0
|
||||
? "No live sessions"
|
||||
: [
|
||||
`${sessions.length} session${sessions.length === 1 ? "" : "s"}`,
|
||||
activeSessions.length ? `${activeSessions.length} active` : null,
|
||||
idleSessions.length ? `${idleSessions.length} idle` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return {
|
||||
sessions,
|
||||
active_count: activeSessions.length,
|
||||
idle_count: idleSessions.length,
|
||||
label,
|
||||
summary,
|
||||
primary_session: primarySession,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeUsersWithActivity(users, sessions) {
|
||||
return users.map((user) => {
|
||||
const matchingSessions = sessions.filter((session) =>
|
||||
sessionMatchesUser(user, session),
|
||||
);
|
||||
const activity = buildActivitySummary(matchingSessions);
|
||||
return {
|
||||
...user,
|
||||
activity,
|
||||
activity_label: activity.label,
|
||||
activity_summary: activity.summary,
|
||||
activity_count: activity.sessions.length,
|
||||
activity_active_count: activity.active_count,
|
||||
activity_idle_count: activity.idle_count,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveUserSelection(users, identifier) {
|
||||
const needle = normalize(identifier);
|
||||
if (!needle) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
users.find((user) => userKeys(user).some((key) => key === needle)) || null
|
||||
);
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
import type { UserDirectoryItem } from "./types";
|
||||
|
||||
export interface UserDetailField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UserContactAction {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface UserContactState {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserDrawerModel {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
contactState: UserContactState;
|
||||
contactActions: UserContactAction[];
|
||||
identity: UserDetailField[];
|
||||
permissions: string[];
|
||||
syncStatus: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export declare function buildUserDrawerModel(
|
||||
user: UserDirectoryItem,
|
||||
): UserDrawerModel;
|
||||
@@ -0,0 +1,90 @@
|
||||
function displayName(user) {
|
||||
return user.display_name || user.username || user.jellyfin_id;
|
||||
}
|
||||
|
||||
function splitValues(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function syncStatus(user) {
|
||||
if (
|
||||
user.jellyseerr_user_id !== null &&
|
||||
user.jellyseerr_user_id !== undefined
|
||||
) {
|
||||
return "Linked with Jellyseerr";
|
||||
}
|
||||
if (String(user.source_summary || user.source || "").includes("jellyseerr")) {
|
||||
return "Enriched via Jellyseerr";
|
||||
}
|
||||
return "Jellyfin only";
|
||||
}
|
||||
|
||||
export function buildUserDrawerModel(user) {
|
||||
const title = displayName(user);
|
||||
const subtitle = user.email || "No email address available";
|
||||
const permissions = splitValues(user.permissions_label);
|
||||
const source = String(user.source_summary || user.source || "jellyfin");
|
||||
const roleLabel = user.role
|
||||
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
|
||||
: "Unknown";
|
||||
const contactLabel = user.contactable ? "Contactable" : "Read-only";
|
||||
const contactDescription = user.contactable
|
||||
? "An email address is available for future communication workflows."
|
||||
: "No direct contact route is available yet.";
|
||||
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
contactState: {
|
||||
label: contactLabel,
|
||||
description: contactDescription,
|
||||
},
|
||||
contactActions: [
|
||||
{
|
||||
label: "Email",
|
||||
enabled: false,
|
||||
hint: user.contactable
|
||||
? "Email action is planned for a future release."
|
||||
: "Disabled until an email address is available.",
|
||||
},
|
||||
{
|
||||
label: "Notify",
|
||||
enabled: false,
|
||||
hint: "Notification workflows are not wired up yet.",
|
||||
},
|
||||
],
|
||||
identity: [
|
||||
{ label: "Email", value: user.email || "—" },
|
||||
{ label: "Email source", value: user.email_source || "none" },
|
||||
{ label: "Avatar source", value: user.avatar_source || "none" },
|
||||
{ label: "Jellyfin ID", value: user.jellyfin_id },
|
||||
{ label: "Name source", value: user.name_source || "jellyfin" },
|
||||
{ label: "Access source", value: user.access_source || "none" },
|
||||
{
|
||||
label: "Jellyseerr user ID",
|
||||
value:
|
||||
user.jellyseerr_user_id === null ||
|
||||
user.jellyseerr_user_id === undefined
|
||||
? "Not linked"
|
||||
: `#${user.jellyseerr_user_id}`,
|
||||
},
|
||||
{ label: "Account type", value: user.user_type_label || "unknown" },
|
||||
{ label: "Role", value: roleLabel },
|
||||
{ label: "Permissions", value: permissions.join(", ") || "none" },
|
||||
{
|
||||
label: "Requests",
|
||||
value:
|
||||
user.request_count === null || user.request_count === undefined
|
||||
? "—"
|
||||
: String(user.request_count),
|
||||
},
|
||||
{ label: "Source", value: source },
|
||||
],
|
||||
permissions,
|
||||
syncStatus: syncStatus(user),
|
||||
source,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user