diff --git a/apps/docs/docs/management/logs/index.mdx b/apps/docs/docs/management/logs/index.mdx new file mode 100644 index 000000000..1bf22f7a9 --- /dev/null +++ b/apps/docs/docs/management/logs/index.mdx @@ -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. diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/client.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/client.tsx index 304505d63..aa3c8da4c 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/client.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/client.tsx @@ -2,7 +2,11 @@ import dynamic from "next/dynamic"; -export const ClientSideTerminalComponent = dynamic( +interface ClientSideTerminalComponentProps { + focusTimestamp?: number; +} + +export const ClientSideTerminalComponent = dynamic( () => import("./terminal").then(({ TerminalComponent }) => TerminalComponent), { ssr: false, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index a981f2f57..e3bd966b3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -29,12 +29,25 @@ export async function generateMetadata() { }; } -export default async function LogsManagementPage() { +interface LogsManagementPageProps { + searchParams: Promise<{ focus?: string | 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).focus; + let focusTimestamp: number | undefined; + if (typeof focus === "string") { + const parsedFocusTimestamp = Number(focus); + if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) { + focusTimestamp = parsedFocusTimestamp; + } + } + return ( @@ -45,7 +58,7 @@ export default async function LogsManagementPage() { - + ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 20f50ffd9..6aacf8561 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -7,25 +7,163 @@ 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 * 20; + +const getFocusMarker = ( + messages: LoggerMessage[], + visibleMessages: LoggerMessage[], + focusTimestamp: number | undefined, + markerText: string, + expiredMarkerText: string, +) => { + if (focusTimestamp === undefined) return null; + + const oldestTimestamp = messages[0]?.timestamp.getTime(); + if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) { + return { index: 0, text: expiredMarkerText }; + } + + return { + index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1, + text: markerText, + }; +}; + +const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => { + const buffer = terminal.buffer.active; + const logicalLineStarts: number[] = []; + const cursorLine = buffer.baseY + buffer.cursorY; + + for (let index = 0; index <= cursorLine; index++) { + const line = buffer.getLine(index); + if (!line) continue; + + if (!line.isWrapped) { + logicalLineStarts.push(index); + } + } + + return logicalLineStarts.at(-linesFromEnd) ?? 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: string[] = []; + let markerOutputIndex: number | null = null; + + visibleMessages.forEach((message, index) => { + if (focusMarker?.index === index) { + markerOutputIndex = output.length; + output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`); + } + output.push(message.message); + }); + + if (focusMarker?.index === visibleMessages.length) { + markerOutputIndex = output.length; + output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`); + } + + let markerLinesFromEnd: number | null = null; + if (markerOutputIndex !== null) { + markerLinesFromEnd = + 1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0); + } + + terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => { + if (markerLinesFromEnd === null) { + terminal.scrollToBottom(); + return; + } + + const markerLine = findMarkerLine(terminal, markerLinesFromEnd); + if (markerLine !== null) { + terminal.scrollToLine(markerLine); + } + }); +}; + +interface TerminalComponentProps { + focusTimestamp?: number; +} + +export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => { const ref = useRef(null); const { activeLevels, fontSize } = useLogContext(); + const t = useI18n(); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const messagesRef = useRef([]); + const initialFontSizeRef = useRef(fontSize); + const activeLevelsRef = useRef(activeLevels); + const markerTextRef = useRef(""); + const expiredMarkerTextRef = useRef(""); + 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 +182,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 +211,9 @@ export const TerminalComponent = () => { fitAddonRef.current?.fit(); }, [fontSize]); + useEffect(() => { + redrawTerminal(); + }, [activeLevels, focusTimestamp]); + return ; }; diff --git a/packages/api/src/router/log.ts b/packages/api/src/router/log.ts index 10d44d0ab..8b6ef550d 100644 --- a/packages/api/src/router/log.ts +++ b/packages/api/src/router/log.ts @@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc"; const logger = createLogger({ module: "logRouter" }); +type LogSubscriptionEvent = + | { type: "history"; messages: LoggerMessage[] } + | { type: "message"; message: LoggerMessage }; + export const logRouter = createTRPCRouter({ subscribe: permissionRequiredProcedure .requiresPermission("other-view-logs") @@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({ }), ) .subscription(({ input }) => { - return observable((emit) => { - const unsubscribe = loggingChannel.subscribe((data) => { - if (!input.levels.includes(data.level)) return; - emit.next(data); - }); - logger.info("A tRPC client has connected to the logging procedure"); + return observable((emit) => { + let isClosed = false; + let unsubscribe: (() => void) | null = null; + let isHistoryLoaded = false; + const bufferedMessages: LoggerMessage[] = []; + + const onMessage = (message: LoggerMessage) => { + if (!input.levels.includes(message.level)) return; + + if (!isHistoryLoaded) { + bufferedMessages.push(message); + return; + } + + emit.next({ type: "message", message }); + }; + + void loggingChannel + .subscribeAsync(onMessage) + .then(async (unsubscribeFromLogging) => { + if (isClosed) { + unsubscribeFromLogging(); + return; + } + + unsubscribe = unsubscribeFromLogging; + logger.info("A tRPC client has connected to the logging procedure"); + + const history = (await loggingChannel.getRecentAsync()).filter((message) => + input.levels.includes(message.level), + ); + if (isClosed) return; + + emit.next({ type: "history", messages: history }); + + const historyIds = new Set(history.map((message) => message.id)); + isHistoryLoaded = true; + for (const message of bufferedMessages) { + if (!historyIds.has(message.id)) { + emit.next({ type: "message", message }); + } + } + bufferedMessages.length = 0; + }) + .catch((error: unknown) => { + unsubscribe?.(); + unsubscribe = null; + if (!isClosed) { + emit.error(error); + } + }); return () => { - unsubscribe(); + isClosed = true; + unsubscribe?.(); }; }); }), diff --git a/packages/api/src/router/test/log.spec.ts b/packages/api/src/router/test/log.spec.ts new file mode 100644 index 000000000..9b9d3f6b1 --- /dev/null +++ b/packages/api/src/router/test/log.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import type { LoggerMessage } from "@homarr/redis"; + +import { logRouter } from "../log"; + +const mocks = vi.hoisted(() => ({ + getRecentAsync: vi.fn(), + subscribeAsync: vi.fn(), +})); + +vi.mock("@homarr/redis", () => ({ + loggingChannel: { + getRecentAsync: mocks.getRecentAsync, + subscribeAsync: mocks.subscribeAsync, + }, +})); + +const session = { + user: { + id: "log-viewer", + permissions: ["other-view-logs"], + colorScheme: "light", + }, + expires: new Date(0).toISOString(), +} satisfies Session; + +const createMessage = (id: string, timestamp: number): LoggerMessage => ({ + id, + timestamp: new Date(timestamp), + level: "info", + message: id, +}); + +describe("log.subscribe", () => { + test("emits history before buffered live messages without duplicates", async () => { + const historyDeferred = Promise.withResolvers(); + let onMessage: ((message: LoggerMessage) => void) | undefined; + const unsubscribe = vi.fn(); + + mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => { + onMessage = callback; + return unsubscribe; + }); + mocks.getRecentAsync.mockReturnValue(historyDeferred.promise); + + const caller = logRouter.createCaller({ + db: null as never, + deviceType: undefined, + session, + }); + const subscription = await caller.subscribe({ levels: ["info"] }); + const events: object[] = []; + const observer = subscription.subscribe({ + next(event) { + events.push(event); + }, + }); + + await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce()); + + const retainedMessage = createMessage("retained", 1); + const liveMessage = createMessage("live", 2); + onMessage?.(retainedMessage); + onMessage?.(liveMessage); + historyDeferred.resolve([retainedMessage]); + + await vi.waitFor(() => + expect(events).toEqual([ + { type: "history", messages: [retainedMessage] }, + { type: "message", message: liveMessage }, + ]), + ); + + observer.unsubscribe(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/infrastructure/logs/constants.ts b/packages/core/src/infrastructure/logs/constants.ts index 763d682d9..8edd63f00 100644 --- a/packages/core/src/infrastructure/logs/constants.ts +++ b/packages/core/src/infrastructure/logs/constants.ts @@ -1,6 +1,20 @@ +import z from "zod/v4"; + export const logLevels = ["error", "warn", "info", "debug"] as const; export type LogLevel = (typeof logLevels)[number]; +export const loggerMessageSchema = z.object({ + id: z.string(), + timestamp: z.date(), + message: z.string(), + level: z.enum(logLevels), +}); +export type LoggerMessage = z.infer; + +export const LOG_HISTORY_MAX_ENTRIES = 5_000; +export const LOG_HISTORY_KEY = "list:logging:history"; +export const LOG_PUBLISH_CHANNEL = "pubSub:logging"; + export const logLevelConfiguration = { error: { prefix: "🔴", diff --git a/packages/core/src/infrastructure/logs/transports/redis-transport.ts b/packages/core/src/infrastructure/logs/transports/redis-transport.ts index e063f60dd..4e8f568a2 100644 --- a/packages/core/src/infrastructure/logs/transports/redis-transport.ts +++ b/packages/core/src/infrastructure/logs/transports/redis-transport.ts @@ -1,8 +1,11 @@ -import superjson from "superjson"; +import { randomUUID } from "node:crypto"; +import { stringify } from "superjson"; import Transport from "winston-transport"; import type { RedisClient } from "../../redis/client"; import { createRedisClient } from "../../redis/client"; +import type { LoggerMessage } from "../constants"; +import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants"; const messageSymbol = Symbol.for("message"); const levelSymbol = Symbol.for("level"); @@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level"); // export class RedisTransport extends Transport { private redis: RedisClient | null = null; - public static readonly publishChannel = "pubSub:logging"; /** - * Log the info to the Redis channel + * Retain the log in a bounded Redis list and publish it to live subscribers. */ - log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) { + log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) { setImmediate(() => { this.emit("logged", info); }); - // Is only initialized here because it did not work when initialized in the constructor or outside the class - this.redis ??= createRedisClient(); + const message: LoggerMessage = { + id: randomUUID(), + timestamp: new Date(), + message: info[messageSymbol], + level: info[levelSymbol], + }; + const serializedMessage = stringify(message); - this.redis - .publish( - RedisTransport.publishChannel, - superjson.stringify({ - message: info[messageSymbol], - level: info[levelSymbol], - }), - ) + callback(); + + void Promise.resolve() .then(() => { - callback(); + // Is only initialized here because it did not work when initialized in the constructor or outside the class + if (!this.redis) { + this.redis = createRedisClient(); + this.redis.options.maxRetriesPerRequest = 1; + this.redis.on("error", () => undefined); + } + + return this.redis + .multi() + .lpush(LOG_HISTORY_KEY, serializedMessage) + .ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1) + .publish(LOG_PUBLISH_CHANNEL, serializedMessage) + .exec(); }) - .catch(() => { - // Ignore errors - }); + .catch(() => undefined); } } diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 24f6046d5..857f87c4f 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,6 +1,16 @@ -import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; +import { parse } from "superjson"; + +import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants"; +import { + loggerMessageSchema, + LOG_HISTORY_KEY, + LOG_HISTORY_MAX_ENTRIES, + LOG_PUBLISH_CHANNEL, +} from "@homarr/core/infrastructure/logs/constants"; import { createListChannel, createSubPubChannel } from "./lib/channel"; +import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker"; +import { createRedisConnection } from "./lib/connection"; export { handshakeAsync, @@ -15,9 +25,31 @@ export const pingChannel = createSubPubChannel< >("ping"); export const pingUrlChannel = createListChannel("ping-url"); -export interface LoggerMessage { - message: string; - level: LogLevel; -} +export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants"; -export const loggingChannel = createSubPubChannel("logging"); +const loggingHistoryClient = createRedisConnection(); + +const parseLoggerMessage = (message: string): LoggerMessage | null => { + try { + const result = loggerMessageSchema.safeParse(parse(message)); + return result.success ? result.data : null; + } catch { + return null; + } +}; + +export const loggingChannel = { + subscribeAsync: (callback: (message: LoggerMessage) => void) => + ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => { + const parsedMessage = parseLoggerMessage(message); + if (parsedMessage) callback(parsedMessage); + }), + getRecentAsync: async () => { + const messages = + (await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? []; + return messages + .map(parseLoggerMessage) + .filter((message): message is LoggerMessage => message !== null) + .toReversed(); + }, +}; diff --git a/packages/redis/src/lib/channel-subscription-tracker.ts b/packages/redis/src/lib/channel-subscription-tracker.ts index 02adc6897..fb70633a2 100644 --- a/packages/redis/src/lib/channel-subscription-tracker.ts +++ b/packages/redis/src/lib/channel-subscription-tracker.ts @@ -18,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise; */ export class ChannelSubscriptionTracker { private static subscriptions = new Map>(); + private static subscriptionReadiness = new Map>(); private static redis = createRedisConnection(); private static listenerActive = false; @@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker { * @returns a function to unsubscribe from the channel */ public static subscribe(channelName: string, callback: SubscriptionCallback) { + return this.registerSubscription(channelName, callback).unsubscribe; + } + + /** + * Subscribes to a channel and resolves once Redis confirms the subscription. + */ + public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) { + const subscription = this.registerSubscription(channelName, callback); + + try { + await subscription.ready; + return subscription.unsubscribe; + } catch (error) { + subscription.unsubscribe(); + throw error; + } + } + + private static registerSubscription(channelName: string, callback: SubscriptionCallback) { logger.debug("Adding redis channel callback", { channel: channelName }); // We only want to activate the listener once @@ -38,11 +58,18 @@ export class ChannelSubscriptionTracker { const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map(); const id = randomUUID(); + let ready = this.subscriptionReadiness.get(channelName); - // If there are no subscriptions to the channel, subscribe to it - if (channelSubscriptions.size === 0) { + // Subscribe when this channel has no active or pending Redis subscription. + if (!ready) { logger.debug("Subscribing to redis channel", { channel: channelName }); - void this.redis.subscribe(channelName); + ready = this.redis.subscribe(channelName).then(() => undefined); + this.subscriptionReadiness.set(channelName, ready); + void ready.catch(() => { + if (this.subscriptionReadiness.get(channelName) === ready) { + this.subscriptionReadiness.delete(channelName); + } + }); } logger.debug("Adding redis channel callback", { channel: channelName, id }); @@ -50,24 +77,26 @@ export class ChannelSubscriptionTracker { this.subscriptions.set(channelName, channelSubscriptions); - // Return a function to unsubscribe - return () => { + const unsubscribe = () => { logger.debug("Removing redis channel callback", { channel: channelName, id }); - const channelSubscriptions = this.subscriptions.get(channelName); - if (!channelSubscriptions) return; + const currentSubscriptions = this.subscriptions.get(channelName); + if (!currentSubscriptions) return; - channelSubscriptions.delete(id); + currentSubscriptions.delete(id); // If there are no subscriptions to the channel, unsubscribe from it - if (channelSubscriptions.size >= 1) { + if (currentSubscriptions.size >= 1) { return; } logger.debug("Unsubscribing from redis channel", { channel: channelName }); void this.redis.unsubscribe(channelName); this.subscriptions.delete(channelName); + this.subscriptionReadiness.delete(channelName); }; + + return { ready, unsubscribe }; } /** diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 31c64fe46..cff0aa9b6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -8385,6 +8385,10 @@ } }, "log": { + "context": { + "widgetError": "Widget reported an error around this time", + "expiredWidgetError": "Logs around this widget error are no longer retained" + }, "level": { "option": { "debug": "Debug", diff --git a/packages/widgets/src/errors/base-component.tsx b/packages/widgets/src/errors/base-component.tsx index 69392923d..a42365f52 100644 --- a/packages/widgets/src/errors/base-component.tsx +++ b/packages/widgets/src/errors/base-component.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { Anchor, Button, Stack, Text } from "@mantine/core"; +import { useSession } from "@homarr/auth/client"; import type { stringOrTranslation } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; @@ -15,14 +17,22 @@ export interface BaseWidgetErrorProps { export const BaseWidgetError = (props: BaseWidgetErrorProps) => { const t = useI18n(); + const { data: session } = useSession(); + const [errorTimestamp] = useState(Date.now); return ( {translateIfNecessary(t, props.message)} - {props.showLogsLink && ( - + {props.showLogsLink && session?.user.permissions.includes("other-view-logs") && ( + {t("common.action.checkLogs")} )} diff --git a/packages/widgets/src/errors/component.spec.ts b/packages/widgets/src/errors/component.spec.ts index c1bc146c4..f03f55af5 100644 --- a/packages/widgets/src/errors/component.spec.ts +++ b/packages/widgets/src/errors/component.spec.ts @@ -11,6 +11,10 @@ vi.mock("@homarr/translation/client", () => ({ useI18n: () => (key: string) => key, })); +vi.mock("@homarr/auth/client", () => ({ + useSession: () => ({ data: null }), +})); + const matchMedia = (query: string) => ({ matches: false, media: query,