mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-02 06:17:47 +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
|
// getLogs fetches the logs for a container
|
||||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||||
query := url.Values{
|
query := url.Values{
|
||||||
"stdout": []string{"1"},
|
"timestamps": []string{"1"},
|
||||||
"stderr": []string{"1"},
|
"stdout": []string{"1"},
|
||||||
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
"stderr": []string{"1"},
|
||||||
|
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||||
}
|
}
|
||||||
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import { Separator } from "../ui/separator"
|
|||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
import { getPagePath } from "@nanostores/router"
|
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"
|
const syntaxTheme = "github-dark-dimmed"
|
||||||
|
|
||||||
@@ -282,7 +283,13 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
|||||||
container: container.id,
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return ""
|
return ""
|
||||||
@@ -318,6 +325,8 @@ function ContainerSheet({
|
|||||||
activeContainer: RefObject<ContainerRecord | null>
|
activeContainer: RefObject<ContainerRecord | null>
|
||||||
}) {
|
}) {
|
||||||
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
||||||
|
const [showTimestamps, setShowTimestamps] = useState(false)
|
||||||
|
const toggleTimestamps = () => setShowTimestamps((shown) => !shown)
|
||||||
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
||||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
||||||
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
||||||
@@ -376,6 +385,8 @@ function ContainerSheet({
|
|||||||
name={container.name}
|
name={container.name}
|
||||||
onRefresh={refreshLogs}
|
onRefresh={refreshLogs}
|
||||||
isRefreshing={isRefreshingLogs}
|
isRefreshing={isRefreshingLogs}
|
||||||
|
showTimestamps={showTimestamps}
|
||||||
|
onToggleTimestamps={toggleTimestamps}
|
||||||
/>
|
/>
|
||||||
<InfoFullscreenDialog
|
<InfoFullscreenDialog
|
||||||
open={infoFullscreenOpen}
|
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="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<h3>{t`Logs`}</h3>
|
<h3>{t`Logs`}</h3>
|
||||||
|
<LogsTimestampToggle showTimestamps={showTimestamps} onToggle={toggleTimestamps} className="ms-auto" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={refreshLogs}
|
onClick={refreshLogs}
|
||||||
className="h-8 w-8 p-0 ms-auto"
|
className="h-8 w-8 p-0"
|
||||||
disabled={isRefreshingLogs}
|
disabled={isRefreshingLogs}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon
|
||||||
@@ -425,7 +437,7 @@ function ContainerSheet({
|
|||||||
<MaximizeIcon className="size-4" />
|
<MaximizeIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} />
|
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} showTimestamps={showTimestamps} />
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<h3>{t`Detail`}</h3>
|
<h3>{t`Detail`}</h3>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,22 +1,49 @@
|
|||||||
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */
|
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */
|
||||||
import { t } from "@lingui/core/macro"
|
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 { type RefObject, useEffect, useRef } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type LogsDisplayProps = {
|
type LogsDisplayProps = {
|
||||||
logsDisplay: string
|
logsDisplay: string
|
||||||
containerRef: RefObject<HTMLDivElement | null>
|
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.
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
"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"]
|
!logsDisplay && ["animate-pulse", "h-full"]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -32,6 +59,8 @@ export function LogsFullscreenDialog({
|
|||||||
name,
|
name,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
showTimestamps,
|
||||||
|
onToggleTimestamps,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -39,6 +68,8 @@ export function LogsFullscreenDialog({
|
|||||||
name: string
|
name: string
|
||||||
onRefresh: () => void | Promise<void>
|
onRefresh: () => void | Promise<void>
|
||||||
isRefreshing: boolean
|
isRefreshing: boolean
|
||||||
|
showTimestamps: boolean
|
||||||
|
onToggleTimestamps: () => void
|
||||||
}) {
|
}) {
|
||||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
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">
|
<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>
|
<DialogTitle className="sr-only">{name} logs</DialogTitle>
|
||||||
<div ref={outerContainerRef} className="absolute inset-x-0 bottom-0 top-12 overflow-auto">
|
<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 className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)}
|
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 { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
@@ -41,7 +42,13 @@ async function getSystemdLogsHtml(systemId: string, serviceName: string): Promis
|
|||||||
query: { system: systemId, service: serviceName },
|
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 }) {
|
export default function SystemdTable({ systemId }: { systemId?: string }) {
|
||||||
@@ -290,6 +297,8 @@ function SystemdSheet({
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [logs, setLogs] = useState("")
|
const [logs, setLogs] = useState("")
|
||||||
|
const [showTimestamps, setShowTimestamps] = useState(true)
|
||||||
|
const toggleTimestamps = () => setShowTimestamps((shown) => !shown)
|
||||||
const [logsStatus, setLogsStatus] = useState<"loading" | "ready" | "empty" | "error">("loading")
|
const [logsStatus, setLogsStatus] = useState<"loading" | "ready" | "empty" | "error">("loading")
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false)
|
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false)
|
||||||
@@ -511,6 +520,8 @@ function SystemdSheet({
|
|||||||
name={service.name}
|
name={service.name}
|
||||||
onRefresh={loadLogs}
|
onRefresh={loadLogs}
|
||||||
isRefreshing={isLoadingLogs}
|
isRefreshing={isLoadingLogs}
|
||||||
|
showTimestamps={showTimestamps}
|
||||||
|
onToggleTimestamps={toggleTimestamps}
|
||||||
/>
|
/>
|
||||||
<SheetContent className="w-full min-w-0 sm:max-w-220 p-6 overflow-y-auto">
|
<SheetContent className="w-full min-w-0 sm:max-w-220 p-6 overflow-y-auto">
|
||||||
<SheetHeader className="p-0">
|
<SheetHeader className="p-0">
|
||||||
@@ -526,11 +537,12 @@ function SystemdSheet({
|
|||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
<Trans>Logs</Trans>
|
<Trans>Logs</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
<LogsTimestampToggle showTimestamps={showTimestamps} onToggle={toggleTimestamps} className="ms-auto" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadLogs}
|
onClick={loadLogs}
|
||||||
className="h-8 w-8 p-0 ms-auto"
|
className="h-8 w-8 p-0"
|
||||||
disabled={isLoadingLogs}
|
disabled={isLoadingLogs}
|
||||||
aria-label={t`Refresh`}
|
aria-label={t`Refresh`}
|
||||||
>
|
>
|
||||||
@@ -550,7 +562,7 @@ function SystemdSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{logs ? (
|
{logs ? (
|
||||||
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} />
|
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} showTimestamps={showTimestamps} />
|
||||||
) : logsStatus === "loading" ? (
|
) : logsStatus === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-28" aria-busy="true">
|
<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
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "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."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Je gebruikersinstellingen zijn bijgewerkt."
|
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