From 5360f762e4fc1f94d2a8ea2964e4fabf82e35328 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sat, 18 Oct 2025 16:32:16 -0400 Subject: [PATCH] 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 --- agent/client.go | 2 + agent/docker.go | 150 +++++- agent/docker_test.go | 48 ++ agent/handlers.go | 53 ++ agent/server.go | 2 + beszel.go | 2 +- internal/common/common-ws.go | 14 + internal/entities/container/container.go | 22 +- internal/hub/hub.go | 40 +- internal/hub/hub_test.go | 41 ++ internal/hub/systems/system.go | 257 ++++++--- internal/hub/systems/system_manager.go | 9 + internal/hub/systems/system_realtime.go | 25 +- internal/hub/ws/handlers.go | 60 ++- internal/hub/ws/ws_test.go | 11 + internal/records/records.go | 18 + internal/site/bun.lockb | Bin 228807 -> 250941 bytes internal/site/package.json | 7 +- .../site/src/components/active-alerts.tsx | 85 +++ .../site/src/components/command-palette.tsx | 17 +- .../containers-table-columns.tsx | 152 ++++++ .../containers-table/containers-table.tsx | 489 ++++++++++++++++++ .../site/src/components/footer-repo-link.tsx | 26 + internal/site/src/components/navbar.tsx | 8 + internal/site/src/components/router.tsx | 1 + .../site/src/components/routes/containers.tsx | 26 + internal/site/src/components/routes/home.tsx | 120 +---- .../site/src/components/routes/system.tsx | 20 +- internal/site/src/index.css | 9 +- internal/site/src/lib/enums.ts | 10 + internal/site/src/lib/shiki.ts | 28 + internal/site/src/main.tsx | 3 + internal/site/src/types.d.ts | 12 + 33 files changed, 1558 insertions(+), 209 deletions(-) create mode 100644 internal/site/src/components/active-alerts.tsx create mode 100644 internal/site/src/components/containers-table/containers-table-columns.tsx create mode 100644 internal/site/src/components/containers-table/containers-table.tsx create mode 100644 internal/site/src/components/footer-repo-link.tsx create mode 100644 internal/site/src/components/routes/containers.tsx create mode 100644 internal/site/src/lib/shiki.ts diff --git a/agent/client.go b/agent/client.go index ad095de9..c3f5a165 100644 --- a/agent/client.go +++ b/agent/client.go @@ -271,6 +271,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error { response.SystemData = v case *common.FingerprintResponse: response.Fingerprint = v + case string: + response.String = &v // case []byte: // response.RawBytes = v // case string: diff --git a/agent/docker.go b/agent/docker.go index 2949b4ea..53b6ab53 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -3,8 +3,11 @@ package agent import ( "bytes" "context" + "encoding/binary" "encoding/json" + "errors" "fmt" + "io" "log/slog" "net" "net/http" @@ -27,6 +30,8 @@ const ( maxNetworkSpeedBps uint64 = 5e9 // Maximum conceivable memory usage of a container (100TB) to detect bad memory stats maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024 + // Number of log lines to request when fetching container logs + dockerLogsTail = 200 ) type dockerManager struct { @@ -301,11 +306,46 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo stats.PrevReadTime = readTime } +func parseDockerStatus(status string) (string, container.DockerHealth) { + trimmed := strings.TrimSpace(status) + if trimmed == "" { + return "", container.DockerHealthNone + } + + // Remove "About " from status + trimmed = strings.Replace(trimmed, "About ", "", 1) + + openIdx := strings.LastIndex(trimmed, "(") + if openIdx == -1 || !strings.HasSuffix(trimmed, ")") { + return trimmed, container.DockerHealthNone + } + + statusText := strings.TrimSpace(trimmed[:openIdx]) + if statusText == "" { + statusText = trimmed + } + + healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))) + // Some Docker statuses include a "health:" prefix inside the parentheses. + // Strip it so it maps correctly to the known health states. + if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 { + prefix := strings.TrimSpace(healthText[:colonIdx]) + if prefix == "health" || prefix == "health status" { + healthText = strings.TrimSpace(healthText[colonIdx+1:]) + } + } + if health, ok := container.DockerHealthStrings[healthText]; ok { + return statusText, health + } + + return trimmed, container.DockerHealthNone +} + // Updates stats for individual container with cache-time-aware delta tracking func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error { name := ctr.Names[0][1:] - resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") + resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort)) if err != nil { return err } @@ -316,10 +356,16 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM // add empty values if they doesn't exist in map stats, initialized := dm.containerStatsMap[ctr.IdShort] if !initialized { - stats = &container.Stats{Name: name} + stats = &container.Stats{Name: name, Id: ctr.IdShort} dm.containerStatsMap[ctr.IdShort] = stats } + stats.Id = ctr.IdShort + + statusText, health := parseDockerStatus(ctr.Status) + stats.Status = statusText + stats.Health = health + // reset current stats stats.Cpu = 0 stats.Mem = 0 @@ -548,3 +594,103 @@ func getDockerHost() string { } return scheme + socks[0] } + +// getContainerInfo fetches the inspection data for a container +func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) (string, error) { + endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + + resp, err := dm.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(data), nil +} + +// getLogs fetches the logs for a container +func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { + endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + + resp, err := dm.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var builder strings.Builder + if err := decodeDockerLogStream(resp.Body, &builder); err != nil { + return "", err + } + + return builder.String(), nil +} + +func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error { + const headerSize = 8 + var header [headerSize]byte + buf := make([]byte, 0, dockerLogsTail*200) + + for { + if _, err := io.ReadFull(reader, header[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil + } + return err + } + + frameLen := binary.BigEndian.Uint32(header[4:]) + if frameLen == 0 { + continue + } + + buf = allocateBuffer(buf, int(frameLen)) + if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + if len(buf) > 0 { + builder.Write(buf[:min(int(frameLen), len(buf))]) + } + return nil + } + return err + } + builder.Write(buf[:frameLen]) + } +} + +func allocateBuffer(current []byte, needed int) []byte { + if cap(current) >= needed { + return current[:needed] + } + return make([]byte, needed) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/agent/docker_test.go b/agent/docker_test.go index 4ce58adf..c9895d8f 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -858,6 +858,54 @@ func TestDeltaTrackerCacheTimeIsolation(t *testing.T) { assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort)) } +func TestParseDockerStatus(t *testing.T) { + tests := []struct { + name string + input string + expectedStatus string + expectedHealth container.DockerHealth + }{ + { + name: "status with About an removed", + input: "Up About an hour (healthy)", + expectedStatus: "Up an hour", + expectedHealth: container.DockerHealthHealthy, + }, + { + name: "status without About an unchanged", + input: "Up 2 hours (healthy)", + expectedStatus: "Up 2 hours", + expectedHealth: container.DockerHealthHealthy, + }, + { + name: "status with About and no parentheses", + input: "Up About an hour", + expectedStatus: "Up an hour", + expectedHealth: container.DockerHealthNone, + }, + { + name: "status without parentheses", + input: "Created", + expectedStatus: "Created", + expectedHealth: container.DockerHealthNone, + }, + { + name: "empty status", + input: "", + expectedStatus: "", + expectedHealth: container.DockerHealthNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, health := parseDockerStatus(tt.input) + assert.Equal(t, tt.expectedStatus, status) + assert.Equal(t, tt.expectedHealth, health) + }) + } +} + func TestConstantsAndUtilityFunctions(t *testing.T) { // Test constants are properly defined assert.Equal(t, uint16(60000), defaultCacheTimeMs) diff --git a/agent/handlers.go b/agent/handlers.go index 0553af09..70e1eb9c 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" @@ -43,6 +44,8 @@ func NewHandlerRegistry() *HandlerRegistry { registry.Register(common.GetData, &GetDataHandler{}) registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{}) + registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{}) + registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) return registry } @@ -99,3 +102,53 @@ type CheckFingerprintHandler struct{} func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error { return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID) } + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// GetContainerLogsHandler handles container log requests +type GetContainerLogsHandler struct{} + +func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error { + if hctx.Agent.dockerManager == nil { + return hctx.SendResponse("", hctx.RequestID) + } + + var req common.ContainerLogsRequest + if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { + return err + } + + ctx := context.Background() + logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID) + if err != nil { + return err + } + + return hctx.SendResponse(logContent, hctx.RequestID) +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// GetContainerInfoHandler handles container info requests +type GetContainerInfoHandler struct{} + +func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error { + if hctx.Agent.dockerManager == nil { + return hctx.SendResponse("", hctx.RequestID) + } + + var req common.ContainerInfoRequest + if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { + return err + } + + ctx := context.Background() + info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID) + if err != nil { + return err + } + + return hctx.SendResponse(info, hctx.RequestID) +} diff --git a/agent/server.go b/agent/server.go index bd103431..29302acd 100644 --- a/agent/server.go +++ b/agent/server.go @@ -168,6 +168,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes switch v := data.(type) { case *system.CombinedData: response.SystemData = v + case string: + response.String = &v default: response.Error = fmt.Sprintf("unsupported response type: %T", data) } diff --git a/beszel.go b/beszel.go index 16423546..53c81615 100644 --- a/beszel.go +++ b/beszel.go @@ -6,7 +6,7 @@ import "github.com/blang/semver" const ( // Version is the current version of the application. - Version = "0.13.2" + Version = "0.14.0-alpha.2" // AppName is the name of the application. AppName = "beszel" ) diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index 886d42ed..9319616a 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -11,6 +11,10 @@ const ( GetData WebSocketAction = iota // Check the fingerprint of the agent CheckFingerprint + // Request container logs from agent + GetContainerLogs + // Request container info from agent + GetContainerInfo // Add new actions here... ) @@ -27,6 +31,8 @@ type AgentResponse struct { SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` Error string `cbor:"3,keyasint,omitempty,omitzero"` + String *string `cbor:"4,keyasint,omitempty,omitzero"` + // Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"` // RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"` } @@ -47,3 +53,11 @@ type DataRequestOptions struct { CacheTimeMs uint16 `cbor:"0,keyasint"` // ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"` } + +type ContainerLogsRequest struct { + ContainerID string `cbor:"0,keyasint"` +} + +type ContainerInfoRequest struct { + ContainerID string `cbor:"0,keyasint"` +} diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index c6b6c4bb..ae503d55 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -8,6 +8,7 @@ type ApiInfo struct { IdShort string Names []string Status string + State string // Image string // ImageID string // Command string @@ -16,7 +17,6 @@ type ApiInfo struct { // SizeRw int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"` // Labels map[string]string - // State string // HostConfig struct { // NetworkMode string `json:",omitempty"` // Annotations map[string]string `json:",omitempty"` @@ -103,6 +103,22 @@ type prevNetStats struct { Recv uint64 } +type DockerHealth = uint8 + +const ( + DockerHealthNone DockerHealth = iota + DockerHealthStarting + DockerHealthHealthy + DockerHealthUnhealthy +) + +var DockerHealthStrings = map[string]DockerHealth{ + "none": DockerHealthNone, + "starting": DockerHealthStarting, + "healthy": DockerHealthHealthy, + "unhealthy": DockerHealthUnhealthy, +} + // Docker container stats type Stats struct { Name string `json:"n" cbor:"0,keyasint"` @@ -110,6 +126,10 @@ type Stats struct { Mem float64 `json:"m" cbor:"2,keyasint"` NetworkSent float64 `json:"ns" cbor:"3,keyasint"` NetworkRecv float64 `json:"nr" cbor:"4,keyasint"` + + Health DockerHealth `json:"-" cbor:"5,keyasint"` + Status string `json:"-" cbor:"6,keyasint"` + Id string `json:"-" cbor:"7,keyasint"` // PrevCpu [2]uint64 `json:"-"` CpuSystem uint64 `json:"-"` CpuContainer uint64 `json:"-"` diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 890abae4..515d0ecb 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -236,7 +236,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // update / delete user alerts apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) - + // get container logs + apiAuth.GET("/containers/logs", h.getContainerLogs) + // get container info + apiAuth.GET("/containers/info", h.getContainerInfo) return nil } @@ -267,6 +270,41 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error { return e.JSON(http.StatusOK, response) } +// containerRequestHandler handles both container logs and info requests +func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error { + systemID := e.Request.URL.Query().Get("system") + containerID := e.Request.URL.Query().Get("container") + + if systemID == "" || containerID == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) + } + + system, err := h.sm.GetSystem(systemID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) + } + + data, err := fetchFunc(system, containerID) + if err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]string{responseKey: data}) +} + +// getContainerLogs handles GET /api/beszel/containers/logs requests +func (h *Hub) getContainerLogs(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerLogsFromAgent(containerID) + }, "logs") +} + +func (h *Hub) getContainerInfo(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerInfoFromAgent(containerID) + }, "info") +} + // generates key pair if it doesn't exist and returns signer func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { if h.signer != nil { diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 2b8762e5..7b880f7b 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -449,6 +449,47 @@ func TestApiRoutesAuthentication(t *testing.T) { }) }, }, + { + Name: "GET /containers/logs - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system&container=test-container", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing system param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?container=test-container", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"system parameter is required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing container param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"container parameter is required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but invalid system should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 404, + ExpectedContent: []string{"system not found"}, + TestAppFactory: testAppFactory, + }, // Auth Optional Routes - Should work without authentication { diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 00144706..253ef347 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -13,12 +13,14 @@ import ( "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/ws" + "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) @@ -135,41 +137,80 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return nil, err } hub := sys.manager.hub - // add system_stats and container_stats records - systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats") - if err != nil { - return nil, err - } - - systemStatsRecord := core.NewRecord(systemStatsCollection) - systemStatsRecord.Set("system", systemRecord.Id) - systemStatsRecord.Set("stats", data.Stats) - systemStatsRecord.Set("type", "1m") - if err := hub.SaveNoValidate(systemStatsRecord); err != nil { - return nil, err - } - // add new container_stats record - if len(data.Containers) > 0 { - containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats") + err = hub.RunInTransaction(func(txApp core.App) error { + // add system_stats and container_stats records + systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats") if err != nil { - return nil, err + return err } - containerStatsRecord := core.NewRecord(containerStatsCollection) - containerStatsRecord.Set("system", systemRecord.Id) - containerStatsRecord.Set("stats", data.Containers) - containerStatsRecord.Set("type", "1m") - if err := hub.SaveNoValidate(containerStatsRecord); err != nil { - return nil, err - } - } - // update system record (do this last because it triggers alerts and we need above records to be inserted first) - systemRecord.Set("status", up) - systemRecord.Set("info", data.Info) - if err := hub.SaveNoValidate(systemRecord); err != nil { - return nil, err + systemStatsRecord := core.NewRecord(systemStatsCollection) + systemStatsRecord.Set("system", systemRecord.Id) + systemStatsRecord.Set("stats", data.Stats) + systemStatsRecord.Set("type", "1m") + if err := txApp.SaveNoValidate(systemStatsRecord); err != nil { + return err + } + if len(data.Containers) > 0 { + // add / update containers records + if data.Containers[0].Id != "" { + if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil { + return err + } + } + // add new container_stats record + containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats") + if err != nil { + return err + } + containerStatsRecord := core.NewRecord(containerStatsCollection) + containerStatsRecord.Set("system", systemRecord.Id) + containerStatsRecord.Set("stats", data.Containers) + containerStatsRecord.Set("type", "1m") + if err := txApp.SaveNoValidate(containerStatsRecord); err != nil { + return err + } + } + // update system record (do this last because it triggers alerts and we need above records to be inserted first) + systemRecord.Set("status", up) + + systemRecord.Set("info", data.Info) + if err := txApp.SaveNoValidate(systemRecord); err != nil { + return err + } + return nil + }) + + return systemRecord, err +} + +// createContainerRecords creates container records +func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { + if len(data) == 0 { + return nil } - return systemRecord, nil + params := dbx.Params{ + "system": systemId, + "updated": time.Now().UTC().UnixMilli(), + } + valueStrings := make([]string, 0, len(data)) + for i, container := range data { + suffix := fmt.Sprintf("%d", i) + valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) + params["id"+suffix] = container.Id + params["name"+suffix] = container.Name + params["status"+suffix] = container.Status + params["health"+suffix] = container.Health + params["cpu"+suffix] = container.Cpu + params["memory"+suffix] = container.Mem + params["net"+suffix] = container.NetworkSent + container.NetworkRecv + } + queryString := fmt.Sprintf( + "INSERT INTO containers (id, system, name, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated", + strings.Join(valueStrings, ","), + ) + _, err := app.DB().NewQuery(queryString).Bind(params).Execute() + return err } // getRecord retrieves the system record from the database. @@ -242,37 +283,74 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy return sys.data, nil } +// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH +func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) { + var result string + err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) { + stdout, err := session.StdoutPipe() + if err != nil { + return false, err + } + stdin, stdinErr := session.StdinPipe() + if stdinErr != nil { + return false, stdinErr + } + if err := session.Shell(); err != nil { + return false, err + } + req := common.HubRequest[any]{Action: action, Data: requestData} + _ = cbor.NewEncoder(stdin).Encode(req) + _ = stdin.Close() + var resp common.AgentResponse + err = cbor.NewDecoder(stdout).Decode(&resp) + if err != nil { + return false, err + } + if resp.String == nil { + return false, errors.New(errorMsg) + } + result = *resp.String + return false, nil + }) + return result, err +} + +// FetchContainerInfoFromAgent fetches container info from the agent +func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) { + // fetch via websocket + if sys.WsConn != nil && sys.WsConn.IsConnected() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return sys.WsConn.RequestContainerInfo(ctx, containerID) + } + // fetch via SSH + return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response") +} + +// FetchContainerLogsFromAgent fetches container logs from the agent +func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) { + // fetch via websocket + if sys.WsConn != nil && sys.WsConn.IsConnected() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return sys.WsConn.RequestContainerLogs(ctx, containerID) + } + // fetch via SSH + return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response") +} + // fetchDataViaSSH handles fetching data using SSH. // This function encapsulates the original SSH logic. // It updates sys.data directly upon successful fetch. func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) { - maxRetries := 1 - for attempt := 0; attempt <= maxRetries; attempt++ { - if sys.client == nil || sys.Status == down { - if err := sys.createSSHClient(); err != nil { - return nil, err - } - } - - session, err := sys.createSessionWithTimeout(4 * time.Second) - if err != nil { - if attempt >= maxRetries { - return nil, err - } - sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err) - sys.closeSSHConnection() - // Reset format detection on connection failure - agent might have been upgraded - continue - } - defer session.Close() - + err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) { stdout, err := session.StdoutPipe() if err != nil { - return nil, err + return false, err } stdin, stdinErr := session.StdinPipe() if err := session.Shell(); err != nil { - return nil, err + return false, err } *sys.data = system.CombinedData{} @@ -280,45 +358,82 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil { req := common.HubRequest[any]{Action: common.GetData, Data: options} _ = cbor.NewEncoder(stdin).Encode(req) - // Close write side to signal end of request _ = stdin.Close() var resp common.AgentResponse if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil { *sys.data = *resp.SystemData - // wait for the session to complete if err := session.Wait(); err != nil { - return nil, err + return false, err } - return sys.data, nil + return false, nil } - // If decoding failed, fall back below } + var decodeErr error if sys.agentVersion.GTE(beszel.MinVersionCbor) { - err = cbor.NewDecoder(stdout).Decode(sys.data) + decodeErr = cbor.NewDecoder(stdout).Decode(sys.data) } else { - err = json.NewDecoder(stdout).Decode(sys.data) + decodeErr = json.NewDecoder(stdout).Decode(sys.data) } - if err != nil { - sys.closeSSHConnection() - if attempt < maxRetries { - continue - } - return nil, err + if decodeErr != nil { + return true, decodeErr } - // wait for the session to complete if err := session.Wait(); err != nil { - return nil, err + return false, err } - return sys.data, nil + return false, nil + }) + if err != nil { + return nil, err } - // this should never be reached due to the return in the loop - return nil, fmt.Errorf("failed to fetch data") + return sys.data, nil +} + +// runSSHOperation establishes an SSH session and executes the provided operation. +// The operation can request a retry by returning true as the first return value. +func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error { + for attempt := 0; attempt <= retries; attempt++ { + if sys.client == nil || sys.Status == down { + if err := sys.createSSHClient(); err != nil { + return err + } + } + + session, err := sys.createSessionWithTimeout(timeout) + if err != nil { + if attempt >= retries { + return err + } + sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err) + sys.closeSSHConnection() + continue + } + + retry, opErr := func() (bool, error) { + defer session.Close() + return operation(session) + }() + + if opErr == nil { + return nil + } + + if retry { + sys.closeSSHConnection() + if attempt < retries { + continue + } + } + + return opErr + } + + return fmt.Errorf("ssh operation failed") } // createSSHClient creates a new SSH client for the system diff --git a/internal/hub/systems/system_manager.go b/internal/hub/systems/system_manager.go index 35e52141..9dbe4b14 100644 --- a/internal/hub/systems/system_manager.go +++ b/internal/hub/systems/system_manager.go @@ -63,6 +63,15 @@ func NewSystemManager(hub hubLike) *SystemManager { } } +// GetSystem returns a system by ID from the store +func (sm *SystemManager) GetSystem(systemID string) (*System, error) { + sys, ok := sm.systems.GetOk(systemID) + if !ok { + return nil, fmt.Errorf("system not found") + } + return sys, nil +} + // Initialize sets up the system manager by binding event hooks and starting existing systems. // It configures SSH client settings and begins monitoring all non-paused systems from the database. // Systems are started with staggered delays to prevent overwhelming the hub during startup. diff --git a/internal/hub/systems/system_realtime.go b/internal/hub/systems/system_realtime.go index 20debda0..4b8998ae 100644 --- a/internal/hub/systems/system_realtime.go +++ b/internal/hub/systems/system_realtime.go @@ -154,19 +154,20 @@ func (sm *SystemManager) startRealtimeWorker() { // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. func (sm *SystemManager) fetchRealtimeDataAndNotify() { for systemId, info := range activeSubscriptions { - system, ok := sm.systems.GetOk(systemId) - if ok { - go func() { - data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) - if err != nil { - return - } - bytes, err := json.Marshal(data) - if err == nil { - notify(sm.hub, info.subscription, bytes) - } - }() + system, err := sm.GetSystem(systemId) + if err != nil { + continue } + go func() { + data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) + if err != nil { + return + } + bytes, err := json.Marshal(data) + if err == nil { + notify(sm.hub, info.subscription, bytes) + } + }() } } diff --git a/internal/hub/ws/handlers.go b/internal/hub/ws/handlers.go index 26ac2d4c..627216eb 100644 --- a/internal/hub/ws/handlers.go +++ b/internal/hub/ws/handlers.go @@ -18,11 +18,11 @@ type ResponseHandler interface { } // BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional -// type BaseHandler struct{} +type BaseHandler struct{} -// func (h *BaseHandler) HandleLegacy(rawData []byte) error { -// return errors.New("legacy format not supported") -// } +func (h *BaseHandler) HandleLegacy(rawData []byte) error { + return errors.New("legacy format not supported") +} //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// @@ -63,6 +63,58 @@ func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedDa //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// +// stringResponseHandler is a generic handler for string responses from agents +type stringResponseHandler struct { + BaseHandler + value string + errorMsg string +} + +func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error { + if agentResponse.String == nil { + return errors.New(h.errorMsg) + } + h.value = *agentResponse.String + return nil +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + +// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket +func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) { + if !ws.IsConnected() { + return "", gws.ErrConnClosed + } + + req, err := ws.requestManager.SendRequest(ctx, action, requestData) + if err != nil { + return "", err + } + + handler := &stringResponseHandler{errorMsg: errorMsg} + if err := ws.handleAgentRequest(req, handler); err != nil { + return "", err + } + + return handler.value, nil +} + +// RequestContainerLogs requests logs for a specific container via WebSocket. +func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) { + return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response") +} + +// RequestContainerInfo requests information about a specific container via WebSocket. +func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) { + return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response") +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// + // fingerprintHandler implements ResponseHandler for fingerprint requests type fingerprintHandler struct { result *common.FingerprintResponse diff --git a/internal/hub/ws/ws_test.go b/internal/hub/ws/ws_test.go index fac446e5..3094152b 100644 --- a/internal/hub/ws/ws_test.go +++ b/internal/hub/ws/ws_test.go @@ -181,6 +181,17 @@ func TestCommonActions(t *testing.T) { // Test that the actions we use exist and have expected values assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0") assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1") + assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2") +} + +func TestLogsHandler(t *testing.T) { + h := &stringResponseHandler{errorMsg: "no logs in response"} + + logValue := "test logs" + resp := common.AgentResponse{String: &logValue} + err := h.Handle(resp) + assert.NoError(t, err) + assert.Equal(t, logValue, h.value) } // TestHandler tests that we can create a Handler diff --git a/internal/records/records.go b/internal/records/records.go index 7c53aed3..53171bf4 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -437,6 +437,10 @@ func (rm *RecordManager) DeleteOldRecords() { if err != nil { return err } + err = deleteOldContainerRecords(txApp) + if err != nil { + return err + } err = deleteOldAlertsHistory(txApp, 200, 250) if err != nil { return err @@ -506,6 +510,20 @@ func deleteOldSystemStats(app core.App) error { return nil } +// Deletes container records that haven't been updated in the last 10 minutes +func deleteOldContainerRecords(app core.App) error { + now := time.Now().UTC() + tenMinutesAgo := now.Add(-10 * time.Minute) + + // Delete container records where updated < tenMinutesAgo + _, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute() + if err != nil { + return fmt.Errorf("failed to delete old container records: %v", err) + } + + return nil +} + /* Round float to two decimals */ func twoDecimals(value float64) float64 { return math.Round(value*100) / 100 diff --git a/internal/site/bun.lockb b/internal/site/bun.lockb index 06672e782e70fc22e19337cb814032836aa6256b..ee3af6e4c2e4a28d8f9f7d32b0490d6d31979567 100755 GIT binary patch delta 60779 zcmeEvcU)B0+Vz~F4uUc&3U=&WQ53L)q9`_O*cAZwyz*K$H8vUBj&Kt4z6s>f+kd(mU`auIyhL$kJ zg@s3iLuz5E)8*Fb60gG|7x*Z`_TVE3JA>;YoCjP*waW)Cgm6`BMHf}Rt8#2;ztEw` zcMQUguzQPqT)+>(4&WxXI-OCcGbIii5hB0+xe zMKJ9K_YIE>MFEB2B{K{M7X_b#_bjkJM5eC}X8Zx@`M^8DcHqU}!a9>K(Gt~ig76sC zC!Yl~p%Zdpfgz!TBVz`j(Lx8t2Sw_16OphW^pRk;&|%e~&8mang6VK@a7=vkAe}BY zG&n3Mc2JxZYL2=j#)bw3L)OK^8+#ULiBcF534#1BZ_1Hk`nygk_luccA}4l>I}FJo zHLC>2M8?Ekgic$8LxbW%b!8CG?kT9+*{b2ks0q`(K^cs{2}V@n&uC(X&mheBgKGRk zG%dqd6z-N-ps>=MCn2yIe*v@BRz;LrzC%2lQ?6B&@VJN2X_pE{10@E>#SKar5UP_9 zX2#` zGzYWuOQ~V+N=h{c#YKkqL8tCTnALOxa}*Wnq!(n`w?Fpc3KVBBFq~8>Za6qlgh)vbZilr@j^|-!5-ke zCI~HYV+2ox0Sk^)9T?%RO!UqOV~`|PtEIHiB6J1ksSk8!Sg*FCJFDT>2(tw~sdo3k zv`a$G*}}gg%yPDZtvPYedWt@l4Jx|VRR-7^Fyl+qQ(7(;nBl0H7__qv14>+F*}$}< zfinK)f;l1jhJ?p5IyNRUG9I(GosY6C)CRNIB4DmtpS%_QHkj2o1jf)zT&40vuq(ph z;1Xb8Fg#Bz2d14R7=zN3c)gJ_LiU5%5zAE`2c}_vFbnoq^=fK3KNv$V(FV*3Gax2z z5GISxy@e94262z49cp8`BcM? z{z^gj!HoYM%mVhR;UE0PRn}!qXF4i9=n|;*P$z|NgV{qTz)ZFg%<(c`)klLlwK`2&aNMYEFPTnkFD! zZYGSt4ToXxC~=jcwrP62GAq7?qZ~287(&eVG?=5e!%$_q*9R9wcsF!%rv$~WJ;HMN zK?8#N;KAlCbdI=Y&{@8g{wPKw!@eVwQC|^^Wu$JR18(Tg!{JIr)4@d$-Ug-v3&4y| z1Q!PP1#=;6GhCc+SJvb-TB+ZF@YtY1p>evXVVD-U?m(v>gTg|i!}})a{!qh~VAiu= zWK190#f??$c7eGL_W&0FcTmGCla==T2+j@NU)7~?a=lH7lW^mJh%w`p3_5{1ZL3aD zxD>*y_#TAWaGSt%><)DDiit}4S74_5SNT+%tT>|NgCVVDR2QDHC)!R?GWJz@NLVDXE@TOceb zE@nuyPWNd#(sM|ULqgVU`3%JXyFqANuueA=IumF+gtjAuLgzxP>7VZ$uBsymK~a(H zqHYM&fhnTma4R)m_gPp{7%_kyjH?6$j&&axuxZ^@!-{GK+E&yjHYgZFMOSu?l5r%M zj_w9?3}_DY4<8iHe8c0a$iro=q}Ufw^LI$X6A%+<1w@6$_6x0n0i+rXpR069-=Mfb z1Cdc|XnY)f9Xd~G0k8SW*hmJmOCnVJkjTL-S7)I*9vl`E5grm0i-*+^JSPm;xj=E) z_Zzf7M{`hgLR1jmjs(T_lM_BmR0^(y5Sx1HLZzU$U=G%ZMGEg!+3H&*A8jo4RQ2{? zxg{4Xld>q7UHux&NoX|T#vV6Imna3ng~$kMhRj%-UISv3H-Is*K{VIZT&p-*2FyX|0;YX#Fl%lJX3Na} zL5bIYY1j~`5j%HuCDI$@mUoH?5#$1l}+sf8j*GS zakG-aEHKyd(<{aKj#W*0wkrP9pP)!gOf~!j$sAyI70i{bzp}3Ao^DabqCjG_-IRgz301mX`t ztb^p9o_j#4$w)A2k{AN!rr8qg40cz!D46rs23!D~@w39$-{%{aB>LvfXDYfY@=>J@ zO~w@b^^vcCr<7mX-FmrX;n_XzWv{)@^P%GP#KaaQOFZ0V^4pMnaa-}ig@ztDw|c?k zh^M7@l}fALeC*idQsLLE9p2d0p7yk#TUv#&_J58#@netlw__dad>Hm)ewwxSzVX_L_?g3XXB<_v?{zy-bCK{Jn?P92oH1?P*g&=KBn@Jeaup z^xXBS)w)IqrEAm}-MdGJ+qIV;JbJgy_C=cto$e6cua9G&U+)atxw6u2Kd1JSUybcJ zJ?w=|g?;X$XSmk=Ev;5?4R8x?;H0zPrKgg z9n-qnzH-;XFZXuuFy8&fgbgmm{nPT5ve`7J{p_E&{8}y7gI?{2*SEj6WYixen~kz` z95Q4?m-wkKZ7Q@*{xEBK)896B&O6jteb*YlhD8EBzfG^b(Pz)`r#%~1S!vt^fOC0l1`)v_F|LLj#ljUiegGx4{<_idrOAI z8D%|{!hp{sZ>g7;pLG>_Gbz*u&593*=UfI-uavla2*cPgtm{HSN zTv)ES*r>Fvu{YEPX0}Rci=gH#?E=&eIc)1&>vT<_$~Az;79vYqs;buJrCGhADz++D zQ*H%JDxI#rjZTM6Hql!0J|K9=KE*(44vEpc?Ab`hZBW}m&E76{wmMy%9BN0XEub2d z4A8RaLp_WY5OjyC&r;D^J)pLPs`)Rsq2U75u23yRp|tFk!QD=$>&(>frdpQ&Qy{ZZ z^qCb+Twt(;$hNl0Zd)B&Kr6@&qOrV^1(pMy?k;4x2{Qdc3mVH|jq!nTmS1ewaZvkE z&GhRo6hA0diqg*_z(47AmvLYG=b^NUgI{mbNw2$A%;KyV@Ck23sgSVWZFN z9)k-WZrBm3wAamGn_c0!+675&EwxU9XpHu>#k$XC$!e=cC;=jSMaXO`xlz`ta#m-t z`q!Ygk&Oe&nYCbb>`xr=S#5=RUf$V9F_dA z&||E47)L|(g<4vb4O9(2Xem#q7MX5Z4TDr$oLW|yVRB2Z)2!6AdU=t^fXa!Z)kh~! z+e+9^M23u@PO^~T&W?#y-eyW`1r+!L7aZ=gZ z@-&i5_!%a3GX0iQeS}myTkQ<)&N^K~N|}Xnti?gCPgSj;TouQ4nyspeZNogRKF~C? z7`b`sV2fp*Sz{*!t3m=)|7>}3Wj&?ZY9$GBqvb86mY?a>-pK|>h_b6{@mw}MjMJd9 zfdo~R7fa(wRn`2rl$Wcrcn09a)_TZF&LJAA4^%UED|;B%L2Z;xrMr#sxYNN*Rq|a2 zmCc12Dqps8zHgz{fr<$xUk@KQ*gpshYr zb8(W{PlC$kgPBfj>z0+tD@3rhwQ*o621I-2!BZ)n)>T(QiSOpN&GlFFW| zSz>j?BC!c-C$sc&^$d2U6dxEeTX>=%_oi{CEYF5xkU8$Na!z04VYDx!)Act~^>TT0 zpvqaGcD&^BtX@LxDH)dw`DVHswC4=R4rQ}@EVeD@X`Bx+yTh!;6R2{Pm0Xc6x5Gx| zl+DV_v5Fop)1bC6b4-ghWR8S$uwWNxF|w1!Lgkg2VWwhKPJ`MrhwYzGb9kBGr-Duw zWNr&@#TgNm9W}Lgd!0SKGi^ZiCU4l=+cm z%9c3}wZ`YR23=*fugZVS%_RK zhd|}D)$&v}n9Wc-Lse^_Qdca_*~P6H9!5^25|Yuts{=7_ab z(>lqGQmC3v*DQw`1eG~u`2#7CsOHOpGFW#&Wev4fS5za`EG}l657zR+ z8UwW2m=ll%|J01R8e-2dUpw(C@?JCsl)1Q;BwrBRuS!sk(P`jArpw-4vX}gXF zXBcGbO0|^~155#z8rbc!Ex@XU()@hf83_yXp(9UWm!nVv%^UFZboM1Nvbp z{(Ni43=APq8`Bgav*-HnAyD~>?r5X>@7S>ox4 zw(Xc*gy~SI*)uRMl1g8<9U+Eh6WIv0F~@r4P-lNpeGWBe)dJAtpEV=$T?I8~z6NYcIc@tu<%G;$ z;q6dA8)8_ApF_p+mASK7$!nK4hAnzCb4|p40w2ae#RSMy%r)M3Z<}7W=7j5rBg>#>k<`M9v z9L7M+**b@z=Jdgc9W|%F9iis5T?jR&zxSZ#EUg;ma?aLCfSNPkG^jcAb-+8woVFdI z=JYoeD#xa_;>*Vr#%rprwi{Y-S#>Id1qsVqqNUn#a;)|wXb3?pnmW-k>*4bmBu)}7 z8=j(h7#m`ab_h?%^7Iju=W@%^fYM1iH zb_jDF$)}ph&no$6L1o%Z6|vfi;qn_I&D&b9Ce5!E?UK8D_Ju5`MRV06ubNg{nw*t9 zmKq#kfb~D~Ai_rEd(8__xme{_liAB^2`tgxP_>a&h^vxQ5LE2AiC-K;#@`WJ9kJG$ z1M*5%61~c**{Z6Q{7$_yRBhE0Pjt_GII^FPh$b+nbyh@xAs2LQx@GC5ZS7sKL1^ zP}V;$dS=tVFo#F?kdL-3W-HjE&tKGG3?jbReuoi}qy1QGBOWSp)PcT2|6+oLi1@q` ze4A;14-sE1yIRPX+Rx9B3;Gv}NJB)9BG|5YCdruqzCzDg2R!!ALj?D4OAIq!B^Al1rcd*&hgVRwV6TT9k2!_E zlF6~!KJN4^TBk!tWoDW^wp)z)X_lH}_Sjv26+1J|kbi)3_NdL5>?uu%#rmAH)R~p! zlwXXKPoU}!RE~SToUioN*k_2vhMcohIp*uFo@0AJnv+=aQGju5#IWzXOzs9fgER2+~bDvyd^F9;YpW^eV^LFUlZ zyig8{?Z)bKZcy!*9WHsU8b?8GXl9#BKDWCHm0N;c=BL{BOj0;mc|$a_v?td-V+_J| zU?w|F1CBAnHmLG!%Y2V#upg&99|@Tsr80S2s7$WaNj`M6{~oFjR5?Gh^Gw4%)!f!R z6T{l^N+vd$%fnMB^^k$tS0X$osBd61U2+@*sS&I!Sq?7wvyn3pJ3&;wNRZgwU^`K1 zVcX2y`>P6@Pib@cvyRP>>2=l@DjbDLN}*aeh%**>*+OHLxuBb+9-^9l_QjHos+?65 z<6}sTkb#x#w7TpV>Q7dmKk?q#Y#<~-=J?S1NBMkjkE&X$S>Qaplcys@KbUHLAumye zZc~&DvOf5CVxoDftS!{a!IN&Px@l$WpBkB}wtR;`X^NusnQgii3L7u$`x|eda8uLV zk*BE9ZJN@0TAwNEV5lCj)!MlLKcBI^3AHa&ZLGGKp1n8ZLtv|MP<_Q?Ym}Mhz{(wk z#Qi4wN3-@b6c@EZl;yrPRE`&|DU}^-1JovTNNWguVrrZ6`7Y)Tg;v&?eO8pk4~5Ly z*fM}itE-!~3>kmavQi9m^0|xQ2GrI_m+joy#ck%7b1SFiW(b1?+n%nQjmTAyIf;bK z8DqQ+$$YvhO;AG2zQoE8C5E<8nLKNYHq3*ReTEn=Lh^-Gp3K=GFEb5hDT&ptvl@nQ z^~|rA$%AHSuzUJ=gpVW+S3uD8_cv}h0i|m z>DCaawV`T#FV8RIR;YAdb5kk%8B|s~+teQspf<7 z+0Q$u%s1OYWAvJreY?OifphN3P-~jyV3eOKc0*+?w0xCE#^+Gk=UKJjp5Z%RS=O>f zVScvzEXZ6cv@GOLX)i$JmZjw&?>a`81yXHyL}i`+4>RJrrBJ2xA0;~ZI>gX)z{O<(HixLA3l%<3c#?3GaY$elH*(lLDUE>VszvQ$pB zeo*Ve)=0N_iRmdG#?4T_3$&uHL`!Jtjsc{VYJm*{xsS!8YR9f(>~Aea z$MT+rD2Pp9$~OsS6JkANc|2=7wecR5wlLIuRpy@8_lmDt2P(b}fl9A5TLa(U7`H=Z z%V?@HM=VyTD_-Utle@|bG9Aczsxl0O)Cdwkf;DsXh$?4wf!#{Acy&>3Xt0Em^<_kP z1bqvMYgG1$HFA|Q&bd7l#96zQ7&@<%;RsZE{@FuXnbpc!VYZFeFc4~W7%TfH&RFw6 zkgKBh9J^kBeziEMvhsCFxxVGrD3xOanpLhJWKIRU%>Bi<6q5O(&F`Oj7|%eJmm0MY zdG#k&|1{klM|mpM0NX&Iy@ znUdVRchtn%z>puhjAeez-ss9~?FLnDpiD32CJ|L*vj>sGs--rBl#NALrwr_Ds-Y&- z%BWP9`?6!vkefo*9OFZnhhZO7wg|_h()_ZqalO(6+8|MecN?hY9jbUS9xD4qvz5Od zW&9bc`Btdp@D?hU4b5Zu-KoKSgYrm_OX=*r3k|kVxD==l%)zq2@sKOqj&d1h*++@N zWuvw*X+1BuX?w_Y{j>dVGUS$7<&?I~e-;X}R7Zr4&6RoXkatt|A&Aax?U@InoVA)n zJTc76R?{+97r9xtK<4_OHI=f}y@uK+huU?sQW0$}bK!H6Aq{Gl!#JOJ+@hRsX=Ydm zF)Ko$(kD%IVP`vfZdKg3);#5RydxoUBhlsnA2@6mLFCTK58f81mN#BhjV#ra${~-# zHub%{sw$7iUQiolD;thGBtWG{xoC^yK;f)p3E;oSD2P&H| zt7hC~r$RNag#7gy;~uCSMcHjXK<%AP<%8m^9XZ+&4~l<6ZDeMvY%{BO>U7aLRF|K! zcbH<^9%|{h7o(!50*2RI(b5zhn(G* zTYkATWi!(2)QcaS7U~bR9WoYFQ7fPZ-|Uo?PiKCDNi#DOyoAnfrfV)jmvRbf;Wk z1elItjga#jpy~s8xJ!>>0bXPp#xsD+oax9ASyhSVRN>FHWuZ} z1&#;PaDvJc!Mw=SCjq&Exd1OR^?54K2lFCRU%&t^G6(cxfcg@Zmx6hjw41Ln6E2g@ z6=rxjKu%TluQBbuQ|-(dj+u0;5n&DBK}2o|?7i}3&TM*Ypz=j#hS&h*>+dkrW0R4u z?2Ps|7nt!5fC;c8$d2s;!&4omknG?QFynt!!@sHFbZ}na2Ef652cX?OfMe=Ul^=tt zX8>00|Caz;;JwOvc)mSKm=O)yZtRu3%QMH<*Js8qD+q1@u2} z20`FOW_SpgHAzr)GH-|Bg&CT_EO0uQ3zY=62QLHj>2e*I={JD6p6pV?d%$@RJ`Uz} zTtNFX!&4Akz<;WSkHFNQftkTaZ~<_B`v314{+H}H|Nj#Q*fI-HQ;zl(YK{LBX8B*tfK9qm z&Dfl&uYyiqt@0W$O@B~%9Rs+?v|F!+$sE+1!5p*)_3HdT0)c&ZLpA*W$8!DuI|VzT zLa)^d{UvkM^7>1I|8l&E1#?Zd=7{=-m}|W~@?(~H@WL_RsOC>**a$|^CS5*N$gdiF ziJ4&`HQt<=QDId#XX-_ua~CWHW+RqSxtwaRF#4Z2)GDeGWG1MrhRvB7%fCdbGWP~g z)$TuHj=!%ZWPx5NfNOUfFl*FFEzq1Pb-@emyQV4f8OvSGlLM7 zLsgy31pU-7nHfcaDGk62(+y-mW!?@_!{$u8c-4Neg;Kr_H*!V^YQ!)#A(^)$)$o^? zj+xYWGH*xYg$0dKbuzQqg{np{ZoFz>&P+H#&0wM$Pu7bg_Y`zx@lz3E#0)i- z%yn)Sb@9@kg5sZhYzcX`&r_|)v|0#e8H-f?Ys}IXt9E3jTcYw(m6xgUUtp7*V7Y2Q zW(KKXKBTMyvw$^fJel!pRsBabyiScL(|&`hZ&dZaW{fsIuI*G4{G{?OH6fW9?oq?$ zOlcop=;%Q($KNq9^%HoZqv;I%1=IelHcrl}3C^i}UgZmF24p(&hpJyv^)E3Ux~|5P z*`>GDFqw|sQNt$QFynh*8a`Ab$h>`m7beJ1b#tcvRE>Y8#*?W(SHooH|56Q;8UIQN zo8%krw2mm95k@fM^3xlYsXM7*bEaNE)yOmv%xHAKA7nj zFj8gSev22TUkYaUJ4<=D;SEBv@SigsSfS=arUNU%?3(qeo`dCgyI(U<6T(D36#J(S z#!SA*m`&mb(BtL+FEYa|7{Enl4O;^A=^r{6Gw}=4a>o6^EHDC~Lpe{zl=xW0@gmdC z{6LI4!*ulT9gLy>6POAYnGTqrh*4))9<5*&I3A$g1eGU(d6B740{-^F7{}k=JsDdH zTV7vdIhI2V*|3|Ep{|{vJ*N(7d{_~b|Hi?X z^8EkzPR9Nl2V?5||Js2U8s@79Vp=?>n-`k#-#QrM`+p`hKM`Yw46|#@PsXSRv!7IE zx)3$}e|j)x;t3h^G(Qnz0SvPMvy(B(|MbCF-+$v|OfFwOF{8ug2V#t2m=6En!5A9h z{|?6f?_kXQK#UWFVFUO}2V#t;&Mof$4#s#;_J0Rs925V02V*7pZWmXM6EjWUm~gtZ z&?tQNHfjIjvx`^EygJ#mH~DFc-R0MAs9U~JYU{pN?uHe1upd0R$h~LRx72K~qjsSm z<_F)JX;-+^{(2u`R)x6!_{elpymZFdwD`9+uTLcZQ0?}-Jh>JJ%pTc&(fsIUlcQf< zY5B;r(4hVaPs*Gef2Q%|g@1VbTB%V7k0tfC)gQ5HMZTg3x4w7iSEvl%!wb5PdH9tK z9+PO7W#@MdqBd2(wRqXPVyR*2b1IK~vfK6H;!2+ynpSLb?p7yn@4`pcrk=2OnO%K- z)r&Vc(0~Rf9b*K0* z6YsrWaq9aM4Z3-^d1g_o*NX*1tON5;*s&z=k8OgByUV6mTjERBJ6EpNh(A79m z?Xr9}V$tXVgAY1?|HrxazqM~UkHnllb^690hw!MqjFS;Z6t+=gXkrsEmA6iqRc7ddVF11Vf?6{od z6QoNWeP#Rjdx^KZuH4cf-@}qUuwrMeW>EYAA z=k6A7y0h$v!;zQ~ad&!m`tx9lZ`%)ZCq=zp@S=2t=feKM4jqeCt7zsOKUmc+%Qdg( zjjz5nZG`2WGmdVKcjH@DZ523GDsJhRw^(%9S-Z=&+Sp9^txJj9zU|KY%o=gh zEh9Pd^!_12&tt8Qw!YbZl4YJ*ZF;vUojkAqZJP#5TiZtuiZ%02{sz11o@GYo&#rgLPhVO;+YS6{TvaH+bf+@PtKLT7gzywIiY z$Cy(t(w&}r{`{@`BC*v4XVZ>Jw^nuETd~HBe&WZJ8WET7Omry|v3SFZV*P(;*Pv$6 zh?KX+8nssjt}mGyl$20#jNko);hTG}Nnh2XT=Tx84(#Puu(Vmh4;i$}GNbwZnQv6d?xIk}^Y%fR)sa=-88ZBumbaMSQp z4{BtLUjJk66)}NV#1HVU?$p(m{a?3rPfe~rFaNLoYi_FP+GaxR{CqWQzv{NG%<+=7 zLVBiW+}u*L<=R_Cw%$&-)5>(T<>mwR{nGQ7Eb#h%&mYXZGit7ZE&upbsnDU?d5f+p zF=Y9^o28D0wJ0^=^s9~6c1<|f-fh*ir@dDGp69oFzq-tRF+aK0HkTPSx&;i(KQ*#X zy(OcH`7GW(p{=;~qO++^uIYozb$j#jpm!P1#^d@_Uiqx+^aA@1zKN19?tb*?!;fLD zCRNPy>P+h6VYRQ_Tv9D4{h+m%$EI9kn)bNZ?qsP2bA0_C&?q8;`B(*fKSyv*WJGuALe*UGQ?%p?!r5 zjH@1YTno#*x{d3M3Jxsfg;b8r=S2oLtl@W}VBYQnEt}09oBSzis`W)1mlamuReOAIe&aKXlMV5ai#i30Qui8z z2IT7~I$Ux#o%whr$uju9%gMc6`~U90>Bz88wrNEyM9j>-wiSIuAt@`*s`8?s)gMz&&vGL!}-jWe!oQL z-cj1n+QRyH^!?e3=f`{u>Aj@)iBZA}m%TL~Z>_Zcc{h_~Ad!nCSJpcZoH#+&j<9J|gDYnRkWFz4LZ^wys}%-U8=0CRT17W$4v)-=+PjTb~vvyL0)o zOZ!J`inz1A`!{!{xP%97tK<{W^vt%oqq|f*H(3AhcFp0Nt~~8jNi2HB*>u8k_Nm3X z#f!(JRM~wncR=@V*8T8cb?UlpWoCW%XxO{z58{_rueqo4{JOW?LO+a{p5{|lz-x#P*lQ(LP`@0WDC)k*(HgFk{(J~qEqZBeTs?{)Fp zLoTi#-RaUTmsPW_@2U21hUBvN)U!60n}e@US?cAVyHLL4;xTwPdP4lh4rxCuU$vvj zH_z;Dq=osPbe{N&uHJ%)RzHWgeV5j`@qj%GdiV~k>lrluyN5k@T(xc1>2>)=+v~k* zvS#p+m#${s6*u>;>Y)i$c6uAVEPmTp|NFqUC;vY1&zU%%8?YE4GeJYN;>TDWyr*f|KH+sGiZWX=RveeEL zzx2VIHd!thQZu8?FG0Ur-utPH?H22_y+yj!xVYXu@5DAwcMS5V^=G-e#~PM6^V9V% z=Bu%*xpyhf?`>||gp{kFVqLV$Q}2s!^Gg@@zPhkrnYFX^h1WZ(mhD%<@!b3vmktF@ z_gc-JlbCPR;vg&cDamzuTMqAV;^3>MVwG#orsr4umi9IExi&6z-i(A}Eu-zD=Gavo zlmBjfcZ=WL%5QtR;_{MCdnzOs?N=^$l^>7w=w(`2Xye6iy509UTBF5_#CXB1?Mj(@ zxBGb16T7b4ue7>&xP;xN4uhI4yyi11EjVUk+R=uN^Pe{k?Q_-nN_%$~W4-N1yY0O) zxV-z51FdbFL=PR7@N(?aVs7c;C3qJ%#OYwCrX61%*~GWa0X;UWtPIPOrA!l`;2j-=`|^ zO%7i^*DilR^NJTbm%m-o`NQr#m&fmJop0y9mA1L^)i}Ok^1ah8bHm~{Ryt|-%lhA@ zHNQLO$-5K#@8=o*`t-1e;?(QTreFt4L&fGxIwzl5{`=MHJ3MmvTuU9dHK6pd<-(I? zza1(a@T_6`F1H31U2o-5b9d;}0X9(^7q}c=mG5b5akiZ_o?mg+yent!op1PrRRyg~ zT>^R+J~n^%{gxLu*j;(|ad_>iJHpT2xG+a(xW>JRRsG^Flg3VIH*@6WinrQ1ZXdVJ zbKs;!_u6bK8M7>W*6;H=I9&z3EyuED;54(-O+}m4Im{9iDHS3#}&Fklt z_ujRML&`K?uz7y(+)WINEnex<&Nr|${`sB7oaBzb6tp$-u7bIDUHdyc{ceJO+L>0# z3xoyZp1iaiH>#J|;8NcmqSc~HTdsVXwQJnIh*~bLhfmyby+6g{?2~Q@)5q1cKep9$ z{F&90%P+-;@NP!msgbYa#dTK4?V^Xgwjcbo?4816b5$@lyWX$!@^Z_5-`k_pj?T$G zE;S$b?zg-|jn_MM^SsLzZlBM)`L3?*rhQm!=3OOo?;=~Qc(Baa^MLh%pwlXvOCDqlUZ?Lg^5-N(KZP388vRB+o7b=>#q9lL~M_wQ6)<(0l6$UQ0VYo8y) zg*Tl|&-QijXuWj#Pn8#aTG+`n&2`L=`Uhz>tDRU{b=sp4n**oLephd|;qv;XzUy`t z+h6|iw)LZPZCV#lVnyY_v$iDJ)Lm`nT@`ch3OsTxbMe{m3O*w(?!9oGGx0*)nxl0$ z*SWPbwOhu(`Azzj&aj*sGBe!ej;X-f-J7bc89L&<@j-H*;Aua3Cg+QCY1&t8b<4Ts z@QMdcdM2k%-#U2H+bhSeoLLbP8Ih|+xnuh3b$(AS)z@pXA?(oRX_IZvK727DEVq5} zd566(dPd)A_s8SM2{HQv_%{LCxT_|noCy?dZ##%D&iD&%qW9TAaT`LZXZ?kmVg^EC zcO1ly=lq3Q;*xWLqVrt`(dxWE{tKjl^MT?agf=7OB?=b;#i92c#Lx@=LS1n^Lgnu} zh)x&%h5BO9#X#{KLI)6PC>s9=6em1z5aa*w7rezZggpOr5X)Th7aEHLF9nKs5ju^K zujqO?P@MbFK}^2vFEkU=5%PQFAlAC#FEkgEt^|s&5xRwtzvy-~P+alYL7aWnUuY#> zL#Xo;2hsPMztBcZxrX{>pneDih~C#xKZH`R`wQ*G41~g-qJB60g^uEq8>rti)bFOh z5GV%RMEwxjj8GR*xP|&XNBwU33*E%^2$g?<`rY;ydWb={Q9pzZAk<4V-a-9dqJDS$ zg&;8vAUYmy=qIKlkyYDZAi%IuUKZI@} z6d}4jK>gmLeh>VGDDfIXo!_B;fBFkCV#=SW-+R;#p@E|JL(~tU)QA2;oS1=7*ay__ zk-rcxE_sCdeMJ2p`wK(FfXApGLYomv5QQhG-zU`XiN7#hT>m6+c6kBy%kURQia{BH zv(F)P03nlTd>SZC5D<)i>MtaTX$X4i5iIk}Ul=0}d=@C(W$-kD$)fA?KxwW8g2~VQ zh4ErKf_|0=)_UPDOcaw|1PYVH^LU*sy1fh(ric^qI#s;(GEnMl4Kv?Y{=#%I-UbSD#g%xSCkpQZh52F^ zye<&ebN--^Z$fx==j9Is2nG`ubqT|Nd1%fx|rT`nHRYpUq_ z33bWMx_t5%z8BLG^vfeS%&sN)3oBNV5e9Yo%)tgdZd? zJ?KX%g|tq(M_Mm=TYxr5^GF+|4ALg4xg}_`w1l)pdPCYO1z3T$Nh?X)CBYiBL+V1> zDXk~{BpGZ#yQCn}ZfQGdk7Tq3?UlkwX;K<#pX6cy?Ux3UewGfC4oI$cpkJf}(m^Sm zbV#a@3v^gYA{~*=la5Mm_N-!IR?!|+JSJU(ARL#x96-NGDWnt9JyN>lof~vgnnyY% zWsrWCn&$zXmX?4d=i!_g9HOw9!g)zB!tjFBg>+F`Px?bLq=BTH(qYmq$<+yTTS_3^ku zf#g;Y^rtkD^iaA+dL(%{gC0vMq$konQikO10(vUVBR!KcNYAC_g+MQ)C8U?q8`3K& zpfKpQw375j5{iJ{N?l0rr1hltlA$Q*gA@dk&Q(TZ94Ly$_#_#N34wZ@AoVXMbkYlg zw2!P8q(a5P7J?K@wiKizWGg``SpsY=NW;iBf^?E>D@YYx!3IGZO|}!H3*=maRHG!= zUXUh{9R%q*IkzCyDFx0WNHfWff^?sZp8@hI4bCe_^U3)H=_xtCAhjq1b`qqe`mqSb;LF!5_EJz#3MFh#NJh-SJ^&uA%q#fkqf|R!c zxCB~&?1~m3mqZIx1eZb!kV~Tl$Ysz1mB3}u0_1XN0dje?KxJ?Rv;esxT7X;$El@@1 zBvh6rRzU|;k*<-dN?uh#)ua?sb?F|-P4cb=sv*rI)s!+w?o#vWpjy%rQf=uC$wLZo z19?g-NnVmr15`)qLaHmRC)JY-H9_^IAW{QqJE@^$bVv73XpGj1cSrYoOKA`UAIYT_ zsIfGV)I>T=@|9d`gPKYSq-Ih&$xo`_0ctKKfuz?>k{A;d`;6v8?}=-3oOytJe#1m{i= zteQa>A_X*qaEQWY3JH?n2VrO+git>S!=?2U%6Ep~)EvS{DX2Mwa}*9xFiFN15GHhi z5Z?kql9Wclvnzx$=tp6UG>~n5m%?cZ$&zbJ2y?qZNNx#Xyp&GCuRDZVtsqR4l3GD{ zP2m=W$&y=Z2rGI(nB5w}ROuRp&OIUcwt+BRN@)YZwikpK6jCJbwh*>aNNo#2lrkuU z^@h+f0K#l(NdN@rAP83NAV^X`I|zp;Y^E?z652x;+6O{tdk71p^%TknLvZQELwHT$7KPQ4TNel`!XV7<0%5ImjY8*e2)2_{Bn~6%669loSl%HHBLgj!JGJ5LU!Nm>mM)m~@Ro=RpvB zLm~VorG!GTjfe1pLb~ML7s56QseK`wk}@cS4TjLMAB5A=l70}Jhd{6jgK$;~2!n8l z!e$ERB_SNb(4i1Q!y#Oh)>9~-0Kut0giBITe+cI&9H4MTGDbj{FbqO`1cYl+8U@ec z5XwYCxFHRUgm9O_X$rR_*C+^cM?gr9f^bJlr{FgdLak^B_oSp~2(KyJqVPa+i-E9W z6olC^5FSd`D0DVK@Eri*v6M0Zf^8y%7Zfri?|~4uQAiyK;hB^{AuI_($5;q2q$RNs zoJT{jii7Y<3W$Snh{9$HZzN$5grQ>~gbsr6PFhc){8$K1@en>pLGcjIQ8+;1lVltW zVL~#5_`%q31--OyFm_B>$BIK4jjTBnwo%GVukx-nc!p3S8Yzpe7cB7yiqOzHai(b;3 zpbVV`CDa6^uwL3grTla#PKi*8>ZLx3P|i^~K&7}|%9{jb!VDPxXG8EE2cepjG7g&$o-asllJ|HJo-ar>r3@0DFD8I$NlQq0z94x> z0TV%Zz94x?!Xyx$FGzKz^&~uBOa|4Lf=GD2AT^YXQ$UTRaFVx_M#96zR8V7SAPEl_ zBwxvO8VC;;q-Ih&2@e<3LCvKkQVZ!k$zO7t0ct5tB(;*Rky=Y$DWEn|3JK2?qyWi# zCa9e>kJMhuAa#(Mi=d9u5>hAW4Jl9xm<8%Att54kgxR33QWub^oBHiOp0ilCH2!{T zbB*`+>MS_uesoxuNjLUb&3Ar($NHDix7I!{Z(2~#wakF1wmtHXY2*D(@`yDhr32Ta zk{A3bR$th#dYk0g4dkN{OZf)@x|`?wux6VXKU;Qr{kU}PdA=1aY@dI+u)B$0`y&T$ zntmSkX~H?D{mv7v80OV}v!`DEyZuV1Kb-l?@xGs2x{ipjm|LUOgm&xYjCK59gmpd4 zGhQ&YUw-#ZGY=fOU|+CW>W;&+&ecnMUiGf)%AIZ{2TdAw!RCJdN}U>bY^*cs;2yv2 z=PP7{w7*rk-S{1|WA=@1S8iP){_22$Z^HA#^`7P#H<7mNs62npn4#l_Y#S1@s#E<- zmxbm|+gJZ6p4-yA)UYb8U;LIdCG=`)N(>lW!ZuJnMNgFAKf=hxMl zZzA&TWu9-Xge$GCkGJnwz~oqLNQ~CszebB7_ z$ilZ{7WI67#OwQy(VH7I^oeL>-7@Bzx#4C_+}k|knon!qI5O1T{@3mg#=pH-?q=a% z-p_gd=dvlSexJQ}exKN|R#)114{e!p*y`iA9rqXX>DZ#%mS=bSv@$tNzP_c+?nifO zs!hz62r|!jK)0ec6&5 zyZOrz_$QBvU~`9;?s&Cm$jjVwUT!!%tbh5oTVC01`e|m>Gu!fo7U^+e_?!F}9`!ps z#o2cKrcE~Kqw_triazOld}pi8;cW_Ed0K0WX=O+J2k!VSZ~p%NKZU~h2_{Q@e7>y> z<_Z1nta>!SZ&~pFrE5>!E;c4IGJb$gx4+@m+{=Zhg4G`U(NS*3^FPeAloD47w)#l_ ztqr~tN?K+mF8F!mTY-v0R(AMi>(-1Ph2r|JRK;F)Au1vyJQgmTi{JX_N1>`-pD$4= zwn4aH^>z&YLQ%;?8n8uhw!1w={;QzGCsU*bn}xwv5!2MVOqbnhxka$GdN~6&nf^OO z2Svw(hv1J%hg2JW@ztPehgFTi4eG={ zqH6paQb$!g3XQk?Ix&6`PWi_5F^K#&5P_d_;|iktT}^C@d-Rm7omMq|H>bT?@EKLJ zgH~VF&Z-)}CDTCF&Z(L`v|6fmUfF4sp9v|>>fpMd8uH8S?^UlaLZiq0>IjCld|gxR zxc#kAwd<@gwjAXmtL$YF7;R{3}~(FIBtZxbLS{ZqiQ8_AEDa4g+`}K0g zRIM`ZqoI{z|68jfKUL!&w3i0ks9IIr^Y4$#f^Ai;8tx}UqoW2@tB!mAT{1O0Rdd5V z|7Mxa=TfyAxE~9R&f7!F&6->tggadMM@q$N>mQkF;{FF1tp?Tr>j3`k@Q=V+U=_f> zn12nt0p0@dfcL-$;3Mz};KCsQdcXp(1grpSfNO~@!1cop$OYH~4*VTwwTfQyxGqpf*q*r~*_5DgY&b;y_Wr32*`UlT`(Q z7ibBtIhVO6oX5>gfK!t5cOl>f@Rz>$({k8~bfbXbzzAR@z^+ULk^m3D3-AQ$0CjK1{=iTm8i)ZRfJk5E49>E+y0=N-z-{C$J1_S}+U{e7wmB9=DV&O7y1-J?v0S*I4 zfkOZ{qZX*uVlaQFgukXT5*P(=Q}SenKzX17z@H4c2)N>Y0GPjHVFdC5`2hZ^P$i%S z`@bq~JOFo~CQu8g2Gj=Jfa*YgzzHY_I0G&K|9r3tT91FfJP25inr{HO`z;5S0!x5z zfNz2MKs%s4&;jTObmBw~#7$?Q3(ytt1)2iQfcq$jKjrWZun^#m`5f8{fWJ3#1Gov? z0)7Y10H=Xdz!Ja&BmzkQcS3G_{Q+)l+{i*`z@O`=22=tn1B0OP*H%j6o0&&0~AQFfI`T>K1aNs8Z&wBEs-5&4+bkny;Uj*R0 z&tTvb^xuIqC|`FLH|K$iz#qV`z&_v(66^*Vqu|;=W!&2UwtyXw3orouv967#NPW>OAYg3n-AgkfP)I=so61YUNx-cQE-74E z+5lXoTLS(-Er3sLe429yYN%myO*PyC%$eK_XbLm|8UsE6A2&?icxeFC1L^?mx7q+B zy@0yveIqdK7++uIhG1WS8TtXOfX+Yw&=$~~>xBCb03BnTrX%blucqxF(ugZ+Aiy<> zgN26Lpy`WyE)V^H5J1Z$R1zBN^YvgfhY@9s!_4kAZ8WrDz)&C>NB{-{@jx693k(DX z05Jf^dw;nIuSncR09sZWn`_Kc(>0EglUp$=Lb+we%B|Dqi5WFWwsO_dYpl5}o4{;{ zY-SpSTg^n{BxoF3BY;uBa6pS2sp?voHs<$Ilmz09Q0tCb@CVbS=Lb&}IWE z0R5N_Oasiz(7M3f4^3mema|`sg?lDelqZ{npW#xrO3=?U5=z$i!;kF)s*gi z%{7khzjBY=q`5{Hcwuy!O)((g_6r5#U0{v9}_nn{)Y^q!_02wexR0at-5z-8bP@CR@a zxB#37ECl7(khbDpk9z^&AOL!fmZ+>V;VkEal5hs+X>h@`E79u z25>ImIqtiF`GJ}Pz?$(l!}9<>h5vUJrL+<>$3H~)!9e~FXo`46yV~JV--&*n`qZ)u4zz@8t0aXFM zFRBbw0{EAN?SKG)Z%Nw#en2yzDc~#MNv?@BwYk2a$sgfX0KeJa0%#312h26v5RB9A zJL5hO=mhX*ojL&R)iAXI09!N^91O7YdxN_IJ%MgO4;~P9$4xI);Jx{KWmX^thz6p7 zNFV~}4}=3@KtG@_z&}tt1Mm>}1n?Vh9M})+1NH)YfZf10U@NeNhd~^gD-hTKtOHg7 z-vi5mML--tk9kNm92f=+2I2v}3rzspBab0qzF%W_HZU6CdwsUXC}0FIQVl19lYp@_ z7z3mL6M*T!IA9u(3`_>7QyZ`HB=AIF3NRIz1u#9+&QMtd&jci34)6^yAD9cw0~P>F z0MlZ;dv;^$4BfufxFhJX*z^}mX0PUFWBtTpCX*$5k!~##L z8t?h$Un0;-{gUVtaS8uD1Q3s4DQ&AGH!#66dP9-Wp2OtyF_1C$21*7N*y8;seMcDQHD z7{Kz0HCPW=13dn=0IUG!!82C249{Eh0ON3<8|(ns1GxaklO2J)fRR6do)0%XtStZ( z0SW`oD!YJFk*E;37{G&FPOVbll7Kd~O5omA4JRUgEKm+%jt`Ctj+F`sYh#6#Q^pA9 zKbwY4!WLjn*^FBAY0akXm{k$SF5#(jXMp|4CT|YZ1L^{7eh;7)z%F3x)&zKZ?FLi_ z+yOZ)4{WsrG^Qbsws}vTM!Y9_;=UVD2lp(1njcUfpi?Z6&Nl)Y09sxQHwBsj%&!T! zG2jE38sdcptdTdsj2PD7tKKsMCT8J`)12Ww3kw7~0WAR@9I}@FKu4eh&>m<7v;#P> zTZ03z3(5b*LmM?x%akKE7+N172Bg-uesQupk7dNR6ZMVL5#yU{`;5O zl!(ZR;i+dK#Ox}SR=xSyvB_$T>Hl^19bi#h>-#VZ8nJ)~2)GJLQPHI=%UYvoutc#% zu|)_7i>%V5C{0Afu1FHYF&0EcBerNnO~feHL_-wyYVu2r*W~6JjWwFsuI2xJb7mM^ zSaa{+C(koG=l#C#eEpm`vrF(GLI-Ru0G;@L+v+9L?t3asbgpj;YgdZy>H2Ci4FIneB6*U-AS?AJLl#p4o}aBj#1`#kmL> z5lKYLb)EWDCl+msUxngG#FGrtjK+2H_9nG zDLpX_1>br2yyq**2A&nYAVTY)f)iAjIWuQM`m%?O6=W|E2O#TnFLxM}Yh?#S5_bxS zJ&>k1-y5C%X0x9a0~~K&bej!)=m=hMPCyi;@c0i(`y{x^e9+n{XAv z$6V8wef14~j)f43%C!bU6J0A@8ndc@mK6{kN3v!b)1u?>9N-X{5jcI6Y!H&W96Zk8 znK;eTQ>)F7RHPkgg88XlzslQoapmq!et;y4%sX`!LPxbyQ9Syi@pN--^?Z+wi-OFEHO3oIE9G6T z4R##8y1xylIG#(XJU;*?Kd$@aoywH+HvXX=q~P5J;s``P=+VaSp17@15Gyo5LR{~R^X)(TVbWtW&?|@|f=Z<~!v2Y_ z=lzNQR(N6z)6=8ku|JK}jai`k*FpuUueRLOmE^Wj);4ArC$wRjFrkP3Bub1@e9@ep z8G~2#rr9X$sHO8jC>ozEx^3`|>NH3~&~g81&(?$q&g$Espw;ZOWy>E%Y`gvx6ai?j zp!knHs|gc)teYTpJF)f#!O=$i2;%rwtle0_8DGz1rMR+@_%)S$qqc(KDB-;8TNc}1 zy3{CHu>2DeD9a^s{a2u%WyQB!QP9xxF)^5v@(QcUB_O1Y_iy8Ub-nXvNeHs%R|j?n zQ|X^byqk7SxiDY2)?czni+wDjL{hKkT)WhPZMPSBQMM>@N7j2B)b(;?W5)@hg3ghZ zjT2T0*Bx2#cp=2*9Qr3!?vGBaa6IPFK@w@9IlQ;+lrDLJg5|HEAe&+WHEG5KgGHv^ zuJVI`4=qE`syea_kf^PaY%$56eD-IJyTDDRo{ccS!8}g z{0QP=qu{`DCPCZa4y-6jaJC8V!2jpX5Stt$I24Bq{)Amk7qqols0WLT1Y>?fptC2t z7Y;Xgc(R%t)P~-Zn;TVdZ0IK`?>PyUq!v=mf=}TryzR+G8bKKeN~oB(p)4l(WMu|& zk0=9>dviQlQ6?rVjS#GawVrHJCMMX?o>fKQ`ne|yo(=XhV5h$HfmiXqi*NTE$Juq5 z2o6Y|jtE@yWbQMC2-#Lo+143e78Y=M-aF$|`k~WD#|xHNcSRMsr6FcK5K`+A$rN@n zQs^oZzafi>1C}E&Hu_>EC(U07YiNxbzwfSXey!L&?7QcU06n}5b=h5&x&G&fv?=T3Rk+~w_$a7AhUQ1cxo#c zJg&?s4qf(LU#5+FrOs_+sz!b+FAnt}XP4fSB~BHbDTcxhKUS5YWL$0HHk^5A4N~Sg z3sPXz$Na?is(Cwxr7gM_^wm$4qi_KzBRyH~T*x-b(b~{TdI)k1;9>K#zVHyIQZ}6J=U{6RDg0QP2{qal-DRNPx5y$ zd2JHOtDCHesr&e6RSVu=N~2KQnk=i(aGi`~MNy%R3j*DkR#@%CPZ{}X`+ATC!KwDRVg9TVGR`y|e%LPZ~Hc#j#tnb6d&l9u`Reksa z!TWv_mwE4fvxkG_wmz(6p3qbHrVsl86vB6XSc~~Wg~aYm*DLd(a^qn3cs_84VAgd3 z7Dm5d-go!DymjBk^|Y6jrOsE^ogOWQ2D3;|=%-2)m$a2Hm)##{sZh)Zf=7b9Mrr3` z;?F$59|4hh%YxZX;ynfmH&ArHmp!6|-GXxp#V>Cw><&%1PvVV_-0}Yg^75`}2W!CMy*FKyVO{_hGMF9#faCTBsl)!`LB`K31am`eB}1 zPFdK83dPi6tms3o%B*3m*-{W63FE5dy*Sj`f75(C=v82(IVQLi#)6j$KA!)?Jx$no zEI(=)xbg9R{b#nz@YQNX6>I-(9}8ag~Auj$_-w zD|8;qPA(Hd)z*{vK}hzkDc>%%zuAF51!(c0Th)j87NT7hOkz2O;BW|M#f4~i>%#dF zd3@&wO}cnLV*+{v=7m3Yg|lA^1s~yPII~+WB-VDS^?mlQS}q8d`ua`IDuNg7H)wy& zU`p=i>8W3Lzo>Pf69X+B8KCW*FREX8`kkm2dvIL2a(L}F?Uy~Id?3Xeu0bHY1|56g zqnR=UVx|m%pbQV9=*^WOD0mq<3#dVx4{B>roX|`eg2_x70znxr{Qwi1DML{3GIZ8o zMeUI{ek)G9zrOFoO2r#ysuH}sDn;eY6eftw6ebW9=A;jyoSDJ|1uslz^(>SwDW?yu z-nagPiY!GrtX;=H6tsE=tQ~X+U+LgvAHMwKeCoe}I^Q06g9?P6BI>SY9KmYVbp_^c zXf@*$^ReE6LSF_Mv}Wz`vI_UVKChJ`{g{N*UClV}FIex0*9vQg2Y4Sxe4MLWbNZ#i z+t6ypd9j*3Bh4CG%{avw1M~j~(i>W{I7LHi7DupVKh6+5U1apzE`5 zo+&zG8LR#Xc^R2dTnrJ@5_t0o>K)|#*7c%|M952dArM+j&)m4UW8Q|pmMO^Q1U8&_ zkAs4o+S}bLV&L@+K?=pcfVcyB|BDX~ew#X|v4W@)*&6VwJren1elcwA>9}FPpHe77 z5?KvNH-LgR6P^L1eBYXQV7WrE00;%-MyU13+m=)DbSdk+HIcPrXjI=OvhEB5gZf`%JKlK(J9hX+6uu@RyYd}FQe%Z@kyZ*axlvbn=&)a_m;t0g? z{I6Y~p3U3~1dAGVJ4nCNkaR2{BpnL~q<2_}23nKClIglUegC1AXoG6gW=B0YP(1t{ zG?QKa7$(Tui_tTg-71(6J(H^u>}X&9OOF+sF$tYk>G1ujnJoD*I4;g)5k&cB24B-7 z-;QqDVNuu*pv2)n9h#5HU{_ZOPF6aLv6*Z;Xk{LOBhJFpO!jmY9EW?~)xgCpadd+z zBeJ>FZWD~Lf2{ZQrS*hs9GlH@K&#FJ1y%6cT#NqU8%n1t6sz%TaZu<#2L zuW8;{>`P@C=YUYY?(~a3b`hDUAh)yG3*v2_!xL2#*U@^yGVAXY3LhXe@2qn#FU*{N zHcmkX(C;0U7pUYQg^Blbj_?r$F zd;9n?9=3EEC#Buc0@k7wvc?p!PNmQ*ynwyf3x}kFgJz#+Hz_{8z>;3jq4nTDi!TdU zQ7N`)CqY5mn{s?xp56mZIDE%?&6V4oKuIt8@Idoo9=k%a7td#T`yi9e=UQyZe32Ab z+6LQIvX!#mV*%^B9uxx?aEixIv*V7AU$IHB^uq{kg2ygkk)W8Jj(ZAJ%!Fe{OKeN6 z122?pMJ#s%Qtr_rF8$p2 ztu=4|xeo6ca}fY)D-Gza=eEL=)!-E*e+j+|i#IB_>IT&5fm}sm`YmC@L8%_Flq)#YfE|UbT^VFm7IFjT z?lPS9=`rF9P*4hY!*>@8**j&B^>R6zkE^<@h=+AU;HBZ~Ha+PkQ9>`&&_BvhLne=Q zY|JJcL$sg6k~aylYM&u|qSQr$UsP=J%#qlUgS=HcQrwn)$eL}&obD@F@Mic#w}MUF zEacHX_%iO*-o;$Vn-8jXpSBC4w{6|vR0xSLW_F)|;t(h(#eDn*KYRSH19cwBH#-;$ z`2;(qX)AdGFNs;P``{0!{s{^y2H24;A&0xJ{-IZx^AJ%*@jxGaZ98}c-o3Q4KCJ5& zSeWrK8@vV1t}mG6Vv)4yv5M{50(0iAVyB7HN$Pkd^DE!l^1KBWqg?7_P@!@))0Sf* z^v03w#W~Bqw|3wD_zRd-Doz~`S`Alj z_PPD&TI@~Iha32~lr6<<>iQJ&E9njESrt(O!HgG5WSN{tHaubv(*KJ}u!J%(JZ>-FeD-$;JhFZA(v#{s0&f-PA zSn0@5So93D2xw~lpJLWWi`bA)ald;D%f-F^&=wwxTaPFI^j@nsbl{X)aA-Nt%$v>J zU7F^5X@N*FxLD4r!K=Os3OiVTb87bdCO!4*K+%mJO9L$Kl^4@_AU@}0AL{{xqM8i_T9=I{bXVB!9Q<&fmS9{ z^0P^;x-TfGkS3%&Yf-V$|6foB-?6~@Mr(e%-Oy(6R@UNx;KD}j7KTx^m(rkAUFKwMh6`3ZSnwX`QD;?av4f|Mkkx$O;tqEB=7hNR zAw6AoutE^4y+A>$@U|uE=jS(n6axxs?4StT!H(`hQU!v7R@mvAf3ld-(M9^|0#SUl zli5{33oIug75G-jd%M_G+^Z{gaha9puT9>PTCqTsDYoCTBIZkbl196^UhRMMi|829 z{x`9+!7u1+yV>Nukn!4m&MwHHea>l6zHXl*c^dm1?LNpfzrW%88f@CJF-1dESFYw3 zgxK!-ByCyEV(3S&KcFA)xZ2k=TRGpPlWtbCAHDe^@|&7z#gleF6y=qCE{0T#J(E^G z7?+cp7FAhgb3~SvoQyRG1T$u=-_0f=e132=yH-I ziJf(-6!ZeWd1Xdfh&+wHc0#?$BbpPlKPcdv^{)zUr?&{lFPfa^2SM;qHeE-DHP zs$?|?lRgF%6rM+GY=55r`RQc}#XKP1Ky0_Y*q*-1vxS1ln#swqVlSV?>%`{K9otOB zUYS}d=^>|$%wc-vE^}x^v@#|Iy^a~I=VA0Y8GrTh*vTrWmQ!>hPInLUv#FEnRxj4x zuyy9@P{xb`LPsDsw;48-oL{$3R?~^_jT(Rbd0dak z^YCVPvsYBqDedqk(3K7$y7kcWnVr?Kkl_RwBk!*NWa8KOniX%Tcys!yi|}Q+Ufde@ zm(EtVK6+SSXGxnt`uE3iF5}-%0;m1zp~BlCDq}n?STsJtt{xE_X4B`kC-~0B_5mw) z>vOhLq0#|yh7Nr-PbEN_Ub{II( zUJiB3u})ZQ2prx+6rnxsVIl=PJJgeX&BYSfIV)aVpr zvdHf}&4@YTqY|g2))J=084?V2ZZRPnbyH~TDyzg*(D;d0QD=7Kd!Yjxc}8gDPMX0U zx&Xpan`jMZNl!GUvgxB$YBu7G(6~9>60LY;rQZtPUF9iEtOk+Eh$g_F^*bwgs$~wY zH3<0!MXg%3MJqudUKMh-zJ|NlVg*E>aJCbu1MUOhz=~k21(7 z3XmL?l4?j1e{!KoF+``Qu$?_sZfxWQ!73<&kE9xt5+SS(4UK5%HyK(I#)0%SW4tlV z$aN9<)fpOjkd!eYTRuwVEs{0Os0H<`Wfzr4M^o)yv4ktE!ES=hIg7urQky!a$p#<)q+~-%TBgRB z7@L%WfU$bY4Gn9$i8IZvA1^S}AJou0US7gdHx+4u(-Wse zC6n9Jqtnt;kSo#gNr{F8V{}SVLR88$o(QQ?i3nzvL6e?hM1&JG_?xVTRI!7#@K(EKC|g>T7Y0P7MC2Iz(hiZOc}SOVfzQCS*z-Vk7rUT{4s*%7K;>bzmJz zQLKi8l>6o&)_$1E%dHM-G@&-8YIpaURg+8t8qh%;XHnrQFIGBC z)!0i;UoO8sgE-D2!?7L^{o68qt)BH+Y9Sa&2S@ddq(xs0wJ6hDmML!^OswR)310mz z`OmsADgU4epbGc27k`l>j?zza$h+*1Y*mL&4VNwmO{$r#>kbQP^U+eQd9q4u^60EA zps%WZ=lZJvXzCA2eNofULP89+3x~44NGt&juSj5O7(hR~J67e{Li|}FQHdYK(C**x zl@RiowYbA#Z)d4ISc@!GBlaRwWyjvkQZ;6pEPRU3QaKag#zL}GZP>Cb)eN@ry3oFO zmZ}-^&sKF(?viuB#HZ)g^DlmSoo*0*|{);3Iz!1vgxmNgq`%G%>bsTukDKBv)vRI$dgFY)sz~FtNJmbRI|-a#i_}NK^0XVhb>o zF+9$1Yh@^7)w$_(g}_GTS7pB%fqjQ23{AXUK($6XHYO=nR~>p!*!fDk!ZQ6FE}1`{ zOg}(rUPzyHSM6^iO?@UL^Z$-0nZBpWw^GX?Ml*7Q7XN?3tE(2*g4n-+NAYXXc8s!TzjC8Rj<_of(EUeR&02_oxE(-Y} z(ms&8A@MJ@Ge}>^NqV)^hp0m(Cq5OSltVgue$DSC*n2~N43bf6e%Byf3Vo3}3Z>J* zR1hq9AS4UiR8pra57_`|hW!R)amYZKZVh8rb3{@?d>{1cDWqA=I*@F(N{}#3y<1xC zFHfYIzdT6h`vevC)>%;yE~tntt425gJq`nYj1&*BiA?gS@1IdoqEafanIvfGX zf;&mRmQ0s{#L!E%Kw@sB4jY;@0+U4-RYy&SL2_aZi5W69zMoE)y@IhxeUjr7to?Ko zqtrPv0D)|IR z=0A&8F4`0q-9gYnGe|lJmWHLJp&KN7?$^4i{yHQJI3?42>e}4hDp_wfQ+u#N3)w?b zK5VY`&=p8#I|x}Ca;4;_L2_; zj(zRafWPjAg`dmS4qRaVrKU?c6tXTi;Eo+-#Yz6yr)^}4Dg%-BK>8%oY`8;^4D1nj%Iyf4`StIq_J8N6 z)@U^l)enZW=BOzmV^4G+qdIOcdGbA;>|D;MyX5pjWoVpCkv zGHijw*rcHeBV%=6Sk)1C9Fi5<4apXWi%A;#&Jft9AwRg(S&3@RHiMu6c7xa?47+jQ znbvj)ZATaizK9c_ednkOJp)OJ8H~Qx>0*&)0Q25g1MVXIMomEbQ!$Jkj8^~%j`cP$ zVADoP!!YSU+lu-m#`MKd(FIRb9gl=$peG<%uol3;_!05+8=n-Y442)LRQqz$fAK_| zfS5rmU~p{WfY?xsV`(s9vf3s6W0FP;hoi*U6>J-wnGeBA( zyUbH7whofZrZuku`wDd|Xf?_{%?2g~D&q?+HY_GFDRwXdU`0+WQ00C|PRq^<)c`R* zlt9LOs0MmUmDbeKi_{f^uS;s@J`Spux_8S3yk1W6a>ffJ}C_XkeHlk*j?l|=9jJ(!BZSB+6x_($$1N#q2>WHcoM!uGi%&*+h zCF$zuvX9S57_hfmRZ};$00tdh` z_o4$Rh#9p_A|)mvAwFq@E@i9QSHrfcn}(L|i!=jo56K0oF{B41mc^Kan0~QY^;p9- zgSX(Y3=CUBvZdFcU@yobXh1*65*RS#IdIt1%Xg_YnFz@S9SZ3U*$uK7WCKX#Nv#6O zd24~B{*Rri{@a&D-c7TuDpKSlZ?937;QE2<+y-7L^ULk$H3ofOA)n#k#uoK`xq4xfG8bK{j_HIM$*F4N}QsJiW3C5x>?WjC9-vWH6m;sWUpqNqzg zN5ajvtg?%-7D_gN^b~1#^;Mb4T*n1x0MMwE&e`5P6^a_UD2#`oXaI$tPO8(1vAC;F zR~wXz?P#?KOEgk+36HF^&R~mk~ zT2!NR(>~3Fnbg3Ctv0%WWswHkqbd=mngygA#qrTfO5HpLgU#;FxI&t`*fRYiJPNt# zbWPv}b+VUe>?lb!sCh^!A!;VeS{e(2W9iO;8r@JWr8w5b7==`Qny?(VF-X-z%J#5I zq-CF`f=4}&pAFaluq#b7GC*v~?(=piumS zRH~>BZ8vi&6j8{*U>KCWQiq_>`rO_qWqZ4uWd`&e;YKJp*Rm^2 zH)IZE(3P~wacwDyqS%fKi#KJ0nYuW!1?>~uZ4PKQq0OgJuq}9{(Tqy4tr-}@Rx+Ch zfn~^K?ICk&zks3x6b6nZ%P2(H%*jm6diXW^Yx?e9)~l8hMOL$A1e}tFJvWw4$XV_5jWWl7u??=7VLx z8mqON6jB$p3*v9C0}#<+We?H8qLX-Of=h9)&UEvdUI!<g>|F{E;1g<5PJ_S z+rz;gT@maFvL#3^QrP_+%~7$OPS-pag=G*FTC*}tB}B`8u+BYJQ0cL@zK$khi)cgB zoDRj?@J`aXIZQu8VIPECkS%q}JI0^AJo5vvb~}33mdm;o@=#2HLR$@#ShO6UK*5^Q zxm~e!pV3@VUBk$6q$nfVlmeD}3C7rH|6RS4&MzEaPnyuC-mX}aybC?Ax+pxJ$<*akh zZoUT!&77k}H81S%7=4oPR#3};LN0=fV0A-nmXECLAp@E(Y7qz=m|a&8M5#l7AbtI96Tm@jX=i{Qz@E` zQV$vnf^RG{z`3&eShj%-0*L~Z!;7*Inr?t)kPOhiwVF%AknS5R<3<;W2jrqgxGO)(CN zTwPs*;*H#L1S6y#xhbaSrg#`-^aS5GTZY-=jh^A^nwmXU+ry479`V`im0O(R5qXq6 z0*V3IbDUdd^aLN9o#)=t0DaUehvG}4XRbUlyn-UvO21Vrmli)4X7mJ~vjli5f)3BM zixQyVjLqI2#*d-kSk&{@!SV_zw4Ab~ad4}vS8EtFq74|1FKzWQPluweR45}=?hBT$ zp+Ltd`z1%Jvc?)cV2c7UA~TOhGNFw~S8cU0rGZ7erXr871ZRV30j6J*EX*}9&9X2R zu&cDl!VChFHP@CLHot@6ZtN(laf9rhCxhXHcG#Q;lht$ahS^K+1tzQK4LNL{fN7V- zb3?Ro)?CxUWUc9WFj;F_3d>5?czS`!YO@ARR?knsIC~EBfR8K{wJq!6VzRqI{atOD z+k$D9)l>1N*-PsMCQC$?EnqlKIHZh#ddU1q+UddM1Ju2*I>JK7q`H8iMmc~Phk)%s zmPb9)pGa|-Qg5;y92{mwpj|Zrq*_2muIzKL+BRyk+qQ4%*oYnMQm{R<_|UJw=YFZ)oqZ3MlfEbz4~;biVCXjl%MnphIjar=!<{xuzniXu)mDjo z%6d?(z0(q9KP{U2fOae#ya!$uVe|mm1U^v+Rug$+Y`O;4G4?gK0-DV}-0il5z~=O@ zC8up8qX$T|p;KW;h8R7fb1Q2ySjS}3A~+8=XIaHDd~(Ls3vAZHI-E3m0WNFg2T zj8;}d?7rFZ<0L*EY|fTB4>oHAIN^I>tmh227uc+(4Z13(S<7Nru*_Lhtxnk+Cioqr z2gs~Wap)tN)hTZ!iep}8GX>iVY>rqhE5LG(a>3vz4=6Y1mX~1P%E5NQ-l^@Y4%@9@ z5k@u-u2_h2dgu)n?U>E>Be2@e@9=9zQSmu!Q^3aLV6WtA+qipfewTsOPBMTsn|eAuP8pv$zWKDVgp;P?alk4!0|!Z_X<;?-P?H% zQrm-Z5t_o%6bkJ;M1{IrzYn$%{D9ymQM$qm+Vm+{>;X!#uFAcncY?alG7#F?0~s(_ zopL!8N1)&g&7mkeM7`6>p%@1Rm#Zv_a-SPL!N+9hd4tqusJcRC$$*W*6Fi1}R!7X> zJ}i%z;-TRBnZ*-xd;p%~cP3h{~AU&X94sbf=(pt)L2nu!p`V@X?h7(o0R!A*4%7_ znv#>3mgh({fNzdNrN5Ms)qJ$$v|`Xkf@LaLcDNC? zten9Xvo6?ElKCa6HLJP67#$GVPuuhpo*ob(a|rV-Vt^M%aOnav$Mtn zV4Hwd#;Tk9xSU&yDGDS_l$O?tw2TAG;N9SkH4Cw2>8nc9PzfTjcS>3%~qsqd-$e+_Z>@`vY#8fgJB+J zBQ&l+if)x5>~R|@Hl8PZD8mxVrCW;$vJlzc9`Az)W*%B`kK%klG#=nP&@*`Z;B<5O zKpj=~p+KLFKsJG?Gim$)q>QX}g!vd!tPe|JowV`m_7V#AB|iu^*Pf{6;Y4DsH7n!$ zP_P77&P{!$Eo4rF@+hz1q^!-PM1YUHEghiXDo8J0fbw|DmQA0RjY?jy3LtLu@-d02Gat!xK2@Bxymxs!PyvWeQD0p zm8oE!3$})2Ely61C!k>1$`Q@<3aO?@DPb!&%I&A>bPY6j+K@Fb0ozPt>5iRi?e6Xd zo`oB@mYXX~Q;%(oTxkS(OtC~m!3vUOQA*^d>0p%;8yNfMWjdc5>rrsJ+G)(KjX#gx zAX~9?lGb&Z}N>%j$0^J`I8PjxrXa(oC<3cUI$WDHo29Tq6#!}DN} zjkCMJFg1S*MXg*EZr#(>Lz`__$4KKxNVJC)&dm7gh|g^GUX(M&I2?&OP+4r-Hb)o_ zBh>;aFOF|Z0h_wKvq6+zR5u;4oJyQy_69Ug1D%z?4O0?{a%Q0+(5Cl)Uo~!Shf^{ zwm-La+Ygqp*%vz=W59I)hdWhFsmAlwIF-neSBuk}1_dKxwYa-$tCjgf2nyK8uS1%~0WWwnWItiDKnOygpUIUVVSL@+GySp$x1 z8<0{$hP^D)-!^NB(E~i&gH3LqgdQP_)hZb@@A#&S8#oSgcjauh5h-o8Rlaqmes%38 zXl0>5M!MA6-6ML5+CAXx@w#PzWbeXCIpbPxgJ=MP<+68_LEz!HRP9XZP(ASV1z8uS z&JF0X2_zjllg39fdp;Ys2}|{5jyAT>NRL>sTJzYObFnm4TF`i2nkYMfTfq!A~1nUOeCPT&~tonVZzf6Qr>l6zWLh{e3!8 zY;>B*j>l)UEtTvXH+0LU)3TdgR;Z(l3TDz2#waMbHy31Q8iyjqS&g*_r~XYyv7-wr zN7B18jwpA92>3#fRn|xU^kGC zD2uOApS;k-8&JP7G88NW(=w_nVWYKb?Cf0DNNZa2NGL*K&W=zzL+<9rwNTW9f}a)? zwf*@?1=DS5%J|jCBgNM_ZdaAwoFM zcW%__hU8!?Y;yFK!$S|SIsNVg+b@TQ5}VaWf{fSUAsK8=4`;yUjLfvf(PXNJql1IZ zAhi~AxSa~NdyWupgVh3Ko2%ny!dA6vY*}R)kW<}!7z$2e4yG)Mr%*J6f}^B3plw+m zA8d1c2!`QTjqAB5xGcunN)obN-9pftO(IMqkm?AZEXc*y<4g_Xr(l|sv1Oczu$Vq_ zOh(!$u^XF$Z44WOZQGOx(X|Nw z!h5%RqRXKe3`P6gTpfU-b#96Rd(_8LSzImc=`8MES~~N|G7=O49KNX+IK@fsFhBvlYEl3vF64DJa8qx!@2c!vd zASClAKo(^GzaxnhNKd5sw+#5Aq{HcuT=Q2$GUHmwZ-Oj{^d87!kY^#;$2TEa;C)C= z+9UZ77R;9E1b-au zeWc$!r1kF%a@Dg*`%1^ol6*h#l(ACwhootolmnT-i&D?|KS&ak9MwZ1IcoWbHCU7P zA=!6}A^G}`N{+w(sKbBJpBL=5qe9B~=LKK?fK=z-Ulq(H`G^egKOwo)e+qx>+LH#9 z&%ts^7C_1LX-HCMWcn-8S z3q`UiOF_Y)OREN1BpsBI4$4YBCGCA>nv#AhLy`)>8}n6VLP}l+DQWb-dZ9`hR+kxq zWd=%K){uOdA=9}@23AYzDS25JZ!D)C6CD4%AQ43(4@p%+sdtvl*GT4XEcKKu zxT#D#OY+UZQ`HJ@%KYO6SH5;q5lt;#e?nvw-%$TTG#u7IT78p%`gay{N?zd`cOlKe)g-z4>r z>KSmeBq&+HR+*+`#%(f9$?f=*RCn}}nWjDMr|J#sE_vYKbBdvdtff4qRdP)Y+7m{5wNb>(v2^Y#U z9OYpS9sdIla@ev~KDbnB_TPVy!*bF8y%7Lky_I)Wvd?1y&YeL3``Gyb4w6cmT{Mgd zyeQd{fBOK3#(2?=he_REJ;0$M^>qB7evq>QKDn9vQx9@<-^s%SFO(N01G)onfdAh< z$l-Gwv_Jd!A9;|2a^JC{V>yWai3d3xH!X2 zQ_pFe=Yt&fJLi8#Ui?Fn<^M+mV9WfA4|4dNMkznn2Q~~Ww?ga!}r4?P1=3^MQ``AP42^tlQ<_`-i0UFP|X&-#*CUm_h&lV-IrB z^2+%8ryk_+d5tpudBNAeBRSpvmk)CI{6?LBxKOs?|L%hvKEILkKL`P^M*r0ZIVi6l z&tEJ!itA<`lB$MM?<|?`fAb)RB9JTy^ z?m-S(I)5C3m?)cm3MBjV|KvfAt=`SD|L>{I|9fil|DM|X|N5!TD^K3AedPb#OD}pD z^{s7_UYNvATNVoXY zB$7RlHi@H5KSSEjgtVs^ZbEwPE0eguv_<$9Lb~f~lbBEl=^`Q%>A7ymSkn_}FOlYn z^d6*dGhJMSnDt#moF2qnGl&x6771?yh^7`0r9`>~#0e5lNt6~13xgPy55%g%Aj*nI zB>Y`KbSeVES7a0cafyU$Q4keGbWsqKTtVz4;V1N7AR_aFi1h+dS!^S5mxNa_5LHA> zF%a_$fH+Q~s<0FX(SlV;E)F7493}CLgr7Hv>SDMzh_yx#7f6H%-x46Yx`UWd0z?gw zNy4olh?*rq)D&qYLF^%Mn?!^NDFq_V1H{}?AZm$QB)m-^n)-mKBhr09oFMU(L_N{4 zG>B1!K&&baqP}=U!rv1_r!pWKii|QKE|G983!<@zE(>Ck8N^N!O@+Q3h)4^F*m5A6 zi)|$ClJN2c(Ne_tf|y?z#Bma>g{3@*7DYfLmj}^S93}CLgkJ>^?Zxm4Al4QIae+j% z@T~}!h4B`ZdrzCoahW;Q%l>o8IA4D(lh=hMh5S^-kh!Gi8KwKi>8UUiNhz!65dKxJ}|65fTC-t~`jj zAs|x3EfU@pKr{^n@t#Nz1#yDJQxaoD!x|t)RRpoB1_-NoM8e+>M5iziX(A&G#3d50 zH9?FQ(KSI#ssv&ui3vg<4kEHLh}dut6U8|BJ^GKQ^a11slpNoF-^o% zOczHX#Iq_e_NxWsnPPY?5NiWKTp*Dyd~3sSwn(9{iA;()qDmczxgw2Xp14dQL`YqT z`C>A~0&$CCp{P|4;zN;6u}IvbSS%VwK`apqD3*#x6w5@*`Vbi+gJQXOPO(BnH-K0v z)={hy`i87)DC^n~!L1S7NZcji)d<8o5z`37{2CyRlh`0EjX|^s1CiVq#3peRgnqLq z(F9_P7*4TOoTS($e49dS7bz4UiA;(eqDnJ}og$55m$*!^TZA-+*dr!W>=m~t_K8|8 zAoh!NiUZ;v#X-@qCBz}Ifa0)tL~%s4Yz1*tWKbLv&nZ3@(U?{GPsBQk<3isC;#1L$ z;xn<0;)F1^g*Yi*lN6c4w*$nN zB8B3-$b>L_r4s?s`p$+6Ix!AX>}dp7x1;r~4PWa-Xh$${jlsbEn6F*X3(}_lJLSEO2g_JjR;xXk-ooLkw@|I34r~FnYUQm9g6CFE4eyL9+1y;VhZJR z)SvPN>faOc57eLXCF)Q4C+gn|EjFqHT5MG>wAgF302sYawCs&G*NY4agLqDnPejK+ zxQKNWu0r1jBERTHQ9x{?a1+M95JnM0;V$-46cmW!s34*fAS#M=6n;WK7^0HsMp0R8qwp8T zArMtW3`Kz0OHox=hC);m@f3mLC`FJcF$|)*7)}u^PEv#j-{BCUB88%c$fO7pRT3d; ziZqIFahW1Qgd{;kipdnU#4U>2qSgqAIwGB-uDC~0Pc%%1h!P7Z>WfDZ!hZmorqf6? zO+%3}5_}`^oT9Oaeg~q7SVz%R=tn^`6Wu79i)|DwgfRu8rHFwL^9RDw@f0{}EiCVH z_6`D({4R*L;wXt{B>dh3(OwLH4>>!ClN8aycQn*p2SYt!G}Ld1OcHKGK-3%qqLWA) z17Z(}+a$V(kg<4+8wz6XSPCh(J2i?jL1j>afyWMI1qhB^f(Zcl0fVv5i9iL5paLejbeba>n zFU25XnE;U>;wc7;qZC6#i4P!#is2Aq?K^OL;RCoGE_^3~=sF6-goz-ML?#Kh6c9Bh zfk+lK)ff?r+_#?;wgzSqTy5!qeg>R zH5G(aJR;#g21KW6Aksv}G!U0axK0N#UPMm^F=;G_og^j*{R|M1RuHi>Kui?dNZcji zH50^S5i=9S{8SLfNlX=%Ss+@Zfk>VOV!Aj=;u#6QbPzMe@N^Js$AP#&B3<~-2GMmq zhzYYn*hD4?xA#HRw1Jo_(rh61kho1kh>$rT;wFHYI|sx9af^ia2Oyfx1@WOsp9|sy ziKiqMi-z++jG72y)jSYO#Um2_lR$J5ATmUT0C9wFL^MD%Ed;Sn#4H3ce=3OMBsK`khag%^1Cjh8h)v=siDxAI7J=9zhA#rK zb~=a)B(@3P#UQ%Q05M@Ph>t`j3AdRbYAyk>Q=}~cv4_NM61zpnQV?;oK+IhVVz0PG z!aE&A(`6v`i}Yn6PLOy?;-F}l0bwVnWto~=|!WBU`8zlvuY!l8+!4W zjQ5Fr5DRLfw@G+bu*an^rGWtFq4*o*-7TMUKqB3iChLIb_?O@if0CR!NBfTj9 z5tyzk!A$rF%oDx%l8oCbFg168d5ZSg0cH=G+hl%6`|Jc0w;IgconW4$eaLvP0n>CB zm_N`yyTF_v^OVe=XrJ9+My&<2YB!kIXdg2E>%er{gU->5j6LX_OC((Pg2*SL_kx(T z9>h)(u0p>LMC1k#vHL(25Zg%HCE>Lngi*xo2Qhymh~p#*3d;cyEjEEjJ^;cbj*@sr z!tWpmPci%;h_#zRTp(c)zK1|`-2!65ArM7GCJDE#IAw$%*0;V_6jTVZkgFf59T zkRu@Cwt<*?1VjmOi-h-f5KWJQC?(R5f;d6qDT≥V}@SJ_51o7>Kgs5efetAUb^v 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 { + const alerts = useStore($alerts) + const systems = useStore($allSystemsById) + + const { activeAlerts, alertsKey } = useMemo(() => { + const activeAlerts: AlertRecord[] = [] + // key to prevent re-rendering if alerts change but active alerts didn't + const alertsKey: string[] = [] + + for (const systemId of Object.keys(alerts)) { + for (const alert of alerts[systemId].values()) { + if (alert.triggered && alert.name in alertInfo) { + activeAlerts.push(alert) + alertsKey.push(`${alert.system}${alert.value}${alert.min}`) + } + } + } + + return { activeAlerts, alertsKey } + }, [alerts]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive + return useMemo(() => { + if (activeAlerts.length === 0) { + return null + } + return ( + + +
+ + Active Alerts + +
+
+ + {activeAlerts.length > 0 && ( +
+ {activeAlerts.map((alert) => { + const info = alertInfo[alert.name as keyof typeof alertInfo] + return ( + + + + {systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")} + + + {alert.name === "Status" ? ( + Connection is down + ) : ( + + Exceeds {alert.value} + {info.unit} in last + + )} + + + + ) + })} +
+ )} +
+
+ ) + }, [alertsKey.join("")]) +} diff --git a/internal/site/src/components/command-palette.tsx b/internal/site/src/components/command-palette.tsx index 8e781b79..b753bbce 100644 --- a/internal/site/src/components/command-palette.tsx +++ b/internal/site/src/components/command-palette.tsx @@ -5,6 +5,7 @@ import { DialogDescription } from "@radix-ui/react-dialog" import { AlertOctagonIcon, BookIcon, + ContainerIcon, DatabaseBackupIcon, FingerprintIcon, LayoutDashboard, @@ -80,7 +81,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; )} { navigate(basePath) setOpen(false) @@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean; Page + { + navigate(getPagePath($router, "containers")) + setOpen(false) + }} + > + + + All Containers + + + Page + + { navigate(getPagePath($router, "settings", { name: "general" })) diff --git a/internal/site/src/components/containers-table/containers-table-columns.tsx b/internal/site/src/components/containers-table/containers-table-columns.tsx new file mode 100644 index 00000000..51fcd290 --- /dev/null +++ b/internal/site/src/components/containers-table/containers-table-columns.tsx @@ -0,0 +1,152 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils" +import type { ContainerRecord } from "@/types" +import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" +import { + ArrowUpDownIcon, + ClockIcon, + ContainerIcon, + CpuIcon, + HashIcon, + MemoryStickIcon, + ServerIcon, + ShieldCheckIcon, +} from "lucide-react" +import { EthernetIcon, HourglassIcon } from "../ui/icons" +import { Badge } from "../ui/badge" +import { t } from "@lingui/core/macro" +import { $allSystemsById } from "@/lib/stores" +import { useStore } from "@nanostores/react" + +export const containerChartCols: ColumnDef[] = [ + { + id: "name", + sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), + accessorFn: (record) => record.name, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "system", + accessorFn: (record) => record.system, + sortingFn: (a, b) => { + const allSystems = $allSystemsById.get() + const systemNameA = allSystems[a.original.system]?.name ?? "" + const systemNameB = allSystems[b.original.system]?.name ?? "" + return systemNameA.localeCompare(systemNameB) + }, + header: ({ column }) => , + cell: ({ getValue }) => { + const allSystems = useStore($allSystemsById) + return {allSystems[getValue() as string]?.name ?? ""} + }, + }, + { + id: "id", + accessorFn: (record) => record.id, + sortingFn: (a, b) => a.original.id.localeCompare(b.original.id), + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "cpu", + accessorFn: (record) => record.cpu, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} + }, + }, + { + id: "memory", + accessorFn: (record) => record.memory, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, false, undefined, true) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "net", + accessorFn: (record) => record.net, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, true, undefined, true) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "health", + invertSorting: true, + accessorFn: (record) => record.health, + header: ({ column }) => , + cell: ({ getValue }) => { + const healthValue = getValue() as number + const healthStatus = ContainerHealthLabels[healthValue] || "Unknown" + return ( + + + + {healthStatus} + + ) + }, + }, + { + id: "status", + accessorFn: (record) => record.status, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as number + return ( + + {hourWithSeconds(new Date(timestamp).toISOString())} + + ) + }, + }, +] + +function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { + const isSorted = column.getIsSorted() + return ( + + ) +} \ No newline at end of file diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx new file mode 100644 index 00000000..b549bfa5 --- /dev/null +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -0,0 +1,489 @@ +import { t } from "@lingui/core/macro" +import { Trans } from "@lingui/react/macro" +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Table as TableType, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { memo, RefObject, useEffect, useRef, useState } from "react" +import { Input } from "@/components/ui/input" +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { pb } from "@/lib/api" +import type { ContainerRecord } from "@/types" +import { containerChartCols } from "@/components/containers-table/containers-table-columns" +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums" +import { cn, useBrowserStorage } from "@/lib/utils" +import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet" +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" +import { Button } from "@/components/ui/button" +import { $allSystemsById } from "@/lib/stores" +import { MaximizeIcon, RefreshCwIcon } from "lucide-react" +import { Separator } from "../ui/separator" +import { Link } from "../router" +import { listenKeys } from "nanostores" + +const syntaxTheme = "github-dark-dimmed" + +export default function ContainersTable({ systemId }: { systemId?: string }) { + const [data, setData] = useState([]) + const [sorting, setSorting] = useBrowserStorage( + `sort-c-${systemId ? 1 : 0}`, + [{ id: systemId ? "name" : "system", desc: false }], + sessionStorage + ) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState("") + + useEffect(() => { + const pbOptions = { + fields: "id,name,cpu,memory,net,health,status,system,updated", + } + + const fetchData = (lastXMs: number) => { + let filter: string + if (systemId) { + filter = pb.filter("system={:system}", { system: systemId }) + } else { + filter = pb.filter("updated > {:updated}", { updated: Date.now() - lastXMs }) + } + pb.collection("containers") + .getList(0, 2000, { + ...pbOptions, + filter, + }) + .then(({ items }) => setData((curItems) => { + const containerIds = new Set(items.map(item => item.id)) + const now = Date.now() + for (const item of curItems) { + if (!containerIds.has(item.id) && now - item.updated < 70_000) { + items.push(item) + } + } + return items + })) + } + + // initial load + fetchData(70_000) + + // if no systemId, poll every 10 seconds + if (!systemId) { + // poll every 10 seconds + const intervalId = setInterval(() => fetchData(10_500), 10_000) + // clear interval on unmount + return () => clearInterval(intervalId) + } + + // if systemId, fetch containers after the system is updated + return listenKeys($allSystemsById, [systemId], (_newSystems) => { + setTimeout(() => fetchData(1000), 100) + }) + }, []) + + const table = useReactTable({ + data, + columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + defaultColumn: { + sortUndefined: "last", + size: 100, + minSize: 0, + }, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const container = row.original + const systemName = $allSystemsById.get()[container.system]?.name ?? "" + const id = container.id ?? "" + const name = container.name ?? "" + const status = container.status ?? "" + const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" + const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status}`.toLowerCase() + + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + const rows = table.getRowModel().rows + const visibleColumns = table.getVisibleLeafColumns() + + return ( + + +
+
+ + All Containers + + + Click on a container to view more information. + +
+ setGlobalFilter(e.target.value)} + className="ms-auto px-4 w-full max-w-full md:w-64" + /> +
+
+
+ +
+
+ ) +} + +const AllContainersTable = memo( + function AllContainersTable({ table, rows, colLength }: { table: TableType; rows: Row[]; colLength: number }) { + // The virtualizer will need a reference to the scrollable container element + const scrollRef = useRef(null) + const activeContainer = useRef(null) + const [sheetOpen, setSheetOpen] = useState(false) + const openSheet = (container: ContainerRecord) => { + activeContainer.current = container + setSheetOpen(true) + } + + const virtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 54, + getScrollElement: () => scrollRef.current, + overscan: 5, + }) + const virtualRows = virtualizer.getVirtualItems() + + const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) + const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) + + return ( +
2) && "min-h-50" + )} + ref={scrollRef} + > + {/* add header height to table size */} +
+ + + + {rows.length ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + return ( + + ) + }) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ) + } +) + + +async function getLogsHtml(container: ContainerRecord): Promise { + try { + const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", { + system: container.system, + container: container.id, + })]) + return highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) + } catch (error) { + console.error(error) + return "" + } +} + +async function getInfoHtml(container: ContainerRecord): Promise { + try { + let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", { + system: container.system, + container: container.id, + })]) + try { + info = JSON.stringify(JSON.parse(info), null, 2) + } catch (_) { } + return highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) + } catch (error) { + console.error(error) + return "" + } +} + +function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject }) { + const container = activeContainer.current + if (!container) return null + + const [logsDisplay, setLogsDisplay] = useState("") + const [infoDisplay, setInfoDisplay] = useState("") + const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false) + const [infoFullscreenOpen, setInfoFullscreenOpen] = useState(false) + const [isRefreshingLogs, setIsRefreshingLogs] = useState(false) + const logsContainerRef = useRef(null) + + function scrollLogsToBottom() { + if (logsContainerRef.current) { + logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight }) + } + } + + const refreshLogs = async () => { + setIsRefreshingLogs(true) + const startTime = Date.now() + + try { + const logsHtml = await getLogsHtml(container) + setLogsDisplay(logsHtml) + setTimeout(scrollLogsToBottom, 20) + } catch (error) { + console.error(error) + } finally { + // Ensure minimum spin duration of 800ms + const elapsed = Date.now() - startTime + const remaining = Math.max(0, 500 - elapsed) + setTimeout(() => { + setIsRefreshingLogs(false) + }, remaining) + } + } + + useEffect(() => { + setLogsDisplay("") + setInfoDisplay(""); + if (!container) return + (async () => { + const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) + setLogsDisplay(logsHtml) + setInfoDisplay(infoHtml) + setTimeout(scrollLogsToBottom, 20) + })() + }, [container]) + + return ( + <> + + + + + + {container.name} + + {$allSystemsById.get()[container.system]?.name ?? ""} + + {container.status} + + {container.id} + + {ContainerHealthLabels[container.health as ContainerHealth]} + + +
+
+

{t`Logs`}

+ + +
+
+
+
+
+

{t`Detail`}

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