diff --git a/agent/client.go b/agent/client.go index ad095de9..c3f5a165 100644 --- a/agent/client.go +++ b/agent/client.go @@ -271,6 +271,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error { response.SystemData = v case *common.FingerprintResponse: response.Fingerprint = v + case string: + response.String = &v // case []byte: // response.RawBytes = v // case string: diff --git a/agent/docker.go b/agent/docker.go index 2949b4ea..53b6ab53 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -3,8 +3,11 @@ package agent import ( "bytes" "context" + "encoding/binary" "encoding/json" + "errors" "fmt" + "io" "log/slog" "net" "net/http" @@ -27,6 +30,8 @@ const ( maxNetworkSpeedBps uint64 = 5e9 // Maximum conceivable memory usage of a container (100TB) to detect bad memory stats maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024 + // Number of log lines to request when fetching container logs + dockerLogsTail = 200 ) type dockerManager struct { @@ -301,11 +306,46 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo stats.PrevReadTime = readTime } +func parseDockerStatus(status string) (string, container.DockerHealth) { + trimmed := strings.TrimSpace(status) + if trimmed == "" { + return "", container.DockerHealthNone + } + + // Remove "About " from status + trimmed = strings.Replace(trimmed, "About ", "", 1) + + openIdx := strings.LastIndex(trimmed, "(") + if openIdx == -1 || !strings.HasSuffix(trimmed, ")") { + return trimmed, container.DockerHealthNone + } + + statusText := strings.TrimSpace(trimmed[:openIdx]) + if statusText == "" { + statusText = trimmed + } + + healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))) + // Some Docker statuses include a "health:" prefix inside the parentheses. + // Strip it so it maps correctly to the known health states. + if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 { + prefix := strings.TrimSpace(healthText[:colonIdx]) + if prefix == "health" || prefix == "health status" { + healthText = strings.TrimSpace(healthText[colonIdx+1:]) + } + } + if health, ok := container.DockerHealthStrings[healthText]; ok { + return statusText, health + } + + return trimmed, container.DockerHealthNone +} + // Updates stats for individual container with cache-time-aware delta tracking func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error { name := ctr.Names[0][1:] - resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") + resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort)) if err != nil { return err } @@ -316,10 +356,16 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM // add empty values if they doesn't exist in map stats, initialized := dm.containerStatsMap[ctr.IdShort] if !initialized { - stats = &container.Stats{Name: name} + stats = &container.Stats{Name: name, Id: ctr.IdShort} dm.containerStatsMap[ctr.IdShort] = stats } + stats.Id = ctr.IdShort + + statusText, health := parseDockerStatus(ctr.Status) + stats.Status = statusText + stats.Health = health + // reset current stats stats.Cpu = 0 stats.Mem = 0 @@ -548,3 +594,103 @@ func getDockerHost() string { } return scheme + socks[0] } + +// getContainerInfo fetches the inspection data for a container +func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) (string, error) { + endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + + resp, err := dm.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(data), nil +} + +// getLogs fetches the logs for a container +func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { + endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + + resp, err := dm.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var builder strings.Builder + if err := decodeDockerLogStream(resp.Body, &builder); err != nil { + return "", err + } + + return builder.String(), nil +} + +func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error { + const headerSize = 8 + var header [headerSize]byte + buf := make([]byte, 0, dockerLogsTail*200) + + for { + if _, err := io.ReadFull(reader, header[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil + } + return err + } + + frameLen := binary.BigEndian.Uint32(header[4:]) + if frameLen == 0 { + continue + } + + buf = allocateBuffer(buf, int(frameLen)) + if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + if len(buf) > 0 { + builder.Write(buf[:min(int(frameLen), len(buf))]) + } + return nil + } + return err + } + builder.Write(buf[:frameLen]) + } +} + +func allocateBuffer(current []byte, needed int) []byte { + if cap(current) >= needed { + return current[:needed] + } + return make([]byte, needed) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/agent/docker_test.go b/agent/docker_test.go index 4ce58adf..c9895d8f 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -858,6 +858,54 @@ func TestDeltaTrackerCacheTimeIsolation(t *testing.T) { assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort)) } +func TestParseDockerStatus(t *testing.T) { + tests := []struct { + name string + input string + expectedStatus string + expectedHealth container.DockerHealth + }{ + { + name: "status with About an removed", + input: "Up About an hour (healthy)", + expectedStatus: "Up an hour", + expectedHealth: container.DockerHealthHealthy, + }, + { + name: "status without About an unchanged", + input: "Up 2 hours (healthy)", + expectedStatus: "Up 2 hours", + expectedHealth: container.DockerHealthHealthy, + }, + { + name: "status with About and no parentheses", + input: "Up About an hour", + expectedStatus: "Up an hour", + expectedHealth: container.DockerHealthNone, + }, + { + name: "status without parentheses", + input: "Created", + expectedStatus: "Created", + expectedHealth: container.DockerHealthNone, + }, + { + name: "empty status", + input: "", + expectedStatus: "", + expectedHealth: container.DockerHealthNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, health := parseDockerStatus(tt.input) + assert.Equal(t, tt.expectedStatus, status) + assert.Equal(t, tt.expectedHealth, health) + }) + } +} + func TestConstantsAndUtilityFunctions(t *testing.T) { // Test constants are properly defined assert.Equal(t, uint16(60000), defaultCacheTimeMs) diff --git a/agent/handlers.go b/agent/handlers.go index 0553af09..70e1eb9c 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" @@ -43,6 +44,8 @@ func NewHandlerRegistry() *HandlerRegistry { registry.Register(common.GetData, &GetDataHandler{}) registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{}) + registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{}) + registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) return registry } @@ -99,3 +102,53 @@ type CheckFingerprintHandler struct{} func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error { return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID) } + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// GetContainerLogsHandler handles container log requests +type GetContainerLogsHandler struct{} + +func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error { + if hctx.Agent.dockerManager == nil { + return hctx.SendResponse("", hctx.RequestID) + } + + var req common.ContainerLogsRequest + if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { + return err + } + + ctx := context.Background() + logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID) + if err != nil { + return err + } + + return hctx.SendResponse(logContent, hctx.RequestID) +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// GetContainerInfoHandler handles container info requests +type GetContainerInfoHandler struct{} + +func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error { + if hctx.Agent.dockerManager == nil { + return hctx.SendResponse("", hctx.RequestID) + } + + var req common.ContainerInfoRequest + if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { + return err + } + + ctx := context.Background() + info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID) + if err != nil { + return err + } + + return hctx.SendResponse(info, hctx.RequestID) +} diff --git a/agent/server.go b/agent/server.go index bd103431..29302acd 100644 --- a/agent/server.go +++ b/agent/server.go @@ -168,6 +168,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes switch v := data.(type) { case *system.CombinedData: response.SystemData = v + case string: + response.String = &v default: response.Error = fmt.Sprintf("unsupported response type: %T", data) } diff --git a/beszel.go b/beszel.go index 16423546..53c81615 100644 --- a/beszel.go +++ b/beszel.go @@ -6,7 +6,7 @@ import "github.com/blang/semver" const ( // Version is the current version of the application. - Version = "0.13.2" + Version = "0.14.0-alpha.2" // AppName is the name of the application. AppName = "beszel" ) diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index 886d42ed..9319616a 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -11,6 +11,10 @@ const ( GetData WebSocketAction = iota // Check the fingerprint of the agent CheckFingerprint + // Request container logs from agent + GetContainerLogs + // Request container info from agent + GetContainerInfo // Add new actions here... ) @@ -27,6 +31,8 @@ type AgentResponse struct { SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` Error string `cbor:"3,keyasint,omitempty,omitzero"` + String *string `cbor:"4,keyasint,omitempty,omitzero"` + // Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"` // RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"` } @@ -47,3 +53,11 @@ type DataRequestOptions struct { CacheTimeMs uint16 `cbor:"0,keyasint"` // ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"` } + +type ContainerLogsRequest struct { + ContainerID string `cbor:"0,keyasint"` +} + +type ContainerInfoRequest struct { + ContainerID string `cbor:"0,keyasint"` +} diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index c6b6c4bb..ae503d55 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -8,6 +8,7 @@ type ApiInfo struct { IdShort string Names []string Status string + State string // Image string // ImageID string // Command string @@ -16,7 +17,6 @@ type ApiInfo struct { // SizeRw int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"` // Labels map[string]string - // State string // HostConfig struct { // NetworkMode string `json:",omitempty"` // Annotations map[string]string `json:",omitempty"` @@ -103,6 +103,22 @@ type prevNetStats struct { Recv uint64 } +type DockerHealth = uint8 + +const ( + DockerHealthNone DockerHealth = iota + DockerHealthStarting + DockerHealthHealthy + DockerHealthUnhealthy +) + +var DockerHealthStrings = map[string]DockerHealth{ + "none": DockerHealthNone, + "starting": DockerHealthStarting, + "healthy": DockerHealthHealthy, + "unhealthy": DockerHealthUnhealthy, +} + // Docker container stats type Stats struct { Name string `json:"n" cbor:"0,keyasint"` @@ -110,6 +126,10 @@ type Stats struct { Mem float64 `json:"m" cbor:"2,keyasint"` NetworkSent float64 `json:"ns" cbor:"3,keyasint"` NetworkRecv float64 `json:"nr" cbor:"4,keyasint"` + + Health DockerHealth `json:"-" cbor:"5,keyasint"` + Status string `json:"-" cbor:"6,keyasint"` + Id string `json:"-" cbor:"7,keyasint"` // PrevCpu [2]uint64 `json:"-"` CpuSystem uint64 `json:"-"` CpuContainer uint64 `json:"-"` diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 890abae4..515d0ecb 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -236,7 +236,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // update / delete user alerts apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) - + // get container logs + apiAuth.GET("/containers/logs", h.getContainerLogs) + // get container info + apiAuth.GET("/containers/info", h.getContainerInfo) return nil } @@ -267,6 +270,41 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error { return e.JSON(http.StatusOK, response) } +// containerRequestHandler handles both container logs and info requests +func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error { + systemID := e.Request.URL.Query().Get("system") + containerID := e.Request.URL.Query().Get("container") + + if systemID == "" || containerID == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) + } + + system, err := h.sm.GetSystem(systemID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) + } + + data, err := fetchFunc(system, containerID) + if err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]string{responseKey: data}) +} + +// getContainerLogs handles GET /api/beszel/containers/logs requests +func (h *Hub) getContainerLogs(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerLogsFromAgent(containerID) + }, "logs") +} + +func (h *Hub) getContainerInfo(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerInfoFromAgent(containerID) + }, "info") +} + // generates key pair if it doesn't exist and returns signer func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { if h.signer != nil { diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 2b8762e5..7b880f7b 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -449,6 +449,47 @@ func TestApiRoutesAuthentication(t *testing.T) { }) }, }, + { + Name: "GET /containers/logs - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system&container=test-container", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing system param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?container=test-container", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"system parameter is required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing container param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"container parameter is required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but invalid system should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 404, + ExpectedContent: []string{"system not found"}, + TestAppFactory: testAppFactory, + }, // Auth Optional Routes - Should work without authentication { diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 00144706..253ef347 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -13,12 +13,14 @@ import ( "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/ws" + "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) @@ -135,41 +137,80 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return nil, err } hub := sys.manager.hub - // add system_stats and container_stats records - systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats") - if err != nil { - return nil, err - } - - systemStatsRecord := core.NewRecord(systemStatsCollection) - systemStatsRecord.Set("system", systemRecord.Id) - systemStatsRecord.Set("stats", data.Stats) - systemStatsRecord.Set("type", "1m") - if err := hub.SaveNoValidate(systemStatsRecord); err != nil { - return nil, err - } - // add new container_stats record - if len(data.Containers) > 0 { - containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats") + err = hub.RunInTransaction(func(txApp core.App) error { + // add system_stats and container_stats records + systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats") if err != nil { - return nil, err + return err } - containerStatsRecord := core.NewRecord(containerStatsCollection) - containerStatsRecord.Set("system", systemRecord.Id) - containerStatsRecord.Set("stats", data.Containers) - containerStatsRecord.Set("type", "1m") - if err := hub.SaveNoValidate(containerStatsRecord); err != nil { - return nil, err - } - } - // update system record (do this last because it triggers alerts and we need above records to be inserted first) - systemRecord.Set("status", up) - systemRecord.Set("info", data.Info) - if err := hub.SaveNoValidate(systemRecord); err != nil { - return nil, err + systemStatsRecord := core.NewRecord(systemStatsCollection) + systemStatsRecord.Set("system", systemRecord.Id) + systemStatsRecord.Set("stats", data.Stats) + systemStatsRecord.Set("type", "1m") + if err := txApp.SaveNoValidate(systemStatsRecord); err != nil { + return err + } + if len(data.Containers) > 0 { + // add / update containers records + if data.Containers[0].Id != "" { + if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil { + return err + } + } + // add new container_stats record + containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats") + if err != nil { + return err + } + containerStatsRecord := core.NewRecord(containerStatsCollection) + containerStatsRecord.Set("system", systemRecord.Id) + containerStatsRecord.Set("stats", data.Containers) + containerStatsRecord.Set("type", "1m") + if err := txApp.SaveNoValidate(containerStatsRecord); err != nil { + return err + } + } + // update system record (do this last because it triggers alerts and we need above records to be inserted first) + systemRecord.Set("status", up) + + systemRecord.Set("info", data.Info) + if err := txApp.SaveNoValidate(systemRecord); err != nil { + return err + } + return nil + }) + + return systemRecord, err +} + +// createContainerRecords creates container records +func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { + if len(data) == 0 { + return nil } - return systemRecord, nil + params := dbx.Params{ + "system": systemId, + "updated": time.Now().UTC().UnixMilli(), + } + valueStrings := make([]string, 0, len(data)) + for i, container := range data { + suffix := fmt.Sprintf("%d", i) + valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) + params["id"+suffix] = container.Id + params["name"+suffix] = container.Name + params["status"+suffix] = container.Status + params["health"+suffix] = container.Health + params["cpu"+suffix] = container.Cpu + params["memory"+suffix] = container.Mem + params["net"+suffix] = container.NetworkSent + container.NetworkRecv + } + queryString := fmt.Sprintf( + "INSERT INTO containers (id, system, name, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated", + strings.Join(valueStrings, ","), + ) + _, err := app.DB().NewQuery(queryString).Bind(params).Execute() + return err } // getRecord retrieves the system record from the database. @@ -242,37 +283,74 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy return sys.data, nil } +// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH +func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) { + var result string + err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) { + stdout, err := session.StdoutPipe() + if err != nil { + return false, err + } + stdin, stdinErr := session.StdinPipe() + if stdinErr != nil { + return false, stdinErr + } + if err := session.Shell(); err != nil { + return false, err + } + req := common.HubRequest[any]{Action: action, Data: requestData} + _ = cbor.NewEncoder(stdin).Encode(req) + _ = stdin.Close() + var resp common.AgentResponse + err = cbor.NewDecoder(stdout).Decode(&resp) + if err != nil { + return false, err + } + if resp.String == nil { + return false, errors.New(errorMsg) + } + result = *resp.String + return false, nil + }) + return result, err +} + +// FetchContainerInfoFromAgent fetches container info from the agent +func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) { + // fetch via websocket + if sys.WsConn != nil && sys.WsConn.IsConnected() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return sys.WsConn.RequestContainerInfo(ctx, containerID) + } + // fetch via SSH + return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response") +} + +// FetchContainerLogsFromAgent fetches container logs from the agent +func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) { + // fetch via websocket + if sys.WsConn != nil && sys.WsConn.IsConnected() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return sys.WsConn.RequestContainerLogs(ctx, containerID) + } + // fetch via SSH + return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response") +} + // fetchDataViaSSH handles fetching data using SSH. // This function encapsulates the original SSH logic. // It updates sys.data directly upon successful fetch. func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) { - maxRetries := 1 - for attempt := 0; attempt <= maxRetries; attempt++ { - if sys.client == nil || sys.Status == down { - if err := sys.createSSHClient(); err != nil { - return nil, err - } - } - - session, err := sys.createSessionWithTimeout(4 * time.Second) - if err != nil { - if attempt >= maxRetries { - return nil, err - } - sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err) - sys.closeSSHConnection() - // Reset format detection on connection failure - agent might have been upgraded - continue - } - defer session.Close() - + err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) { stdout, err := session.StdoutPipe() if err != nil { - return nil, err + return false, err } stdin, stdinErr := session.StdinPipe() if err := session.Shell(); err != nil { - return nil, err + return false, err } *sys.data = system.CombinedData{} @@ -280,45 +358,82 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil { req := common.HubRequest[any]{Action: common.GetData, Data: options} _ = cbor.NewEncoder(stdin).Encode(req) - // Close write side to signal end of request _ = stdin.Close() var resp common.AgentResponse if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil { *sys.data = *resp.SystemData - // wait for the session to complete if err := session.Wait(); err != nil { - return nil, err + return false, err } - return sys.data, nil + return false, nil } - // If decoding failed, fall back below } + var decodeErr error if sys.agentVersion.GTE(beszel.MinVersionCbor) { - err = cbor.NewDecoder(stdout).Decode(sys.data) + decodeErr = cbor.NewDecoder(stdout).Decode(sys.data) } else { - err = json.NewDecoder(stdout).Decode(sys.data) + decodeErr = json.NewDecoder(stdout).Decode(sys.data) } - if err != nil { - sys.closeSSHConnection() - if attempt < maxRetries { - continue - } - return nil, err + if decodeErr != nil { + return true, decodeErr } - // wait for the session to complete if err := session.Wait(); err != nil { - return nil, err + return false, err } - return sys.data, nil + return false, nil + }) + if err != nil { + return nil, err } - // this should never be reached due to the return in the loop - return nil, fmt.Errorf("failed to fetch data") + return sys.data, nil +} + +// runSSHOperation establishes an SSH session and executes the provided operation. +// The operation can request a retry by returning true as the first return value. +func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error { + for attempt := 0; attempt <= retries; attempt++ { + if sys.client == nil || sys.Status == down { + if err := sys.createSSHClient(); err != nil { + return err + } + } + + session, err := sys.createSessionWithTimeout(timeout) + if err != nil { + if attempt >= retries { + return err + } + sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err) + sys.closeSSHConnection() + continue + } + + retry, opErr := func() (bool, error) { + defer session.Close() + return operation(session) + }() + + if opErr == nil { + return nil + } + + if retry { + sys.closeSSHConnection() + if attempt < retries { + continue + } + } + + return opErr + } + + return fmt.Errorf("ssh operation failed") } // createSSHClient creates a new SSH client for the system diff --git a/internal/hub/systems/system_manager.go b/internal/hub/systems/system_manager.go index 35e52141..9dbe4b14 100644 --- a/internal/hub/systems/system_manager.go +++ b/internal/hub/systems/system_manager.go @@ -63,6 +63,15 @@ func NewSystemManager(hub hubLike) *SystemManager { } } +// GetSystem returns a system by ID from the store +func (sm *SystemManager) GetSystem(systemID string) (*System, error) { + sys, ok := sm.systems.GetOk(systemID) + if !ok { + return nil, fmt.Errorf("system not found") + } + return sys, nil +} + // Initialize sets up the system manager by binding event hooks and starting existing systems. // It configures SSH client settings and begins monitoring all non-paused systems from the database. // Systems are started with staggered delays to prevent overwhelming the hub during startup. diff --git a/internal/hub/systems/system_realtime.go b/internal/hub/systems/system_realtime.go index 20debda0..4b8998ae 100644 --- a/internal/hub/systems/system_realtime.go +++ b/internal/hub/systems/system_realtime.go @@ -154,19 +154,20 @@ func (sm *SystemManager) startRealtimeWorker() { // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. func (sm *SystemManager) fetchRealtimeDataAndNotify() { for systemId, info := range activeSubscriptions { - system, ok := sm.systems.GetOk(systemId) - if ok { - go func() { - data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) - if err != nil { - return - } - bytes, err := json.Marshal(data) - if err == nil { - notify(sm.hub, info.subscription, bytes) - } - }() + system, err := sm.GetSystem(systemId) + if err != nil { + continue } + go func() { + data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) + if err != nil { + return + } + bytes, err := json.Marshal(data) + if err == nil { + notify(sm.hub, info.subscription, bytes) + } + }() } } diff --git a/internal/hub/ws/handlers.go b/internal/hub/ws/handlers.go index 26ac2d4c..627216eb 100644 --- a/internal/hub/ws/handlers.go +++ b/internal/hub/ws/handlers.go @@ -18,11 +18,11 @@ type ResponseHandler interface { } // BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional -// type BaseHandler struct{} +type BaseHandler struct{} -// func (h *BaseHandler) HandleLegacy(rawData []byte) error { -// return errors.New("legacy format not supported") -// } +func (h *BaseHandler) HandleLegacy(rawData []byte) error { + return errors.New("legacy format not supported") +} //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// @@ -63,6 +63,58 @@ func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedDa //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// +// stringResponseHandler is a generic handler for string responses from agents +type stringResponseHandler struct { + BaseHandler + value string + errorMsg string +} + +func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error { + if agentResponse.String == nil { + return errors.New(h.errorMsg) + } + h.value = *agentResponse.String + return nil +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket +func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) { + if !ws.IsConnected() { + return "", gws.ErrConnClosed + } + + req, err := ws.requestManager.SendRequest(ctx, action, requestData) + if err != nil { + return "", err + } + + handler := &stringResponseHandler{errorMsg: errorMsg} + if err := ws.handleAgentRequest(req, handler); err != nil { + return "", err + } + + return handler.value, nil +} + +// RequestContainerLogs requests logs for a specific container via WebSocket. +func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) { + return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response") +} + +// RequestContainerInfo requests information about a specific container via WebSocket. +func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) { + return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response") +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + // fingerprintHandler implements ResponseHandler for fingerprint requests type fingerprintHandler struct { result *common.FingerprintResponse diff --git a/internal/hub/ws/ws_test.go b/internal/hub/ws/ws_test.go index fac446e5..3094152b 100644 --- a/internal/hub/ws/ws_test.go +++ b/internal/hub/ws/ws_test.go @@ -181,6 +181,17 @@ func TestCommonActions(t *testing.T) { // Test that the actions we use exist and have expected values assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0") assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1") + assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2") +} + +func TestLogsHandler(t *testing.T) { + h := &stringResponseHandler{errorMsg: "no logs in response"} + + logValue := "test logs" + resp := common.AgentResponse{String: &logValue} + err := h.Handle(resp) + assert.NoError(t, err) + assert.Equal(t, logValue, h.value) } // TestHandler tests that we can create a Handler diff --git a/internal/records/records.go b/internal/records/records.go index 7c53aed3..53171bf4 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -437,6 +437,10 @@ func (rm *RecordManager) DeleteOldRecords() { if err != nil { return err } + err = deleteOldContainerRecords(txApp) + if err != nil { + return err + } err = deleteOldAlertsHistory(txApp, 200, 250) if err != nil { return err @@ -506,6 +510,20 @@ func deleteOldSystemStats(app core.App) error { return nil } +// Deletes container records that haven't been updated in the last 10 minutes +func deleteOldContainerRecords(app core.App) error { + now := time.Now().UTC() + tenMinutesAgo := now.Add(-10 * time.Minute) + + // Delete container records where updated < tenMinutesAgo + _, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute() + if err != nil { + return fmt.Errorf("failed to delete old container records: %v", err) + } + + return nil +} + /* Round float to two decimals */ func twoDecimals(value float64) float64 { return math.Round(value*100) / 100 diff --git a/internal/site/bun.lockb b/internal/site/bun.lockb index 06672e78..ee3af6e4 100755 Binary files a/internal/site/bun.lockb and b/internal/site/bun.lockb differ diff --git a/internal/site/package.json b/internal/site/package.json index 6e991a64..bbe1be2d 100644 --- a/internal/site/package.json +++ b/internal/site/package.json @@ -1,7 +1,7 @@ { "name": "beszel", "private": true, - "version": "0.13.2", + "version": "0.14.0-alpha.2", "type": "module", "scripts": { "dev": "vite --host", @@ -49,11 +49,12 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "recharts": "^2.15.4", + "shiki": "^3.13.0", "tailwind-merge": "^3.3.1", "valibot": "^0.42.1" }, "devDependencies": { - "@biomejs/biome": "2.2.3", + "@biomejs/biome": "2.2.4", "@lingui/cli": "^5.4.1", "@lingui/swc-plugin": "^5.6.1", "@lingui/vite-plugin": "^5.4.1", @@ -76,4 +77,4 @@ "optionalDependencies": { "@esbuild/linux-arm64": "^0.21.5" } -} +} \ No newline at end of file diff --git a/internal/site/src/components/active-alerts.tsx b/internal/site/src/components/active-alerts.tsx new file mode 100644 index 00000000..fca607e5 --- /dev/null +++ b/internal/site/src/components/active-alerts.tsx @@ -0,0 +1,85 @@ +import { alertInfo } from "@/lib/alerts" +import { $alerts, $allSystemsById } from "@/lib/stores" +import type { AlertRecord } from "@/types" +import { Plural, Trans } from "@lingui/react/macro" +import { useStore } from "@nanostores/react" +import { getPagePath } from "@nanostores/router" +import { useMemo } from "react" +import { $router, Link } from "./router" +import { Alert, AlertTitle, AlertDescription } from "./ui/alert" +import { Card, CardHeader, CardTitle, CardContent } from "./ui/card" + +export const ActiveAlerts = () => { + const alerts = useStore($alerts) + const systems = useStore($allSystemsById) + + const { activeAlerts, alertsKey } = useMemo(() => { + const activeAlerts: AlertRecord[] = [] + // key to prevent re-rendering if alerts change but active alerts didn't + const alertsKey: string[] = [] + + for (const systemId of Object.keys(alerts)) { + for (const alert of alerts[systemId].values()) { + if (alert.triggered && alert.name in alertInfo) { + activeAlerts.push(alert) + alertsKey.push(`${alert.system}${alert.value}${alert.min}`) + } + } + } + + return { activeAlerts, alertsKey } + }, [alerts]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive + return useMemo(() => { + if (activeAlerts.length === 0) { + return null + } + return ( + + +
+ + Active Alerts + +
+
+ + {activeAlerts.length > 0 && ( +
+ {activeAlerts.map((alert) => { + const info = alertInfo[alert.name as keyof typeof alertInfo] + return ( + + + + {systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")} + + + {alert.name === "Status" ? ( + Connection is down + ) : ( + + Exceeds {alert.value} + {info.unit} in last + + )} + + + + ) + })} +
+ )} +
+
+ ) + }, [alertsKey.join("")]) +} diff --git a/internal/site/src/components/command-palette.tsx b/internal/site/src/components/command-palette.tsx index 8e781b79..b753bbce 100644 --- a/internal/site/src/components/command-palette.tsx +++ b/internal/site/src/components/command-palette.tsx @@ -5,6 +5,7 @@ import { DialogDescription } from "@radix-ui/react-dialog" import { AlertOctagonIcon, BookIcon, + ContainerIcon, DatabaseBackupIcon, FingerprintIcon, LayoutDashboard, @@ -80,7 +81,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; )} { navigate(basePath) setOpen(false) @@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; Page + { + navigate(getPagePath($router, "containers")) + setOpen(false) + }} + > + + + All Containers + + + Page + + { navigate(getPagePath($router, "settings", { name: "general" })) diff --git a/internal/site/src/components/containers-table/containers-table-columns.tsx b/internal/site/src/components/containers-table/containers-table-columns.tsx new file mode 100644 index 00000000..51fcd290 --- /dev/null +++ b/internal/site/src/components/containers-table/containers-table-columns.tsx @@ -0,0 +1,152 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils" +import type { ContainerRecord } from "@/types" +import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" +import { + ArrowUpDownIcon, + ClockIcon, + ContainerIcon, + CpuIcon, + HashIcon, + MemoryStickIcon, + ServerIcon, + ShieldCheckIcon, +} from "lucide-react" +import { EthernetIcon, HourglassIcon } from "../ui/icons" +import { Badge } from "../ui/badge" +import { t } from "@lingui/core/macro" +import { $allSystemsById } from "@/lib/stores" +import { useStore } from "@nanostores/react" + +export const containerChartCols: ColumnDef[] = [ + { + id: "name", + sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), + accessorFn: (record) => record.name, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "system", + accessorFn: (record) => record.system, + sortingFn: (a, b) => { + const allSystems = $allSystemsById.get() + const systemNameA = allSystems[a.original.system]?.name ?? "" + const systemNameB = allSystems[b.original.system]?.name ?? "" + return systemNameA.localeCompare(systemNameB) + }, + header: ({ column }) => , + cell: ({ getValue }) => { + const allSystems = useStore($allSystemsById) + return {allSystems[getValue() as string]?.name ?? ""} + }, + }, + { + id: "id", + accessorFn: (record) => record.id, + sortingFn: (a, b) => a.original.id.localeCompare(b.original.id), + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "cpu", + accessorFn: (record) => record.cpu, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} + }, + }, + { + id: "memory", + accessorFn: (record) => record.memory, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, false, undefined, true) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "net", + accessorFn: (record) => record.net, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, true, undefined, true) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "health", + invertSorting: true, + accessorFn: (record) => record.health, + header: ({ column }) => , + cell: ({ getValue }) => { + const healthValue = getValue() as number + const healthStatus = ContainerHealthLabels[healthValue] || "Unknown" + return ( + + + + {healthStatus} + + ) + }, + }, + { + id: "status", + accessorFn: (record) => record.status, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as number + return ( + + {hourWithSeconds(new Date(timestamp).toISOString())} + + ) + }, + }, +] + +function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { + const isSorted = column.getIsSorted() + return ( + + ) +} \ No newline at end of file diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx new file mode 100644 index 00000000..b549bfa5 --- /dev/null +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -0,0 +1,489 @@ +import { t } from "@lingui/core/macro" +import { Trans } from "@lingui/react/macro" +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Table as TableType, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { memo, RefObject, useEffect, useRef, useState } from "react" +import { Input } from "@/components/ui/input" +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { pb } from "@/lib/api" +import type { ContainerRecord } from "@/types" +import { containerChartCols } from "@/components/containers-table/containers-table-columns" +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums" +import { cn, useBrowserStorage } from "@/lib/utils" +import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet" +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" +import { Button } from "@/components/ui/button" +import { $allSystemsById } from "@/lib/stores" +import { MaximizeIcon, RefreshCwIcon } from "lucide-react" +import { Separator } from "../ui/separator" +import { Link } from "../router" +import { listenKeys } from "nanostores" + +const syntaxTheme = "github-dark-dimmed" + +export default function ContainersTable({ systemId }: { systemId?: string }) { + const [data, setData] = useState([]) + const [sorting, setSorting] = useBrowserStorage( + `sort-c-${systemId ? 1 : 0}`, + [{ id: systemId ? "name" : "system", desc: false }], + sessionStorage + ) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState("") + + useEffect(() => { + const pbOptions = { + fields: "id,name,cpu,memory,net,health,status,system,updated", + } + + const fetchData = (lastXMs: number) => { + let filter: string + if (systemId) { + filter = pb.filter("system={:system}", { system: systemId }) + } else { + filter = pb.filter("updated > {:updated}", { updated: Date.now() - lastXMs }) + } + pb.collection("containers") + .getList(0, 2000, { + ...pbOptions, + filter, + }) + .then(({ items }) => setData((curItems) => { + const containerIds = new Set(items.map(item => item.id)) + const now = Date.now() + for (const item of curItems) { + if (!containerIds.has(item.id) && now - item.updated < 70_000) { + items.push(item) + } + } + return items + })) + } + + // initial load + fetchData(70_000) + + // if no systemId, poll every 10 seconds + if (!systemId) { + // poll every 10 seconds + const intervalId = setInterval(() => fetchData(10_500), 10_000) + // clear interval on unmount + return () => clearInterval(intervalId) + } + + // if systemId, fetch containers after the system is updated + return listenKeys($allSystemsById, [systemId], (_newSystems) => { + setTimeout(() => fetchData(1000), 100) + }) + }, []) + + const table = useReactTable({ + data, + columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + defaultColumn: { + sortUndefined: "last", + size: 100, + minSize: 0, + }, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const container = row.original + const systemName = $allSystemsById.get()[container.system]?.name ?? "" + const id = container.id ?? "" + const name = container.name ?? "" + const status = container.status ?? "" + const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" + const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status}`.toLowerCase() + + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + const rows = table.getRowModel().rows + const visibleColumns = table.getVisibleLeafColumns() + + return ( + + +
+
+ + All Containers + + + Click on a container to view more information. + +
+ setGlobalFilter(e.target.value)} + className="ms-auto px-4 w-full max-w-full md:w-64" + /> +
+
+
+ +
+
+ ) +} + +const AllContainersTable = memo( + function AllContainersTable({ table, rows, colLength }: { table: TableType; rows: Row[]; colLength: number }) { + // The virtualizer will need a reference to the scrollable container element + const scrollRef = useRef(null) + const activeContainer = useRef(null) + const [sheetOpen, setSheetOpen] = useState(false) + const openSheet = (container: ContainerRecord) => { + activeContainer.current = container + setSheetOpen(true) + } + + const virtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 54, + getScrollElement: () => scrollRef.current, + overscan: 5, + }) + const virtualRows = virtualizer.getVirtualItems() + + const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) + const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) + + return ( +
2) && "min-h-50" + )} + ref={scrollRef} + > + {/* add header height to table size */} +
+ + + + {rows.length ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + return ( + + ) + }) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ) + } +) + + +async function getLogsHtml(container: ContainerRecord): Promise { + try { + const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", { + system: container.system, + container: container.id, + })]) + return highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) + } catch (error) { + console.error(error) + return "" + } +} + +async function getInfoHtml(container: ContainerRecord): Promise { + try { + let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", { + system: container.system, + container: container.id, + })]) + try { + info = JSON.stringify(JSON.parse(info), null, 2) + } catch (_) { } + return highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) + } catch (error) { + console.error(error) + return "" + } +} + +function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject }) { + const container = activeContainer.current + if (!container) return null + + const [logsDisplay, setLogsDisplay] = useState("") + const [infoDisplay, setInfoDisplay] = useState("") + const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false) + const [infoFullscreenOpen, setInfoFullscreenOpen] = useState(false) + const [isRefreshingLogs, setIsRefreshingLogs] = useState(false) + const logsContainerRef = useRef(null) + + function scrollLogsToBottom() { + if (logsContainerRef.current) { + logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight }) + } + } + + const refreshLogs = async () => { + setIsRefreshingLogs(true) + const startTime = Date.now() + + try { + const logsHtml = await getLogsHtml(container) + setLogsDisplay(logsHtml) + setTimeout(scrollLogsToBottom, 20) + } catch (error) { + console.error(error) + } finally { + // Ensure minimum spin duration of 800ms + const elapsed = Date.now() - startTime + const remaining = Math.max(0, 500 - elapsed) + setTimeout(() => { + setIsRefreshingLogs(false) + }, remaining) + } + } + + useEffect(() => { + setLogsDisplay("") + setInfoDisplay(""); + if (!container) return + (async () => { + const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) + setLogsDisplay(logsHtml) + setInfoDisplay(infoHtml) + setTimeout(scrollLogsToBottom, 20) + })() + }, [container]) + + return ( + <> + + + + + + {container.name} + + {$allSystemsById.get()[container.system]?.name ?? ""} + + {container.status} + + {container.id} + + {ContainerHealthLabels[container.health as ContainerHealth]} + + +
+
+

{t`Logs`}

+ + +
+
+
+
+
+

{t`Detail`}

+ +
+
+
+
+ +
+ + + + + ) +} + +function ContainersTableHead({ table }: { table: TableType }) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + ) +} + +const ContainerTableRow = memo( + function ContainerTableRow({ + row, + virtualRow, + openSheet, + }: { + row: Row + virtualRow: VirtualItem + openSheet: (container: ContainerRecord) => void + }) { + return ( + openSheet(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + } +) + +function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise, isRefreshing: boolean }) { + const outerContainerRef = useRef(null) + + useEffect(() => { + if (open && logsDisplay) { + // Scroll the outer container to bottom + const scrollToBottom = () => { + if (outerContainerRef.current) { + outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight + } + } + setTimeout(scrollToBottom, 50) + } + }, [open, logsDisplay]) + + return ( + + + {containerName} logs +
+
+
+
+
+ + +
+ ) +} + +function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) { + return ( + + + {containerName} info +
+
+
+
+
+ +
+ ) +} diff --git a/internal/site/src/components/footer-repo-link.tsx b/internal/site/src/components/footer-repo-link.tsx new file mode 100644 index 00000000..70368d43 --- /dev/null +++ b/internal/site/src/components/footer-repo-link.tsx @@ -0,0 +1,26 @@ +import { GithubIcon } from "lucide-react" +import { Separator } from "./ui/separator" + +export function FooterRepoLink() { + return ( + + ) +} diff --git a/internal/site/src/components/navbar.tsx b/internal/site/src/components/navbar.tsx index 7f185c12..456ef21a 100644 --- a/internal/site/src/components/navbar.tsx +++ b/internal/site/src/components/navbar.tsx @@ -1,6 +1,7 @@ import { Trans } from "@lingui/react/macro" import { getPagePath } from "@nanostores/router" import { + ContainerIcon, DatabaseBackupIcon, LogOutIcon, LogsIcon, @@ -47,6 +48,13 @@ export default function Navbar() {
import("@/components/routes/settings/general")}> + + + { + const { t } = useLingui() + + useEffect(() => { + document.title = `${t`All Containers`} / Beszel` + }, [t]) + + return useMemo( + () => ( + <> +
+ + +
+ + + ), + [] + ) +}) diff --git a/internal/site/src/components/routes/home.tsx b/internal/site/src/components/routes/home.tsx index 098ab85f..b0072488 100644 --- a/internal/site/src/components/routes/home.tsx +++ b/internal/site/src/components/routes/home.tsx @@ -1,128 +1,28 @@ -import { Plural, Trans, useLingui } from "@lingui/react/macro" -import { useStore } from "@nanostores/react" -import { getPagePath } from "@nanostores/router" -import { GithubIcon } from "lucide-react" +import { useLingui } from "@lingui/react/macro" import { memo, Suspense, useEffect, useMemo } from "react" -import { $router, Link } from "@/components/router" import SystemsTable from "@/components/systems-table/systems-table" -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" -import { alertInfo } from "@/lib/alerts" -import { $alerts, $allSystemsById } from "@/lib/stores" -import type { AlertRecord } from "@/types" +import { ActiveAlerts } from "@/components/active-alerts" +import { FooterRepoLink } from "@/components/footer-repo-link" export default memo(() => { const { t } = useLingui() useEffect(() => { - document.title = `${t`Dashboard`} / Beszel` + document.title = `${t`All Systems`} / Beszel` }, [t]) return useMemo( () => ( <> - - - - - -
- - GitHub - - - - Beszel {globalThis.BESZEL.HUB_VERSION} - +
+ + + +
+ ), [] ) }) - -const ActiveAlerts = () => { - const alerts = useStore($alerts) - const systems = useStore($allSystemsById) - - const { activeAlerts, alertsKey } = useMemo(() => { - const activeAlerts: AlertRecord[] = [] - // key to prevent re-rendering if alerts change but active alerts didn't - const alertsKey: string[] = [] - - for (const systemId of Object.keys(alerts)) { - for (const alert of alerts[systemId].values()) { - if (alert.triggered && alert.name in alertInfo) { - activeAlerts.push(alert) - alertsKey.push(`${alert.system}${alert.value}${alert.min}`) - } - } - } - - return { activeAlerts, alertsKey } - }, [alerts]) - - // biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive - return useMemo(() => { - if (activeAlerts.length === 0) { - return null - } - return ( - - -
- - Active Alerts - -
-
- - {activeAlerts.length > 0 && ( -
- {activeAlerts.map((alert) => { - const info = alertInfo[alert.name as keyof typeof alertInfo] - return ( - - - - {systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")} - - - {alert.name === "Status" ? ( - Connection is down - ) : ( - - Exceeds {alert.value} - {info.unit} in last - - )} - - - - ) - })} -
- )} -
-
- ) - }, [alertsKey.join("")]) -} diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 61db64e3..c21d03b1 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -13,7 +13,7 @@ import { XIcon, } from "lucide-react" import { subscribeKeys } from "nanostores" -import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart" import ContainerChart from "@/components/charts/container-chart" import DiskChart from "@/components/charts/disk-chart" @@ -73,6 +73,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/ import NetworkSheet from "./system/network-sheet" import LineChartDefault from "../charts/line-chart" + + type ChartTimeData = { time: number data: { @@ -214,7 +216,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { // subscribe to realtime metrics if chart time is 1m // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary useEffect(() => { - let unsub = () => {} + let unsub = () => { } if (!system.id || chartTime !== "1m") { return } @@ -987,6 +989,9 @@ export default memo(function SystemDetail({ id }: { id: string }) { })}
)} + {id && containerData.length > 0 && ( + + )}
{/* add space for tooltip if more than 12 containers */} @@ -1116,3 +1121,14 @@ export function ChartCard({ ) } + +const ContainersTable = lazy(() => import("../containers-table/containers-table")) + +function LazyContainersTable({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver() + return ( +
+ {isIntersecting && } +
+ ) +} \ No newline at end of file diff --git a/internal/site/src/index.css b/internal/site/src/index.css index 5bbb5106..ae752b4c 100644 --- a/internal/site/src/index.css +++ b/internal/site/src/index.css @@ -82,6 +82,8 @@ --color-green-900: hsl(140 54% 12%); --color-green-950: hsl(140 57% 6%); + --color-gh-dark: #22272e; + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -110,12 +112,14 @@ } @layer utilities { + /* Fonts */ @supports (font-variation-settings: normal) { :root { font-family: Inter, InterVariable, sans-serif; } } + @font-face { font-family: InterVariable; font-style: normal; @@ -130,9 +134,11 @@ @apply border-border outline-ring/50; overflow-anchor: none; } + body { @apply bg-background text-foreground; } + button { cursor: pointer; } @@ -149,6 +155,7 @@ @utility ns-dialog { /* New system dialog width */ min-width: 30.3rem; + :where(:lang(zh), :lang(zh-CN), :lang(ko)) & { min-width: 27.9rem; } @@ -161,4 +168,4 @@ .recharts-yAxis { @apply tabular-nums; -} +} \ No newline at end of file diff --git a/internal/site/src/lib/enums.ts b/internal/site/src/lib/enums.ts index a2b514f4..4145086a 100644 --- a/internal/site/src/lib/enums.ts +++ b/internal/site/src/lib/enums.ts @@ -54,6 +54,16 @@ export enum HourFormat { "24h" = "24h", } +/** Container health status */ +export enum ContainerHealth { + None, + Starting, + Healthy, + Unhealthy, +} + +export const ContainerHealthLabels = ["None", "Starting", "Healthy", "Unhealthy"] as const + /** Connection type */ export enum ConnectionType { SSH = 1, diff --git a/internal/site/src/lib/shiki.ts b/internal/site/src/lib/shiki.ts new file mode 100644 index 00000000..d60e25b7 --- /dev/null +++ b/internal/site/src/lib/shiki.ts @@ -0,0 +1,28 @@ +// https://shiki.style/guide/bundles#fine-grained-bundle + +// directly import the theme and language modules, only the ones you imported will be bundled. +import githubDarkDimmed from '@shikijs/themes/github-dark-dimmed' + +// `shiki/core` entry does not include any themes or languages or the wasm binary. +import { createHighlighterCore } from 'shiki/core' +import { createOnigurumaEngine } from 'shiki/engine/oniguruma' + +export const highlighter = await createHighlighterCore({ + themes: [ + // instead of strings, you need to pass the imported module + githubDarkDimmed, + // or a dynamic import if you want to do chunk splitting + // import('@shikijs/themes/material-theme-ocean') + ], + langs: [ + import('@shikijs/langs/log'), + import('@shikijs/langs/json'), + // shiki will try to interop the module with the default export + // () => import('@shikijs/langs/css'), + ], + // `shiki/wasm` contains the wasm binary inlined as base64 string. + engine: createOnigurumaEngine(import('shiki/wasm')) +}) + +// optionally, load themes and languages after creation +// await highlighter.loadTheme(import('@shikijs/themes/vitesse-light')) diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index 2e09ddfe..cb4341a0 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -19,6 +19,7 @@ import * as systemsManager from "@/lib/systemsManager.ts" const LoginPage = lazy(() => import("@/components/login/login.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx")) +const Containers = lazy(() => import("@/components/routes/containers.tsx")) const SystemDetail = lazy(() => import("@/components/routes/system.tsx")) const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx")) @@ -59,6 +60,8 @@ const App = memo(() => { return } else if (page.route === "system") { return + } else if (page.route === "containers") { + return } else if (page.route === "settings") { return } diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 66527407..eba8b07f 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -236,6 +236,18 @@ export interface AlertsHistoryRecord extends RecordModel { resolved?: string | null } +export interface ContainerRecord extends RecordModel { + id: string + system: string + name: string + cpu: number + memory: number + net: number + health: number + status: string + updated: number +} + export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d" export interface ChartTimeData {