Files
frigate/web/src/views/settings/FrigatePlusSettingsView.tsx
T

235 lines
9.3 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import useSWR from "swr";
import axios from "axios";
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { LuExternalLink } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
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";
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";
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
import { getPrimaryModel } from "@/utils/modelUtil";
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
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();
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
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]);
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>
{!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={
<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 && (
<FrigatePlusCurrentModelSummary
plusModel={getPrimaryModel(config)?.plus}
action={
<Button
size="sm"
variant="outline"
onClick={() =>
navigate("/settings?page=systemDetectorsAndModel")
}
>
{t("frigatePlus.changeInDetectorsAndModel")}
</Button>
}
/>
2026-05-12 11:20:39 -05: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
);
}