feat: show systemd service logs in service details (#2440)

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
spatiumstas
2026-09-28 20:57:36 +03:00
committed by GitHub
parent ad215788ed
commit 7626e5e3c8
19 changed files with 618 additions and 78 deletions

View File

@@ -13,14 +13,16 @@ import {
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { LoaderCircleIcon } from "lucide-react"
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon } from "lucide-react"
import { listenKeys } from "nanostores"
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
@@ -29,6 +31,19 @@ import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
import { Separator } from "../ui/separator"
const syntaxTheme = "github-dark-dimmed"
async function getSystemdLogsHtml(systemId: string, serviceName: string): Promise<string> {
const [{ highlighter }, { logs }] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ logs: string }>("/api/beszel/systemd/logs", {
requestKey: null,
query: { system: systemId, service: serviceName },
}),
])
return logs ? highlighter.codeToHtml(logs, { lang: "log", theme: syntaxTheme }) : ""
}
export default function SystemdTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<SystemdRecord[]>([])
@@ -199,8 +214,10 @@ const AllSystemdTable = memo(function AllSystemdTable({
const scrollRef = useRef<HTMLDivElement>(null)
const activeService = useRef<SystemdRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetSession, setSheetSession] = useState(0)
const openSheet = (service: SystemdRecord) => {
activeService.current = service
setSheetSession((session) => session + 1)
setSheetOpen(true)
}
@@ -245,6 +262,7 @@ const AllSystemdTable = memo(function AllSystemdTable({
</table>
</div>
<SystemdSheet
key={sheetSession}
sheetOpen={sheetOpen}
setSheetOpen={setSheetOpen}
activeService={activeService}
@@ -266,9 +284,23 @@ function SystemdSheet({
systemId?: string
}) {
const service = activeService.current
const targetSystemId = systemId ?? service?.system
const canReadLogs = !!targetSystemId && !!$allSystemsById.get()[targetSystemId]?.info?.jl
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState("")
const [logsStatus, setLogsStatus] = useState<"loading" | "ready" | "empty" | "error">("loading")
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false)
const logsContainerRef = useRef<HTMLDivElement>(null)
const logsRequestId = useRef(0)
const scrollLogsToBottom = () => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
}
}
useEffect(() => {
if (!sheetOpen || !service) {
@@ -283,7 +315,7 @@ function SystemdSheet({
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
query: {
system: systemId,
system: targetSystemId,
service: service.name,
},
})
@@ -310,7 +342,46 @@ function SystemdSheet({
return () => {
cancelled = true
}
}, [sheetOpen, service, systemId])
}, [sheetOpen, service, targetSystemId])
const loadLogs = async () => {
if (!service || !targetSystemId || !canReadLogs) return
const requestId = ++logsRequestId.current
setLogsStatus(logs ? "ready" : "loading")
setIsLoadingLogs(true)
try {
const logs = await getSystemdLogsHtml(targetSystemId, service.name)
if (requestId !== logsRequestId.current) return
setLogs(logs)
setLogsStatus(logs ? "ready" : "empty")
} catch (err) {
if (requestId !== logsRequestId.current) return
console.error(err)
setLogsStatus("error")
} finally {
if (requestId === logsRequestId.current) setIsLoadingLogs(false)
}
}
useEffect(() => {
if (sheetOpen && canReadLogs) {
loadLogs()
}
return () => {
logsRequestId.current++
}
}, [sheetOpen, service, targetSystemId, canReadLogs])
useEffect(() => {
if (!sheetOpen) setLogsFullscreenOpen(false)
}, [sheetOpen])
useEffect(() => {
if (logs) {
setTimeout(scrollLogsToBottom, 20)
}
}, [logs])
if (!service) return null
@@ -433,19 +504,75 @@ function SystemdSheet({
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
<LogsFullscreenDialog
open={logsFullscreenOpen && !!logs}
onOpenChange={setLogsFullscreenOpen}
logsDisplay={logs}
name={service.name}
onRefresh={loadLogs}
isRefreshing={isLoadingLogs}
/>
<SheetContent className="w-full min-w-0 sm:max-w-220 p-6 overflow-y-auto">
<SheetHeader className="p-0">
<SheetTitle>
<Trans>Service Details</Trans>
</SheetTitle>
<SheetDescription className="sr-only">{service.name}</SheetDescription>
</SheetHeader>
<div className="grid gap-6">
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
<Trans>Loading...</Trans>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-6">
{canReadLogs && (
<div className="min-w-0">
<div className="flex items-center mb-3">
<h3 className="text-sm font-medium">
<Trans>Logs</Trans>
</h3>
<Button
variant="ghost"
size="sm"
onClick={loadLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isLoadingLogs}
aria-label={t`Refresh`}
>
<RefreshCwIcon
className={cn("size-4 transition-transform duration-300", isLoadingLogs && "animate-spin")}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
aria-label={t`Logs`}
disabled={!logs}
>
<MaximizeIcon className="size-4" />
</Button>
</div>
{logs ? (
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} />
) : logsStatus === "loading" ? (
<>
<div className="h-28" aria-busy="true">
<LogsDisplay logsDisplay="" containerRef={logsContainerRef} />
</div>
<output className="sr-only">
<Trans>Loading...</Trans>
</output>
</>
) : (
<output className="flex min-h-28 items-center justify-center rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
{logsStatus === "error" ? <Trans>Failed to load logs.</Trans> : <Trans>No logs found.</Trans>}
</output>
)}
{logs && logsStatus === "error" && (
<output className="mt-2 block text-sm text-destructive">
<Trans>Failed to load logs.</Trans>
</output>
)}
</div>
)}
{error && (
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
<AlertTitle>
@@ -454,6 +581,12 @@ function SystemdSheet({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
<Trans>Loading...</Trans>
</div>
)}
<div>
<div className="border rounded-md">