From 7626e5e3c84806a67d9e10c8791a6aca9991d2ce Mon Sep 17 00:00:00 2001 From: spatiumstas <79056064+spatiumstas@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:57:36 +0300 Subject: [PATCH] feat: show systemd service logs in service details (#2440) Co-authored-by: hank --- agent/agent.go | 3 + agent/handlers.go | 28 ++++ agent/systemd.go | 119 +++++++++++++- agent/systemd_nonlinux.go | 5 + agent/systemd_test.go | 125 ++++++++++++++ internal/common/common-ws.go | 6 + internal/entities/system/system.go | 1 + internal/hub/api.go | 41 +++++ internal/hub/api_test.go | 19 +++ internal/hub/systems/system.go | 9 ++ internal/hub/transport/transport.go | 10 ++ internal/hub/transport/transport_test.go | 9 ++ .../containers-table/containers-table.tsx | 65 +------- internal/site/src/components/logs-display.tsx | 76 +++++++++ .../systemd-table/systemd-table.tsx | 153 ++++++++++++++++-- internal/site/src/components/ui/dialog.tsx | 10 +- internal/site/src/components/ui/sheet.tsx | 8 +- internal/site/src/types.d.ts | 2 + supplemental/scripts/install-agent.sh | 7 + 19 files changed, 618 insertions(+), 78 deletions(-) create mode 100644 internal/site/src/components/logs-display.tsx diff --git a/agent/agent.go b/agent/agent.go index a859ea723..8e5a75c1f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -151,6 +151,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { if err != nil { slog.Debug("Systemd", "err", err) } + if agent.systemdManager != nil { + agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled + } agent.smartManager, err = NewSmartManager() if err != nil { diff --git a/agent/handlers.go b/agent/handlers.go index 482e2f710..de6a1f982 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -53,6 +53,7 @@ func NewHandlerRegistry() *HandlerRegistry { registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) registry.Register(common.GetSmartData, &GetSmartDataHandler{}) registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{}) + registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{}) registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{}) registry.Register(common.GetZfsData, &GetZfsDataHandler{}) registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{}) @@ -245,6 +246,33 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error { //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// +// GetSystemdLogsHandler handles recent systemd service log requests. +type GetSystemdLogsHandler struct{} + +func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error { + if hctx.Agent.systemdManager == nil { + return errors.ErrUnsupported + } + + var req common.SystemdLogsRequest + if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { + return err + } + if req.ServiceName == "" { + return errors.New("service name is required") + } + + logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName) + if err != nil { + return err + } + + return hctx.SendResponse(logs, hctx.RequestID) +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + // SyncNetworkMonitorsHandler handles monitor configuration sync from hub type SyncNetworkMonitorsHandler struct{} diff --git a/agent/systemd.go b/agent/systemd.go index 25e20a226..d731ded84 100644 --- a/agent/systemd.go +++ b/agent/systemd.go @@ -3,12 +3,15 @@ package agent import ( + "bytes" "context" "errors" + "fmt" "log/slog" "maps" "math" "os" + "os/exec" "strconv" "strings" "sync" @@ -20,6 +23,50 @@ import ( ) var errNoActiveTime = errors.New("no active time") +var errSystemdLogLimitReached = errors.New("systemd log size limit reached") + +const systemdLogsTail = 200 + +// canReadSystemJournal probes whether the agent's current credentials can read +// the system journal. A successful empty result is still readable: entries may +// be written after the agent starts. +func canReadSystemJournal() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond) + defer cancel() + + _, err := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--lines", "1").Output() + return err == nil +} + +// systemdLogsEnabled reports whether service logs can be served to the hub. +func systemdLogsEnabled() bool { + if skip, _ := utils.GetEnv("SKIP_SYSTEMD_LOGS"); skip == "true" { + return false + } + return canReadSystemJournal() +} + +// limitedBuffer bounds command output before it is sent over the agent connection. +type limitedBuffer struct { + buffer bytes.Buffer + limit int +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + remaining := b.limit - b.buffer.Len() + if remaining <= 0 { + return 0, errSystemdLogLimitReached + } + if len(p) > remaining { + _, _ = b.buffer.Write(p[:remaining]) + return remaining, errSystemdLogLimitReached + } + return b.buffer.Write(p) +} + +func (b *limitedBuffer) String() string { + return b.buffer.String() +} // systemdManager manages the collection of systemd service statistics. type systemdManager struct { @@ -27,6 +74,7 @@ type systemdManager struct { serviceStatsMap map[string]*systemd.Service isRunning bool hasFreshStats bool + logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS patterns []string } @@ -68,6 +116,7 @@ func newSystemdManager() (*systemdManager, error) { manager := &systemdManager{ serviceStatsMap: make(map[string]*systemd.Service), + logsEnabled: systemdLogsEnabled(), patterns: getServicePatterns(), } @@ -232,6 +281,14 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat return service, nil } +// serviceUnitName preserves monitored timer units and defaults bare names to services. +func serviceUnitName(name string) string { + if strings.HasSuffix(name, ".service") || strings.HasSuffix(name, ".timer") { + return name + } + return name + ".service" +} + // getServiceDetails collects extended information for a specific systemd service. func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) { conn, err := dbus.NewSystemConnectionContext(context.Background()) @@ -240,10 +297,7 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service } defer conn.Close() - unitName := serviceName - if !strings.HasSuffix(unitName, ".service") { - unitName += ".service" - } + unitName := serviceUnitName(serviceName) ctx := context.Background() props, err := conn.GetUnitPropertiesContext(ctx, unitName) @@ -278,6 +332,63 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service return details, nil } +// monitoredUnitName resolves a service name to the unit name of a monitored +// service. Only monitored units are accepted so a request can't read other +// journal entries (journalctl --unit also accepts glob patterns). +func (sm *systemdManager) monitoredUnitName(serviceName string) (string, bool) { + sm.Lock() + defer sm.Unlock() + + unitName := serviceUnitName(serviceName) + if _, ok := sm.serviceStatsMap[unitName]; ok { + return unitName, true + } + // Service names are unescaped, so match against the stored name as well. + for unitName, service := range sm.serviceStatsMap { + if service.Name == serviceName { + return unitName, true + } + } + return "", false +} + +// getServiceLogs returns the newest journal entries for a monitored service. +// journalctl receives the unit name as an argument (rather than through a +// shell), so a service name can never alter the command being run. +func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) { + if !sm.logsEnabled { + return "", errors.New("systemd logs disabled") + } + unitName, ok := sm.monitoredUnitName(serviceName) + if !ok { + return "", fmt.Errorf("service %q is not monitored", serviceName) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond) + defer cancel() + + cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail)) + output := limitedBuffer{limit: maxTotalLogSize} + cmd.Stdout = &output + stderr := limitedBuffer{limit: 1024} + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if errors.Is(err, errSystemdLogLimitReached) { + return output.String(), nil + } + if ctx.Err() != nil { + return "", ctx.Err() + } + message := strings.TrimSpace(stderr.String()) + if message != "" { + return "", fmt.Errorf("journalctl failed: %s", message) + } + return "", fmt.Errorf("journalctl failed: %w", err) + } + + return output.String(), nil +} + // unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d func unescapeServiceName(name string) string { if !strings.Contains(name, "\\x") { diff --git a/agent/systemd_nonlinux.go b/agent/systemd_nonlinux.go index cd5861900..309d4b57f 100644 --- a/agent/systemd_nonlinux.go +++ b/agent/systemd_nonlinux.go @@ -11,6 +11,7 @@ import ( // systemdManager manages the collection of systemd service statistics. type systemdManager struct { hasFreshStats bool + logsEnabled bool } // newSystemdManager creates a new systemdManager. @@ -36,3 +37,7 @@ func (sm *systemdManager) getFailedServiceCount() uint16 { func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) { return nil, errors.New("systemd manager unavailable") } + +func (sm *systemdManager) getServiceLogs(string) (string, error) { + return "", errors.New("systemd manager unavailable") +} diff --git a/agent/systemd_test.go b/agent/systemd_test.go index 5810468b9..4947805a3 100644 --- a/agent/systemd_test.go +++ b/agent/systemd_test.go @@ -3,10 +3,14 @@ package agent import ( + "errors" "os" + "os/exec" + "path/filepath" "strings" "testing" + "github.com/henrygd/beszel/internal/entities/systemd" "github.com/stretchr/testify/assert" ) @@ -32,6 +36,127 @@ func TestUnescapeServiceName(t *testing.T) { } } +func TestLimitedBuffer(t *testing.T) { + buffer := limitedBuffer{limit: 5} + + n, err := buffer.Write([]byte("abcdef")) + assert.Equal(t, 5, n) + assert.ErrorIs(t, err, errSystemdLogLimitReached) + assert.Equal(t, "abcde", buffer.String()) + + n, err = buffer.Write([]byte("g")) + assert.Zero(t, n) + assert.True(t, errors.Is(err, errSystemdLogLimitReached)) +} + +func TestLimitedBufferCapsExecOutput(t *testing.T) { + buffer := limitedBuffer{limit: 5} + cmd := exec.Command("sh", "-c", "printf 'abcdef'") + cmd.Stdout = &buffer + + err := cmd.Run() + assert.ErrorIs(t, err, errSystemdLogLimitReached) + assert.Equal(t, "abcde", buffer.String()) +} + +func TestServiceUnitName(t *testing.T) { + tests := map[string]string{ + "nginx": "nginx.service", + "nginx.service": "nginx.service", + "backup.timer": "backup.timer", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + assert.Equal(t, want, serviceUnitName(input)) + }) + } +} + +func TestCanReadSystemJournal(t *testing.T) { + tests := []struct { + name string + script string + want bool + }{ + {"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true}, + {"empty", "#!/bin/sh\nexit 0\n", true}, + {"denied", "#!/bin/sh\nexit 1\n", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + assert.Equal(t, test.want, canReadSystemJournal()) + }) + } +} + +func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) { + // Fake journalctl prints the unit it was asked for + dir := t.TempDir() + script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n" + if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{ + "nginx.service": {Name: "nginx"}, + "backup.timer": {Name: "backup.timer"}, + "foo\\x2dbar.service": {Name: "foo-bar"}, + "getty@tty1.service": {Name: "getty@tty1"}, + }} + + tests := []struct { + name string + want string + }{ + {"nginx", "nginx.service"}, + {"nginx.service", "nginx.service"}, + {"backup.timer", "backup.timer"}, + {"foo-bar", "foo\\x2dbar.service"}, + {"getty@tty1", "getty@tty1.service"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + logs, err := sm.getServiceLogs(test.name) + assert.NoError(t, err) + assert.Equal(t, test.want, logs) + }) + } + + for _, name := range []string{"sshd", "*", "*.service", "nginx*"} { + t.Run("rejects "+name, func(t *testing.T) { + logs, err := sm.getServiceLogs(name) + assert.Error(t, err) + assert.Empty(t, logs) + }) + } + + t.Run("disabled", func(t *testing.T) { + sm.logsEnabled = false + logs, err := sm.getServiceLogs("nginx") + assert.Error(t, err) + assert.Empty(t, logs) + }) +} + +func TestSystemdLogsEnabled(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + assert.True(t, systemdLogsEnabled()) + + t.Setenv("SKIP_SYSTEMD_LOGS", "true") + assert.False(t, systemdLogsEnabled()) +} + func TestUnescapeServiceNameInvalid(t *testing.T) { // Test invalid escape sequences - should return original string invalidInputs := []string{ diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index 23b2fd572..d6584ccb9 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -28,6 +28,8 @@ const ( SyncNetworkMonitors // Request the list of pending package updates from agent GetPackageUpdates + // Request recent logs for a systemd service from the agent. + GetSystemdLogs // Add new actions here... ) @@ -85,3 +87,7 @@ type ContainerInfoRequest struct { type SystemdInfoRequest struct { ServiceName string `cbor:"0,keyasint"` } + +type SystemdLogsRequest struct { + ServiceName string `cbor:"0,keyasint"` +} diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 8b9c21d46..29bb9f675 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -194,6 +194,7 @@ type Info struct { RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name) PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown) WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces + SystemdLogs bool `json:"jl,omitempty" cbor:"27,keyasint,omitempty"` // agent can read the system journal } // Data that does not change during process lifetime and is not needed in All Systems table diff --git a/internal/hub/api.go b/internal/hub/api.go index 59da88f34..9d48960fd 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -15,6 +15,7 @@ import ( "github.com/blang/semver" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/alerts" + systementity "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/systems" @@ -202,6 +203,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole) // get systemd service details apiAuth.GET("/systemd/info", h.getSystemdInfo) + // get recent logs for a systemd service + apiAuth.GET("/systemd/logs", h.getSystemdLogs) // get pending package updates apiAuth.GET("/package-updates", h.getPackageUpdates) // /containers routes @@ -447,6 +450,44 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]any{"details": details}) } +// getSystemdLogs handles GET /api/beszel/systemd/logs requests. +func (h *Hub) getSystemdLogs(e *core.RequestEvent) error { + query := e.Request.URL.Query() + systemID := query.Get("system") + serviceName := query.Get("service") + + if systemID == "" || serviceName == "" { + return e.BadRequestError("Invalid system or service parameter", nil) + } + system, err := h.sm.GetSystem(systemID) + if err != nil || !system.HasUser(e.App, e.Auth) { + return e.NotFoundError("", nil) + } + // Only fetch logs for services that are currently monitored on this system. + _, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{ + "system": systemID, + "name": serviceName, + }) + if err != nil { + return e.NotFoundError("", err) + } + // Old agents and agents without journal access do not advertise this capability. + systemRecord, err := e.App.FindRecordById("systems", systemID) + if err != nil { + return e.NotFoundError("", err) + } + var info systementity.Info + if err := systemRecord.UnmarshalJSONField("info", &info); err != nil || !info.SystemdLogs { + return e.JSON(http.StatusOK, map[string]string{"logs": ""}) + } + + logs, err := system.FetchSystemdLogsFromAgent(serviceName) + if err != nil { + return e.InternalServerError("", err) + } + return e.JSON(http.StatusOK, map[string]string{"logs": logs}) +} + // getPackageUpdates handles GET /api/beszel/package-updates requests func (h *Hub) getPackageUpdates(e *core.RequestEvent) error { systemID := e.Request.URL.Query().Get("system") diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index 745d3abef..cbb08a12e 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -684,6 +684,25 @@ func TestApiRoutesAuthentication(t *testing.T) { }) }, }, + { + Name: "GET /systemd/logs - old agent without capability returns empty logs", + Method: http.MethodGet, + URL: fmt.Sprintf("/api/beszel/systemd/logs?system=%s&service=nginx.service", system.Id), + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{`"logs":""`}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + beszelTests.CreateRecord(app, "systemd_services", map[string]any{ + "system": system.Id, + "name": "nginx.service", + "state": 0, + "sub": 1, + }) + }, + }, // Auth Optional Routes - Should work without authentication { diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 1bd1662a7..349304e21 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -777,6 +777,15 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic return result, err } +// FetchSystemdLogsFromAgent fetches recent journal entries for a systemd service from the agent. +func (sys *System) FetchSystemdLogsFromAgent(serviceName string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var result string + err := sys.request(ctx, common.GetSystemdLogs, common.SystemdLogsRequest{ServiceName: serviceName}, &result) + return result, err +} + // FetchSmartDataFromAgent fetches SMART data from the agent. func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) diff --git a/internal/hub/transport/transport.go b/internal/hub/transport/transport.go index 8f99940f6..8bd8e6fdf 100644 --- a/internal/hub/transport/transport.go +++ b/internal/hub/transport/transport.go @@ -115,6 +115,16 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA } *d = resp.ServiceInfo return nil + case common.GetSystemdLogs: + d, ok := dest.(*string) + if !ok { + return fmt.Errorf("unexpected dest type for GetSystemdLogs: %T", dest) + } + if resp.String == nil { + return errors.New("no systemd logs in response") + } + *d = *resp.String + return nil } return fmt.Errorf("unsupported action: %d", action) } diff --git a/internal/hub/transport/transport_test.go b/internal/hub/transport/transport_test.go index 9ad417b50..12328fc8f 100644 --- a/internal/hub/transport/transport_test.go +++ b/internal/hub/transport/transport_test.go @@ -31,3 +31,12 @@ func TestUnmarshalSmartDataResponse(t *testing.T) { }) } } + +func TestUnmarshalSystemdLogsResponse(t *testing.T) { + logs := "2026-09-27T12:00:00+00:00 host nginx[1]: started" + response := common.AgentResponse{String: &logs} + + var result string + require.NoError(t, UnmarshalResponse(response, common.GetSystemdLogs, &result)) + assert.Equal(t, logs, result) +} diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx index 295479f29..68fe8cf88 100644 --- a/internal/site/src/components/containers-table/containers-table.tsx +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -32,6 +32,7 @@ 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" const syntaxTheme = "github-dark-dimmed" @@ -372,7 +373,7 @@ function ContainerSheet({ open={logsFullscreenOpen} onOpenChange={setLogsFullscreenOpen} logsDisplay={logsDisplay} - containerName={container.name} + name={container.name} onRefresh={refreshLogs} isRefreshing={isRefreshingLogs} /> @@ -424,15 +425,7 @@ function ContainerSheet({ -
-
-
+

{t`Detail`}

- - - ) -} - function InfoFullscreenDialog({ open, onOpenChange, diff --git a/internal/site/src/components/logs-display.tsx b/internal/site/src/components/logs-display.tsx new file mode 100644 index 000000000..6538e7d23 --- /dev/null +++ b/internal/site/src/components/logs-display.tsx @@ -0,0 +1,76 @@ +/** 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 { type RefObject, useEffect, useRef } from "react" +import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +type LogsDisplayProps = { + logsDisplay: string + containerRef: RefObject +} + +// Shared by Docker and systemd service sheets so logs behave identically. +export function LogsDisplay({ logsDisplay, containerRef }: LogsDisplayProps) { + return ( +
+
+
+ ) +} + +export function LogsFullscreenDialog({ + open, + onOpenChange, + logsDisplay, + name, + onRefresh, + isRefreshing, +}: { + open: boolean + onOpenChange: (open: boolean) => void + logsDisplay: string + name: string + onRefresh: () => void | Promise + isRefreshing: boolean +}) { + const outerContainerRef = useRef(null) + + useEffect(() => { + if (open && logsDisplay) { + setTimeout(() => { + if (outerContainerRef.current) { + outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight + } + }, 50) + } + }, [open, logsDisplay]) + + return ( + + + {name} logs +
+
+
+
+
+ + +
+ ) +} diff --git a/internal/site/src/components/systemd-table/systemd-table.tsx b/internal/site/src/components/systemd-table/systemd-table.tsx index 4ba1c21df..6dfd5764c 100644 --- a/internal/site/src/components/systemd-table/systemd-table.tsx +++ b/internal/site/src/components/systemd-table/systemd-table.tsx @@ -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 { + 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([]) @@ -199,8 +214,10 @@ const AllSystemdTable = memo(function AllSystemdTable({ const scrollRef = useRef(null) const activeService = useRef(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({
(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(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(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 ( - + + Service Details + {service.name} -
- {isLoading && ( -
- - Loading... +
+ {canReadLogs && ( +
+
+

+ Logs +

+ + +
+ {logs ? ( + + ) : logsStatus === "loading" ? ( + <> +
+ +
+ + Loading... + + + ) : ( + + {logsStatus === "error" ? Failed to load logs. : No logs found.} + + )} + {logs && logsStatus === "error" && ( + + Failed to load logs. + + )}
)} + {error && ( @@ -454,6 +581,12 @@ function SystemdSheet({ {error} )} + {isLoading && ( +
+ + Loading... +
+ )}
diff --git a/internal/site/src/components/ui/dialog.tsx b/internal/site/src/components/ui/dialog.tsx index c4e7bd075..0cf7b60e0 100644 --- a/internal/site/src/components/ui/dialog.tsx +++ b/internal/site/src/components/ui/dialog.tsx @@ -12,6 +12,8 @@ const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close +const dialogIconButtonClassName = "inline-flex size-6 items-center justify-center" + const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -42,7 +44,12 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close @@ -82,6 +89,7 @@ const DialogDescription = React.forwardRef< DialogDescription.displayName = DialogPrimitive.Description.displayName export { + dialogIconButtonClassName, Dialog, DialogPortal, DialogOverlay, diff --git a/internal/site/src/components/ui/sheet.tsx b/internal/site/src/components/ui/sheet.tsx index b14a88cb7..3869396f9 100644 --- a/internal/site/src/components/ui/sheet.tsx +++ b/internal/site/src/components/ui/sheet.tsx @@ -2,6 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" import type * as React from "react" +import { dialogIconButtonClassName } from "@/components/ui/dialog" import { cn } from "@/lib/utils" function Sheet({ ...props }: React.ComponentProps) { @@ -61,7 +62,12 @@ function SheetContent({ {...props} > {children} - + Close diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index e4cd94768..33adc3e9d 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -71,6 +71,8 @@ export interface SystemInfo { bb?: number /** agent version */ v: string + /** agent can read the system journal */ + jl?: boolean /** system is using podman */ p?: boolean /** highest gpu utilization */ diff --git a/supplemental/scripts/install-agent.sh b/supplemental/scripts/install-agent.sh index 6abbd669a..09e6b59d1 100755 --- a/supplemental/scripts/install-agent.sh +++ b/supplemental/scripts/install-agent.sh @@ -1389,6 +1389,13 @@ EOF [ "$HUB_URL_PROVIDED" = "true" ] && sed -i "s|^Environment=\"HUB_URL=.*\"|Environment=\"HUB_URL=$SED_HUB_URL\"|" /etc/systemd/system/beszel-agent.service fi + # Let the agent service (not the beszel user) read the system journal for service logs. + # Admins can opt out with a drop-in that sets an empty SupplementaryGroups=. + if getent group systemd-journal >/dev/null 2>&1 && ! grep -q '^SupplementaryGroups=' /etc/systemd/system/beszel-agent.service; then + echo "Granting the agent service read access to the systemd journal" + sed -i '/^User=beszel$/a SupplementaryGroups=systemd-journal' /etc/systemd/system/beszel-agent.service + fi + # Load and start the service printf "\nLoading and starting the agent service...\n" systemctl daemon-reload