Use new UI (#8983)

* fixup build

* swap frontends
This commit is contained in:
Blake Blackshear
2023-12-16 16:20:59 +00:00
parent a2c6f45454
commit bdebb99b5a
286 changed files with 20010 additions and 20007 deletions

View File

@@ -0,0 +1,157 @@
import useSWR from "swr";
import * as monaco from "monaco-editor";
import { configureMonacoYaml } from "monaco-yaml";
import { useCallback, useEffect, useRef, useState } from "react";
import { useApiHost } from "@/api";
import Heading from "@/components/ui/heading";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button";
import axios from "axios";
import copy from "copy-to-clipboard";
import { useTheme } from "@/context/theme-provider";
type SaveOptions = "saveonly" | "restart";
function ConfigEditor() {
const apiHost = useApiHost();
const { data: config } = useSWR<string>("config/raw");
const { theme } = useTheme();
const [success, setSuccess] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>();
const modelRef = useRef<monaco.editor.IEditorModel | null>();
const onHandleSaveConfig = useCallback(
async (save_option: SaveOptions) => {
if (!editorRef.current) {
return;
}
axios
.post(
`config/save?save_option=${save_option}`,
editorRef.current.getValue(),
{
headers: { "Content-Type": "text/plain" },
}
)
.then((response) => {
if (response.status === 200) {
setError("");
setSuccess(response.data.message);
}
})
.catch((error) => {
setSuccess("");
if (error.response) {
setError(error.response.data.message);
} else {
setError(error.message);
}
});
},
[editorRef]
);
const handleCopyConfig = useCallback(async () => {
if (!editorRef.current) {
return;
}
copy(editorRef.current.getValue());
}, [editorRef]);
useEffect(() => {
if (!config) {
return;
}
if (modelRef.current != null) {
// we don't need to recreate the editor if it already exists
return;
}
const modelUri = monaco.Uri.parse("a://b/api/config/schema.json");
if (monaco.editor.getModels().length > 0) {
modelRef.current = monaco.editor.getModel(modelUri);
} else {
modelRef.current = monaco.editor.createModel(config, "yaml", modelUri);
}
configureMonacoYaml(monaco, {
enableSchemaRequest: true,
hover: true,
completion: true,
validate: true,
format: true,
schemas: [
{
uri: `${apiHost}api/config/schema.json`,
fileMatch: [String(modelUri)],
},
],
});
const container = document.getElementById("container");
if (container != undefined) {
editorRef.current = monaco.editor.create(container, {
language: "yaml",
model: modelRef.current,
scrollBeyondLastLine: false,
theme: theme == "dark" ? "vs-dark" : "vs-light",
});
}
});
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="absolute h-[70%] w-[96%] md:h-[85%] md:w-[88%]">
<div className="lg:flex justify-between mr-1">
<Heading as="h2">Config</Heading>
<div>
<Button
size="sm"
className="mx-1"
onClick={(_) => handleCopyConfig()}
>
Copy Config
</Button>
<Button
size="sm"
className="mx-1"
onClick={(_) => onHandleSaveConfig("restart")}
>
Save & Restart
</Button>
<Button
size="sm"
className="mx-1"
onClick={(_) => onHandleSaveConfig("saveonly")}
>
Save Only
</Button>
</div>
</div>
{success && <div className="max-h-20 text-green-500">{success}</div>}
{error && (
<div className="p-4 overflow-scroll text-red-500 whitespace-pre-wrap">
{error}
</div>
)}
<div id="container" className="h-full mt-2" />
</div>
);
}
export default ConfigEditor;

171
web/src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,171 @@
import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator";
import {
useAudioState,
useDetectState,
useRecordingsState,
useSnapshotsState,
} from "@/api/ws";
import useSWR from "swr";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
import CameraImage from "@/components/camera/CameraImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { AiOutlinePicture } from "react-icons/ai";
import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
import MiniEventCard from "@/components/card/MiniEventCard";
import { Event } from "@/types/event";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config");
const recentTimestamp = useMemo(() => {
const now = new Date();
now.setMinutes(now.getMinutes() - 30);
return now.getTime() / 1000;
}, []);
const { data: events, mutate: updateEvents } = useSWR<Event[]>([
"events",
{ limit: 10, after: recentTimestamp },
]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
return (
<>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />}
{config && (
<div>
{events && events.length > 0 && (
<>
<Heading as="h4">Recent Events</Heading>
<ScrollArea>
<div className="flex">
{events.map((event) => {
return (
<MiniEventCard
key={event.id}
event={event}
onUpdate={() => updateEvents()}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
)}
<Heading as="h4">Cameras</Heading>
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{sortedCameras.map((camera) => {
return <Camera key={camera.name} camera={camera} />;
})}
</div>
</div>
)}
</>
);
}
function Camera({ camera }: { camera: CameraConfig }) {
const { payload: detectValue, send: sendDetect } = useDetectState(
camera.name
);
const { payload: recordValue, send: sendRecord } = useRecordingsState(
camera.name
);
const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState(
camera.name
);
const { payload: audioValue, send: sendAudio } = useAudioState(camera.name);
return (
<>
<Card className="">
<a href={`/live/${camera.name}`}>
<AspectRatio
ratio={16 / 9}
className="bg-black flex justify-center items-center"
>
<CameraImage camera={camera.name} fitAspect={16 / 9} />
</AspectRatio>
<div className="flex justify-between items-center">
<div className="text-lg capitalize p-2">
{camera.name.replaceAll("_", " ")}
</div>
<div>
<Button
variant="ghost"
size="sm"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendDetect(detectValue == "ON" ? "OFF" : "ON")}
>
<FaWalking />
</Button>
<Button
variant="ghost"
size="sm"
className={
camera.record.enabled_in_config
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-red-500"
}
onClick={() =>
camera.record.enabled_in_config
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
: {}
}
>
<TbMovie />
</Button>
<Button
variant="ghost"
size="sm"
className={`${
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendSnapshot(detectValue == "ON" ? "OFF" : "ON")}
>
<AiOutlinePicture />
</Button>
{camera.audio.enabled_in_config && (
<Button
variant="ghost"
size="sm"
className={`${
audioValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendAudio(detectValue == "ON" ? "OFF" : "ON")}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</>
);
}
export default Dashboard;

324
web/src/pages/Export.tsx Normal file
View File

@@ -0,0 +1,324 @@
import { baseUrl } from "@/api/baseUrl";
import ExportCard from "@/components/card/ExportCard";
import VideoPlayer from "@/components/player/VideoPlayer";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenuRadioGroup,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import Heading from "@/components/ui/heading";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { format } from "date-fns";
import { useCallback, useState } from "react";
import { DateRange } from "react-day-picker";
import useSWR from "swr";
type ExportItem = {
name: string;
};
function Export() {
const { data: config } = useSWR<FrigateConfig>("config");
const { data: exports, mutate } = useSWR<ExportItem[]>(
"exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data)
);
// Export States
const [camera, setCamera] = useState<string | undefined>();
const [playback, setPlayback] = useState<string | undefined>();
const [message, setMessage] = useState({ text: "", error: false });
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const [date, setDate] = useState<DateRange | undefined>({
from: currentDate,
});
const [startTime, setStartTime] = useState("00:00:00");
const [endTime, setEndTime] = useState("23:59:59");
const [selectedClip, setSelectedClip] = useState<string | undefined>();
const [deleteClip, setDeleteClip] = useState<string | undefined>();
const onHandleExport = () => {
if (camera == "select") {
setMessage({ text: "A camera needs to be selected.", error: true });
return;
}
if (playback == "select") {
setMessage({
text: "A playback factor needs to be selected.",
error: true,
});
return;
}
if (!date?.from || !startTime || !endTime) {
setMessage({
text: "A start and end time needs to be selected",
error: true,
});
return;
}
const startDate = new Date(date.from.getTime());
const [startHour, startMin, startSec] = startTime.split(":");
startDate.setHours(
parseInt(startHour),
parseInt(startMin),
parseInt(startSec),
0
);
const start = startDate.getTime() / 1000;
const endDate = new Date((date.to || date.from).getTime());
const [endHour, endMin, endSec] = endTime.split(":");
endDate.setHours(parseInt(endHour), parseInt(endMin), parseInt(endSec), 0);
const end = endDate.getTime() / 1000;
if (end <= start) {
setMessage({
text: "The end time must be after the start time.",
error: true,
});
return;
}
axios
.post(`export/${camera}/start/${start}/end/${end}`, { playback })
.then((response) => {
if (response.status == 200) {
setMessage({
text: "Successfully started export. View the file in the /exports folder.",
error: false,
});
}
mutate();
})
.catch((error) => {
if (error.response?.data?.message) {
setMessage({
text: `Failed to start export: ${error.response.data.message}`,
error: true,
});
} else {
setMessage({
text: `Failed to start export: ${error.message}`,
error: true,
});
}
});
};
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
return;
}
axios.delete(`export/${deleteClip}`).then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
}
});
}, [deleteClip]);
return (
<>
<Heading as="h2">Export</Heading>
{message.text && (
<div
className={`max-h-20 ${
message.error ? "text-red-500" : "text-green-500"
}`}
>
{message.text}
</div>
)}
<AlertDialog
open={deleteClip != undefined}
onOpenChange={(_) => setDeleteClip(undefined)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Export</AlertDialogTitle>
<AlertDialogDescription>
Confirm deletion of {deleteClip}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={() => onHandleDelete()}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog
open={selectedClip != undefined}
onOpenChange={(_) => setSelectedClip(undefined)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Playback</DialogTitle>
</DialogHeader>
<VideoPlayer
options={{
preload: "auto",
autoplay: true,
sources: [
{
src: `${baseUrl}exports/${selectedClip}`,
type: "video/mp4",
},
],
}}
seekOptions={{ forward: 10, backward: 5 }}
/>
</DialogContent>
</Dialog>
<div className="xl:flex justify-between">
<div>
<div className="my-2 flex">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{camera?.replaceAll("_", " ") || "Select A Camera"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={camera}
onValueChange={setCamera}
>
{Object.keys(config?.cameras || {}).map((item) => (
<DropdownMenuRadioItem
className="capitalize"
key={item}
value={item}
>
{item.replaceAll("_", " ")}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{playback?.split("_")[0] || "Select A Playback Factor"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
Select A Playback Factor
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={playback}
onValueChange={setPlayback}
>
<DropdownMenuRadioItem value="realtime">
Realtime
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="timelapse_25x">
Timelapse
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{`${
date?.from ? format(date?.from, "LLL dd, y") : ""
} ${startTime} -> ${
date?.to ? format(date?.to, "LLL dd, y") : ""
} ${endTime}`}</Button>
</PopoverTrigger>
<PopoverContent className="w-84">
<Calendar mode="range" selected={date} onSelect={setDate} />
<div className="flex justify-between">
<input
className="p-1 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startTime}
step="1"
onChange={(e) => setStartTime(e.target.value)}
/>
<input
className="p-1 mx-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endTime}
step="1"
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</PopoverContent>
</Popover>
<div>
<Button className="my-4" onClick={() => onHandleExport()}>
Submit
</Button>
</div>
</div>
{exports && (
<Card className="p-4 xl:w-1/2">
<Heading as="h3">Exports</Heading>
{Object.values(exports).map((item) => (
<ExportCard
key={item.name}
file={item}
onSelect={(file) => setSelectedClip(file)}
onDelete={(file) => setDeleteClip(file)}
/>
))}
</Card>
)}
</div>
</>
);
}
export default Export;

258
web/src/pages/History.tsx Normal file
View File

@@ -0,0 +1,258 @@
import { useCallback, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import { FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading";
import ActivityIndicator from "@/components/ui/activity-indicator";
import HistoryCard from "@/components/card/HistoryCard";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import axios from "axios";
import TimelinePlayerCard from "@/components/card/TimelineCardPlayer";
const API_LIMIT = 120;
function History() {
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useMemo(
() =>
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
[config]
);
const timelineFetcher = useCallback((key: any) => {
const [path, params] = Array.isArray(key) ? key : [key, undefined];
return axios.get(path, { params }).then((res) => res.data);
}, []);
const getKey = useCallback((index: number, prevData: HourlyTimeline) => {
if (index > 0) {
const lastDate = prevData.end;
const pagedParams = { before: lastDate, timezone, limit: API_LIMIT };
return ["timeline/hourly", pagedParams];
}
return ["timeline/hourly", { timezone, limit: API_LIMIT }];
}, []);
const {
data: timelinePages,
size,
setSize,
isValidating,
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
const { data: allPreviews } = useSWR<Preview[]>(
timelinePages
? `preview/all/start/${timelinePages?.at(0)
?.start}/end/${timelinePages?.at(-1)?.end}`
: null,
{ revalidateOnFocus: false }
);
const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal");
const [playback, setPlayback] = useState<Card | undefined>();
const shouldAutoPlay = useMemo(() => {
return playback == undefined && window.innerWidth < 480;
}, [playback]);
const timelineCards: CardsData | never[] = useMemo(() => {
if (!timelinePages) {
return [];
}
const cards: CardsData = {};
timelinePages.forEach((hourlyTimeline) => {
Object.keys(hourlyTimeline["hours"])
.reverse()
.forEach((hour) => {
const day = new Date(parseInt(hour) * 1000);
day.setHours(0, 0, 0, 0);
const dayKey = (day.getTime() / 1000).toString();
const source_to_types: { [key: string]: string[] } = {};
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
const time = new Date(i.timestamp * 1000);
time.setSeconds(0);
time.setMilliseconds(0);
const key = `${i.source_id}-${time.getMinutes()}`;
if (key in source_to_types) {
source_to_types[key].push(i.class_type);
} else {
source_to_types[key] = [i.class_type];
}
});
if (!Object.keys(cards).includes(dayKey)) {
cards[dayKey] = {};
}
cards[dayKey][hour] = {};
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
const time = new Date(i.timestamp * 1000);
const key = `${i.camera}-${time.getMinutes()}`;
// detail level for saving items
// detail level determines which timeline items for each moment is returned
// values can be normal, extra, or full
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
// extra: return all items except attribute / gone / visible unless that is the only item
// full: return all items
let add = true;
if (detailLevel == "normal") {
if (
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
1 &&
[
"active",
"attribute",
"gone",
"stationary",
"visible",
].includes(i.class_type)
) {
add = false;
}
} else if (detailLevel == "extra") {
if (
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
1 &&
i.class_type in ["attribute", "gone", "visible"]
) {
add = false;
}
}
if (add) {
if (key in cards[dayKey][hour]) {
cards[dayKey][hour][key].entries.push(i);
} else {
cards[dayKey][hour][key] = {
camera: i.camera,
time: time.getTime() / 1000,
entries: [i],
};
}
}
});
});
});
return cards;
}, [detailLevel, timelinePages]);
const isDone =
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
// hooks for infinite scroll
const observer = useRef<IntersectionObserver | null>();
const lastTimelineRef = useCallback(
(node: HTMLElement | null) => {
if (isValidating) return;
if (observer.current) observer.current.disconnect();
try {
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
} catch (e) {
// no op
}
},
[size, setSize, isValidating, isDone]
);
if (!config || !timelineCards || timelineCards.length == 0) {
return <ActivityIndicator />;
}
return (
<>
<Heading as="h2">Review</Heading>
<div className="text-xs mb-4">
Dates and times are based on the timezone {timezone}
</div>
<TimelinePlayerCard
timeline={playback}
onDismiss={() => setPlayback(undefined)}
/>
<div>
{Object.entries(timelineCards)
.reverse()
.map(([day, timelineDay], dayIdx) => {
return (
<div key={day}>
<Heading as="h3">
{formatUnixTimestampToDateTime(parseInt(day), {
strftime_fmt: "%A %b %d",
time_style: "medium",
date_style: "medium",
})}
</Heading>
{Object.entries(timelineDay).map(
([hour, timelineHour], hourIdx) => {
if (Object.values(timelineHour).length == 0) {
return <div key={hour}></div>;
}
const lastRow =
dayIdx == Object.values(timelineCards).length - 1 &&
hourIdx == Object.values(timelineDay).length - 1;
const previewMap: { [key: string]: Preview | undefined } =
{};
return (
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
<Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt: "%I:00",
time_style: "medium",
date_style: "medium",
})}
</Heading>
<div className="flex flex-wrap">
{Object.entries(timelineHour)
.reverse()
.map(([key, timeline]) => {
const startTs = Object.values(timeline.entries)[0]
.timestamp;
let relevantPreview = previewMap[timeline.camera];
if (relevantPreview == undefined) {
relevantPreview = previewMap[timeline.camera] =
Object.values(allPreviews || []).find(
(preview) =>
preview.camera == timeline.camera &&
preview.start < startTs &&
preview.end > startTs
);
}
return (
<HistoryCard
key={key}
timeline={timeline}
shouldAutoPlay={shouldAutoPlay}
relevantPreview={relevantPreview}
onClick={() => {
setPlayback(timeline);
}}
/>
);
})}
</div>
{lastRow && <ActivityIndicator />}
</div>
);
}
)}
</div>
);
})}
</div>
</>
);
}
export default History;

127
web/src/pages/Live.tsx Normal file
View File

@@ -0,0 +1,127 @@
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import useSWR from "swr";
function Live() {
const { data: config } = useSWR<FrigateConfig>("config");
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>(
openedCamera ?? "Select A Camera"
);
const cameraConfig = useMemo(() => {
return config?.cameras[camera];
}, [camera, config]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const restreamEnabled = useMemo(() => {
return (
config &&
cameraConfig &&
Object.keys(config.go2rtc.streams || {}).includes(
cameraConfig.live.stream_name
)
);
}, [config, cameraConfig]);
const defaultLiveMode = useMemo(() => {
if (cameraConfig) {
if (restreamEnabled) {
return cameraConfig.ui.live_mode;
}
return "jsmpeg";
}
return undefined;
}, [cameraConfig, restreamEnabled]);
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(
`${camera}-source`,
defaultLiveMode
);
return (
<div className=" w-full">
<div className="flex justify-between">
<Heading as="h2">Live</Heading>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{camera?.replaceAll("_", " ") || "Select A Camera"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={camera} onValueChange={setCamera}>
{(sortedCameras).map((item) => (
<DropdownMenuRadioItem
className="capitalize"
key={item.name}
value={item.name}
>
{item.name.replaceAll("_", " ")}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{viewSource || defaultLiveMode || "Select A Live Mode"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Live Mode</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={`${viewSource}`}
onValueChange={setViewSource}
>
<DropdownMenuRadioItem value="webrtc">
Webrtc
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="mse">MSE</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="jsmpeg">
Jsmpeg
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="debug">
Debug
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{cameraConfig && sourceIsLoaded && (
<LivePlayer
liveMode={`${viewSource ?? defaultLiveMode}`}
cameraConfig={cameraConfig}
/>
)}
</div>
);
}
export default Live;

11
web/src/pages/Logs.tsx Normal file
View File

@@ -0,0 +1,11 @@
import Heading from "@/components/ui/heading";
function Logs() {
return (
<>
<Heading as="h2">Logs</Heading>
</>
);
}
export default Logs;

12
web/src/pages/NoMatch.tsx Normal file
View File

@@ -0,0 +1,12 @@
import Heading from "@/components/ui/heading";
function NoMatch() {
return (
<>
<Heading as="h2">404</Heading>
<p>Page not found</p>
</>
);
}
export default NoMatch;

View File

@@ -0,0 +1,44 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function Settings() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="detect">
Always show PTZ controls for ONVIF cameras
</Label>
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Default Live Mode" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</>
);
}
export default Settings;

245
web/src/pages/Storage.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { useWs } from "@/api/ws";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Heading from "@/components/ui/heading";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useMemo } from "react";
import { LuAlertCircle } from "react-icons/lu";
import useSWR from "swr";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
const emptyObject = Object.freeze({});
function Storage() {
const { data: storage } = useSWR<CameraStorage>("recordings/storage");
const {
value: { payload: stats },
} = useWs("stats", "");
const { data: initialStats } = useSWR("stats");
const { service } = stats || initialStats || emptyObject;
const hasSeparateMedia = useMemo(() => {
return (
service &&
service["storage"]["/media/frigate/recordings"]["total"] !=
service["storage"]["/media/frigate/clips"]["total"]
);
}, service);
const getUnitSize = (MB: number) => {
if (isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
if (!service || !storage) {
return <ActivityIndicator />;
}
return (
<>
<Heading as="h2">Storage</Heading>
<Heading className="my-4" as="h3">
Overview
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<div className="flex items-center">
<CardTitle>Data</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuAlertCircle />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Overview of total used storage and total capacity of the
drives that hold the recordings and snapshots directories.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Location</TableHead>
<TableHead>Used</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
{hasSeparateMedia ? "Recordings" : "Recordings & Snapshots"}
</TableCell>
<TableCell>
{getUnitSize(
service["storage"]["/media/frigate/recordings"]["used"]
)}
</TableCell>
<TableCell>
{getUnitSize(
service["storage"]["/media/frigate/recordings"]["total"]
)}
</TableCell>
</TableRow>
{hasSeparateMedia && (
<TableRow>
<TableCell>Snapshots</TableCell>
<TableCell>
{getUnitSize(
service["storage"]["/media/frigate/clips"]["used"]
)}
</TableCell>
<TableCell>
{getUnitSize(
service["storage"]["/media/frigate/clips"]["total"]
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center">
<CardTitle>Memory</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuAlertCircle />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Overview of used and total memory in frigate process.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Location</TableHead>
<TableHead>Used</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>/dev/shm</TableCell>
<TableCell>
{getUnitSize(service["storage"]["/dev/shm"]["used"])}
</TableCell>
<TableCell>
{getUnitSize(service["storage"]["/dev/shm"]["total"])}
</TableCell>
</TableRow>
<TableRow>
<TableCell>/tmp/cache</TableCell>
<TableCell>
{getUnitSize(service["storage"]["/tmp/cache"]["used"])}
</TableCell>
<TableCell>
{getUnitSize(service["storage"]["/tmp/cache"]["total"])}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div className="flex items-center my-4">
<Heading as="h4">Cameras</Heading>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost">
<LuAlertCircle />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Overview of per-camera storage usage and bandwidth.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-4">
{Object.entries(storage).map(([name, camera]) => (
<Card key={name}>
<div className="capitalize text-lg flex justify-between">
<Button variant="link">
<a className="capitalize" href={`/cameras/${name}`}>
{name.replaceAll("_", " ")}
</a>
</Button>
</div>
<div className="p-2">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableCell>Usage</TableCell>
<TableCell>Stream Bandwidth</TableCell>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
{Math.round(camera["usage_percent"] ?? 0)}%
</TableCell>
<TableCell>
{camera["bandwidth"]
? `${getUnitSize(camera["bandwidth"])}/hr`
: "Calculating..."}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
))}
</div>
</>
);
}
export default Storage;

11
web/src/pages/System.tsx Normal file
View File

@@ -0,0 +1,11 @@
import Heading from "@/components/ui/heading";
function System() {
return (
<>
<Heading as="h2">System</Heading>
</>
);
}
export default System;