mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-01 09:31:14 +03:00
Compare commits
16 Commits
3dc8c5e96f
...
2ca0fe2da1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ca0fe2da1 | ||
|
|
af15f9ed7b | ||
|
|
b1aca87697 | ||
|
|
265401c746 | ||
|
|
653fdbeb07 | ||
|
|
f539b1c441 | ||
|
|
9c86779630 | ||
|
|
b6ad34bbcf | ||
|
|
fe080a7d9d | ||
|
|
178651147a | ||
|
|
7b4bd53f2f | ||
|
|
2965fc7952 | ||
|
|
dd51a00a47 | ||
|
|
de12460c08 | ||
|
|
3806de817a | ||
|
|
2c2627406f |
@ -432,5 +432,3 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
roles:
|
||||
- detect
|
||||
```
|
||||
|
||||
The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ).
|
||||
|
||||
@ -10,14 +10,13 @@ A reverse proxy is typically needed if you want to set up Frigate on a custom UR
|
||||
Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs:
|
||||
|Topic|Docs|
|
||||
|-|-|
|
||||
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|
||||
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|
||||
|Authentication|Please see the [authentication](../configuration/authentication.md) documentation|
|
||||
|IPv6|[Enabling IPv6](../configuration/advanced/system.md#enabling-ipv6)
|
||||
|
||||
**Note about TLS**
|
||||
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
|
||||
**Note about TLS**
|
||||
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
|
||||
To disable TLS, set the following in your Frigate configuration:
|
||||
|
||||
```yml
|
||||
tls:
|
||||
enabled: false
|
||||
@ -25,26 +24,18 @@ tls:
|
||||
|
||||
:::warning
|
||||
A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices.
|
||||
This page does not attempt to outline the specific steps needed to secure your internal website.
|
||||
This page does not attempt to outline the specific steps needed to secure your internal website.
|
||||
Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system.
|
||||
:::
|
||||
|
||||
## WebSocket support
|
||||
|
||||
Frigate relies on WebSockets for real-time communication between the browser and the backend. Features such as camera controls (enabling/disabling a camera, audio, detect, recordings, and other toggles), live stream playback, and other live-updating parts of the UI will not function correctly if WebSocket connections are not proxied.
|
||||
|
||||
Your reverse proxy must be configured to forward the `Upgrade` and `Connection` headers so that WebSocket connections can be established. Each proxy example below already includes the directives needed to do this, but if you are adapting your own configuration, ensure these headers are passed through.
|
||||
|
||||
Note that some proxies disable WebSocket support by default — for example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
|
||||
|
||||
## Proxies
|
||||
|
||||
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
|
||||
|
||||
- [Apache2](#apache2-reverse-proxy)
|
||||
- [Nginx](#nginx-reverse-proxy)
|
||||
- [Traefik](#traefik-reverse-proxy)
|
||||
- [Caddy](#caddy-reverse-proxy)
|
||||
* [Apache2](#apache2-reverse-proxy)
|
||||
* [Nginx](#nginx-reverse-proxy)
|
||||
* [Traefik](#traefik-reverse-proxy)
|
||||
* [Caddy](#caddy-reverse-proxy)
|
||||
|
||||
## Apache2 Reverse Proxy
|
||||
|
||||
@ -168,7 +159,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
|
||||
|
||||
## Traefik Reverse Proxy
|
||||
|
||||
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
|
||||
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
|
||||
Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/)
|
||||
|
||||
```yml
|
||||
@ -212,7 +203,7 @@ This example shows Frigate running under a subdomain with logging and a tls cert
|
||||
}
|
||||
|
||||
frigate.YOUR_DOMAIN.TLD {
|
||||
reverse_proxy http://localhost:8971
|
||||
reverse_proxy http://localhost:8971
|
||||
import tls
|
||||
import logging frigate.YOUR_DOMAIN.TLD
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
"toothbrush": "Zahnbürste",
|
||||
"bicycle": "Fahrrad",
|
||||
"door": "Tür",
|
||||
"keyboard": "Klavier",
|
||||
"keyboard": "Klaviatur",
|
||||
"bus": "Bus",
|
||||
"horse": "Pferd",
|
||||
"cat": "Katze",
|
||||
@ -123,7 +123,7 @@
|
||||
"chicken": "Huhn",
|
||||
"sitar": "Sitar",
|
||||
"ukulele": "Ukulele",
|
||||
"tapping": "Tippen",
|
||||
"tapping": "Klopfen",
|
||||
"flapping_wings": "Flügelschlagen",
|
||||
"strum": "Herumklimpern",
|
||||
"electronic_organ": "Elektrische Orgel",
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"description": "Mindest-RMS-Lautstärkeschwelle, die für die Audioerkennung erforderlich ist; niedrigere Werte erhöhen die Empfindlichkeit (z. B. 200 hoch, 500 mittel, 1000 niedrig)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, Gespräche, Rufen).",
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, schreien, sprechen, rufen).",
|
||||
"label": "Hörtypen"
|
||||
},
|
||||
"filters": {
|
||||
@ -204,11 +204,11 @@
|
||||
"description": "Einstellungen zum Aktivieren und Verwalten von Benachrichtigungen für diese Kamera."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "Streams (FFmpeg)",
|
||||
"description": "Kamera-Stream-Eingaben und FFmpeg-Optionen, einschließlich Binärpfad, Argumente, hwaccel und rollenspezifische Ausgabeargumente.",
|
||||
"label": "FFmpeg",
|
||||
"description": "FFmpeg-Einstellungen, einschließlich Binärpfad, Argumente, hwaccel-Optionen und rollenspezifische Ausgabeargumente.",
|
||||
"path": {
|
||||
"label": "FFmpeg-Pfad",
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („7.0” oder „8.0”)."
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („5.0” oder „7.0”)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globale Argumente von FFmpeg",
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
"description": "Mindest-RMS-Lautstärkeschwelle, die für die Audioerkennung erforderlich ist; niedrigere Werte erhöhen die Empfindlichkeit (z. B. 200 hoch, 500 mittel, 1000 niedrig)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, Gespräche, Rufen).",
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, schreien, sprechen, rufen).",
|
||||
"label": "Hörtypen"
|
||||
},
|
||||
"filters": {
|
||||
@ -380,7 +380,7 @@
|
||||
"description": "FFmpeg-Einstellungen, einschließlich Binärpfad, Argumente, hwaccel-Optionen und rollenspezifische Ausgabeargumente.",
|
||||
"path": {
|
||||
"label": "FFmpeg-Pfad",
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („7.0” oder „8.0”)."
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („5.0” oder „7.0”)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globale Argumente von FFmpeg",
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
"laptop": "Laptop",
|
||||
"mouse": "Maus",
|
||||
"goat": "Ziege",
|
||||
"keyboard": "Klavier",
|
||||
"keyboard": "Klaviatur",
|
||||
"cell_phone": "Handy",
|
||||
"remote": "Fernbedienung",
|
||||
"airplane": "Flugzeug",
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
"integrationObjectClassification": "Objekt Klassifizierung",
|
||||
"integrationAudioTranscription": "Audio-Transkription",
|
||||
"cameraDetect": "Objekterkennung",
|
||||
"cameraFfmpeg": "Streams (FFmpeg)",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Aufnahme",
|
||||
"cameraSnapshots": "Momentaufnahme",
|
||||
"cameraMotion": "Bewegungserkennung",
|
||||
@ -1768,17 +1768,7 @@
|
||||
}
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Stream {{index}}",
|
||||
"sourceMode": {
|
||||
"restream": "Restream (go2rtc)",
|
||||
"manual": "Pfad für die manuelle Eingabe",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Wählen Sie einen go2rtc-Stream aus",
|
||||
"noGo2rtcStreams": "Es sind keine go2rtc-Streams konfiguriert",
|
||||
"go2rtcStreamSearch": "Suche Streams...",
|
||||
"availableStreams": "Verfügbare Streams",
|
||||
"noMatchingStreams": "Keine passenden Streams"
|
||||
}
|
||||
"itemTitle": "Stream {{index}}"
|
||||
},
|
||||
"restartRequiredField": "Neustart erforderlich",
|
||||
"restartRequiredFooter": "Konfiguration geändert – Neustart erforderlich",
|
||||
@ -2133,9 +2123,6 @@
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Für die automatische Verfolgung ist mindestens eine Zone erforderlich. Definieren Sie unter „Masken / Zonen“ eine Zone für diese Kamera und legen Sie diese anschließend unten als erforderliche Zone fest."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Explizite Definitionen der Hardware-beschleunigungs Variablen sind nicht empfohlen. Wähle die Voreinstellung die zu deiner Hardware passt, außer wenn spezifische Anforderungen eine andere Konfiguration erfordern."
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
|
||||
@ -173,22 +173,7 @@
|
||||
"tips": {
|
||||
"title": "Kamera-Untersuchsungsinfo"
|
||||
},
|
||||
"aspectRatio": "Seitenverhältnis",
|
||||
"keyframes": {
|
||||
"title": "Keyframe-Analyse",
|
||||
"analyzing": "Keyframes werden analysiert... Noch {{seconds}} Sekunden",
|
||||
"stillAnalyzing": "Keyframes werden noch analysiert...",
|
||||
"recordStream": "Stream aufzeichnen:",
|
||||
"keyframeCount": "Beobachtete Keyframes:",
|
||||
"observedDuration": "Beobachtete Dauer:",
|
||||
"gap": "Keyframe-Abstand (min. / durchschnittlich / max.):",
|
||||
"segmentLength": "Länge des Aufzeichnungssegments:",
|
||||
"ok": "Keyframes alle ~{{seconds}}s, gut geeignet für Aufzeichnung und Wiedergabe.",
|
||||
"warning": "Seltene oder unregelmäßige Keyframes (längste Lücke ~{{seconds}}s), wahrscheinlich ein „Smart“-Codec (H.264+/H.265+); dies wird nicht empfohlen.",
|
||||
"error": "Die Lücke zwischen den Keyframes (~{{seconds}}s) überschreitet die Länge des Aufzeichnungssegments ({{segmentTime}}s). Einige Segmente enthalten möglicherweise keinen Keyframe, was zu einer Unterbrechung der Wiedergabe führt. Deaktivieren Sie den Smart/+-Codec an der Kamera oder verkürzen Sie dessen Keyframe-Intervall.",
|
||||
"unknown": "Der Abstand zwischen den Keyframes konnte nicht ermittelt werden.",
|
||||
"recordDisabled": "Die Aufzeichnung ist für diese Kamera deaktiviert."
|
||||
}
|
||||
"aspectRatio": "Seitenverhältnis"
|
||||
},
|
||||
"overview": "Übersicht",
|
||||
"label": {
|
||||
|
||||
@ -30,13 +30,11 @@
|
||||
"description": "Поріг мінімального середньоквадратичного значення необхідний для запуску розпізнавання звуку; чим нижче значення, тим вища чутливість (наприклад, 200 — висока, 500 — середня, 1000 — низька)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Список звукових подій для виявлення (наприклад, bark, fire_alarm, speech, yell).",
|
||||
"label": "Типи звукових подій"
|
||||
"description": "Список звукових подій для виявлення (наприклад, bark, fire_alarm, speech, yell)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Фільтри звуку"
|
||||
},
|
||||
"description": "Налаштування звукових подій для цієї камери."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Транскрипція аудіо",
|
||||
|
||||
@ -16,8 +16,7 @@
|
||||
"description": "Поріг мінімального середньоквадратичного значення необхідний для запуску розпізнавання звуку; чим нижче значення, тим вища чутливість (наприклад, 200 — висока, 500 — середня, 1000 — низька)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Список звукових подій для виявлення (наприклад, bark, fire_alarm, speech, yell).",
|
||||
"label": "Типи звукових подій"
|
||||
"description": "Список звукових подій для виявлення (наприклад, bark, fire_alarm, speech, yell)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Фільтри звуку"
|
||||
|
||||
@ -40,10 +40,7 @@
|
||||
"tips": {
|
||||
"title": "Інформація про зонд камери"
|
||||
},
|
||||
"aspectRatio": "співвідношення сторін",
|
||||
"keyframes": {
|
||||
"observedDuration": "Тривалість спостереження:"
|
||||
}
|
||||
"aspectRatio": "співвідношення сторін"
|
||||
},
|
||||
"overview": "Огляд",
|
||||
"framesAndDetections": "Кадри / Виявлення"
|
||||
@ -181,8 +178,7 @@
|
||||
"logs": {
|
||||
"frigate": "Фрегатні журнали - Фрегат",
|
||||
"go2rtc": "Журнали Go2RTC - Фрегат",
|
||||
"nginx": "Журнали Nginx - Фрегат",
|
||||
"websocket": "Журнал повідомлень - Frigate"
|
||||
"nginx": "Журнали Nginx - Фрегат"
|
||||
}
|
||||
},
|
||||
"title": "Система",
|
||||
@ -208,25 +204,6 @@
|
||||
"fetchingLogsFailed": "Помилка отримання журналів: {{errorMessage}}",
|
||||
"whileStreamingLogs": "Помилка під час потокової передачі журналів: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"websocket": {
|
||||
"label": "Повідомлення",
|
||||
"pause": "Павза",
|
||||
"resume": "Продовжити",
|
||||
"clear": "Очистити",
|
||||
"filter": {
|
||||
"all": "Всі теми (topics)",
|
||||
"topics": "Теми (topics)",
|
||||
"events": "Події",
|
||||
"reviews": "Перевірки",
|
||||
"classification": "Класифікація",
|
||||
"face_recognition": "Розпізнавання обличчя",
|
||||
"lpr": "Розпізнавання номерних знаків (LPR)",
|
||||
"camera_activity": "Активність камери",
|
||||
"system": "Система",
|
||||
"camera": "Камера",
|
||||
"all_cameras": "Всі камери"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,8 +34,6 @@ import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { maskCredentials } from "@/utils/credentialMask";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
@ -662,11 +660,6 @@ export default function Settings() {
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// for unmasked go2rtc stream sources
|
||||
const { data: rawPaths } = useSWR<{
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
}>(isAdmin ? "config/raw_paths" : null);
|
||||
|
||||
const visibleSettingsViews = !isAdmin
|
||||
? ALLOWED_VIEWS_FOR_VIEWER
|
||||
: allSettingsViews;
|
||||
@ -795,40 +788,6 @@ export default function Settings() {
|
||||
},
|
||||
);
|
||||
|
||||
// go2rtc streams aren't schema-backed, so build their preview items directly
|
||||
if ("go2rtc_streams" in pendingDataBySection) {
|
||||
const live =
|
||||
(pendingDataBySection["go2rtc_streams"] as Record<string, string[]>) ??
|
||||
{};
|
||||
const saved: Record<string, string[]> = {};
|
||||
for (const [name, urls] of Object.entries(
|
||||
rawPaths?.go2rtc?.streams ?? {},
|
||||
)) {
|
||||
saved[name] = Array.isArray(urls) ? urls : [urls];
|
||||
}
|
||||
|
||||
// Added or changed streams
|
||||
for (const [name, urls] of Object.entries(live)) {
|
||||
if (name in saved && isEqual(urls, saved[name])) continue;
|
||||
const masked = urls.map((url) => maskCredentials(url));
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: masked.length === 1 ? masked[0] : masked,
|
||||
});
|
||||
}
|
||||
|
||||
// Deleted streams (present in saved config, absent from pending)
|
||||
for (const name of Object.keys(saved)) {
|
||||
if (name in live) continue;
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((left, right) => {
|
||||
const scopeCompare = left.scope.localeCompare(right.scope);
|
||||
if (scopeCompare !== 0) return scopeCompare;
|
||||
@ -838,13 +797,7 @@ export default function Settings() {
|
||||
if (cameraCompare !== 0) return cameraCompare;
|
||||
return left.fieldPath.localeCompare(right.fieldPath);
|
||||
});
|
||||
}, [
|
||||
config,
|
||||
fullSchema,
|
||||
pendingDataBySection,
|
||||
profileFriendlyNames,
|
||||
rawPaths,
|
||||
]);
|
||||
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
|
||||
|
||||
// Map a pendingDataKey to SettingsType menu key for clearing section status
|
||||
const pendingKeyToMenuKey = useCallback(
|
||||
@ -916,7 +869,10 @@ export default function Settings() {
|
||||
// after `mutate("config")` resolves
|
||||
const keysToClear: string[] = [];
|
||||
|
||||
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
|
||||
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
|
||||
// which saves them atomically (single combined PUT with a pre-clear when
|
||||
// detector keys change or the Plus/Custom tab flips). Doing the same here
|
||||
// keeps Save All consistent with the page's own Save button
|
||||
const hasPendingDetectors = "detectors" in pendingDataBySection;
|
||||
const hasPendingModel = "model" in pendingDataBySection;
|
||||
if (hasPendingDetectors || hasPendingModel) {
|
||||
@ -1019,58 +975,8 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
// go2rtc streams are owned by Go2RtcStreamsSettingsView
|
||||
if ("go2rtc_streams" in pendingDataBySection) {
|
||||
try {
|
||||
const liveStreams =
|
||||
(pendingDataBySection["go2rtc_streams"] as Record<
|
||||
string,
|
||||
string[]
|
||||
>) ?? {};
|
||||
const streamsPayload: Record<string, string[] | string> = {
|
||||
...liveStreams,
|
||||
};
|
||||
const deletedStreamNames = Object.keys(
|
||||
config.go2rtc?.streams ?? {},
|
||||
).filter((name) => !(name in liveStreams));
|
||||
for (const deleted of deletedStreamNames) {
|
||||
streamsPayload[deleted] = "";
|
||||
}
|
||||
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { go2rtc: { streams: streamsPayload } },
|
||||
});
|
||||
|
||||
// Update the running go2rtc instance to match
|
||||
const go2rtcUpdates: Promise<unknown>[] = [];
|
||||
for (const [streamName, urls] of Object.entries(liveStreams)) {
|
||||
if (urls[0]) {
|
||||
go2rtcUpdates.push(
|
||||
axios.put(
|
||||
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const deleted of deletedStreamNames) {
|
||||
go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`));
|
||||
}
|
||||
await Promise.allSettled(go2rtcUpdates);
|
||||
|
||||
keysToClear.push("go2rtc_streams");
|
||||
savedKeys.push("go2rtc_streams");
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Save All – error saving go2rtc streams", error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const pendingKeys = Object.keys(pendingDataBySection).filter(
|
||||
(key) =>
|
||||
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
|
||||
(key) => key !== "detectors" && key !== "model",
|
||||
);
|
||||
|
||||
for (const key of pendingKeys) {
|
||||
|
||||
@ -58,13 +58,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
import type { ConfigSectionData } from "@/types/configForm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isMaskedPath,
|
||||
@ -90,8 +85,18 @@ type RawPathsResponse = {
|
||||
go2rtc: { streams: Record<string, string | string[]> };
|
||||
};
|
||||
|
||||
const SECTION_KEY = "go2rtc_streams";
|
||||
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
|
||||
type Go2RtcStreamsSettingsViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSectionStatusChange?: (
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
status: {
|
||||
hasChanges: boolean;
|
||||
isOverridden: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
|
||||
const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
@ -109,11 +114,7 @@ function normalizeStreams(
|
||||
export default function Go2RtcStreamsSettingsView({
|
||||
setUnsavedChanges,
|
||||
onSectionStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
isSavingAll,
|
||||
onSectionSavingChange,
|
||||
}: SettingsPageProps) {
|
||||
}: Go2RtcStreamsSettingsViewProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config, mutate: updateConfig } =
|
||||
@ -121,6 +122,13 @@ export default function Go2RtcStreamsSettingsView({
|
||||
const { data: rawPaths, mutate: updateRawPaths } =
|
||||
useSWR<RawPathsResponse>("config/raw_paths");
|
||||
|
||||
const [editedStreams, setEditedStreams] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
const [serverStreams, setServerStreams] = useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [credentialVisibility, setCredentialVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
@ -130,51 +138,34 @@ export default function Go2RtcStreamsSettingsView({
|
||||
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
|
||||
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(new Set());
|
||||
|
||||
const childPending = pendingDataBySection ?? EMPTY_PENDING;
|
||||
// Initialize from config — wait for both config and rawPaths to avoid
|
||||
// a mismatch when rawPaths arrives after config with different data
|
||||
useEffect(() => {
|
||||
if (!config || !rawPaths) return;
|
||||
|
||||
// Saved/server state. Always read from rawPaths
|
||||
const serverStreams = useMemo<Record<string, string[]>>(
|
||||
() => normalizeStreams(rawPaths?.go2rtc?.streams),
|
||||
[rawPaths],
|
||||
);
|
||||
// Always use rawPaths for go2rtc streams — the /config endpoint masks
|
||||
// credentials, so using config.go2rtc.streams would save masked values
|
||||
const normalized = normalizeStreams(rawPaths.go2rtc?.streams);
|
||||
|
||||
// Pending edits live in the parent's store so they survive navigation; fall back to saved state
|
||||
const liveStreams = useMemo<Record<string, string[]>>(
|
||||
() =>
|
||||
(childPending[SECTION_KEY] as Record<string, string[]> | undefined) ??
|
||||
serverStreams,
|
||||
[childPending, serverStreams],
|
||||
);
|
||||
|
||||
// Persist edits to the parent store, clearing the entry when an edit returns
|
||||
// the section to its saved state so Save All and the sidebar dot reset cleanly.
|
||||
const commitStreams = useCallback(
|
||||
(next: Record<string, string[]>) => {
|
||||
if (isEqual(next, serverStreams)) {
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
} else {
|
||||
onPendingDataChange?.(
|
||||
SECTION_KEY,
|
||||
undefined,
|
||||
next as ConfigSectionData,
|
||||
);
|
||||
}
|
||||
},
|
||||
[serverStreams, onPendingDataChange],
|
||||
);
|
||||
setServerStreams(normalized);
|
||||
if (!initialized) {
|
||||
setEditedStreams(normalized);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [config, rawPaths, initialized]);
|
||||
|
||||
// Track unsaved changes
|
||||
const hasChanges = useMemo(
|
||||
() => !isEqual(liveStreams, serverStreams),
|
||||
[liveStreams, serverStreams],
|
||||
() => initialized && !isEqual(editedStreams, serverStreams),
|
||||
[editedStreams, serverStreams, initialized],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setUnsavedChanges?.(hasChanges);
|
||||
setUnsavedChanges(hasChanges);
|
||||
}, [hasChanges, setUnsavedChanges]);
|
||||
|
||||
const hasValidationErrors = useMemo(() => {
|
||||
const names = Object.keys(liveStreams);
|
||||
const names = Object.keys(editedStreams);
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
for (const name of names) {
|
||||
@ -182,43 +173,13 @@ export default function Go2RtcStreamsSettingsView({
|
||||
if (seenNames.has(name)) return true;
|
||||
seenNames.add(name);
|
||||
|
||||
const urls = liveStreams[name];
|
||||
const urls = editedStreams[name];
|
||||
if (!urls || urls.length === 0 || urls.every((u) => !u.trim()))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [liveStreams]);
|
||||
|
||||
// Pending changes for this section's Save All preview popover. Diff the
|
||||
// pending streams against the saved state and mask credentials for display.
|
||||
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
|
||||
if (!hasChanges) return [];
|
||||
const items: SaveAllPreviewItem[] = [];
|
||||
|
||||
// Added or changed streams
|
||||
for (const [name, urls] of Object.entries(liveStreams)) {
|
||||
if (name in serverStreams && isEqual(urls, serverStreams[name])) continue;
|
||||
const masked = urls.map((url) => maskCredentials(url));
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: masked.length === 1 ? masked[0] : masked,
|
||||
});
|
||||
}
|
||||
|
||||
// Deleted streams (present in saved config, absent from pending)
|
||||
for (const name of Object.keys(serverStreams)) {
|
||||
if (name in liveStreams) continue;
|
||||
items.push({
|
||||
scope: "global",
|
||||
fieldPath: `go2rtc.streams.${name}`,
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [hasChanges, liveStreams, serverStreams]);
|
||||
}, [editedStreams]);
|
||||
|
||||
// Report status to parent for sidebar red dot
|
||||
useEffect(() => {
|
||||
@ -232,14 +193,13 @@ export default function Go2RtcStreamsSettingsView({
|
||||
// Save handler
|
||||
const saveToConfig = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
onSectionSavingChange?.(true);
|
||||
|
||||
try {
|
||||
const streamsPayload: Record<string, string[] | string> = {
|
||||
...liveStreams,
|
||||
...editedStreams,
|
||||
};
|
||||
const deletedStreamNames = Object.keys(serverStreams).filter(
|
||||
(name) => !(name in liveStreams),
|
||||
(name) => !(name in editedStreams),
|
||||
);
|
||||
for (const deleted of deletedStreamNames) {
|
||||
streamsPayload[deleted] = "";
|
||||
@ -252,7 +212,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
|
||||
// Update running go2rtc instance
|
||||
const go2rtcUpdates: Promise<unknown>[] = [];
|
||||
for (const [streamName, urls] of Object.entries(liveStreams)) {
|
||||
for (const [streamName, urls] of Object.entries(editedStreams)) {
|
||||
if (urls[0]) {
|
||||
go2rtcUpdates.push(
|
||||
axios.put(
|
||||
@ -273,9 +233,9 @@ export default function Go2RtcStreamsSettingsView({
|
||||
}),
|
||||
);
|
||||
|
||||
await updateConfig();
|
||||
await updateRawPaths();
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
setServerStreams(editedStreams);
|
||||
updateConfig();
|
||||
updateRawPaths();
|
||||
} catch {
|
||||
toast.error(
|
||||
t("toast.error", {
|
||||
@ -285,94 +245,82 @@ export default function Go2RtcStreamsSettingsView({
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
onSectionSavingChange?.(false);
|
||||
}
|
||||
}, [
|
||||
liveStreams,
|
||||
serverStreams,
|
||||
t,
|
||||
updateConfig,
|
||||
updateRawPaths,
|
||||
onPendingDataChange,
|
||||
onSectionSavingChange,
|
||||
]);
|
||||
}, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]);
|
||||
|
||||
// Reset handler
|
||||
const onReset = useCallback(() => {
|
||||
onPendingDataChange?.(SECTION_KEY, undefined, null);
|
||||
setEditedStreams(serverStreams);
|
||||
setCredentialVisibility({});
|
||||
}, [onPendingDataChange]);
|
||||
}, [serverStreams]);
|
||||
|
||||
// Stream CRUD operations
|
||||
const addStream = useCallback(
|
||||
(name: string) => {
|
||||
commitStreams({ ...liveStreams, [name]: [""] });
|
||||
setNewlyAdded((prev) => new Set(prev).add(name));
|
||||
setAddStreamDialogOpen(false);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
const addStream = useCallback((name: string) => {
|
||||
setEditedStreams((prev) => ({ ...prev, [name]: [""] }));
|
||||
setNewlyAdded((prev) => new Set(prev).add(name));
|
||||
setAddStreamDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const deleteStream = useCallback(
|
||||
(streamName: string) => {
|
||||
const { [streamName]: _removed, ...rest } = liveStreams;
|
||||
commitStreams(rest);
|
||||
setDeleteDialog(null);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
const deleteStream = useCallback((streamName: string) => {
|
||||
setEditedStreams((prev) => {
|
||||
const { [streamName]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDeleteDialog(null);
|
||||
}, []);
|
||||
|
||||
const renameStream = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
if (oldName === newName || !newName.trim()) return;
|
||||
if (!(oldName in liveStreams)) return;
|
||||
const renameStream = useCallback((oldName: string, newName: string) => {
|
||||
if (oldName === newName || !newName.trim()) return;
|
||||
|
||||
setEditedStreams((prev) => {
|
||||
const urls = prev[oldName];
|
||||
if (!urls) return prev;
|
||||
|
||||
const entries = Object.entries(prev);
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(liveStreams)) {
|
||||
result[key === oldName ? newName : key] = value;
|
||||
for (const [key, value] of entries) {
|
||||
if (key === oldName) {
|
||||
result[newName] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
commitStreams(result);
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(streamName: string, urlIndex: number, newUrl: string) => {
|
||||
const urls = [...(liveStreams[streamName] || [])];
|
||||
urls[urlIndex] = newUrl;
|
||||
commitStreams({ ...liveStreams, [streamName]: urls });
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const addUrl = useCallback(
|
||||
(streamName: string) => {
|
||||
const urls = [...(liveStreams[streamName] || []), ""];
|
||||
commitStreams({ ...liveStreams, [streamName]: urls });
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
);
|
||||
|
||||
const removeUrl = useCallback(
|
||||
(streamName: string, urlIndex: number) => {
|
||||
const urls = (liveStreams[streamName] || []).filter(
|
||||
(_, i) => i !== urlIndex,
|
||||
);
|
||||
commitStreams({
|
||||
...liveStreams,
|
||||
[streamName]: urls.length > 0 ? urls : [""],
|
||||
setEditedStreams((prev) => {
|
||||
const urls = [...(prev[streamName] || [])];
|
||||
urls[urlIndex] = newUrl;
|
||||
return { ...prev, [streamName]: urls };
|
||||
});
|
||||
},
|
||||
[liveStreams, commitStreams],
|
||||
[],
|
||||
);
|
||||
|
||||
const addUrl = useCallback((streamName: string) => {
|
||||
setEditedStreams((prev) => {
|
||||
const urls = [...(prev[streamName] || []), ""];
|
||||
return { ...prev, [streamName]: urls };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeUrl = useCallback((streamName: string, urlIndex: number) => {
|
||||
setEditedStreams((prev) => {
|
||||
const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex);
|
||||
return { ...prev, [streamName]: urls.length > 0 ? urls : [""] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCredentialVisibility = useCallback((key: string) => {
|
||||
setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const streamEntries = Object.entries(liveStreams);
|
||||
const streamEntries = Object.entries(editedStreams);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col lg:pr-2">
|
||||
@ -443,12 +391,6 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges")}
|
||||
</span>
|
||||
<SaveAllPreviewPopover
|
||||
items={sectionPreviewItems}
|
||||
className="h-7 w-7"
|
||||
align="start"
|
||||
side="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||
@ -456,7 +398,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="outline"
|
||||
disabled={isLoading || isSavingAll}
|
||||
disabled={isLoading}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{t("button.undo", { ns: "common" })}
|
||||
@ -465,9 +407,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<Button
|
||||
onClick={saveToConfig}
|
||||
variant="select"
|
||||
disabled={
|
||||
!hasChanges || isLoading || isSavingAll || hasValidationErrors
|
||||
}
|
||||
disabled={!hasChanges || isLoading || hasValidationErrors}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
@ -519,7 +459,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
<RenameStreamDialog
|
||||
open={renameDialog !== null}
|
||||
streamName={renameDialog ?? ""}
|
||||
allStreamNames={Object.keys(liveStreams)}
|
||||
allStreamNames={Object.keys(editedStreams)}
|
||||
onRename={(oldName, newName) => {
|
||||
renameStream(oldName, newName);
|
||||
setRenameDialog(null);
|
||||
@ -529,7 +469,7 @@ export default function Go2RtcStreamsSettingsView({
|
||||
|
||||
<AddStreamDialog
|
||||
open={addStreamDialogOpen}
|
||||
allStreamNames={Object.keys(liveStreams)}
|
||||
allStreamNames={Object.keys(editedStreams)}
|
||||
onAdd={addStream}
|
||||
onClose={() => setAddStreamDialogOpen(false)}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user