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