feat: retain application log history
This commit is contained in:
1 parent
a2219b72f6
commit
ce61c87cd8
12 files changed
+396
-51
No files matched your search
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags:
|
||||
- Administration
|
||||
- Logs
|
||||
- Troubleshooting
|
||||
---
|
||||
|
||||
# Logs
|
||||
|
||||
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
|
||||
widget, integration, and server errors without opening the container logs separately.
|
||||
|
||||
## Opening the logs page
|
||||
|
||||
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
|
||||
**View logs** permission.
|
||||
|
||||
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
|
||||
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
|
||||
have already been removed from the retained history, the marker explains that they are no longer available.
|
||||
|
||||
## Retained and live logs
|
||||
|
||||
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
|
||||
live events without a gap. The history is bounded, so older events are removed automatically.
|
||||
|
||||
The history survives normal application restarts when the configured Redis instance persists its data. For complete
|
||||
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
|
||||
your deployment platform.
|
||||
|
||||
## Display controls
|
||||
|
||||
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
|
||||
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
interface ClientSideTerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic(
|
||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
) as ComponentType<ClientSideTerminalComponentProps>;
|
||||
@@ -29,12 +29,25 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage() {
|
||||
interface LogsManagementPageProps {
|
||||
searchParams: Promise<{ focus?: string }>;
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("other-view-logs")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { focus } = await searchParams;
|
||||
let focusTimestamp: number | undefined;
|
||||
if (focus !== undefined) {
|
||||
const parsedFocusTimestamp = Number(focus);
|
||||
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
|
||||
focusTimestamp = parsedFocusTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
|
||||
</Box>
|
||||
</LogContextProvider>
|
||||
);
|
||||
|
||||
@@ -7,25 +7,176 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useLogContext } from "./log-context";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
export const TerminalComponent = () => {
|
||||
const ALL_LOG_LEVELS = [...logLevels];
|
||||
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 4;
|
||||
|
||||
interface FocusMarker {
|
||||
index: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const getFocusMarker = (
|
||||
messages: LoggerMessage[],
|
||||
visibleMessages: LoggerMessage[],
|
||||
focusTimestamp: number | undefined,
|
||||
markerText: string,
|
||||
expiredMarkerText: string,
|
||||
): FocusMarker | null => {
|
||||
if (focusTimestamp === undefined) return null;
|
||||
|
||||
let oldestTimestamp = Number.POSITIVE_INFINITY;
|
||||
for (const message of messages) {
|
||||
oldestTimestamp = Math.min(oldestTimestamp, message.timestamp.getTime());
|
||||
}
|
||||
if (oldestTimestamp === Number.POSITIVE_INFINITY || focusTimestamp < oldestTimestamp) {
|
||||
return { index: 0, text: expiredMarkerText };
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
for (const [index, message] of visibleMessages.entries()) {
|
||||
if (message.timestamp.getTime() <= focusTimestamp) {
|
||||
markerIndex = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: markerIndex,
|
||||
text: markerText,
|
||||
};
|
||||
};
|
||||
|
||||
const findMarkerLine = (terminal: Terminal, markerText: string) => {
|
||||
const buffer = terminal.buffer.active;
|
||||
let logicalLine = "";
|
||||
let logicalLineStart = 0;
|
||||
|
||||
for (let index = 0; index < buffer.length; index++) {
|
||||
const line = buffer.getLine(index);
|
||||
if (!line) continue;
|
||||
|
||||
if (!line.isWrapped) {
|
||||
logicalLine = "";
|
||||
logicalLineStart = index;
|
||||
}
|
||||
logicalLine += line.translateToString(true);
|
||||
|
||||
if (logicalLine.includes(markerText)) {
|
||||
return logicalLineStart;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderMessages = ({
|
||||
terminal,
|
||||
messages,
|
||||
activeLevels,
|
||||
focusTimestamp,
|
||||
markerText,
|
||||
expiredMarkerText,
|
||||
}: {
|
||||
terminal: Terminal;
|
||||
messages: LoggerMessage[];
|
||||
activeLevels: LogLevel[];
|
||||
focusTimestamp: number | undefined;
|
||||
markerText: string;
|
||||
expiredMarkerText: string;
|
||||
}) => {
|
||||
const activeLevelSet = new Set(activeLevels);
|
||||
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
|
||||
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
|
||||
const output = visibleMessages.flatMap((message, index) => {
|
||||
if (focusMarker?.index === index) {
|
||||
return [`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`, message.message];
|
||||
}
|
||||
return [message.message];
|
||||
});
|
||||
|
||||
if (focusMarker?.index === visibleMessages.length) {
|
||||
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||
}
|
||||
|
||||
terminal.reset();
|
||||
if (output.length === 0) return;
|
||||
|
||||
terminal.write(`${output.join("\r\n")}\r\n`, () => {
|
||||
if (!focusMarker) {
|
||||
terminal.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const markerLine = findMarkerLine(terminal, focusMarker.text);
|
||||
if (markerLine !== null) {
|
||||
terminal.scrollToLine(markerLine);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface TerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
const t = useI18n();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
const messagesRef = useRef<LoggerMessage[]>([]);
|
||||
const initialFontSizeRef = useRef(fontSize);
|
||||
const activeLevelsRef = useRef(activeLevels);
|
||||
const markerTextRef = useRef(t("log.context.widgetError"));
|
||||
const expiredMarkerTextRef = useRef(t("log.context.expiredWidgetError"));
|
||||
const focusTimestampRef = useRef(focusTimestamp);
|
||||
|
||||
activeLevelsRef.current = activeLevels;
|
||||
markerTextRef.current = t("log.context.widgetError");
|
||||
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
|
||||
focusTimestampRef.current = focusTimestamp;
|
||||
|
||||
const redrawTerminal = () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
renderMessages({
|
||||
terminal,
|
||||
messages: messagesRef.current,
|
||||
activeLevels: activeLevelsRef.current,
|
||||
focusTimestamp: focusTimestampRef.current,
|
||||
markerText: markerTextRef.current,
|
||||
expiredMarkerText: expiredMarkerTextRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
levels: activeLevels,
|
||||
levels: ALL_LOG_LEVELS,
|
||||
},
|
||||
{
|
||||
onData(data) {
|
||||
terminalRef.current?.writeln(data.message);
|
||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||
onData(event) {
|
||||
if (event.type === "history") {
|
||||
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
|
||||
redrawTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
messagesRef.current.push(event.message);
|
||||
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
|
||||
messagesRef.current.shift();
|
||||
}
|
||||
|
||||
if (activeLevelsRef.current.includes(event.message.level)) {
|
||||
terminalRef.current?.writeln(event.message.message);
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
alert(err);
|
||||
@@ -44,22 +195,26 @@ export const TerminalComponent = () => {
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
fontSize: initialFontSizeRef.current,
|
||||
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitTimeout = window.setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
redrawTerminal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(fitTimeout);
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -69,5 +224,9 @@ export const TerminalComponent = () => {
|
||||
fitAddonRef.current?.fit();
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
redrawTerminal();
|
||||
}, [activeLevels, focusTimestamp]);
|
||||
|
||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||
};
|
||||
Reference in new issue
Block a user