feat: add real time logger page (#276)
* feat: add real time logger * feat: add subscription for logging * feat: use timestamp and level in xterm, migrate to new xterm package * feat: improve design on log page * fit: remove xterm fit addon * fix: dispose terminal correctly * style: format code * refactor: add jsdoc for redis-transport * fix: redis connection not possible sometimes * feat: make terminal full size * fix: deepsource issues * fix: lint issue --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -44,8 +44,11 @@
|
|||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
|
"@xterm/addon-canvas": "^0.6.0",
|
||||||
|
"@xterm/xterm": "^5.4.0",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"jotai": "^2.7.2",
|
"jotai": "^2.7.2",
|
||||||
"next": "^14.1.4",
|
"next": "^14.1.4",
|
||||||
"postcss-preset-mantine": "^1.13.0",
|
"postcss-preset-mantine": "^1.13.0",
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
"sass": "^1.72.0",
|
"sass": "^1.72.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"dotenv": "^16.4.5"
|
"xterm-addon-fit": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"dotenv-cli": "^7.4.1",
|
"dotenv-cli": "^7.4.1",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"dotenv-cli": "^7.4.1",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Box, LoadingOverlay, Stack } from "@homarr/ui";
|
|||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||||
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
import { useIsBoardReady, useRequiredBoard } from "./_context";
|
import { useIsBoardReady, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
let boardName: string | null = null;
|
let boardName: string | null = null;
|
||||||
@@ -58,7 +59,7 @@ export const ClientBoard = () => {
|
|||||||
visible={!isReady}
|
visible={!isReady}
|
||||||
transitionProps={{ duration: 500 }}
|
transitionProps={{ duration: 500 }}
|
||||||
loaderProps={{ size: "lg" }}
|
loaderProps={{ size: "lg" }}
|
||||||
h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))"
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
/>
|
/>
|
||||||
<Stack
|
<Stack
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconHome,
|
IconHome,
|
||||||
IconInfoSmall,
|
IconInfoSmall,
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
|
IconLogs,
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconTool,
|
IconTool,
|
||||||
@@ -61,6 +62,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.logs"),
|
||||||
|
icon: IconLogs,
|
||||||
|
href: "/manage/tools/logs",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
34
apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx
Normal file
34
apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { Box } from "@homarr/ui";
|
||||||
|
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getScopedI18n("management");
|
||||||
|
const metaTitle = `${t("metaTitle")} • Homarr`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function LogsManagementPage() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
|
p="md"
|
||||||
|
bg="black"
|
||||||
|
>
|
||||||
|
<ClientSideTerminalComponent />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.outerTerminal > div {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
66
apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx
Normal file
66
apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { Box } from "@homarr/ui";
|
||||||
|
|
||||||
|
import classes from "./terminal.module.css";
|
||||||
|
|
||||||
|
export default function TerminalComponent() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const terminalRef = useRef<Terminal>();
|
||||||
|
clientApi.log.subscribe.useSubscription(undefined, {
|
||||||
|
onData(data) {
|
||||||
|
terminalRef.current?.writeln(
|
||||||
|
`${data.timestamp} ${data.level} ${data.message}`,
|
||||||
|
);
|
||||||
|
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
// This makes sense as logging might cause an infinite loop
|
||||||
|
alert(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasAddon = new CanvasAddon();
|
||||||
|
|
||||||
|
terminalRef.current = new Terminal({
|
||||||
|
cursorBlink: false,
|
||||||
|
disableStdin: true,
|
||||||
|
convertEol: true,
|
||||||
|
});
|
||||||
|
terminalRef.current.open(ref.current);
|
||||||
|
terminalRef.current.loadAddon(canvasAddon);
|
||||||
|
|
||||||
|
// This is a hack to make sure the terminal is rendered before we try to fit it
|
||||||
|
// You can blame @Meierschlumpf for this
|
||||||
|
setTimeout(() => {
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminalRef.current?.loadAddon(fitAddon);
|
||||||
|
fitAddon.fit();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
terminalRef.current?.dispose();
|
||||||
|
canvasAddon.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
id="terminal"
|
||||||
|
className={classes.outerTerminal}
|
||||||
|
h="100%"
|
||||||
|
></Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/nextjs/src/constants.ts
Normal file
2
apps/nextjs/src/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const fullHeightWithoutHeaderAndFooter =
|
||||||
|
"calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
|
import { logRouter } from "./router/log";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
app: innerAppRouter,
|
app: innerAppRouter,
|
||||||
|
log: logRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
18
packages/api/src/router/log.ts
Normal file
18
packages/api/src/router/log.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
import type { LoggerMessage } from "@homarr/redis";
|
||||||
|
import { loggingChannel } from "@homarr/redis";
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const logRouter = createTRPCRouter({
|
||||||
|
subscribe: publicProcedure.subscription(() => {
|
||||||
|
return observable<LoggerMessage>((emit) => {
|
||||||
|
loggingChannel.subscribe((data) => {
|
||||||
|
emit.next(data);
|
||||||
|
});
|
||||||
|
logger.info("A tRPC client has connected to the logging procedure");
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ioredis": "5.3.2",
|
||||||
|
"superjson": "2.2.1",
|
||||||
"winston": "3.13.0"
|
"winston": "3.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import winston, { format, transports } from "winston";
|
import winston, { format, transports } from "winston";
|
||||||
|
|
||||||
|
import { RedisTransport } from "./redis-transport.mjs";
|
||||||
|
|
||||||
const logMessageFormat = format.printf(({ level, message, timestamp }) => {
|
const logMessageFormat = format.printf(({ level, message, timestamp }) => {
|
||||||
return `${timestamp} ${level}: ${message}`;
|
return `${timestamp} ${level}: ${message}`;
|
||||||
});
|
});
|
||||||
@@ -10,7 +12,7 @@ const logger = winston.createLogger({
|
|||||||
format.timestamp(),
|
format.timestamp(),
|
||||||
logMessageFormat,
|
logMessageFormat,
|
||||||
),
|
),
|
||||||
transports: [new transports.Console()],
|
transports: [new transports.Console(), new RedisTransport()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export { logger };
|
export { logger };
|
||||||
|
|||||||
44
packages/log/src/redis-transport.mjs
Normal file
44
packages/log/src/redis-transport.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import Transport from "winston-transport";
|
||||||
|
|
||||||
|
//
|
||||||
|
// Inherit from `winston-transport` so you can take advantage
|
||||||
|
// of the base functionality and `.exceptions.handle()`.
|
||||||
|
//
|
||||||
|
export class RedisTransport extends Transport {
|
||||||
|
/** @type {Redis} */
|
||||||
|
redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log the info to the Redis channel
|
||||||
|
* @param {{ message: string; timestamp: string; level: string; }} info
|
||||||
|
* @param {() => void} callback
|
||||||
|
*/
|
||||||
|
log(info, callback) {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit("logged", info);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.redis) {
|
||||||
|
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||||
|
this.redis = new Redis();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.redis
|
||||||
|
.publish(
|
||||||
|
"logging",
|
||||||
|
superjson.stringify({
|
||||||
|
message: info.message,
|
||||||
|
timestamp: info.timestamp,
|
||||||
|
level: info.level,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
callback();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,4 +36,12 @@ const createChannel = <TData>(name: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface LoggerMessage {
|
||||||
|
message: string;
|
||||||
|
level: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loggingChannel = createChannel<LoggerMessage>("logging");
|
||||||
|
|
||||||
export const exampleChannel = createChannel<{ message: string }>("example");
|
export const exampleChannel = createChannel<{ message: string }>("example");
|
||||||
|
|||||||
@@ -620,6 +620,7 @@ export default {
|
|||||||
label: "Tools",
|
label: "Tools",
|
||||||
items: {
|
items: {
|
||||||
docker: "Docker",
|
docker: "Docker",
|
||||||
|
logs: "Logs",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -228,6 +228,12 @@ importers:
|
|||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: next
|
specifier: next
|
||||||
version: 11.0.0-next-beta.289
|
version: 11.0.0-next-beta.289
|
||||||
|
'@xterm/addon-canvas':
|
||||||
|
specifier: ^0.6.0
|
||||||
|
version: 0.6.0(@xterm/xterm@5.4.0)
|
||||||
|
'@xterm/xterm':
|
||||||
|
specifier: ^5.4.0
|
||||||
|
version: 5.4.0
|
||||||
chroma-js:
|
chroma-js:
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2
|
version: 2.4.2
|
||||||
@@ -261,6 +267,9 @@ importers:
|
|||||||
use-deep-compare-effect:
|
use-deep-compare-effect:
|
||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1(react@18.2.0)
|
version: 1.8.1(react@18.2.0)
|
||||||
|
xterm-addon-fit:
|
||||||
|
specifier: ^0.8.0
|
||||||
|
version: 0.8.0(xterm@5.3.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -534,6 +543,12 @@ importers:
|
|||||||
|
|
||||||
packages/log:
|
packages/log:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
ioredis:
|
||||||
|
specifier: 5.3.2
|
||||||
|
version: 5.3.2
|
||||||
|
superjson:
|
||||||
|
specifier: 2.2.1
|
||||||
|
version: 2.2.1
|
||||||
winston:
|
winston:
|
||||||
specifier: 3.13.0
|
specifier: 3.13.0
|
||||||
version: 3.13.0
|
version: 3.13.0
|
||||||
@@ -4117,6 +4132,18 @@ packages:
|
|||||||
'@xtuc/long': 4.2.2
|
'@xtuc/long': 4.2.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@xterm/addon-canvas@0.6.0(@xterm/xterm@5.4.0):
|
||||||
|
resolution: {integrity: sha512-+nj2x595vItxfuAFxzXp46Izrh4EnEyS0Z60hX1iy6OFliP5OQu8Wu7n59m7m1vT6Q4nIWoN1WiH+VLAk4D9jQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^5.0.0
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 5.4.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@xterm/xterm@5.4.0:
|
||||||
|
resolution: {integrity: sha512-GlyzcZZ7LJjhFevthHtikhiDIl8lnTSgol6eTM4aoSNLcuXu3OEhnbqdCVIjtIil3jjabf3gDtb1S8FGahsuEw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@xtuc/ieee754@1.2.0:
|
/@xtuc/ieee754@1.2.0:
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -10751,6 +10778,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
/xterm-addon-fit@0.8.0(xterm@5.3.0):
|
||||||
|
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==}
|
||||||
|
peerDependencies:
|
||||||
|
xterm: ^5.0.0
|
||||||
|
dependencies:
|
||||||
|
xterm: 5.3.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/xterm@5.3.0:
|
||||||
|
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/y18n@5.0.8:
|
/y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|||||||
Reference in New Issue
Block a user