feat: add toggle for log timestamps (#2471)

This commit is contained in:
Jeroen De Wilde
2026-09-30 21:14:13 -04:00
committed by GitHub
parent 8a91a0b5ab
commit 2502dd9626
7 changed files with 108 additions and 14 deletions
@@ -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
+44 -3
View File
@@ -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
View 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,
}))
}
+8
View File
@@ -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"
+7
View File
@@ -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"