mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-01 05:47:48 +02:00
feat: add toggle for log timestamps (#2471)
This commit is contained in:
@@ -866,9 +866,10 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
|
||||
// getLogs fetches the logs for a container
|
||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||
query := url.Values{
|
||||
"stdout": []string{"1"},
|
||||
"stderr": []string{"1"},
|
||||
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||
"timestamps": []string{"1"},
|
||||
"stdout": []string{"1"},
|
||||
"stderr": []string{"1"},
|
||||
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||
}
|
||||
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,7 +32,8 @@ import { Separator } from "../ui/separator"
|
||||
import { $router, Link } from "../router"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
|
||||
import { LogsDisplay, LogsFullscreenDialog, LogsTimestampToggle } from "@/components/logs-display"
|
||||
import { getLogTimestampDecorations } from "@/lib/logs"
|
||||
|
||||
const syntaxTheme = "github-dark-dimmed"
|
||||
|
||||
@@ -282,7 +283,13 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
||||
container: container.id,
|
||||
}),
|
||||
])
|
||||
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
||||
return logsHtml.logs
|
||||
? highlighter.codeToHtml(logsHtml.logs, {
|
||||
lang: "log",
|
||||
theme: syntaxTheme,
|
||||
decorations: getLogTimestampDecorations(logsHtml.logs),
|
||||
})
|
||||
: t`No results.`
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ""
|
||||
@@ -318,6 +325,8 @@ function ContainerSheet({
|
||||
activeContainer: RefObject<ContainerRecord | null>
|
||||
}) {
|
||||
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
||||
const [showTimestamps, setShowTimestamps] = useState(false)
|
||||
const toggleTimestamps = () => setShowTimestamps((shown) => !shown)
|
||||
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
||||
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
||||
@@ -376,6 +385,8 @@ function ContainerSheet({
|
||||
name={container.name}
|
||||
onRefresh={refreshLogs}
|
||||
isRefreshing={isRefreshingLogs}
|
||||
showTimestamps={showTimestamps}
|
||||
onToggleTimestamps={toggleTimestamps}
|
||||
/>
|
||||
<InfoFullscreenDialog
|
||||
open={infoFullscreenOpen}
|
||||
@@ -410,11 +421,12 @@ function ContainerSheet({
|
||||
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
|
||||
<div className="flex items-center w-full">
|
||||
<h3>{t`Logs`}</h3>
|
||||
<LogsTimestampToggle showTimestamps={showTimestamps} onToggle={toggleTimestamps} className="ms-auto" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refreshLogs}
|
||||
className="h-8 w-8 p-0 ms-auto"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isRefreshingLogs}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
@@ -425,7 +437,7 @@ function ContainerSheet({
|
||||
<MaximizeIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} />
|
||||
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} showTimestamps={showTimestamps} />
|
||||
<div className="flex items-center w-full">
|
||||
<h3>{t`Detail`}</h3>
|
||||
<Button
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react"
|
||||
import { type RefObject, useEffect, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type LogsDisplayProps = {
|
||||
logsDisplay: string
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
showTimestamps?: boolean
|
||||
}
|
||||
|
||||
export function LogsTimestampToggle({
|
||||
showTimestamps,
|
||||
onToggle,
|
||||
className,
|
||||
}: {
|
||||
showTimestamps: boolean
|
||||
onToggle: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className={cn("h-8 w-8 p-0", showTimestamps && "bg-accent text-accent-foreground", className)}
|
||||
aria-label={t`Show timestamps`}
|
||||
aria-pressed={showTimestamps}
|
||||
title={showTimestamps ? t`Hide timestamps` : t`Show timestamps`}
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Shared by Docker and systemd service sheets so logs behave identically.
|
||||
export function LogsDisplay({ logsDisplay, containerRef }: LogsDisplayProps) {
|
||||
export function LogsDisplay({ logsDisplay, containerRef, showTimestamps = true }: LogsDisplayProps) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
||||
!showTimestamps && "[&_.log-timestamp]:hidden",
|
||||
!logsDisplay && ["animate-pulse", "h-full"]
|
||||
)}
|
||||
>
|
||||
@@ -32,6 +59,8 @@ export function LogsFullscreenDialog({
|
||||
name,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
showTimestamps,
|
||||
onToggleTimestamps,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -39,6 +68,8 @@ export function LogsFullscreenDialog({
|
||||
name: string
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing: boolean
|
||||
showTimestamps: boolean
|
||||
onToggleTimestamps: () => void
|
||||
}) {
|
||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -57,10 +88,20 @@ export function LogsFullscreenDialog({
|
||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||
<DialogTitle className="sr-only">{name} logs</DialogTitle>
|
||||
<div ref={outerContainerRef} className="absolute inset-x-0 bottom-0 top-12 overflow-auto">
|
||||
<div className="min-h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm",
|
||||
!showTimestamps && "[&_.log-timestamp]:hidden"
|
||||
)}
|
||||
>
|
||||
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||
</div>
|
||||
</div>
|
||||
<LogsTimestampToggle
|
||||
showTimestamps={showTimestamps}
|
||||
onToggle={onToggleTimestamps}
|
||||
className="absolute end-18 top-2 hover:bg-white/10 hover:text-white aria-pressed:bg-white/15 aria-pressed:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)}
|
||||
|
||||
@@ -21,7 +21,8 @@ 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 { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
|
||||
import { LogsDisplay, LogsFullscreenDialog, LogsTimestampToggle } from "@/components/logs-display"
|
||||
import { getLogTimestampDecorations } from "@/lib/logs"
|
||||
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"
|
||||
@@ -41,7 +42,13 @@ async function getSystemdLogsHtml(systemId: string, serviceName: string): Promis
|
||||
query: { system: systemId, service: serviceName },
|
||||
}),
|
||||
])
|
||||
return logs ? highlighter.codeToHtml(logs, { lang: "log", theme: syntaxTheme }) : ""
|
||||
return logs
|
||||
? highlighter.codeToHtml(logs, {
|
||||
lang: "log",
|
||||
theme: syntaxTheme,
|
||||
decorations: getLogTimestampDecorations(logs),
|
||||
})
|
||||
: ""
|
||||
}
|
||||
|
||||
export default function SystemdTable({ systemId }: { systemId?: string }) {
|
||||
@@ -290,6 +297,8 @@ function SystemdSheet({
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logs, setLogs] = useState("")
|
||||
const [showTimestamps, setShowTimestamps] = useState(true)
|
||||
const toggleTimestamps = () => setShowTimestamps((shown) => !shown)
|
||||
const [logsStatus, setLogsStatus] = useState<"loading" | "ready" | "empty" | "error">("loading")
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false)
|
||||
@@ -511,6 +520,8 @@ function SystemdSheet({
|
||||
name={service.name}
|
||||
onRefresh={loadLogs}
|
||||
isRefreshing={isLoadingLogs}
|
||||
showTimestamps={showTimestamps}
|
||||
onToggleTimestamps={toggleTimestamps}
|
||||
/>
|
||||
<SheetContent className="w-full min-w-0 sm:max-w-220 p-6 overflow-y-auto">
|
||||
<SheetHeader className="p-0">
|
||||
@@ -526,11 +537,12 @@ function SystemdSheet({
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>Logs</Trans>
|
||||
</h3>
|
||||
<LogsTimestampToggle showTimestamps={showTimestamps} onToggle={toggleTimestamps} className="ms-auto" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadLogs}
|
||||
className="h-8 w-8 p-0 ms-auto"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isLoadingLogs}
|
||||
aria-label={t`Refresh`}
|
||||
>
|
||||
@@ -550,7 +562,7 @@ function SystemdSheet({
|
||||
</Button>
|
||||
</div>
|
||||
{logs ? (
|
||||
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} />
|
||||
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} showTimestamps={showTimestamps} />
|
||||
) : logsStatus === "loading" ? (
|
||||
<>
|
||||
<div className="h-28" aria-busy="true">
|
||||
|
||||
13
internal/site/src/lib/logs.ts
Normal file
13
internal/site/src/lib/logs.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { DecorationItem } from "shiki/core"
|
||||
|
||||
// Docker uses RFC3339Nano; journalctl short-iso uses an offset such as +0200.
|
||||
// Only mark the first timestamp, leaving dates in the log message untouched.
|
||||
export function getLogTimestampDecorations(logs: string): DecorationItem[] {
|
||||
const timestamps = logs.matchAll(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}) /gm)
|
||||
return Array.from(timestamps, (match) => ({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
properties: { class: "log-timestamp" },
|
||||
alwaysWrap: true,
|
||||
}))
|
||||
}
|
||||
@@ -2354,3 +2354,11 @@ msgstr "Yes"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Your user settings have been updated."
|
||||
|
||||
#: src/components/logs-display.tsx
|
||||
msgid "Show timestamps"
|
||||
msgstr "Show timestamps"
|
||||
|
||||
#: src/components/logs-display.tsx
|
||||
msgid "Hide timestamps"
|
||||
msgstr "Hide timestamps"
|
||||
|
||||
@@ -2360,3 +2360,10 @@ msgstr "Ja"
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Je gebruikersinstellingen zijn bijgewerkt."
|
||||
|
||||
#: src/components/logs-display.tsx
|
||||
msgid "Show timestamps"
|
||||
msgstr "Tijdstempels tonen"
|
||||
|
||||
#: src/components/logs-display.tsx
|
||||
msgid "Hide timestamps"
|
||||
msgstr "Tijdstempels verbergen"
|
||||
|
||||
Reference in New Issue
Block a user