fixes and improvements

This commit is contained in:
2026-05-06 16:33:02 +02:00
parent d8d80867ce
commit 5277f21577
20 changed files with 577 additions and 222 deletions
+9 -3
View File
@@ -27,7 +27,7 @@ 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 { Applications } from "./pages/Applications";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
@@ -173,7 +173,12 @@ function Shell({
component={NavLink}
to="/monitoring"
/>
<Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab
value="/applications"
label="Applications"
component={NavLink}
to="/applications"
/>
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab value="/files" label="Files" component={NavLink} to="/files" />
<Tab
@@ -192,7 +197,8 @@ function Shell({
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/settings" element={<Settings />} />
+79 -24
View File
@@ -25,6 +25,8 @@ import type {
JobTemplate,
JobResult,
ResolvedPath,
ResetLocalDatabaseInput,
ResetLocalDatabaseResponse,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
@@ -126,12 +128,26 @@ async function del<T>(path: string): Promise<T> {
}
// Dashboard
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchActivity = () =>
get<NowPlayingSession[]>("/api/dashboard/activity");
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
export const fetchCounts = (machineId?: string) =>
get<MediaCounts>(
"/api/dashboard/counts",
machineId ? { machine_id: machineId } : undefined,
);
export const fetchLibraries = (machineId?: string) =>
get<LibraryCount[]>(
"/api/dashboard/libraries",
machineId ? { machine_id: machineId } : undefined,
);
export const fetchActivity = (machineId?: string) =>
get<NowPlayingSession[]>(
"/api/dashboard/activity",
machineId ? { machine_id: machineId } : undefined,
);
export const fetchUsers = (machineId?: string) =>
get<UserDirectoryResponse>(
"/api/users",
machineId ? { machine_id: machineId } : undefined,
);
// Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity;
@@ -211,16 +227,36 @@ export const deleteMonitoringMachine = (machineId: string) =>
del<{ status: string }>(
`/api/settings/machines/${encodeURIComponent(machineId)}`,
);
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
post<ResetLocalDatabaseResponse>(
"/api/settings/reset-local-database",
payload,
);
// Media
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<MediaIndexActionResponse>("/api/media/build");
export const stopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/stop");
export const forceStopMediaIndexBuild = () =>
post<MediaIndexActionResponse>("/api/media/force-stop");
export const fetchMediaStatus = (machineId?: string) =>
get<MediaIndexStatus>(
"/api/media/status",
machineId ? { machine_id: machineId } : undefined,
);
export const buildMediaIndex = (machineId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/build",
);
export const stopMediaIndexBuild = (machineId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/stop",
);
export const forceStopMediaIndexBuild = (machineId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/force-stop",
);
export const queryMedia = (params: {
libraries?: string;
types?: string;
@@ -230,6 +266,7 @@ export const queryMedia = (params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
@@ -240,23 +277,41 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}),
});
// Files
export const fetchDirectoryListing = (path: string) =>
get<DirectoryListing>("/api/files/list", { path });
export const fetchFfprobe = (path: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", { path });
export const fetchStat = (path: string) =>
get<{ path: string; output: string }>("/api/files/stat", { path });
export const resolvePath = (path: string) =>
get<ResolvedPath>("/api/files/resolve-path", { path });
export const fetchDirectoryListing = (path: string, machineId?: string) =>
get<DirectoryListing>("/api/files/list", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const fetchFfprobe = (path: string, machineId?: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const fetchStat = (path: string, machineId?: string) =>
get<{ path: string; output: string }>("/api/files/stat", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
export const resolvePath = (path: string, machineId?: string) =>
get<ResolvedPath>("/api/files/resolve-path", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
// Jobs
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 runJob = (jobKey: string, path: string, machineId?: string) =>
post<JobResult>(
machineId
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
: "/api/jobs/run",
{ job_key: jobKey, path },
);
export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status");
+9 -9
View File
@@ -6,26 +6,26 @@ import {
fetchMonitoringOverview,
} from "../api/client";
export function useCounts() {
export function useCounts(machineId?: string) {
return useQuery({
queryKey: ["dashboard", "counts"],
queryFn: fetchCounts,
queryKey: ["dashboard", "counts", machineId ?? "default"],
queryFn: () => fetchCounts(machineId),
staleTime: 5 * 60 * 1000,
});
}
export function useLibraries() {
export function useLibraries(machineId?: string) {
return useQuery({
queryKey: ["dashboard", "libraries"],
queryFn: fetchLibraries,
queryKey: ["dashboard", "libraries", machineId ?? "default"],
queryFn: () => fetchLibraries(machineId),
staleTime: 5 * 60 * 1000,
});
}
export function useActivity() {
export function useActivity(machineId?: string) {
return useQuery({
queryKey: ["dashboard", "activity"],
queryFn: fetchActivity,
queryKey: ["dashboard", "activity", machineId ?? "default"],
queryFn: () => fetchActivity(machineId),
refetchInterval: 15_000,
});
}
+11 -11
View File
@@ -7,28 +7,28 @@ import {
runJob,
} from "../api/client";
export function useDirectoryListing(path: string) {
export function useDirectoryListing(path: string, machineId?: string) {
return useQuery({
queryKey: ["files", "list", path],
queryFn: () => fetchDirectoryListing(path),
queryKey: ["files", "list", path, machineId ?? "default"],
queryFn: () => fetchDirectoryListing(path, machineId),
enabled: !!path,
staleTime: 30_000,
});
}
export function useFfprobe(path: string, enabled = false) {
export function useFfprobe(path: string, enabled = false, machineId?: string) {
return useQuery({
queryKey: ["files", "ffprobe", path],
queryFn: () => fetchFfprobe(path),
queryKey: ["files", "ffprobe", path, machineId ?? "default"],
queryFn: () => fetchFfprobe(path, machineId),
enabled: enabled && !!path,
staleTime: 5 * 60_000,
});
}
export function useStat(path: string, enabled = false) {
export function useStat(path: string, enabled = false, machineId?: string) {
return useQuery({
queryKey: ["files", "stat", path],
queryFn: () => fetchStat(path),
queryKey: ["files", "stat", path, machineId ?? "default"],
queryFn: () => fetchStat(path, machineId),
enabled: enabled && !!path,
});
}
@@ -41,9 +41,9 @@ export function useJobTemplates() {
});
}
export function useRunJob() {
export function useRunJob(machineId?: string) {
return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path),
runJob(jobKey, path, machineId),
});
}
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild,
} from "../api/client";
export function useMediaStatus() {
export function useMediaStatus(machineId?: string) {
return useQuery({
queryKey: ["media", "status"],
queryFn: fetchMediaStatus,
queryKey: ["media", "status", machineId ?? "default"],
queryFn: () => fetchMediaStatus(machineId),
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
@@ -27,11 +27,11 @@ export function useMediaQuery(params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
// Feature: Sync file browser with selected media path
return useQuery({
queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams),
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] });
}
export function useBuildIndex() {
export function useBuildIndex(machineId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: buildMediaIndex,
mutationFn: () => buildMediaIndex(machineId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useStopBuildIndex() {
export function useStopBuildIndex(machineId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: stopMediaIndexBuild,
mutationFn: () => stopMediaIndexBuild(machineId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useForceStopBuildIndex() {
export function useForceStopBuildIndex(machineId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: forceStopMediaIndexBuild,
mutationFn: () => forceStopMediaIndexBuild(machineId),
onSuccess: () => {
invalidateMedia(queryClient);
},
+5 -4
View File
@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
export function useUsers(machineId?: string) {
return useQuery<UserDirectoryResponse>({
queryKey: ["users", machineId ?? "default"],
queryFn: () => fetchUsers(machineId),
staleTime: 30_000,
});
}
+53 -4
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
@@ -25,6 +26,7 @@ import {
useRunJob,
} from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState";
import { useMonitoringSettings } from "../hooks/useSettings";
interface DisplayRow {
id: string;
@@ -556,9 +558,22 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const { data: machines } = useMonitoringSettings();
const fileMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) =>
machine.enabled &&
(machine.services.includes("files") ||
machine.services.includes("monitoring")),
),
[machines],
);
const initialRequestedPath = searchParams.get("path");
const initialMachineId =
searchParams.get("machine_id") || fileMachines[0]?.id || "";
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
FILE_BROWSER_STATE_KEY,
() => {
@@ -580,6 +595,7 @@ export function FileBrowser() {
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
@@ -588,7 +604,7 @@ export function FileBrowser() {
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
@@ -596,9 +612,10 @@ export function FileBrowser() {
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
selectedMachineId || undefined,
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const runJob = useRunJob(selectedMachineId || undefined);
const navigate = (path: string) => {
updateBrowserState({
@@ -608,6 +625,18 @@ export function FileBrowser() {
});
};
const setMachine = (machineId: string) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (machineId) next.set("machine_id", machineId);
else next.delete("machine_id");
return next;
},
{ replace: true },
);
};
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
@@ -657,7 +686,27 @@ export function FileBrowser() {
return (
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">File Browser</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) => setMachine(String(e.target.value))}
>
{fileMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name} · {machine.mode}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField
+69 -7
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
import {
@@ -28,6 +28,8 @@ import {
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
@@ -64,11 +66,26 @@ function defaultMediaTabState(): MediaTabState {
export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const stopBuildIndex = useStopBuildIndex();
const forceStopBuildIndex = useForceStopBuildIndex();
const { data: machines } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
const { data: status } = useMediaStatus(selectedMachineId || undefined);
const buildIndex = useBuildIndex(selectedMachineId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedMachineId || undefined);
const forceStopBuildIndex = useForceStopBuildIndex(
selectedMachineId || undefined,
);
const [mediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
@@ -79,6 +96,19 @@ export function Media() {
setMediaState((current) => ({ ...current, ...patch }));
const limit = 100;
useEffect(() => {
if (!searchParams.get("machine_id") && selectedMachineId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", selectedMachineId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedMachineId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
@@ -87,6 +117,7 @@ export function Media() {
sort_order: sortOrder,
limit,
offset,
machineId: selectedMachineId || undefined,
enabled: status?.exists ?? false,
});
@@ -155,7 +186,30 @@ export function Media() {
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Media</Typography>
<Typography variant="h5">Jellyfin</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", String(e.target.value));
return next;
},
{ replace: true },
)
}
>
{jellyfinMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
{status?.exists ? (
<Typography variant="body2" color="text.secondary">
Index: {status.item_count.toLocaleString()} items
@@ -168,6 +222,14 @@ export function Media() {
No index built yet.
</Alert>
)}
{counts && (
<Typography variant="body2" color="text.secondary">
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
{counts.series.toLocaleString()} series ·{" "}
{counts.episodes.toLocaleString()} episodes ·{" "}
{(libraries?.length ?? 0).toLocaleString()} libraries
</Typography>
)}
<Button
variant="outlined"
onClick={() => buildIndex.mutate()}
+27
View File
@@ -94,6 +94,7 @@ export interface MonitoringMachine {
name: string;
mode: "local" | "ssh";
enabled: boolean;
services: string[];
host: string;
port: number;
username: string;
@@ -102,6 +103,11 @@ export interface MonitoringMachine {
password_set: boolean;
media_root: string;
path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key_set: boolean;
jellyseerr_url: string;
jellyseerr_api_key_set: boolean;
notes: string;
}
@@ -110,6 +116,7 @@ export interface MonitoringMachineInput {
name: string;
mode: "local" | "ssh";
enabled: boolean;
services: string[];
host: string;
port: number;
username: string;
@@ -118,6 +125,11 @@ export interface MonitoringMachineInput {
password: string;
media_root: string;
path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key: string;
jellyseerr_url: string;
jellyseerr_api_key: string;
notes: string;
}
@@ -143,6 +155,21 @@ export interface MetricSummary {
count: number;
}
export interface ResetLocalDatabaseInput {
confirm_phrase: string;
acknowledge_settings_loss: boolean;
acknowledge_media_index_loss: boolean;
acknowledge_irreversible: boolean;
}
export interface ResetLocalDatabaseResponse {
status: string;
settings_db_removed: boolean;
media_index_removed: boolean;
settings_files: string[];
media_index_files: string[];
}
export interface MonitoringPollerStatus {
worker_running: boolean;
stop_requested: boolean;