mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-16 18:26:16 +01:00
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:
@@ -271,6 +271,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case *common.FingerprintResponse:
|
case *common.FingerprintResponse:
|
||||||
response.Fingerprint = v
|
response.Fingerprint = v
|
||||||
|
case string:
|
||||||
|
response.String = &v
|
||||||
// case []byte:
|
// case []byte:
|
||||||
// response.RawBytes = v
|
// response.RawBytes = v
|
||||||
// case string:
|
// case string:
|
||||||
|
|||||||
150
agent/docker.go
150
agent/docker.go
@@ -3,8 +3,11 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -27,6 +30,8 @@ const (
|
|||||||
maxNetworkSpeedBps uint64 = 5e9
|
maxNetworkSpeedBps uint64 = 5e9
|
||||||
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
||||||
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
||||||
|
// Number of log lines to request when fetching container logs
|
||||||
|
dockerLogsTail = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
@@ -301,11 +306,46 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo
|
|||||||
stats.PrevReadTime = readTime
|
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
|
// Updates stats for individual container with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
||||||
name := ctr.Names[0][1:]
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -316,10 +356,16 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// add empty values if they doesn't exist in map
|
// add empty values if they doesn't exist in map
|
||||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
if !initialized {
|
if !initialized {
|
||||||
stats = &container.Stats{Name: name}
|
stats = &container.Stats{Name: name, Id: ctr.IdShort}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stats.Id = ctr.IdShort
|
||||||
|
|
||||||
|
statusText, health := parseDockerStatus(ctr.Status)
|
||||||
|
stats.Status = statusText
|
||||||
|
stats.Health = health
|
||||||
|
|
||||||
// reset current stats
|
// reset current stats
|
||||||
stats.Cpu = 0
|
stats.Cpu = 0
|
||||||
stats.Mem = 0
|
stats.Mem = 0
|
||||||
@@ -548,3 +594,103 @@ func getDockerHost() string {
|
|||||||
}
|
}
|
||||||
return scheme + socks[0]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -858,6 +858,54 @@ func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
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) {
|
func TestConstantsAndUtilityFunctions(t *testing.T) {
|
||||||
// Test constants are properly defined
|
// Test constants are properly defined
|
||||||
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
|
|
||||||
registry.Register(common.GetData, &GetDataHandler{})
|
registry.Register(common.GetData, &GetDataHandler{})
|
||||||
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
||||||
|
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||||
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -99,3 +102,53 @@ type CheckFingerprintHandler struct{}
|
|||||||
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
||||||
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case *system.CombinedData:
|
case *system.CombinedData:
|
||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
|
case string:
|
||||||
|
response.String = &v
|
||||||
default:
|
default:
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// 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 is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const (
|
|||||||
GetData WebSocketAction = iota
|
GetData WebSocketAction = iota
|
||||||
// Check the fingerprint of the agent
|
// Check the fingerprint of the agent
|
||||||
CheckFingerprint
|
CheckFingerprint
|
||||||
|
// Request container logs from agent
|
||||||
|
GetContainerLogs
|
||||||
|
// Request container info from agent
|
||||||
|
GetContainerInfo
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +31,8 @@ type AgentResponse struct {
|
|||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
Error string `cbor:"3,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"`
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +53,11 @@ type DataRequestOptions struct {
|
|||||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContainerLogsRequest struct {
|
||||||
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerInfoRequest struct {
|
||||||
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ApiInfo struct {
|
|||||||
IdShort string
|
IdShort string
|
||||||
Names []string
|
Names []string
|
||||||
Status string
|
Status string
|
||||||
|
State string
|
||||||
// Image string
|
// Image string
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
@@ -16,7 +17,6 @@ type ApiInfo struct {
|
|||||||
// SizeRw int64 `json:",omitempty"`
|
// SizeRw int64 `json:",omitempty"`
|
||||||
// SizeRootFs int64 `json:",omitempty"`
|
// SizeRootFs int64 `json:",omitempty"`
|
||||||
// Labels map[string]string
|
// Labels map[string]string
|
||||||
// State string
|
|
||||||
// HostConfig struct {
|
// HostConfig struct {
|
||||||
// NetworkMode string `json:",omitempty"`
|
// NetworkMode string `json:",omitempty"`
|
||||||
// Annotations map[string]string `json:",omitempty"`
|
// Annotations map[string]string `json:",omitempty"`
|
||||||
@@ -103,6 +103,22 @@ type prevNetStats struct {
|
|||||||
Recv uint64
|
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
|
// Docker container stats
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
@@ -110,6 +126,10 @@ type Stats struct {
|
|||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"4,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:"-"`
|
// PrevCpu [2]uint64 `json:"-"`
|
||||||
CpuSystem uint64 `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
CpuContainer uint64 `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
|
|||||||
@@ -236,7 +236,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +270,41 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, response)
|
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
|
// generates key pair if it doesn't exist and returns signer
|
||||||
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||||
if h.signer != nil {
|
if h.signer != nil {
|
||||||
|
|||||||
@@ -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
|
// Auth Optional Routes - Should work without authentication
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"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/internal/entities/system"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -135,41 +137,80 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hub := sys.manager.hub
|
hub := sys.manager.hub
|
||||||
// add system_stats and container_stats records
|
err = hub.RunInTransaction(func(txApp core.App) error {
|
||||||
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
// add system_stats and container_stats records
|
||||||
if err != nil {
|
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
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")
|
|
||||||
if err != nil {
|
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)
|
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
||||||
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
systemStatsRecord.Set("system", systemRecord.Id)
|
||||||
return nil, err
|
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.
|
// getRecord retrieves the system record from the database.
|
||||||
@@ -242,37 +283,74 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
|||||||
return sys.data, nil
|
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.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
// This function encapsulates the original SSH logic.
|
// This function encapsulates the original SSH logic.
|
||||||
// It updates sys.data directly upon successful fetch.
|
// It updates sys.data directly upon successful fetch.
|
||||||
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
maxRetries := 1
|
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
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()
|
|
||||||
|
|
||||||
stdout, err := session.StdoutPipe()
|
stdout, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
stdin, stdinErr := session.StdinPipe()
|
stdin, stdinErr := session.StdinPipe()
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
*sys.data = system.CombinedData{}
|
*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 {
|
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||||
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
// Close write side to signal end of request
|
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
|
|
||||||
var resp common.AgentResponse
|
var resp common.AgentResponse
|
||||||
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
||||||
*sys.data = *resp.SystemData
|
*sys.data = *resp.SystemData
|
||||||
// wait for the session to complete
|
|
||||||
if err := session.Wait(); err != nil {
|
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) {
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
} else {
|
} else {
|
||||||
err = json.NewDecoder(stdout).Decode(sys.data)
|
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if decodeErr != nil {
|
||||||
sys.closeSSHConnection()
|
return true, decodeErr
|
||||||
if attempt < maxRetries {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the session to complete
|
|
||||||
if err := session.Wait(); err != nil {
|
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 sys.data, nil
|
||||||
return nil, fmt.Errorf("failed to fetch data")
|
}
|
||||||
|
|
||||||
|
// 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
|
// createSSHClient creates a new SSH client for the system
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// 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.
|
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
||||||
|
|||||||
@@ -154,19 +154,20 @@ func (sm *SystemManager) startRealtimeWorker() {
|
|||||||
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||||
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||||
for systemId, info := range activeSubscriptions {
|
for systemId, info := range activeSubscriptions {
|
||||||
system, ok := sm.systems.GetOk(systemId)
|
system, err := sm.GetSystem(systemId)
|
||||||
if ok {
|
if err != nil {
|
||||||
go func() {
|
continue
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ type ResponseHandler interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
// 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 {
|
func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
||||||
// return errors.New("legacy format not supported")
|
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
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
type fingerprintHandler struct {
|
type fingerprintHandler struct {
|
||||||
result *common.FingerprintResponse
|
result *common.FingerprintResponse
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ func TestCommonActions(t *testing.T) {
|
|||||||
// Test that the actions we use exist and have expected values
|
// 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(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(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
|
// TestHandler tests that we can create a Handler
|
||||||
|
|||||||
@@ -437,6 +437,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = deleteOldContainerRecords(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -506,6 +510,20 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
return nil
|
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 */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.13.2",
|
"version": "0.14.0-alpha.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -49,11 +49,12 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.3",
|
"@biomejs/biome": "2.2.4",
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
@@ -76,4 +77,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
85
internal/site/src/components/active-alerts.tsx
Normal file
85
internal/site/src/components/active-alerts.tsx
Normal 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("")])
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { DialogDescription } from "@radix-ui/react-dialog"
|
|||||||
import {
|
import {
|
||||||
AlertOctagonIcon,
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -80,7 +81,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home"]}
|
keywords={["home", t`All Systems`]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</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
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
internal/site/src/components/footer-repo-link.tsx
Normal file
26
internal/site/src/components/footer-repo-link.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
@@ -47,6 +48,13 @@ export default function Navbar() {
|
|||||||
<SearchButton />
|
<SearchButton />
|
||||||
|
|
||||||
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
<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 />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter } from "@nanostores/router"
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
|
containers: "/containers",
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
26
internal/site/src/components/routes/containers.tsx
Normal file
26
internal/site/src/components/routes/containers.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,128 +1,28 @@
|
|||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import { GithubIcon } from "lucide-react"
|
|
||||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||||
import { $router, Link } from "@/components/router"
|
|
||||||
import SystemsTable from "@/components/systems-table/systems-table"
|
import SystemsTable from "@/components/systems-table/systems-table"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { alertInfo } from "@/lib/alerts"
|
|
||||||
import { $alerts, $allSystemsById } from "@/lib/stores"
|
|
||||||
import type { AlertRecord } from "@/types"
|
|
||||||
|
|
||||||
export default memo(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t`Dashboard`} / Beszel`
|
document.title = `${t`All Systems`} / Beszel`
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<ActiveAlerts />
|
<div className="grid gap-4">
|
||||||
<Suspense>
|
<ActiveAlerts />
|
||||||
<SystemsTable />
|
<Suspense>
|
||||||
</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>
|
</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("")])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { subscribeKeys } from "nanostores"
|
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 AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||||
import ContainerChart from "@/components/charts/container-chart"
|
import ContainerChart from "@/components/charts/container-chart"
|
||||||
import DiskChart from "@/components/charts/disk-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 NetworkSheet from "./system/network-sheet"
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
data: {
|
data: {
|
||||||
@@ -214,7 +216,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
// subscribe to realtime metrics if chart time is 1m
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsub = () => {}
|
let unsub = () => { }
|
||||||
if (!system.id || chartTime !== "1m") {
|
if (!system.id || chartTime !== "1m") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -987,6 +989,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{id && containerData.length > 0 && (
|
||||||
|
<LazyContainersTable systemId={id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
@@ -1116,3 +1121,14 @@ export function ChartCard({
|
|||||||
</Card>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@
|
|||||||
--color-green-900: hsl(140 54% 12%);
|
--color-green-900: hsl(140 54% 12%);
|
||||||
--color-green-950: hsl(140 57% 6%);
|
--color-green-950: hsl(140 57% 6%);
|
||||||
|
|
||||||
|
--color-gh-dark: #22272e;
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -110,12 +112,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, InterVariable, sans-serif;
|
font-family: Inter, InterVariable, sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: InterVariable;
|
font-family: InterVariable;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -130,9 +134,11 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,7 @@
|
|||||||
@utility ns-dialog {
|
@utility ns-dialog {
|
||||||
/* New system dialog width */
|
/* New system dialog width */
|
||||||
min-width: 30.3rem;
|
min-width: 30.3rem;
|
||||||
|
|
||||||
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
||||||
min-width: 27.9rem;
|
min-width: 27.9rem;
|
||||||
}
|
}
|
||||||
@@ -161,4 +168,4 @@
|
|||||||
|
|
||||||
.recharts-yAxis {
|
.recharts-yAxis {
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,16 @@ export enum HourFormat {
|
|||||||
"24h" = "24h",
|
"24h" = "24h",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Container health status */
|
||||||
|
export enum ContainerHealth {
|
||||||
|
None,
|
||||||
|
Starting,
|
||||||
|
Healthy,
|
||||||
|
Unhealthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContainerHealthLabels = ["None", "Starting", "Healthy", "Unhealthy"] as const
|
||||||
|
|
||||||
/** Connection type */
|
/** Connection type */
|
||||||
export enum ConnectionType {
|
export enum ConnectionType {
|
||||||
SSH = 1,
|
SSH = 1,
|
||||||
|
|||||||
28
internal/site/src/lib/shiki.ts
Normal file
28
internal/site/src/lib/shiki.ts
Normal 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'))
|
||||||
@@ -19,6 +19,7 @@ import * as systemsManager from "@/lib/systemsManager.ts"
|
|||||||
|
|
||||||
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
||||||
const Home = lazy(() => import("@/components/routes/home.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 SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ const App = memo(() => {
|
|||||||
return <Home />
|
return <Home />
|
||||||
} else if (page.route === "system") {
|
} else if (page.route === "system") {
|
||||||
return <SystemDetail id={page.params.id} />
|
return <SystemDetail id={page.params.id} />
|
||||||
|
} else if (page.route === "containers") {
|
||||||
|
return <Containers />
|
||||||
} else if (page.route === "settings") {
|
} else if (page.route === "settings") {
|
||||||
return <Settings />
|
return <Settings />
|
||||||
}
|
}
|
||||||
|
|||||||
12
internal/site/src/types.d.ts
vendored
12
internal/site/src/types.d.ts
vendored
@@ -236,6 +236,18 @@ export interface AlertsHistoryRecord extends RecordModel {
|
|||||||
resolved?: string | null
|
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 type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"
|
||||||
|
|
||||||
export interface ChartTimeData {
|
export interface ChartTimeData {
|
||||||
|
|||||||
Reference in New Issue
Block a user