expand container monitoring functionality (#928)

- Add new /containers route with virtualized table showing all containers across systems
- Implement container stats collection (CPU, memory, network usage) with health status tracking
- Add container logs and info API endpoints with syntax highlighting using Shiki
- Create detailed container views with fullscreen logs/info dialogs and refresh functionality
- Add container table to individual system pages with lazy loading
- Implement container record storage with automatic cleanup and historical averaging
- Update navbar with container navigation icon
- Extract reusable ActiveAlerts component from home page
- Add FooterRepoLink component for consistent GitHub/version display
- Enhance filtering and search capabilities across container tables
This commit is contained in:
henrygd
2025-10-18 16:32:16 -04:00
parent 0d464787f2
commit 5360f762e4
33 changed files with 1558 additions and 209 deletions

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"
)

View File

@@ -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"`
}

View File

@@ -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:"-"`

View File

@@ -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 {

View File

@@ -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
{

View File

@@ -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

View File

@@ -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.

View File

@@ -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)
}
}()
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

View File

@@ -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"
}
}
}

View File

@@ -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 (
<Card>
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
}, [alertsKey.join("")])
}

View File

@@ -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;
)}
<CommandGroup heading={t`Pages / Settings`}>
<CommandItem
keywords={["home"]}
keywords={["home", t`All Systems`]}
onSelect={() => {
navigate(basePath)
setOpen(false)
@@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "containers"))
setOpen(false)
}}
>
<ContainerIcon className="me-2 size-4" />
<span>
<Trans>All Containers</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))

View File

@@ -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<ContainerRecord>[] = [
{
id: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
accessorFn: (record) => record.name,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={ContainerIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-45 block truncate">{getValue() as string}</span>
},
},
{
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 }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-32 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{
id: "id",
accessorFn: (record) => record.id,
sortingFn: (a, b) => a.original.id.localeCompare(b.original.id),
header: ({ column }) => <HeaderButton column={column} name="ID" Icon={HashIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 me-3 font-mono">{getValue() as string}</span>
},
},
{
id: "cpu",
accessorFn: (record) => record.cpu,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "memory",
accessorFn: (record) => record.memory,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, false, undefined, true)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "net",
accessorFn: (record) => record.net,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, true, undefined, true)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "health",
invertSorting: true,
accessorFn: (record) => record.health,
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
cell: ({ getValue }) => {
const healthValue = getValue() as number
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
return (
<Badge variant="outline" className="dark:border-white/12">
<span className={cn("size-2 me-1.5 rounded-full", {
"bg-green-500": healthValue === ContainerHealth.Healthy,
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
"bg-yellow-500": healthValue === ContainerHealth.Starting,
"bg-zinc-500": healthValue === ContainerHealth.None,
})}>
</span>
{healthStatus}
</Badge>
)
},
},
{
id: "status",
accessorFn: (record) => record.status,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 w-25 block truncate">{getValue() as string}</span>
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return (
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
)
},
},
]
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
<ArrowUpDownIcon className="size-4" />
</Button>
)
}

View File

@@ -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<ContainerRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-c-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
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<ContainerRecord>("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 (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Containers</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a container to view more information.</Trans>
</CardDescription>
</div>
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
/>
</div>
</CardHeader>
<div className="rounded-md">
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
</Card>
)
}
const AllContainersTable = memo(
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const activeContainer = useRef<ContainerRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (container: ContainerRecord) => {
activeContainer.current = container
setSheetOpen(true)
}
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
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 (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 50}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<ContainerTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
openSheet={openSheet}
/>
)
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
)
}
)
async function getLogsHtml(container: ContainerRecord): Promise<string> {
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<string> {
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<ContainerRecord | null> }) {
const container = activeContainer.current
if (!container) return null
const [logsDisplay, setLogsDisplay] = useState<string>("")
const [infoDisplay, setInfoDisplay] = useState<string>("")
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
const logsContainerRef = useRef<HTMLDivElement>(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 (
<>
<LogsFullscreenDialog
open={logsFullscreenOpen}
onOpenChange={setLogsFullscreenOpen}
logsDisplay={logsDisplay}
containerName={container.name}
onRefresh={refreshLogs}
isRefreshing={isRefreshingLogs}
/>
<InfoFullscreenDialog
open={infoFullscreenOpen}
onOpenChange={setInfoFullscreenOpen}
infoDisplay={infoDisplay}
containerName={container.name}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-220 p-2">
<SheetHeader>
<SheetTitle>{container.name}</SheetTitle>
<SheetDescription className="flex items-center gap-2">
<Link className="hover:underline" href={`/system/${container.system}`}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.status}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.id}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{ContainerHealthLabels[container.health as ContainerHealth]}
</SheetDescription>
</SheetHeader>
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
<div className="flex items-center w-full">
<h3>{t`Logs`}</h3>
<Button
variant="ghost"
size="sm"
onClick={refreshLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isRefreshingLogs}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
>
<MaximizeIcon className="size-4" />
</Button>
</div>
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
<div className="flex items-center w-full">
<h3>{t`Detail`}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setInfoFullscreenOpen(true)}
className="h-8 w-8 p-0 ms-auto"
>
<MaximizeIcon className="size-4" />
</Button>
</div>
<div className={cn("grow h-[calc(50dvh-2rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !infoDisplay && "animate-pulse")}>
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</SheetContent>
</Sheet>
</>
)
}
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const ContainerTableRow = memo(
function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
)
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
const outerContainerRef = useRef<HTMLDivElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
<DialogTitle className="sr-only">{containerName} logs</DialogTitle>
<div ref={outerContainerRef} className="h-full overflow-auto">
<div className="h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
</div>
<button
onClick={() => {
void onRefresh()
}}
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
disabled={isRefreshing}
title={t`Refresh logs`}
aria-label={t`Refresh logs`}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
</DialogContent>
</Dialog>
)
}
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
<DialogTitle className="sr-only">{containerName} info</DialogTitle>
<div className="flex-1 overflow-auto">
<div className="h-full w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm leading-relaxed">
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,26 @@
import { GithubIcon } from "lucide-react"
import { Separator } from "./ui/separator"
export function FooterRepoLink() {
return (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
</div>
)
}

View File

@@ -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() {
<SearchButton />
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
<Link
href={getPagePath($router, "containers")}
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Containers"
>
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<LangToggle />
<ModeToggle />
<Link

View File

@@ -2,6 +2,7 @@ import { createRouter } from "@nanostores/router"
const routes = {
home: "/",
containers: "/containers",
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,

View File

@@ -0,0 +1,26 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import ContainersTable from "@/components/containers-table/containers-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
const { t } = useLingui()
useEffect(() => {
document.title = `${t`All Containers`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<ContainersTable />
</div>
<FooterRepoLink />
</>
),
[]
)
})

View File

@@ -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(
() => (
<>
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
href="https://github.com/henrygd/beszel"
target="_blank"
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
<GithubIcon className="h-3 w-3" /> GitHub
</a>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href="https://github.com/henrygd/beszel/releases"
target="_blank"
className="text-muted-foreground hover:text-foreground duration-75"
rel="noopener"
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
<div className="grid gap-4">
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
</div>
<FooterRepoLink />
</>
),
[]
)
})
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 (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
}, [alertsKey.join("")])
}

View File

@@ -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 }) {
})}
</div>
)}
{id && containerData.length > 0 && (
<LazyContainersTable systemId={id} />
)}
</div>
{/* add space for tooltip if more than 12 containers */}
@@ -1116,3 +1121,14 @@ export function ChartCard({
</Card>
)
}
const ContainersTable = lazy(() => import("../containers-table/containers-table"))
function LazyContainersTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref}>
{isIntersecting && <ContainersTable systemId={systemId} />}
</div>
)
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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'))

View File

@@ -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 <Home />
} else if (page.route === "system") {
return <SystemDetail id={page.params.id} />
} else if (page.route === "containers") {
return <Containers />
} else if (page.route === "settings") {
return <Settings />
}

View File

@@ -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 {