2026-11-09 07:30:35 -06:00
|
|
|
import { useCallback, useEffect, useState } from "react";
|
2026-05-17 12:54:21 -05:00
|
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
|
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
|
|
|
import useSWR from "swr";
|
2026-11-09 07:30:35 -06:00
|
|
|
import axios from "axios";
|
|
|
|
|
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
2026-05-17 12:54:21 -05:00
|
|
|
import { LuExternalLink } from "react-icons/lu";
|
2025-03-24 15:19:58 +00:00
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-05-17 12:54:21 -05:00
|
|
|
import Heading from "@/components/ui/heading";
|
2026-02-27 09:55:36 -06:00
|
|
|
import {
|
|
|
|
|
SettingsGroupCard,
|
|
|
|
|
SplitCardRow,
|
|
|
|
|
} from "@/components/card/SettingsGroupCard";
|
|
|
|
|
import FrigatePlusCurrentModelSummary from "@/views/settings/components/FrigatePlusCurrentModelSummary";
|
2026-05-17 12:54:21 -05:00
|
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
|
|
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
2026-05-21 09:12:53 -05:00
|
|
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
2026-05-17 12:54:21 -05:00
|
|
|
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
2026-08-18 08:11:50 -06:00
|
|
|
import { getPrimaryModel } from "@/utils/modelUtil";
|
2026-11-09 07:30:35 -06:00
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
|
|
|
|
|
|
|
|
|
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
|
|
|
|
|
|
|
|
|
|
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
|
2025-03-17 13:44:57 -05:00
|
|
|
|
2026-05-17 12:54:21 -05:00
|
|
|
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
2025-03-17 13:44:57 -05:00
|
|
|
const { t } = useTranslation("views/settings");
|
2025-05-28 20:10:45 +08:00
|
|
|
const { getLocaleDocUrl } = useDocDomain();
|
2026-05-17 12:54:21 -05:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const navigate = useNavigate();
|
2026-11-09 07:30:35 -06:00
|
|
|
const isAdmin = useIsAdmin();
|
|
|
|
|
|
|
|
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
|
|
|
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
|
|
|
|
|
|
|
|
|
|
const validateKey = useCallback(async () => {
|
|
|
|
|
setIsValidating(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await axios.post<PlusValidateResponse>("plus/validate");
|
|
|
|
|
setKeyStatus(response.data.status);
|
|
|
|
|
} catch {
|
|
|
|
|
setKeyStatus("unreachable");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsValidating(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2025-03-17 13:44:57 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
document.title = t("documentTitle.frigatePlus");
|
|
|
|
|
}, [t]);
|
|
|
|
|
|
2025-03-24 15:19:58 +00:00
|
|
|
if (!config) {
|
|
|
|
|
return <ActivityIndicator />;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-17 13:44:57 -05:00
|
|
|
return (
|
2026-05-12 11:20:39 -05:00
|
|
|
<div className="flex size-full flex-col md:pr-2">
|
|
|
|
|
<div className="w-full max-w-5xl space-y-6 pt-2">
|
|
|
|
|
<div className="flex flex-col gap-0">
|
|
|
|
|
<Heading as="h4" className="mb-2">
|
|
|
|
|
{t("frigatePlus.title")}
|
|
|
|
|
</Heading>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{t("frigatePlus.description")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-03-17 13:44:57 -05:00
|
|
|
|
2026-05-12 11:20:39 -05:00
|
|
|
<div className="space-y-6">
|
|
|
|
|
<SettingsGroupCard title={t("frigatePlus.cardTitles.api")}>
|
|
|
|
|
<SplitCardRow
|
|
|
|
|
label={t("frigatePlus.apiKey.title")}
|
|
|
|
|
description={
|
|
|
|
|
<>
|
|
|
|
|
<p>{t("frigatePlus.apiKey.desc")}</p>
|
2026-08-18 08:11:50 -06:00
|
|
|
{!getPrimaryModel(config)?.plus && (
|
2026-05-12 11:20:39 -05:00
|
|
|
<div className="mt-2 flex items-center text-primary-variant">
|
|
|
|
|
<Link
|
|
|
|
|
to="https://frigate.video/plus"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline"
|
2026-02-27 09:55:36 -06:00
|
|
|
>
|
2026-05-12 11:20:39 -05:00
|
|
|
{t("frigatePlus.apiKey.plusLink")}
|
|
|
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
content={
|
2026-11-09 07:30:35 -06:00
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{config?.plus?.enabled ? (
|
|
|
|
|
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="size-5 shrink-0 text-red-500" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-sm">
|
|
|
|
|
{config?.plus?.enabled
|
|
|
|
|
? t("frigatePlus.apiKey.formatValid")
|
|
|
|
|
: t("frigatePlus.apiKey.formatInvalid")}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{config?.plus?.enabled && isAdmin && (
|
|
|
|
|
<div className="flex flex-col items-start gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={isValidating}
|
|
|
|
|
onClick={validateKey}
|
|
|
|
|
aria-label={t("frigatePlus.apiKey.validate")}
|
|
|
|
|
>
|
|
|
|
|
{isValidating && (
|
|
|
|
|
<ActivityIndicator className="mr-2" size={16} />
|
|
|
|
|
)}
|
|
|
|
|
{isValidating
|
|
|
|
|
? t("frigatePlus.apiKey.validating")
|
|
|
|
|
: t("frigatePlus.apiKey.validate")}
|
|
|
|
|
</Button>
|
|
|
|
|
{keyStatus && !isValidating && (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{keyStatus === "valid" ? (
|
|
|
|
|
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
|
|
|
|
) : keyStatus === "unreachable" ? (
|
|
|
|
|
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="size-5 shrink-0 text-red-500" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-sm">
|
|
|
|
|
{keyStatus === "valid"
|
|
|
|
|
? t("frigatePlus.apiKey.serverValid")
|
|
|
|
|
: keyStatus === "unreachable"
|
|
|
|
|
? t("frigatePlus.apiKey.serverUnreachable")
|
|
|
|
|
: t("frigatePlus.apiKey.serverInvalid")}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-12 11:20:39 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroupCard>
|
|
|
|
|
|
|
|
|
|
{config?.plus?.enabled && (
|
2026-05-17 12:54:21 -05:00
|
|
|
<FrigatePlusCurrentModelSummary
|
2026-08-18 08:11:50 -06:00
|
|
|
plusModel={getPrimaryModel(config)?.plus}
|
2026-05-17 12:54:21 -05:00
|
|
|
action={
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
navigate("/settings?page=systemDetectorsAndModel")
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{t("frigatePlus.changeInDetectorsAndModel")}
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-05-12 11:20:39 -05:00
|
|
|
)}
|
2025-03-24 15:19:58 +00:00
|
|
|
|
2026-05-12 11:20:39 -05:00
|
|
|
<SettingsGroupCard title={t("frigatePlus.cardTitles.configuration")}>
|
|
|
|
|
<SplitCardRow
|
|
|
|
|
label={t("frigatePlus.snapshotConfig.title")}
|
|
|
|
|
description={
|
|
|
|
|
<>
|
|
|
|
|
<p>
|
|
|
|
|
<Trans ns="views/settings">
|
|
|
|
|
frigatePlus.snapshotConfig.desc
|
|
|
|
|
</Trans>
|
|
|
|
|
</p>
|
|
|
|
|
<div className="mt-2 flex items-center text-primary-variant">
|
|
|
|
|
<Link
|
|
|
|
|
to={getLocaleDocUrl("plus/faq")}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="inline"
|
|
|
|
|
>
|
|
|
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
|
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
content={
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b border-secondary">
|
|
|
|
|
<th className="px-4 py-2 text-left">
|
|
|
|
|
{t("frigatePlus.snapshotConfig.table.camera")}
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-4 py-2 text-center">
|
|
|
|
|
{t("frigatePlus.snapshotConfig.table.snapshots")}
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-05-21 09:12:53 -05:00
|
|
|
{Object.entries(config.cameras)
|
|
|
|
|
.filter(([name]) => !isReplayCamera(name))
|
2026-07-07 11:56:43 -05:00
|
|
|
.sort(([, a], [, b]) => a.ui.order - b.ui.order)
|
2026-05-21 09:12:53 -05:00
|
|
|
.map(([name, camera]) => (
|
2026-05-12 11:20:39 -05:00
|
|
|
<tr
|
|
|
|
|
key={name}
|
|
|
|
|
className="border-b border-secondary"
|
|
|
|
|
>
|
|
|
|
|
<td className="px-4 py-2">
|
|
|
|
|
<CameraNameLabel camera={name} />
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-4 py-2 text-center">
|
|
|
|
|
{camera.snapshots.enabled ? (
|
|
|
|
|
<CheckCircle2 className="mx-auto size-5 text-green-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<XCircle className="mx-auto size-5 text-danger" />
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
2026-05-21 09:12:53 -05:00
|
|
|
))}
|
2026-05-12 11:20:39 -05:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SettingsGroupCard>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-27 09:55:36 -06:00
|
|
|
</div>
|
2025-03-17 13:44:57 -05:00
|
|
|
);
|
|
|
|
|
}
|