autobrr/web/src/screens/Logs.tsx
soup 4ade1b0ecf
feat(logs): sanitize logfile on download (#767)
* initial commit

* handle tleech urls

* improved and simplified regex

* add sanitization status & loading anim for log dl

* removed unused imports

* improved regex

* fixed regex and added tests

* regex improvements and tests

* added unicode matching to saslRegex

* added missing baseurl

* swapped the css animator for a react component

the css version froze when served through a reverse proxy

* optimized regex compilation

---------

Co-authored-by: soup <soup@r4tio.cat>
2023-03-19 21:57:16 +01:00

373 lines
12 KiB
TypeScript

import { Fragment, useEffect, useRef, useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import format from "date-fns/format";
import { DebounceInput } from "react-debounce-input";
import { APIClient } from "../api/APIClient";
import { Checkbox } from "../components/Checkbox";
import { classNames, simplifyDate } from "../utils";
import { SettingsContext } from "../utils/Context";
import { EmptySimple } from "../components/emptystates";
import {
Cog6ToothIcon,
DocumentArrowDownIcon
} from "@heroicons/react/24/outline";
import { useQuery } from "react-query";
import { Menu, Transition } from "@headlessui/react";
import { baseUrl } from "../utils";
type LogEvent = {
time: string;
level: string;
message: string;
};
type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR" | "WARN";
const LogColors: Record<LogLevel, string> = {
"TRACE": "text-purple-300",
"DEBUG": "text-yellow-500",
"INFO": "text-green-500",
"ERROR": "text-red-500",
"WARN": "text-yellow-500"
};
export const Logs = () => {
const [settings] = SettingsContext.use();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<LogEvent[]>([]);
const [searchFilter, setSearchFilter] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogEvent[]>([]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
};
useEffect(() => {
const es = APIClient.events.logs();
es.onmessage = (event) => {
const newData = JSON.parse(event.data) as LogEvent;
setLogs((prevState) => [...prevState, newData]);
if (settings.scrollOnNewLog)
scrollToBottom();
};
return () => es.close();
}, [setLogs, settings]);
useEffect(() => {
if (!searchFilter.length) {
setFilteredLogs(logs);
return;
}
const newLogs: LogEvent[] = [];
logs.forEach((log) => {
if (log.message.indexOf(searchFilter) !== -1)
newLogs.push(log);
});
setFilteredLogs(newLogs);
}, [logs, searchFilter]);
return (
<main>
<header className="pt-10 pb-5">
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-black dark:text-white">Logs</h1>
</div>
</header>
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-4 lg:px-8">
<div className="flex justify-center py-4">
<ExclamationTriangleIcon
className="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
<p className="ml-2 text-sm text-black dark:text-gray-400">This page shows only new logs, i.e. no history.</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4 pb-3 sm:pb-4">
<div className="flex relative mb-3">
<DebounceInput
minLength={2}
debounceTimeout={200}
onChange={(event) => setSearchFilter(event.target.value.toLowerCase().trim())}
id="filter"
type="text"
autoComplete="off"
className={classNames(
"focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
"block w-full dark:bg-gray-900 shadow-sm dark:text-gray-100 sm:text-sm rounded-md"
)}
placeholder="Enter a string to filter logs by..."
/>
<LogsDropdown />
</div>
<div className="overflow-y-auto px-2 rounded-lg h-[60vh] min-w-full bg-gray-100 dark:bg-gray-900 overflow-auto">
{filteredLogs.map((entry, idx) => (
<div
key={idx}
className={classNames(
settings.indentLogLines ? "grid justify-start grid-flow-col" : "",
settings.hideWrappedText ? "truncate hover:text-ellipsis hover:whitespace-normal" : ""
)}
>
<span
title={entry.time}
className="font-mono text-gray-500 dark:text-gray-600 mr-2 h-full"
>
{format(new Date(entry.time), "HH:mm:ss.SSS")}
</span>
{entry.level in LogColors ? (
<span
className={classNames(
LogColors[entry.level as LogLevel],
"font-mono font-semibold h-full"
)}
>
{entry.level}
{" "}
</span>
) : null}
<span className="ml-2 text-black dark:text-gray-300">
{entry.message}
</span>
</div>
))}
<div className="mt-6" ref={messagesEndRef} />
</div>
</div>
</div>
<div className="max-w-screen-xl mx-auto pb-10 px-2 sm:px-4 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4">
<LogFiles />
</div>
</div>
</main>
);
};
export const LogFiles = () => {
const { isLoading, data } = useQuery(
["log-files"],
() => APIClient.logs.files(),
{
retry: false,
refetchOnWindowFocus: false,
onError: err => console.log(err)
}
);
return (
<div>
<div className="mt-2">
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Log files</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Download old log files.
</p>
</div>
{data && data.files.length > 0 ? (
<section className="py-3 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol className="min-w-full relative">
<li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-5 px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</div>
<div className="col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Size
</div>
<div className="col-span-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last modified
</div>
</li>
{data && data.files.map((f, idx) => <LogFilesItem key={idx} file={f} />)}
</ol>
</section>
) : (
<EmptySimple
title="No old log files"
subtitle=""
/>
)}
</div>
);
};
interface LogFilesItemProps {
file: LogFile;
}
const Dots = () => {
const [step, setStep] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
setStep((prevStep) => (prevStep % 3) + 1);
}, 300);
return () => clearInterval(interval);
}, []);
return (
<div className="flex">
<div
className={`h-2 w-2 bg-blue-500 rounded-full mx-1 ${
step === 1 ? "opacity-100" : "opacity-30"
}`}
/>
<div
className={`h-2 w-2 bg-blue-500 rounded-full mx-1 ${
step === 2 ? "opacity-100" : "opacity-30"
}`}
/>
<div
className={`h-2 w-2 bg-blue-500 rounded-full mx-1 ${
step === 3 ? "opacity-100" : "opacity-30"
}`}
/>
</div>
);
};
const LogFilesItem = ({ file }: LogFilesItemProps) => {
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
setIsDownloading(true);
const response = await fetch(`${baseUrl()}api/logs/files/${file.filename}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.filename;
link.click();
URL.revokeObjectURL(url);
setIsDownloading(false);
};
return (
<li className="text-gray-500 dark:text-gray-400">
<div className="sm:grid grid-cols-12 gap-4 items-center py-2">
<div className="col-span-5 px-2 py-2 sm:py-0 truncate block sm:text-sm text-md font-medium text-gray-900 dark:text-gray-200">
<div className="flex justify-between">
{file.filename}
</div>
</div>
<div className="col-span-2 flex items-center text-sm font-medium text-gray-900 dark:text-gray-200">
{file.size}
</div>
<div className="col-span-4 flex items-center text-sm font-medium text-gray-900 dark:text-gray-200" title={file.updated_at}>
{simplifyDate(file.updated_at)}
</div>
<div className="col-span-1 hidden sm:flex items-center justify-center text-sm font-medium text-gray-900 dark:text-white">
<div className="logFilesItem">
<button
className={classNames(
"text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center px-2 py-2 text-sm"
)}
title="Download file"
onClick={handleDownload}
>
{!isDownloading ? (
<DocumentArrowDownIcon
className="text-blue-500 w-5 h-5 iconHeight"
aria-hidden="true"
/>
) : (
<div className="h-5 flex items-center">
<span className="sanitizing-text">Sanitizing log</span>
<Dots />
</div>
)}
</button>
</div>
</div>
</div>
</li>
);
};
// interface LogsDropdownProps {}
const LogsDropdown = () => {
const [settings, setSettings] = SettingsContext.use();
const onSetValue = (
key: "scrollOnNewLog" | "indentLogLines" | "hideWrappedText",
newValue: boolean
) => setSettings((prevState) => ({
...prevState,
[key]: newValue
}));
return (
<Menu as="div">
<Menu.Button className="px-4 py-2">
<Cog6ToothIcon
className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 mt-1 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
>
<div className="p-3">
<Menu.Item>
{({ active }) => (
<Checkbox
label="Scroll to bottom on new message"
value={settings.scrollOnNewLog}
setValue={(newValue) => onSetValue("scrollOnNewLog", newValue)}
/>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Checkbox
label="Indent log lines"
description="Indent each log line according to their respective starting position."
value={settings.indentLogLines}
setValue={(newValue) => onSetValue("indentLogLines", newValue)}
/>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Checkbox
label="Hide wrapped text"
description="Hides text that is meant to be wrapped."
value={settings.hideWrappedText}
setValue={(newValue) => onSetValue("hideWrappedText", newValue)}
/>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
};