Compare commits

..
Author SHA1 Message Date
ajnart 5b41d169c0 fix: simplify settings layout 2026-08-20 12:21:35 +02:00
ajnart 62b3c0aa5e fix: rework settings section navigation 2026-08-20 12:18:55 +02:00
ajnart a07e6a2188 fix: align settings responsive breakpoint 2026-08-20 11:59:10 +02:00
ajnart c09f3e7501 feat: rework management settings layout 2026-08-20 11:48:13 +02:00
20 changed files with 82 additions and 498 deletions

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
---
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 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
@@ -29,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<SectionCard title={tBoard("title")}>
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
withinPortal
/>
<BoardSelect
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
withinPortal
/>
<Text fw={500}>{tBoard("status.title")}</Text>
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
label={tCulture("defaultLocale.label")}
{...localeInputProps}
value={localeInputProps.value as SupportedLanguage}
withinPortal
/>
</SectionCard>
);
@@ -2,7 +2,7 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<SectionCard title={tSearch("title")}>
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
interface SettingsFormProps {
initialData: ServerSettings;
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
notFound();
}
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
api.serverSettings.getAll(),
api.board.getPublicBoards(),
api.searchEngine.getSelectable({ withIntegrations: false }),
getScopedI18n("management.page.settings"),
]);
return (
<ManagePageLayout title={tSettings("title")}>
<SettingsForm initialData={serverSettings} />
<SettingsForm
initialData={serverSettings}
selectableBoards={selectableBoards}
selectableSearchEngines={selectableSearchEngines}
/>
</ManagePageLayout>
);
}
@@ -2,11 +2,7 @@
import dynamic from "next/dynamic";
interface ClientSideTerminalComponentProps {
focusTimestamp?: number;
}
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
export const ClientSideTerminalComponent = dynamic(
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
{
ssr: false,
@@ -29,25 +29,12 @@ export async function generateMetadata() {
};
}
interface LogsManagementPageProps {
searchParams: Promise<{ focus?: string | string[] }>;
}
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
export default async function LogsManagementPage() {
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 (
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
@@ -58,7 +45,7 @@ export default async function LogsManagementPage({ searchParams }: LogsManagemen
</Group>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
<ClientSideTerminalComponent />
</Box>
</LogContextProvider>
);
@@ -7,163 +7,25 @@ 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";
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) => {
export const TerminalComponent = () => {
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("");
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: ALL_LOG_LEVELS,
levels: activeLevels,
},
{
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);
}
onData(data) {
terminalRef.current?.writeln(data.message);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
alert(err);
@@ -182,26 +44,22 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: initialFontSizeRef.current,
scrollback: TERMINAL_SCROLLBACK_LINES,
fontSize,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
const fitTimeout = window.setTimeout(() => {
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;
};
}, []);
@@ -211,9 +69,5 @@ export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) =>
fitAddonRef.current?.fit();
}, [fontSize]);
useEffect(() => {
redrawTerminal();
}, [activeLevels, focusTimestamp]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
+7 -57
View File
@@ -11,10 +11,6 @@ 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")
@@ -24,61 +20,15 @@ export const logRouter = createTRPCRouter({
}),
)
.subscription(({ input }) => {
return observable<LogSubscriptionEvent>((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 observable<LoggerMessage>((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 () => {
isClosed = true;
unsubscribe?.();
unsubscribe();
};
});
}),
-79
View File
@@ -1,79 +0,0 @@
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<LoggerMessage[]>();
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();
});
});
@@ -1,20 +1,6 @@
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<typeof loggerMessageSchema>;
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: "🔴",
@@ -1,11 +1,8 @@
import { randomUUID } from "node:crypto";
import { stringify } from "superjson";
import superjson 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");
@@ -16,41 +13,32 @@ const levelSymbol = Symbol.for("level");
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Retain the log in a bounded Redis list and publish it to live subscribers.
* Log the info to the Redis channel
*/
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
const message: LoggerMessage = {
id: randomUUID(),
timestamp: new Date(),
message: info[messageSymbol],
level: info[levelSymbol],
};
const serializedMessage = stringify(message);
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
callback();
void Promise.resolve()
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
.then(() => {
// 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();
callback();
})
.catch(() => undefined);
.catch(() => {
// Ignore errors
});
}
}
+6 -38
View File
@@ -1,16 +1,6 @@
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 type { LogLevel } 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,
@@ -25,31 +15,9 @@ export const pingChannel = createSubPubChannel<
>("ping");
export const pingUrlChannel = createListChannel<string>("ping-url");
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
export interface LoggerMessage {
message: string;
level: LogLevel;
}
const loggingHistoryClient = createRedisConnection();
const parseLoggerMessage = (message: string): LoggerMessage | null => {
try {
const result = loggerMessageSchema.safeParse(parse<unknown>(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();
},
};
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
@@ -18,7 +18,6 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
*/
export class ChannelSubscriptionTracker {
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
private static subscriptionReadiness = new Map<string, Promise<void>>();
private static redis = createRedisConnection();
private static listenerActive = false;
@@ -29,25 +28,6 @@ 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
@@ -58,18 +38,11 @@ export class ChannelSubscriptionTracker {
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
const id = randomUUID();
let ready = this.subscriptionReadiness.get(channelName);
// Subscribe when this channel has no active or pending Redis subscription.
if (!ready) {
// If there are no subscriptions to the channel, subscribe to it
if (channelSubscriptions.size === 0) {
logger.debug("Subscribing to redis channel", { channel: 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);
}
});
void this.redis.subscribe(channelName);
}
logger.debug("Adding redis channel callback", { channel: channelName, id });
@@ -77,26 +50,24 @@ export class ChannelSubscriptionTracker {
this.subscriptions.set(channelName, channelSubscriptions);
const unsubscribe = () => {
// Return a function to unsubscribe
return () => {
logger.debug("Removing redis channel callback", { channel: channelName, id });
const currentSubscriptions = this.subscriptions.get(channelName);
if (!currentSubscriptions) return;
const channelSubscriptions = this.subscriptions.get(channelName);
if (!channelSubscriptions) return;
currentSubscriptions.delete(id);
channelSubscriptions.delete(id);
// If there are no subscriptions to the channel, unsubscribe from it
if (currentSubscriptions.size >= 1) {
if (channelSubscriptions.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 };
}
/**
-4
View File
@@ -8385,10 +8385,6 @@
}
},
"log": {
"context": {
"widgetError": "Widget reported an error around this time",
"expiredWidgetError": "Logs around this widget error are no longer retained"
},
"level": {
"option": {
"debug": "Debug",
+2 -12
View File
@@ -1,7 +1,5 @@
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";
@@ -17,22 +15,14 @@ export interface BaseWidgetErrorProps {
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
const { data: session } = useSession();
const [errorTimestamp] = useState(Date.now);
return (
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
{props.showLogsLink && session?.user.permissions.includes("other-view-logs") && (
<Anchor
component={Link}
href={`/manage/tools/logs?focus=${errorTimestamp}`}
target="_blank"
ta="center"
size="sm"
>
{props.showLogsLink && (
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
{t("common.action.checkLogs")}
</Anchor>
)}
@@ -11,10 +11,6 @@ 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,