Desktop: button to show a modal with logs (#48)
* Add an endpoint to stream logs /stream_logs * Display logs in desktop app * UI component to stream logs
This commit is contained in:
parent
f7345ae6df
commit
9385524a1a
21 changed files with 521 additions and 125 deletions
181
crates/librqbit/webui/src/components/LogStream.tsx
Normal file
181
crates/librqbit/webui/src/components/LogStream.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
|
||||
interface LogStreamProps {
|
||||
httpApiBase: string;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
id: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const mergeBuffers = (a1: Uint8Array, a2: Uint8Array): Uint8Array => {
|
||||
const merged = new Uint8Array(a1.length + a2.length);
|
||||
merged.set(a1);
|
||||
merged.set(a2, a1.length);
|
||||
return merged;
|
||||
};
|
||||
|
||||
const streamLogs = (
|
||||
httpApiBase: string,
|
||||
addLine: (text: string) => void,
|
||||
setError: (error: ErrorWithLabel | null) => void
|
||||
): (() => void) => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
let canceled = true;
|
||||
|
||||
const cancel = () => {
|
||||
console.log("cancelling fetch");
|
||||
canceled = true;
|
||||
controller.abort();
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
let response = null;
|
||||
try {
|
||||
response = await fetch(httpApiBase + "/stream_logs", { signal });
|
||||
} catch (e: any) {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setError({
|
||||
text: "network error fetching logs",
|
||||
details: {
|
||||
text: e.toString(),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let text = await response.text();
|
||||
setError({
|
||||
text: "error fetching logs",
|
||||
details: {
|
||||
statusText: response.statusText,
|
||||
text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
setError({
|
||||
text: "error fetching logs: ReadableStream not supported.",
|
||||
});
|
||||
throw new Error("ReadableStream not supported.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
let buffer = new Uint8Array();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Handle stream completion or errors
|
||||
break;
|
||||
}
|
||||
|
||||
buffer = mergeBuffers(buffer, value);
|
||||
|
||||
while (true) {
|
||||
const newLineIdx = buffer.indexOf(10);
|
||||
if (newLineIdx === -1) {
|
||||
break;
|
||||
}
|
||||
let lineBytes = buffer.slice(0, newLineIdx);
|
||||
let line = new TextDecoder().decode(lineBytes);
|
||||
addLine(line);
|
||||
buffer = buffer.slice(newLineIdx + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
run();
|
||||
|
||||
return cancel;
|
||||
};
|
||||
|
||||
const SplitByLevelRegexp = new RegExp(
|
||||
/(.*?) +(INFO|WARN|TRACE|ERROR|DEBUG) +(.*)/
|
||||
);
|
||||
|
||||
const LogLine = ({ line }: { line: string }) => {
|
||||
line.split;
|
||||
const getClassNameByLevel = (level: string) => {
|
||||
switch (level) {
|
||||
case "INFO":
|
||||
return "text-success";
|
||||
case "WARN":
|
||||
return "text-warning";
|
||||
case "ERROR":
|
||||
return "text-danger";
|
||||
case "DEBUG":
|
||||
return "text-primary";
|
||||
default:
|
||||
return "text-secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const getContent = () => {
|
||||
let match = line.match(SplitByLevelRegexp);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
const [beforeLevel, level, afterLevel] = match.slice(1);
|
||||
return (
|
||||
<>
|
||||
{beforeLevel}
|
||||
<span className={`${getClassNameByLevel(level)} m-2`}>{level}</span>
|
||||
{afterLevel}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="font-monospace m-0" style={{ fontSize: "10px" }}>
|
||||
{getContent()}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogStream: React.FC<LogStreamProps> = ({
|
||||
httpApiBase,
|
||||
maxLines,
|
||||
}) => {
|
||||
const [logLines, setLogLines] = useState<Line[]>([]);
|
||||
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||
const maxL = maxLines ?? 1000;
|
||||
|
||||
const addLine = (text: string) => {
|
||||
setLogLines((logLines: Line[]) => {
|
||||
const nextLineId = logLines.length == 0 ? 0 : logLines[0].id + 1;
|
||||
|
||||
let newLogLines = [
|
||||
{
|
||||
id: nextLineId,
|
||||
content: text,
|
||||
},
|
||||
...logLines.slice(0, maxL - 1),
|
||||
];
|
||||
return newLogLines;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return streamLogs(httpApiBase, addLine, setError);
|
||||
}, [httpApiBase]);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<ErrorComponent error={error} />
|
||||
{logLines.map((line) => (
|
||||
<LogLine key={line.id} line={line.content} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
crates/librqbit/webui/src/components/LogStreamModal.tsx
Normal file
37
crates/librqbit/webui/src/components/LogStreamModal.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useContext } from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { APIContext } from "../context";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { LogStream } from "./LogStream";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LogStreamModal: React.FC<Props> = ({ show, onClose }) => {
|
||||
const api = useContext(APIContext);
|
||||
const apiBase = api.getHttpBaseUrl();
|
||||
|
||||
return (
|
||||
<Modal size="xl" show={show} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>rqbit server logs</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{apiBase ? (
|
||||
<LogStream httpApiBase={apiBase} />
|
||||
) : (
|
||||
<ErrorComponent
|
||||
error={{ text: "HTTP API not available to stream logs" }}
|
||||
></ErrorComponent>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import { Container } from "react-bootstrap";
|
||||
import { TorrentId, ErrorDetails as ApiErrorDetails } from "../api-types";
|
||||
import { AppContext } from "../context";
|
||||
import { APIContext, AppContext } from "../context";
|
||||
import { TorrentsList } from "./TorrentsList";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { Buttons } from "./Buttons";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue