diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index a490a046a..9b7e44588 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -36,6 +36,7 @@ import NotificationView from "@/views/settings/NotificationsSettingsView"; import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; +import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; @@ -81,6 +82,7 @@ const allSettingsViews = [ "roles", "notifications", "frigateplus", + "maintenance", ] as const; type SettingsType = (typeof allSettingsViews)[number]; @@ -120,6 +122,10 @@ const settingsGroups = [ label: "frigateplus", items: [{ key: "frigateplus", component: FrigatePlusSettingsView }], }, + { + label: "maintenance", + items: [{ key: "maintenance", component: MaintenanceSettingsView }], + }, ]; const CAMERA_SELECT_BUTTON_PAGES = [ diff --git a/web/src/views/settings/MaintenanceSettingsView.tsx b/web/src/views/settings/MaintenanceSettingsView.tsx new file mode 100644 index 000000000..f2d1bad30 --- /dev/null +++ b/web/src/views/settings/MaintenanceSettingsView.tsx @@ -0,0 +1,442 @@ +import Heading from "@/components/ui/heading"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Toaster } from "@/components/ui/sonner"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import axios from "axios"; +import { toast } from "sonner"; +import { useJobStatus } from "@/api/ws"; +import { Switch } from "@/components/ui/switch"; +import { LuCheck, LuX } from "react-icons/lu"; +import { cn } from "@/lib/utils"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { MediaSyncStats } from "@/types/ws"; + +export default function MaintenanceSettingsView() { + const { t } = useTranslation("views/settings"); + const [selectedMediaTypes, setSelectedMediaTypes] = useState([ + "all", + ]); + const [dryRun, setDryRun] = useState(true); + const [force, setForce] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const MEDIA_TYPES = [ + { id: "event_snapshots", label: t("maintenance.sync.event_snapshots") }, + { id: "event_thumbnails", label: t("maintenance.sync.event_thumbnails") }, + { id: "review_thumbnails", label: t("maintenance.sync.review_thumbnails") }, + { id: "previews", label: t("maintenance.sync.previews") }, + { id: "exports", label: t("maintenance.sync.exports") }, + { id: "recordings", label: t("maintenance.sync.recordings") }, + ]; + + // Subscribe to media sync status via WebSocket + const { payload: currentJob } = useJobStatus("media_sync"); + + const isJobRunning = Boolean( + currentJob && + (currentJob.status === "queued" || currentJob.status === "running"), + ); + + const handleMediaTypeChange = useCallback((id: string, checked: boolean) => { + setSelectedMediaTypes((prev) => { + if (id === "all") { + return checked ? ["all"] : []; + } + + let next = prev.filter((t) => t !== "all"); + if (checked) { + next.push(id); + } else { + next = next.filter((t) => t !== id); + } + return next.length === 0 ? ["all"] : next; + }); + }, []); + + const handleStartSync = useCallback(async () => { + setIsSubmitting(true); + + try { + const response = await axios.post( + "/media/sync", + { + dry_run: dryRun, + media_types: selectedMediaTypes, + force: force, + }, + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (response.status === 202) { + toast.success(t("maintenance.sync.started"), { + position: "top-center", + closeButton: true, + }); + } else if (response.status === 409) { + toast.error(t("maintenance.sync.alreadyRunning"), { + position: "top-center", + closeButton: true, + }); + } + } catch { + toast.error(t("maintenance.sync.error"), { + position: "top-center", + closeButton: true, + }); + } finally { + setIsSubmitting(false); + } + }, [selectedMediaTypes, dryRun, force, t]); + + return ( + <> +
+ +
+
+
+ + {t("maintenance.sync.title")} + + +
+
+

{t("maintenance.sync.desc")}

+
+
+ +
+ {/* Media Types Selection */} +
+ +
+
+ + + handleMediaTypeChange("all", checked) + } + disabled={isJobRunning} + /> +
+
+ {MEDIA_TYPES.map((type) => ( +
+ + + handleMediaTypeChange(type.id, checked) + } + disabled={ + isJobRunning || selectedMediaTypes.includes("all") + } + /> +
+ ))} +
+
+
+ + {/* Options */} +
+
+
+ +
+ +

+ {dryRun + ? t("maintenance.sync.dryRunEnabled") + : t("maintenance.sync.dryRunDisabled")} +

+
+
+
+ +
+
+ +
+ +

+ {t("maintenance.sync.forceDesc")} +

+
+
+
+
+ + {/* Action Buttons */} +
+ +
+
+
+ +
+
+ +
+ + {t("maintenance.sync.currentStatus")} + +
+ {currentJob?.status === "success" && ( + + )} + {currentJob?.status === "failed" && ( + + )} + {(currentJob?.status === "running" || + currentJob?.status === "queued") && ( + + )} + {t( + `maintenance.sync.status.${currentJob?.status ?? "notRunning"}`, + )} +
+
+ + {/* Current Job Status */} +
+ {currentJob?.start_time && ( +
+ + {t("maintenance.sync.startTime")}: + + + {formatUnixTimestampToDateTime( + currentJob?.start_time ?? "-", + )} + +
+ )} + {currentJob?.end_time && ( +
+ + {t("maintenance.sync.endTime")}: + + + {formatUnixTimestampToDateTime(currentJob?.end_time)} + +
+ )} + {currentJob?.results && ( +
+

+ {t("maintenance.sync.results")} +

+
+ {/* Individual media type results */} +
+ {Object.entries(currentJob.results) + .filter(([key]) => key !== "totals") + .map(([mediaType, stats]) => { + const mediaStats = stats as MediaSyncStats; + return ( +
+

+ {t(`maintenance.sync.${mediaType}`)} +

+
+
+ + {t( + "maintenance.sync.resultsFields.filesChecked", + )} + + {mediaStats.files_checked} +
+
+ + {t( + "maintenance.sync.resultsFields.orphansFound", + )} + + 0 + ? "text-yellow-500" + : "" + } + > + {mediaStats.orphans_found} + +
+
+ + {t( + "maintenance.sync.resultsFields.orphansDeleted", + )} + + 0 && + "text-success", + mediaStats.orphans_deleted === 0 && + mediaStats.aborted && + "text-destructive", + )} + > + {mediaStats.orphans_deleted} + +
+ {mediaStats.aborted && ( +
+ + + {t( + "maintenance.sync.resultsFields.aborted", + )} +
+ )} + {mediaStats.error && ( +
+ {t( + "maintenance.sync.resultsFields.error", + )} + {": "} + {mediaStats.error} +
+ )} +
+
+ ); + })} +
+ {/* Totals */} + {currentJob.results.totals && ( +
+

+ {t("maintenance.sync.resultsFields.totals")} +

+
+
+ + {t( + "maintenance.sync.resultsFields.filesChecked", + )} + + + {currentJob.results.totals.files_checked} + +
+
+ + {t( + "maintenance.sync.resultsFields.orphansFound", + )} + + 0 + ? "font-medium text-yellow-500" + : "font-medium" + } + > + {currentJob.results.totals.orphans_found} + +
+
+ + {t( + "maintenance.sync.resultsFields.orphansDeleted", + )} + + + 0 + ? "text-success" + : "text-muted-foreground", + )} + > + {currentJob.results.totals.orphans_deleted} + +
+
+
+ )} +
+
+ )} + {currentJob?.error_message && ( +
+

+ {t("maintenance.sync.errorLabel")} +

+

{currentJob?.error_message}

+
+ )} +
+
+
+
+
+
+ + ); +}