feat: retain application log history

This commit is contained in:
ajnart committed 2026-08-20 11:36:42 +02:00
1 parent a2219b72f6
commit ce61c87cd8
12 files changed
+396 -51

No files matched your search

+17 -6
View File
@@ -1,6 +1,10 @@
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { parse } from "superjson";
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES } from "@homarr/core/infrastructure/logs/constants";
import { createListChannel, createSubPubChannel } from "./lib/channel";
import { createRedisConnection } from "./lib/connection";
export {
handshakeAsync,
@@ -15,9 +19,16 @@ export const pingChannel = createSubPubChannel<
>("ping");
export const pingUrlChannel = createListChannel<string>("ping-url");
export interface LoggerMessage {
message: string;
level: LogLevel;
}
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
const liveLoggingChannel = createSubPubChannel<LoggerMessage>("logging", { persist: false });
const loggingHistoryClient = createRedisConnection();
export const loggingChannel = {
subscribe: liveLoggingChannel.subscribe,
subscribeAsync: liveLoggingChannel.subscribeAsync,
getRecentAsync: async () => {
const messages = await loggingHistoryClient.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1);
return messages.map((message) => parse<LoggerMessage>(message)).toReversed();
},
};
@@ -18,6 +18,7 @@ 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;
@@ -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,14 @@ export class ChannelSubscriptionTracker {
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
const id = randomUUID();
let ready = this.subscriptionReadiness.get(channelName) ?? Promise.resolve();
// If there are no subscriptions to the channel, subscribe to it
if (channelSubscriptions.size === 0) {
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(() => undefined);
}
logger.debug("Adding redis channel callback", { channel: channelName, id });
@@ -50,24 +73,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 };
}
/**
+20 -4
View File
@@ -1,4 +1,4 @@
import superjson from "superjson";
import superjson, { parse } from "superjson";
import { createId } from "@homarr/common";
@@ -8,6 +8,12 @@ import { createRedisConnection } from "./connection";
const publisher = createRedisConnection();
const lastDataClient = createRedisConnection();
const deserializeCallback =
<TData>(callback: (data: TData) => void) =>
(message: string) => {
callback(parse(message));
};
/**
* Creates a new pub/sub channel.
* @param name name of the channel
@@ -29,9 +35,19 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
}
});
}
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
callback(superjson.parse(message));
});
return ChannelSubscriptionTracker.subscribe(channelName, deserializeCallback(callback));
},
/**
* Subscribes to the channel and resolves once Redis confirms the subscription.
*/
subscribeAsync: async (callback: (data: TData) => void) => {
if (persist) {
const data = await lastDataClient.get(lastChannelName);
if (data) {
callback(superjson.parse(data));
}
}
return await ChannelSubscriptionTracker.subscribeAsync(channelName, deserializeCallback(callback));
},
/**
* Publish data to the channel with last data saved.