blakeblackshear.frigate/web/src/pages/Logs.tsx
Nicolas Mowen e6790d9a6a
Add ability to select all on desktop (#12044)
* Add ability to select all review items

* Refactor keybaord listener
2024-06-18 09:32:17 -05:00

436 lines
13 KiB
TypeScript

import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip";
import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { MdVerticalAlignBottom } from "react-icons/md";
import { parseLogLines } from "@/utils/logUtil";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type LogRange = { start: number; end: number };
function Logs() {
const [logService, setLogService] = useState<LogType>("frigate");
useEffect(() => {
document.title = `${logService[0].toUpperCase()}${logService.substring(1)} Logs - Frigate`;
}, [logService]);
// log data handling
const logPageSize = useMemo(() => {
if (isMobileOnly) {
return 15;
}
if (isTablet) {
return 25;
}
return 40;
}, []);
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
useEffect(() => {
axios
.get(`logs/${logService}?start=-${logPageSize}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
setLogRange({
start: Math.max(0, data.totalLines - logPageSize),
end: data.totalLines,
});
setLogs(data.lines);
setLogLines(parseLogLines(logService, data.lines));
}
})
.catch(() => {});
}, [logPageSize, logService]);
useEffect(() => {
if (!logs || logs.length == 0) {
return;
}
const id = setTimeout(() => {
axios
.get(`logs/${logService}?start=${logRange.end}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
if (data.lines.length > 0) {
setLogRange({
start: logRange.start,
end: data.totalLines,
});
setLogs([...logs, ...data.lines]);
setLogLines([
...logLines,
...parseLogLines(logService, data.lines),
]);
}
}
})
.catch(() => {});
}, 5000);
return () => {
if (id) {
clearTimeout(id);
}
};
// we need to listen on the current range of visible items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService, logRange]);
// convert to log data
const handleCopyLogs = useCallback(() => {
if (logs) {
copy(logs.join("\n"));
toast.success(
logRange.start == 0
? "Copied logs to clipboard"
: "Copied visible logs to clipboard",
);
} else {
toast.error("Could not copy logs to clipboard");
}
}, [logs, logRange]);
// scroll to bottom
const [initialScroll, setInitialScroll] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const [endVisible, setEndVisible] = useState(true);
const endObserver = useRef<IntersectionObserver | null>(null);
const endLogRef = useCallback(
(node: HTMLElement | null) => {
if (endObserver.current) endObserver.current.disconnect();
try {
endObserver.current = new IntersectionObserver((entries) => {
setEndVisible(entries[0].isIntersecting);
});
if (node) endObserver.current.observe(node);
} catch (e) {
// no op
}
},
[setEndVisible],
);
const startObserver = useRef<IntersectionObserver | null>(null);
const startLogRef = useCallback(
(node: HTMLElement | null) => {
if (startObserver.current) startObserver.current.disconnect();
if (logs.length == 0 || !initialScroll) {
return;
}
try {
startObserver.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - logPageSize);
axios
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
.then((resp) => {
if (resp.status == 200) {
const data = resp.data as LogData;
if (data.lines.length > 0) {
setLogRange({
start: start,
end: logRange.end,
});
setLogs([...data.lines, ...logs]);
setLogLines([
...parseLogLines(logService, data.lines),
...logLines,
]);
}
}
})
.catch(() => {});
contentRef.current?.scrollBy({
top: 10,
});
}
},
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
);
if (node) startObserver.current.observe(node);
} catch (e) {
// no op
}
},
// we need to listen on the current range of visible items
// eslint-disable-next-line react-hooks/exhaustive-deps
[logRange, initialScroll],
);
useEffect(() => {
if (logLines.length == 0) {
setInitialScroll(false);
return;
}
if (initialScroll) {
return;
}
if (!contentRef.current) {
return;
}
if (contentRef.current.scrollHeight <= contentRef.current.clientHeight) {
setInitialScroll(true);
return;
}
contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight,
behavior: "instant",
});
setTimeout(() => setInitialScroll(true), 300);
// we need to listen on the current range of visible items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService]);
// log filtering
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
// log selection
const [selectedLog, setSelectedLog] = useState<LogLine>();
// interaction
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, modifiers) => {
if (!modifiers.down) {
return;
}
switch (key) {
case "PageDown":
contentRef.current?.scrollBy({
top: 480,
});
break;
case "PageUp":
contentRef.current?.scrollBy({
top: -480,
});
break;
case "ArrowDown":
contentRef.current?.scrollBy({
top: 48,
});
break;
case "ArrowUp":
contentRef.current?.scrollBy({
top: -48,
});
break;
}
},
);
return (
<div className="flex size-full flex-col p-2">
<Toaster position="top-center" closeButton={true} />
<LogInfoDialog logLine={selectedLog} setLogLine={setSelectedLog} />
<div className="flex items-center justify-between">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={logService}
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setLogLines([]);
setFilterSeverity(undefined);
setLogService(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(logTypes).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-muted-foreground"}`}
value={item}
aria-label={`Select ${item}`}
>
<div className="capitalize">{item}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
Copy to Clipboard
</div>
</Button>
<LogLevelFilterButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
/>
</div>
</div>
{initialScroll && !endVisible && (
<Button
className="absolute bottom-8 left-[50%] z-20 flex -translate-x-[50%] items-center gap-1 rounded-md p-2"
onClick={() =>
contentRef.current?.scrollTo({
top: contentRef.current?.scrollHeight,
behavior: "smooth",
})
}
>
<MdVerticalAlignBottom />
Jump to Bottom
</Button>
)}
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-sm sm:p-2">
<div className="grid grid-cols-5 *:px-2 *:py-3 *:text-sm *:text-primary/40 sm:grid-cols-8 md:grid-cols-12">
<div className="flex items-center p-1 capitalize">Type</div>
<div className="col-span-2 flex items-center sm:col-span-1">
Timestamp
</div>
<div className="col-span-2 flex items-center">Tag</div>
<div className="col-span-5 flex items-center sm:col-span-4 md:col-span-8">
Message
</div>
</div>
<div
ref={contentRef}
className="no-scrollbar flex w-full flex-col overflow-y-auto overscroll-contain"
>
{logLines.length > 0 &&
[...Array(logRange.end).keys()].map((idx) => {
const logLine =
idx >= logRange.start
? logLines[idx - logRange.start]
: undefined;
if (logLine) {
const line = logLines[idx - logRange.start];
if (filterSeverity && !filterSeverity.includes(line.severity)) {
return (
<div
ref={idx == logRange.start + 10 ? startLogRef : undefined}
/>
);
}
return (
<LogLineData
key={`${idx}-${logService}`}
startRef={
idx == logRange.start + 10 ? startLogRef : undefined
}
className={initialScroll ? "" : "invisible"}
line={line}
onClickSeverity={() => setFilterSeverity([line.severity])}
onSelect={() => setSelectedLog(line)}
/>
);
}
return (
<div
key={`${idx}-${logService}`}
className={isDesktop ? "h-12" : "h-16"}
/>
);
})}
{logLines.length > 0 && <div id="page-bottom" ref={endLogRef} />}
</div>
{logLines.length == 0 && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
</div>
</div>
);
}
type LogLineDataProps = {
startRef?: (node: HTMLDivElement | null) => void;
className: string;
line: LogLine;
onClickSeverity: () => void;
onSelect: () => void;
};
function LogLineData({
startRef,
className,
line,
onClickSeverity,
onSelect,
}: LogLineDataProps) {
return (
<div
ref={startRef}
className={cn(
"grid w-full cursor-pointer grid-cols-5 gap-2 border-t border-secondary py-2 hover:bg-muted sm:grid-cols-8 md:grid-cols-12",
className,
"*:text-sm",
)}
onClick={onSelect}
>
<div className="flex h-full items-center gap-2 p-1">
<LogChip severity={line.severity} onClickSeverity={onClickSeverity} />
</div>
<div className="col-span-2 flex h-full items-center sm:col-span-1">
{line.dateStamp}
</div>
<div className="col-span-2 flex size-full items-center pr-2">
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.section}
</div>
</div>
<div className="col-span-5 flex size-full items-center justify-between pl-2 pr-2 sm:col-span-4 sm:pl-0 md:col-span-8">
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{line.content}
</div>
</div>
</div>
);
}
export default Logs;