mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 23:32:48 +03:00
add onboarding wizard for new users
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
|
||||
type SetupCameraProps = {
|
||||
onNext: (cameraNames?: string[]) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [addedCameras, setAddedCameras] = useState<string[]>([]);
|
||||
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setShowWizard(false);
|
||||
// Delay to let CameraWizardDialog's config save and go2rtc setup complete,
|
||||
// then force a fresh fetch from the server (bypassing SWR cache)
|
||||
setTimeout(() => {
|
||||
fetch(`${window.baseUrl || ""}api/config`)
|
||||
.then((res) => res.json())
|
||||
.then((freshConfig: FrigateConfig) => {
|
||||
const cameraNames = Object.keys(freshConfig.cameras || {});
|
||||
if (cameraNames.length > addedCameras.length) {
|
||||
mutateConfig(freshConfig, { revalidate: false });
|
||||
setAddedCameras(cameraNames);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fetch failed, stay on this step
|
||||
});
|
||||
}, 1000);
|
||||
}, [mutateConfig, addedCameras]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
onNext(addedCameras.length > 0 ? addedCameras : undefined);
|
||||
}, [onNext, addedCameras]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.camera.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.camera.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addedCameras.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{addedCameras.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Button
|
||||
variant="select"
|
||||
className="w-full"
|
||||
onClick={() => setShowWizard(true)}
|
||||
>
|
||||
{addedCameras.length > 0
|
||||
? t("setupWizard.camera.addAnother")
|
||||
: t("setupWizard.camera.addCamera")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
{addedCameras.length > 0 && (
|
||||
<Button type="button" variant="select" onClick={handleNext}>
|
||||
{t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CameraWizardDialog open={showWizard} onClose={handleClose} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
|
||||
type ConfiguredItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
type SetupCompleteProps = {
|
||||
cameraNames: string[];
|
||||
configuredSteps: {
|
||||
camera: boolean;
|
||||
hwaccel: boolean;
|
||||
detector: boolean;
|
||||
recording: boolean;
|
||||
};
|
||||
restartRequired: boolean;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export default function SetupComplete({
|
||||
cameraNames,
|
||||
configuredSteps,
|
||||
restartRequired,
|
||||
onBack,
|
||||
}: SetupCompleteProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const cameraItems: ConfiguredItem[] =
|
||||
configuredSteps.camera && cameraNames.length > 0
|
||||
? cameraNames.map((name) => ({
|
||||
key: `camera-${name}`,
|
||||
label: t("setupWizard.complete.camera"),
|
||||
value: name,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: "camera",
|
||||
label: t("setupWizard.complete.camera"),
|
||||
value: null,
|
||||
},
|
||||
];
|
||||
|
||||
const items: ConfiguredItem[] = [
|
||||
...cameraItems,
|
||||
{
|
||||
key: "hwaccel",
|
||||
label: t("setupWizard.complete.hwaccel"),
|
||||
value: configuredSteps.hwaccel
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
key: "detector",
|
||||
label: t("setupWizard.complete.detector"),
|
||||
value: configuredSteps.detector
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
key: "recording",
|
||||
label: t("setupWizard.complete.recording"),
|
||||
value: configuredSteps.recording
|
||||
? t("setupWizard.complete.configured")
|
||||
: null,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFinish = useCallback(async () => {
|
||||
setFinishing(true);
|
||||
try {
|
||||
// Mark setup as complete
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
onboarding: { setup_complete: true },
|
||||
},
|
||||
requires_restart: 0,
|
||||
});
|
||||
|
||||
// camera adds were applied live, so with no pending requires_restart
|
||||
// saves the wizard can hand off without restarting
|
||||
if (!restartRequired) {
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
}
|
||||
|
||||
setRestarting(true);
|
||||
|
||||
// Trigger restart
|
||||
await axios.post("restart");
|
||||
|
||||
// Poll for server availability with max retries
|
||||
let retries = 0;
|
||||
const maxRetries = 60; // 2 minutes max
|
||||
pollRef.current = setInterval(async () => {
|
||||
retries++;
|
||||
if (retries > maxRetries) {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
// Give up polling, redirect anyway
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await axios.get("version", { timeout: 2000 });
|
||||
if (resp.status === 200) {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
window.location.href = window.baseUrl || "/";
|
||||
}
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
setRestarting(false);
|
||||
setFinishing(false);
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
}
|
||||
}, [restartRequired, t]);
|
||||
|
||||
if (restarting) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-12">
|
||||
<Logo className="h-12 w-12" />
|
||||
<ActivityIndicator />
|
||||
<div className="text-center">
|
||||
<p className="font-semibold">
|
||||
{t("setupWizard.complete.restarting")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.restartingDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.complete.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.value && <FaCircleCheck className="size-4 text-success" />}
|
||||
<span
|
||||
className={`text-sm ${item.value ? "" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.value ?? t("setupWizard.complete.notConfigured")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.nextSteps")}
|
||||
</p>
|
||||
|
||||
{restartRequired && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.complete.restartNotice")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleFinish}
|
||||
disabled={finishing}
|
||||
>
|
||||
{restartRequired
|
||||
? t("setupWizard.complete.applyAndRestart")
|
||||
: t("setupWizard.complete.goToLiveView")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import type { FrigatePlusModel } from "@/components/config-form/theme/fields/ModelSourcePicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { DetectionHardware } from "@/types/hardware";
|
||||
import { recommendedDetectorCount } from "@/utils/detectionHardware";
|
||||
import axios from "axios";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
// detectors that ship no default model; configuring them without one leaves
|
||||
// the detector unable to start
|
||||
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
|
||||
|
||||
const CPU_FALLBACK: DetectionHardware[] = [
|
||||
{
|
||||
key: "cpu",
|
||||
detector: "cpu",
|
||||
name: "CPU",
|
||||
units: [{ device: "cpu", label: "CPU" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
];
|
||||
|
||||
type SetupDetectorProps = {
|
||||
cameraCount: number;
|
||||
onNext: (hardwareKey: string) => void;
|
||||
onBack: () => void;
|
||||
onSkip: (hardwareKey?: string) => void;
|
||||
};
|
||||
|
||||
export default function SetupDetector({
|
||||
cameraCount,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupDetectorProps) {
|
||||
const { t } = useTranslation(["views/setup", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const {
|
||||
data: hardware,
|
||||
isLoading,
|
||||
error: probeError,
|
||||
} = useSWR<DetectionHardware[]>("hardware/probe", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const plusEnabled = Boolean(config?.plus?.enabled);
|
||||
|
||||
// the cpu is always probed, so an empty or failed probe leaves a fallback
|
||||
const options = useMemo(
|
||||
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
|
||||
[hardware],
|
||||
);
|
||||
|
||||
// the prober orders accelerators ahead of the cpu
|
||||
const recommendedKey = options[0].key;
|
||||
const [selectedKey, setSelectedKey] = useState<string>();
|
||||
const selected =
|
||||
options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ??
|
||||
options[0];
|
||||
|
||||
const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector);
|
||||
|
||||
const { data: plusModels } = useSWR<FrigatePlusModel[]>(
|
||||
plusEnabled && needsModel ? "/plus/models" : null,
|
||||
{
|
||||
fetcher: async (url) => {
|
||||
const res = await axios.get(url, { withCredentials: true });
|
||||
return res.data;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [plusModelId, setPlusModelId] = useState("");
|
||||
|
||||
const compatiblePlusModels = useMemo(
|
||||
() =>
|
||||
(plusModels ?? []).filter((model) =>
|
||||
model.supportedDetectors.includes(selected.detector),
|
||||
),
|
||||
[plusModels, selected.detector],
|
||||
);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const buildDevices = useCallback(
|
||||
(entry: DetectionHardware): string[] => {
|
||||
const first = entry.units[0]?.device;
|
||||
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!entry.unlimited) {
|
||||
return [first];
|
||||
}
|
||||
|
||||
// repeating a device runs an extra inference process on it
|
||||
const count = recommendedDetectorCount(Math.max(cameraCount, 1));
|
||||
return Array.from({ length: count }, () => first);
|
||||
},
|
||||
[cameraCount],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (needsModel && !plusModelId) {
|
||||
// no model to run on this hardware yet, so leave the config untouched
|
||||
onSkip(selected.key);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const model: Record<string, unknown> = {
|
||||
devices: buildDevices(selected),
|
||||
};
|
||||
|
||||
if (needsModel) {
|
||||
model.path = `plus://${plusModelId}`;
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
models: [model],
|
||||
detect: { enabled: true },
|
||||
},
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext(selected.key);
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [needsModel, plusModelId, selected, buildDevices, onNext, onSkip, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<ActivityIndicator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.detecting")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.detector.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{probeError && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.detector.probeFailed")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<RadioGroup
|
||||
value={selected.key}
|
||||
onValueChange={(value) => {
|
||||
setSelectedKey(value);
|
||||
setPlusModelId("");
|
||||
}}
|
||||
>
|
||||
{options.map((entry) => (
|
||||
<div key={entry.key} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={entry.key}
|
||||
id={`detector-${entry.key}`}
|
||||
className={
|
||||
selected.key === entry.key
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`detector-${entry.key}`}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{entry.name}
|
||||
{entry.count > 1 ? ` (${entry.count})` : ""}
|
||||
{entry.key === recommendedKey && entry.key !== "cpu" && (
|
||||
<span className="ml-2 text-xs text-selected">
|
||||
{t("setupWizard.detector.recommended")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{needsModel && (
|
||||
<div className="flex flex-col gap-3 rounded-md bg-muted p-3 text-sm">
|
||||
<p>
|
||||
{t("setupWizard.detector.modelRequired", { name: selected.name })}
|
||||
</p>
|
||||
{plusEnabled ? (
|
||||
<Select value={plusModelId} onValueChange={setPlusModelId}>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue
|
||||
placeholder={t("setupWizard.detector.plusModelPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{compatiblePlusModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{`${model.name} (${model.width}x${model.height})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<a
|
||||
href={getLocaleDocUrl("configuration/object_detectors")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-primary"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSkip(selected.key)}
|
||||
>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: needsModel && !plusModelId
|
||||
? t("setupWizard.detector.continueWithout")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { HwaccelRecommendation } from "@/types/hardware";
|
||||
import axios from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
const AUTO = "auto";
|
||||
const NONE = "none";
|
||||
|
||||
const HWACCEL_PRESETS = [
|
||||
{ value: "preset-vaapi", key: "vaapi" },
|
||||
{ value: "preset-nvidia", key: "cuda" },
|
||||
{ value: "preset-intel-qsv-h264", key: "qsv" },
|
||||
{ value: "preset-rkmpp", key: "rkmpp" },
|
||||
{ value: "preset-rpi-64-h264", key: "rpi" },
|
||||
{ value: "preset-jetson-h264", key: "jetson" },
|
||||
] as const;
|
||||
|
||||
type SetupHwAccelProps = {
|
||||
detectorHardwareKey?: string;
|
||||
// saved reports whether a config write happened, so the wizard knows
|
||||
// whether finishing requires a restart
|
||||
onNext: (saved: boolean) => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupHwAccel({
|
||||
detectorHardwareKey,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupHwAccelProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
|
||||
const {
|
||||
data: recommendation,
|
||||
isLoading,
|
||||
error: recommendError,
|
||||
} = useSWR<HwaccelRecommendation>(
|
||||
detectorHardwareKey
|
||||
? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}`
|
||||
: "hardware/hwaccel",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<string>(AUTO);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const derived = recommendation?.preset ?? "";
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Auto with nothing derived writes nothing: the config default of "auto"
|
||||
// stays in place and the backend decides at startup
|
||||
if (selected === AUTO && !derived) {
|
||||
onNext(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// an empty string would make config/set delete the key (reviving the
|
||||
// "auto" default), so an explicit no-hwaccel is an empty list
|
||||
const preset: string | string[] =
|
||||
selected === AUTO ? derived : selected === NONE ? [] : selected;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
ffmpeg: { hwaccel_args: preset },
|
||||
},
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext(true);
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selected, derived, onNext, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<ActivityIndicator />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("setupWizard.hwaccel.detecting")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const radioClass = (value: string) =>
|
||||
selected === value
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.hwaccel.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.hwaccel.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={selected} onValueChange={setSelected}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={AUTO}
|
||||
id="hwaccel-auto"
|
||||
className={radioClass(AUTO)}
|
||||
/>
|
||||
<label htmlFor="hwaccel-auto" className="cursor-pointer text-sm">
|
||||
{t("setupWizard.hwaccel.auto")}
|
||||
</label>
|
||||
</div>
|
||||
<p className="ml-6 text-xs text-muted-foreground">
|
||||
{derived
|
||||
? t("setupWizard.hwaccel.autoResolved", { preset: derived })
|
||||
: recommendError
|
||||
? t("setupWizard.hwaccel.recommendFailed")
|
||||
: t("setupWizard.hwaccel.autoNone")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{HWACCEL_PRESETS.map((preset) => (
|
||||
<div key={preset.key} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={preset.value}
|
||||
id={`hwaccel-${preset.key}`}
|
||||
className={radioClass(preset.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`hwaccel-${preset.key}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t(`setupWizard.hwaccel.presets.${preset.key}`)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={NONE}
|
||||
id="hwaccel-none"
|
||||
className={radioClass(NONE)}
|
||||
/>
|
||||
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
|
||||
{t("setupWizard.hwaccel.presets.none")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
|
||||
type SetupRecordingProps = {
|
||||
cameraNames: string[];
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupRecording({
|
||||
cameraNames,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
}: SetupRecordingProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [retentionDays, setRetentionDays] = useState(10);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
|
||||
|
||||
// Calculate storage estimate
|
||||
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
|
||||
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
|
||||
const cameraCount = cameraNames.length;
|
||||
// Rough estimate: ~2 Mbps per camera continuous recording
|
||||
const estimatedDays =
|
||||
freeGb && cameraCount > 0
|
||||
? Math.round((freeGb * 1024) / ((2 * 0.125 * 86400) / 1024) / cameraCount)
|
||||
: null;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
record: {
|
||||
enabled,
|
||||
alerts: { retain: { days: retentionDays } },
|
||||
detections: { retain: { days: retentionDays } },
|
||||
},
|
||||
},
|
||||
requires_restart: 1,
|
||||
});
|
||||
onNext();
|
||||
} catch {
|
||||
toast.error(t("setupWizard.errors.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [enabled, retentionDays, onNext, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("setupWizard.recording.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cameraCount === 0 && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.noCameras")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<Label htmlFor="recording-toggle" className="font-medium">
|
||||
{t("setupWizard.recording.enableRecording")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="recording-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="retention-days">
|
||||
{t("setupWizard.recording.retentionDays")}
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={retentionDays}
|
||||
onChange={(e) =>
|
||||
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{freeGb !== null && estimatedDays !== null && cameraCount > 0 && (
|
||||
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||
{t("setupWizard.recording.storageEstimate", {
|
||||
free: freeGb,
|
||||
days: estimatedDays,
|
||||
cameras: cameraCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onBack}>
|
||||
{t("setupWizard.actions.back")}
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onSkip}>
|
||||
{t("setupWizard.actions.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? t("setupWizard.actions.saving")
|
||||
: t("setupWizard.actions.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Logo from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SetupWelcomeProps = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export default function SetupWelcome({ onNext, onSkip }: SetupWelcomeProps) {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-4">
|
||||
<Logo className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t("setupWizard.welcome.title")}
|
||||
</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t("setupWizard.welcome.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 pt-4">
|
||||
<Button variant="select" className="w-full" onClick={onNext}>
|
||||
{t("setupWizard.welcome.getStarted")}
|
||||
</Button>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-primary"
|
||||
onClick={onSkip}
|
||||
>
|
||||
{t("setupWizard.welcome.skipSetup")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user