mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-24 14:36:17 +01:00
Compare commits
1 Commits
quiet-hour
...
928-contai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40165dca32 |
101
.github/workflows/docker-images.yml
vendored
101
.github/workflows/docker-images.yml
vendored
@@ -10,141 +10,67 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 5
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# henrygd/beszel
|
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
context: ./
|
||||||
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia
|
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-intel
|
|
||||||
- image: henrygd/beszel-agent-intel
|
- image: henrygd/beszel-agent-intel
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=raw,value=latest
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent-intel
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||||
|
context: ./
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel-agent:alpine
|
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password_secret: GITHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -174,7 +100,12 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: ${{ matrix.tags }}
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
@@ -192,7 +123,7 @@ jobs:
|
|||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./
|
context: "${{ matrix.context }}"
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
|
|||||||
17
.github/workflows/inactivity-actions.yml
vendored
17
.github/workflows/inactivity-actions.yml
vendored
@@ -10,25 +10,12 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lock-inactive:
|
|
||||||
name: Lock Inactive Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
|
||||||
id: lock
|
|
||||||
with:
|
|
||||||
days-inactive-issues: 14
|
|
||||||
lock-reason-issues: ""
|
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-inactive-prs: 36524
|
|
||||||
lock-reason-prs: ""
|
|
||||||
|
|
||||||
close-stale:
|
close-stale:
|
||||||
name: Close Stale Issues
|
name: Close Stale Issues
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Close Stale Issues
|
- name: Close Stale Issues
|
||||||
uses: actions/stale@v10
|
uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -45,8 +32,6 @@ jobs:
|
|||||||
# Timing
|
# Timing
|
||||||
days-before-issue-stale: 14
|
days-before-issue-stale: 14
|
||||||
days-before-issue-close: 7
|
days-before-issue-close: 7
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-before-pr-stale: 36524
|
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ project_name: beszel
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate -run fetchsmartctl ./agent
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -7,7 +7,7 @@ SKIP_WEB ?= false
|
|||||||
# Set executable extension based on target OS
|
# Set executable extension based on target OS
|
||||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -46,14 +46,8 @@ build-dotnet-conditional:
|
|||||||
fi; \
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download smartctl.exe at build time for Windows (skips if already present)
|
|
||||||
fetch-smartctl-conditional:
|
|
||||||
@if [ "$(OS)" = "windows" ]; then \
|
|
||||||
go generate -run fetchsmartctl ./agent; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update build-agent to include conditional .NET build
|
# Update build-agent to include conditional .NET build
|
||||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
build-agent: tidy build-dotnet-conditional
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ type Agent struct {
|
|||||||
server *ssh.Server // SSH server
|
server *ssh.Server // SSH server
|
||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
|
||||||
systemdManager *systemdManager // Manages systemd services
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -102,20 +100,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
agent.systemdManager, err = newSystemdManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Systemd", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("SMART", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
agent.gpuManager, err = NewGPUManager()
|
if gm, err := NewGPUManager(); err != nil {
|
||||||
if err != nil {
|
|
||||||
slog.Debug("GPU", "err", err)
|
slog.Debug("GPU", "err", err)
|
||||||
|
} else {
|
||||||
|
agent.gpuManager = gm
|
||||||
}
|
}
|
||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
@@ -160,20 +149,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
|
||||||
if totalCount > 0 {
|
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
|
||||||
data.Info.Services = []uint16{totalCount, numFailed}
|
|
||||||
}
|
|
||||||
if a.systemdManager.hasFreshStats {
|
|
||||||
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
@@ -182,11 +158,6 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
// Add percentages to Info struct for dashboard
|
|
||||||
if stats.DiskTotal > 0 {
|
|
||||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
|
||||||
data.Info.ExtraFsPct[key] = pct
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ package battery
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var systemHasBattery = false
|
||||||
systemHasBattery = false
|
var haveCheckedBattery = false
|
||||||
haveCheckedBattery = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
func HasReadableBattery() bool {
|
||||||
@@ -22,13 +19,8 @@ func HasReadableBattery() bool {
|
|||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
batteries, err := battery.GetAll()
|
bat, err := battery.Get(0)
|
||||||
for _, bat := range batteries {
|
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
||||||
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
|
||||||
systemHasBattery = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !systemHasBattery {
|
if !systemHasBattery {
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
@@ -36,49 +28,25 @@ func HasReadableBattery() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state
|
// GetBatteryStats returns the current battery percent and charge state
|
||||||
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
if !HasReadableBattery() {
|
if !systemHasBattery {
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
batteries, err := battery.GetAll()
|
batteries, err := battery.GetAll()
|
||||||
// we'll handle errors later by skipping batteries with errors, rather
|
if err != nil || len(batteries) == 0 {
|
||||||
// than skipping everything because of the presence of some errors.
|
return batteryPercent, batteryState, err
|
||||||
if len(batteries) == 0 {
|
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCapacity := float64(0)
|
totalCapacity := float64(0)
|
||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
errs, partialErrs := err.(battery.Errors)
|
for _, bat := range batteries {
|
||||||
|
if bat.Design != 0 {
|
||||||
batteryState = math.MaxUint8
|
totalCapacity += bat.Design
|
||||||
|
} else {
|
||||||
for i, bat := range batteries {
|
totalCapacity += bat.Full
|
||||||
if partialErrs && errs[i] != nil {
|
|
||||||
// if there were some errors, like missing data, skip it
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if bat == nil || bat.Full == 0 {
|
|
||||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
|
||||||
// we can't guarantee that, so don't skip based on charge.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalCapacity += bat.Full
|
|
||||||
totalCharge += bat.Current
|
totalCharge += bat.Current
|
||||||
if bat.State.Raw >= 0 {
|
|
||||||
batteryState = uint8(bat.State.Raw)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
|
||||||
// https://github.com/distatus/battery/issues/34
|
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
|
||||||
// and return an error. This also prevents a divide by zero.
|
|
||||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
|
||||||
}
|
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
|
batteryState = uint8(batteries[0].State.Raw)
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -273,12 +271,6 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case *common.FingerprintResponse:
|
case *common.FingerprintResponse:
|
||||||
response.Fingerprint = v
|
response.Fingerprint = v
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
// case []byte:
|
// case []byte:
|
||||||
// response.RawBytes = v
|
// response.RawBytes = v
|
||||||
// case string:
|
// case string:
|
||||||
|
|||||||
92
agent/cpu.go
92
agent/cpu.go
@@ -4,12 +4,10 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
|
||||||
|
|
||||||
// init initializes the CPU monitoring by storing the initial CPU times
|
// init initializes the CPU monitoring by storing the initial CPU times
|
||||||
// for the default 60-second cache interval.
|
// for the default 60-second cache interval.
|
||||||
@@ -17,92 +15,23 @@ func init() {
|
|||||||
if times, err := cpu.Times(false); err == nil {
|
if times, err := cpu.Times(false); err == nil {
|
||||||
lastCpuTimes[60000] = times[0]
|
lastCpuTimes[60000] = times[0]
|
||||||
}
|
}
|
||||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
|
||||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CpuMetrics contains detailed CPU usage breakdown
|
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
|
||||||
type CpuMetrics struct {
|
// It uses the specified cache time interval to determine the time window for calculation.
|
||||||
Total float64
|
// Returns the CPU usage percentage (0-100) and any error encountered.
|
||||||
User float64
|
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
|
||||||
System float64
|
|
||||||
Iowait float64
|
|
||||||
Steal float64
|
|
||||||
Idle float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
|
||||||
// It returns percentages for total, user, system, iowait, and steal time.
|
|
||||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
|
||||||
times, err := cpu.Times(false)
|
times, err := cpu.Times(false)
|
||||||
if err != nil || len(times) == 0 {
|
if err != nil || len(times) == 0 {
|
||||||
return CpuMetrics{}, err
|
return 0, err
|
||||||
}
|
}
|
||||||
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
||||||
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
||||||
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
||||||
}
|
}
|
||||||
|
delta := calculateBusy(lastCpuTimes[cacheTimeMs], times[0])
|
||||||
t1 := lastCpuTimes[cacheTimeMs]
|
|
||||||
t2 := times[0]
|
|
||||||
|
|
||||||
t1All, _ := getAllBusy(t1)
|
|
||||||
t2All, _ := getAllBusy(t2)
|
|
||||||
|
|
||||||
totalDelta := t2All - t1All
|
|
||||||
if totalDelta <= 0 {
|
|
||||||
return CpuMetrics{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := CpuMetrics{
|
|
||||||
Total: calculateBusy(t1, t2),
|
|
||||||
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
|
|
||||||
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
|
|
||||||
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
|
|
||||||
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
|
|
||||||
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCpuTimes[cacheTimeMs] = times[0]
|
lastCpuTimes[cacheTimeMs] = times[0]
|
||||||
return metrics, nil
|
return delta, nil
|
||||||
}
|
|
||||||
|
|
||||||
// clampPercent ensures the percentage is between 0 and 100
|
|
||||||
func clampPercent(value float64) float64 {
|
|
||||||
return math.Min(100, math.Max(0, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
|
|
||||||
// It uses cached previous measurements for the provided cache interval.
|
|
||||||
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|
||||||
perCoreTimes, err := cpu.Times(true)
|
|
||||||
if err != nil || len(perCoreTimes) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize cache if needed
|
|
||||||
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
|
||||||
|
|
||||||
// Limit to the number of cores available in both samples
|
|
||||||
length := len(perCoreTimes)
|
|
||||||
if len(lastTimes) < length {
|
|
||||||
length = len(lastTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := make([]uint8, length)
|
|
||||||
for i := 0; i < length; i++ {
|
|
||||||
t1 := lastTimes[i]
|
|
||||||
t2 := perCoreTimes[i]
|
|
||||||
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
|
|
||||||
return usage, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||||
@@ -112,10 +41,13 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|||||||
t1All, t1Busy := getAllBusy(t1)
|
t1All, t1Busy := getAllBusy(t1)
|
||||||
t2All, t2Busy := getAllBusy(t2)
|
t2All, t2Busy := getAllBusy(t2)
|
||||||
|
|
||||||
if t2All <= t1All || t2Busy <= t1Busy {
|
if t2Busy <= t1Busy {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
|
if t2All <= t1All {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return math.Min(100, math.Max(0, (t2Busy-t1Busy)/(t2All-t1All)*100))
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
efPath := "/extra-filesystems"
|
efPath := "/extra-filesystems"
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.Partitions(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,13 +38,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
slog.Debug("Disk", "partitions", partitions)
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
// trim trailing backslash for Windows devices (#1361)
|
|
||||||
if isWindows {
|
|
||||||
for i, p := range partitions {
|
|
||||||
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ioContext := context.WithValue(a.sensorsContext,
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
// )
|
// )
|
||||||
@@ -60,7 +52,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
||||||
var key string
|
var key string
|
||||||
if isWindows {
|
if runtime.GOOS == "windows" {
|
||||||
key = device
|
key = device
|
||||||
} else {
|
} else {
|
||||||
key = filepath.Base(device)
|
key = filepath.Base(device)
|
||||||
@@ -95,9 +87,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate root mount point for this system
|
|
||||||
rootMountPoint := a.getRootMountPoint()
|
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
if filesystem != "" {
|
if filesystem != "" {
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
@@ -141,7 +130,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
// Binary root fallback or docker root fallback
|
// Binary root fallback or docker root fallback
|
||||||
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
if match {
|
if match {
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
@@ -177,8 +166,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
@@ -315,32 +304,3 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system
|
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
|
||||||
func (a *Agent) getRootMountPoint() string {
|
|
||||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
|
||||||
content := string(osReleaseContent)
|
|
||||||
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
|
||||||
strings.Contains(content, "coreos") ||
|
|
||||||
strings.Contains(content, "flatcar") ||
|
|
||||||
strings.Contains(content, "rhel-atomic") ||
|
|
||||||
strings.Contains(content, "centos-atomic") {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
|
||||||
if _, err := os.Stat("/run/ostree"); err == nil {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|||||||
405
agent/docker.go
405
agent/docker.go
@@ -3,17 +3,13 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,16 +25,18 @@ const (
|
|||||||
dockerTimeoutMs = 2100
|
dockerTimeoutMs = 2100
|
||||||
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
||||||
maxNetworkSpeedBps uint64 = 5e9
|
maxNetworkSpeedBps uint64 = 5e9
|
||||||
|
// Container and health constants
|
||||||
|
composeProjectLabel = "com.docker.compose.project"
|
||||||
|
healthStatusNone = "none"
|
||||||
|
containerStateRunning = "running"
|
||||||
|
containerStateUnknown = "unknown"
|
||||||
|
volumeTypeVolume = "volume"
|
||||||
|
diskOpRead = "read"
|
||||||
|
diskOpReadCap = "Read"
|
||||||
|
diskOpWrite = "write"
|
||||||
|
diskOpWriteCap = "Write"
|
||||||
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
||||||
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
||||||
// Number of log lines to request when fetching container logs
|
|
||||||
dockerLogsTail = 200
|
|
||||||
// Maximum size of a single log frame (1MB) to prevent memory exhaustion
|
|
||||||
// A single log line larger than 1MB is likely an error or misconfiguration
|
|
||||||
maxLogFrameSize = 1024 * 1024
|
|
||||||
// Maximum total log content size (5MB) to prevent memory exhaustion
|
|
||||||
// This provides a reasonable limit for network transfer and browser rendering
|
|
||||||
maxTotalLogSize = 5 * 1024 * 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
@@ -54,7 +52,8 @@ type dockerManager struct {
|
|||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
volumeSizeCache map[string]float64 // Cached volume sizes (name -> size in MB)
|
||||||
|
volumeSizeUpdated time.Time // Last time volume sizes were updated
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -66,6 +65,11 @@ type dockerManager struct {
|
|||||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
|
||||||
|
// Disk I/O delta trackers - one per cache time to avoid interference
|
||||||
|
// cacheTimeMs -> DeltaTracker for disk bytes read/written
|
||||||
|
diskReadTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
diskWriteTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -96,19 +100,6 @@ func (d *dockerManager) dequeue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldExcludeContainer checks if a container name matches any exclusion pattern
|
|
||||||
func (dm *dockerManager) shouldExcludeContainer(name string) bool {
|
|
||||||
if len(dm.excludeContainers) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, pattern := range dm.excludeContainers {
|
|
||||||
if match, _ := path.Match(pattern, name); match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns stats for all running containers with cache-time-aware delta tracking
|
// Returns stats for all running containers with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
||||||
resp, err := dm.client.Get("http://localhost/containers/json")
|
resp, err := dm.client.Get("http://localhost/containers/json")
|
||||||
@@ -136,13 +127,6 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
ctr.IdShort = ctr.Id[:12]
|
ctr.IdShort = ctr.Id[:12]
|
||||||
|
|
||||||
// Skip this container if it matches the exclusion pattern
|
|
||||||
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
|
|
||||||
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.validIds[ctr.IdShort] = struct{}{}
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
// check if container is less than 1 minute old (possible restart)
|
// check if container is less than 1 minute old (possible restart)
|
||||||
// note: can't use Created field because it's not updated on restart
|
// note: can't use Created field because it's not updated on restart
|
||||||
@@ -192,8 +176,9 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepare network trackers for next interval for this cache time
|
// prepare network and disk trackers for next interval for this cache time
|
||||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||||
|
dm.cycleDiskDeltasForCacheTime(cacheTimeMs)
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
@@ -272,6 +257,32 @@ func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDiskTracker returns the DeltaTracker for disk I/O for a specific cache time, creating it if needed
|
||||||
|
func (dm *dockerManager) getDiskTracker(cacheTimeMs uint16, isRead bool) *deltatracker.DeltaTracker[string, uint64] {
|
||||||
|
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
if isRead {
|
||||||
|
trackers = dm.diskReadTrackers
|
||||||
|
} else {
|
||||||
|
trackers = dm.diskWriteTrackers
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackers[cacheTimeMs] == nil {
|
||||||
|
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackers[cacheTimeMs]
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleDiskDeltasForCacheTime cycles the disk delta trackers for a specific cache time
|
||||||
|
func (dm *dockerManager) cycleDiskDeltasForCacheTime(cacheTimeMs uint16) {
|
||||||
|
if dm.diskReadTrackers[cacheTimeMs] != nil {
|
||||||
|
dm.diskReadTrackers[cacheTimeMs].Cycle()
|
||||||
|
}
|
||||||
|
if dm.diskWriteTrackers[cacheTimeMs] != nil {
|
||||||
|
dm.diskWriteTrackers[cacheTimeMs].Cycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
||||||
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
||||||
var total_sent, total_recv uint64
|
var total_sent, total_recv uint64
|
||||||
@@ -317,6 +328,50 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
|
|||||||
return sent_delta, recv_delta
|
return sent_delta, recv_delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculateDiskStats calculates disk read/write deltas using DeltaTracker
|
||||||
|
func (dm *dockerManager) calculateDiskStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, cacheTimeMs uint16) (uint64, uint64) {
|
||||||
|
var total_read, total_write uint64
|
||||||
|
for _, entry := range apiStats.BlkioStats.IoServiceBytesRecursive {
|
||||||
|
switch entry.Op {
|
||||||
|
case diskOpRead, diskOpReadCap:
|
||||||
|
total_read += entry.Value
|
||||||
|
case diskOpWrite, diskOpWriteCap:
|
||||||
|
total_write += entry.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the DeltaTracker for this specific cache time
|
||||||
|
readTracker := dm.getDiskTracker(cacheTimeMs, true)
|
||||||
|
writeTracker := dm.getDiskTracker(cacheTimeMs, false)
|
||||||
|
|
||||||
|
// Set current values in the cache-time-specific DeltaTracker
|
||||||
|
readTracker.Set(ctr.IdShort, total_read)
|
||||||
|
writeTracker.Set(ctr.IdShort, total_write)
|
||||||
|
|
||||||
|
// Get deltas (bytes since last measurement)
|
||||||
|
read_delta_raw := readTracker.Delta(ctr.IdShort)
|
||||||
|
write_delta_raw := writeTracker.Delta(ctr.IdShort)
|
||||||
|
|
||||||
|
// Calculate bytes per second if we have previous data
|
||||||
|
var read_delta, write_delta uint64
|
||||||
|
if initialized {
|
||||||
|
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||||
|
if millisecondsElapsed > 0 {
|
||||||
|
if read_delta_raw > 0 {
|
||||||
|
read_delta = read_delta_raw * 1000 / millisecondsElapsed
|
||||||
|
}
|
||||||
|
if write_delta_raw > 0 {
|
||||||
|
write_delta = write_delta_raw * 1000 / millisecondsElapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current disk values for legacy compatibility
|
||||||
|
stats.PrevDisk.Read, stats.PrevDisk.Write = total_read, total_write
|
||||||
|
|
||||||
|
return read_delta, write_delta
|
||||||
|
}
|
||||||
|
|
||||||
// validateCpuPercentage checks if CPU percentage is within valid range
|
// validateCpuPercentage checks if CPU percentage is within valid range
|
||||||
func validateCpuPercentage(cpuPct float64, containerName string) error {
|
func validateCpuPercentage(cpuPct float64, containerName string) error {
|
||||||
if cpuPct > 100 {
|
if cpuPct > 100 {
|
||||||
@@ -326,54 +381,21 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateContainerStatsValues updates the final stats values
|
// updateContainerStatsValues updates the final stats values
|
||||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta, read_delta, write_delta uint64, readTime time.Time) {
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
|
stats.DiskRead = bytesToMegabytes(float64(read_delta))
|
||||||
|
stats.DiskWrite = bytesToMegabytes(float64(write_delta))
|
||||||
stats.PrevReadTime = readTime
|
stats.PrevReadTime = readTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
|
||||||
trimmed := strings.TrimSpace(status)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "", container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove "About " from status
|
|
||||||
trimmed = strings.Replace(trimmed, "About ", "", 1)
|
|
||||||
|
|
||||||
openIdx := strings.LastIndex(trimmed, "(")
|
|
||||||
if openIdx == -1 || !strings.HasSuffix(trimmed, ")") {
|
|
||||||
return trimmed, container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
statusText := strings.TrimSpace(trimmed[:openIdx])
|
|
||||||
if statusText == "" {
|
|
||||||
statusText = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
|
|
||||||
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
|
||||||
// Strip it so it maps correctly to the known health states.
|
|
||||||
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
|
||||||
prefix := strings.TrimSpace(healthText[:colonIdx])
|
|
||||||
if prefix == "health" || prefix == "health status" {
|
|
||||||
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if health, ok := container.DockerHealthStrings[healthText]; ok {
|
|
||||||
return statusText, health
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed, container.DockerHealthNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates stats for individual container with cache-time-aware delta tracking
|
// Updates stats for individual container with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
||||||
name := ctr.Names[0][1:]
|
name := ctr.Names[0][1:]
|
||||||
|
|
||||||
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
|
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -384,21 +406,68 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// add empty values if they doesn't exist in map
|
// add empty values if they doesn't exist in map
|
||||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
if !initialized {
|
if !initialized {
|
||||||
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
stats = &container.Stats{Name: name}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Id = ctr.IdShort
|
// Update name in case it changed
|
||||||
|
stats.Name = name
|
||||||
|
|
||||||
statusText, health := parseDockerStatus(ctr.Status)
|
// Set container metadata
|
||||||
stats.Status = statusText
|
stats.IdShort = ctr.IdShort
|
||||||
stats.Health = health
|
stats.Status = ctr.State
|
||||||
|
if stats.Status == "" {
|
||||||
|
stats.Status = containerStateUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set health status
|
||||||
|
stats.Health = healthStatusNone
|
||||||
|
if ctr.Health != "" {
|
||||||
|
stats.Health = ctr.Health
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Docker Compose project name
|
||||||
|
if ctr.Labels != nil {
|
||||||
|
if projectName, exists := ctr.Labels[composeProjectLabel]; exists {
|
||||||
|
stats.Project = projectName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate uptime for running containers
|
||||||
|
if ctr.StartedAt > 0 && stats.Status == containerStateRunning {
|
||||||
|
startedTime := time.Unix(ctr.StartedAt, 0)
|
||||||
|
stats.Uptime = twoDecimals(time.Since(startedTime).Seconds())
|
||||||
|
} else {
|
||||||
|
stats.Uptime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect volume information and fetch sizes
|
||||||
|
volumeCount := 0
|
||||||
|
for _, mount := range ctr.Mounts {
|
||||||
|
if mount.Type == volumeTypeVolume && mount.Name != "" {
|
||||||
|
volumeCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if volumeCount > 0 {
|
||||||
|
stats.Volumes = make(map[string]float64, volumeCount)
|
||||||
|
for _, mount := range ctr.Mounts {
|
||||||
|
if mount.Type == volumeTypeVolume && mount.Name != "" {
|
||||||
|
// Fetch volume size using Docker system df API
|
||||||
|
size := dm.getVolumeSize(mount.Name)
|
||||||
|
stats.Volumes[mount.Name] = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.Volumes = nil
|
||||||
|
}
|
||||||
|
|
||||||
// reset current stats
|
// reset current stats
|
||||||
stats.Cpu = 0
|
stats.Cpu = 0
|
||||||
stats.Mem = 0
|
stats.Mem = 0
|
||||||
stats.NetworkSent = 0
|
stats.NetworkSent = 0
|
||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
stats.DiskRead = 0
|
||||||
|
stats.DiskWrite = 0
|
||||||
|
|
||||||
res := dm.apiStats
|
res := dm.apiStats
|
||||||
res.Networks = nil
|
res.Networks = nil
|
||||||
@@ -448,8 +517,11 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
}
|
}
|
||||||
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
||||||
|
|
||||||
|
// Calculate disk I/O stats using DeltaTracker
|
||||||
|
read_delta, write_delta := dm.calculateDiskStats(ctr, res, stats, initialized, cacheTimeMs)
|
||||||
|
|
||||||
// Update final stats values
|
// Update final stats values
|
||||||
updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read)
|
updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, read_delta, write_delta, res.Read)
|
||||||
// store per-cache-time read time for Windows CPU percent calc
|
// store per-cache-time read time for Windows CPU percent calc
|
||||||
dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
|
dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
|
||||||
|
|
||||||
@@ -525,19 +597,6 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
userAgent: "Docker-Client/",
|
userAgent: "Docker-Client/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
|
||||||
var excludeContainers []string
|
|
||||||
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
|
||||||
parts := strings.SplitSeq(excludeStr, ",")
|
|
||||||
for part := range parts {
|
|
||||||
trimmed := strings.TrimSpace(part)
|
|
||||||
if trimmed != "" {
|
|
||||||
excludeContainers = append(excludeContainers, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -547,7 +606,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
apiStats: &container.ApiStats{},
|
apiStats: &container.ApiStats{},
|
||||||
excludeContainers: excludeContainers,
|
volumeSizeCache: make(map[string]float64),
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
@@ -555,6 +614,8 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
diskReadTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
diskWriteTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
@@ -609,6 +670,49 @@ func (dm *dockerManager) checkDockerVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getVolumeSize returns the cached size of a Docker volume
|
||||||
|
// Refreshes the cache every 5 minutes using the system df API
|
||||||
|
// Returns size in MB (megabytes)
|
||||||
|
func (dm *dockerManager) getVolumeSize(volumeName string) float64 {
|
||||||
|
// Refresh cache if older than 5 minutes
|
||||||
|
if time.Since(dm.volumeSizeUpdated) > 5*time.Minute {
|
||||||
|
dm.refreshVolumeSizes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return dm.volumeSizeCache[volumeName]
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshVolumeSizes fetches all volume sizes from Docker and updates the cache
|
||||||
|
func (dm *dockerManager) refreshVolumeSizes() {
|
||||||
|
type volumeInfo struct {
|
||||||
|
Name string
|
||||||
|
UsageData struct {
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type systemDfResponse struct {
|
||||||
|
Volumes []volumeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := dm.client.Get("http://localhost/system/df")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var dfData systemDfResponse
|
||||||
|
if err := dm.decode(resp, &dfData); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all volume sizes in cache
|
||||||
|
for _, vol := range dfData.Volumes {
|
||||||
|
// Convert bytes to MB (megabytes)
|
||||||
|
dm.volumeSizeCache[vol.Name] = float64(vol.UsageData.Size) / 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.volumeSizeUpdated = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||||
if dm.buf == nil {
|
if dm.buf == nil {
|
||||||
@@ -636,122 +740,3 @@ func getDockerHost() string {
|
|||||||
}
|
}
|
||||||
return scheme + socks[0]
|
return scheme + socks[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// getContainerInfo fetches the inspection data for a container
|
|
||||||
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
|
||||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := dm.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
||||||
return nil, fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove sensitive environment variables from Config.Env
|
|
||||||
var containerInfo map[string]any
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&containerInfo); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if config, ok := containerInfo["Config"].(map[string]any); ok {
|
|
||||||
delete(config, "Env")
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(containerInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
totalBytesRead := 0
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent memory exhaustion from excessively large frames
|
|
||||||
if frameLen > maxLogFrameSize {
|
|
||||||
return fmt.Errorf("log frame size (%d) exceeds maximum (%d)", frameLen, maxLogFrameSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if reading this frame would exceed total log size limit
|
|
||||||
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
|
||||||
// Read and discard remaining data to avoid blocking
|
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(reader, int64(frameLen)))
|
|
||||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
totalBytesRead += int(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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -860,61 +858,11 @@ func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDockerStatus(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expectedStatus string
|
|
||||||
expectedHealth container.DockerHealth
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "status with About an removed",
|
|
||||||
input: "Up About an hour (healthy)",
|
|
||||||
expectedStatus: "Up an hour",
|
|
||||||
expectedHealth: container.DockerHealthHealthy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status without About an unchanged",
|
|
||||||
input: "Up 2 hours (healthy)",
|
|
||||||
expectedStatus: "Up 2 hours",
|
|
||||||
expectedHealth: container.DockerHealthHealthy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status with About and no parentheses",
|
|
||||||
input: "Up About an hour",
|
|
||||||
expectedStatus: "Up an hour",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status without parentheses",
|
|
||||||
input: "Created",
|
|
||||||
expectedStatus: "Created",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty status",
|
|
||||||
input: "",
|
|
||||||
expectedStatus: "",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
status, health := parseDockerStatus(tt.input)
|
|
||||||
assert.Equal(t, tt.expectedStatus, status)
|
|
||||||
assert.Equal(t, tt.expectedHealth, health)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConstantsAndUtilityFunctions(t *testing.T) {
|
func TestConstantsAndUtilityFunctions(t *testing.T) {
|
||||||
// Test constants are properly defined
|
// Test constants are properly defined
|
||||||
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
||||||
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
||||||
assert.Equal(t, 2100, dockerTimeoutMs)
|
assert.Equal(t, 2100, dockerTimeoutMs)
|
||||||
assert.Equal(t, uint32(1024*1024), uint32(maxLogFrameSize)) // 1MB
|
|
||||||
assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB
|
|
||||||
|
|
||||||
// Test utility functions
|
// Test utility functions
|
||||||
assert.Equal(t, 1.5, twoDecimals(1.499))
|
assert.Equal(t, 1.5, twoDecimals(1.499))
|
||||||
@@ -925,281 +873,3 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
|
|||||||
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
|
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
|
||||||
assert.Equal(t, 0.0, bytesToMegabytes(0))
|
assert.Equal(t, 0.0, bytesToMegabytes(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodeDockerLogStream(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []byte
|
|
||||||
expected string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "simple log entry",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 11 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B,
|
|
||||||
'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd',
|
|
||||||
},
|
|
||||||
expected: "Hello World",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple frames",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'H', 'e', 'l', 'l', 'o',
|
|
||||||
// Frame 2: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'W', 'o', 'r', 'l', 'd',
|
|
||||||
},
|
|
||||||
expected: "HelloWorld",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero length frame",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 0 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
// Frame 2: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'H', 'e', 'l', 'l', 'o',
|
|
||||||
},
|
|
||||||
expected: "Hello",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: []byte{},
|
|
||||||
expected: "",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
reader := bytes.NewReader(tt.input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, builder.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|
||||||
t.Run("excessively large frame should error", func(t *testing.T) {
|
|
||||||
// Create a frame with size exceeding maxLogFrameSize
|
|
||||||
excessiveSize := uint32(maxLogFrameSize + 1)
|
|
||||||
input := []byte{
|
|
||||||
// Frame header with excessive size
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(excessiveSize >> 24), byte(excessiveSize >> 16), byte(excessiveSize >> 8), byte(excessiveSize),
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bytes.NewReader(input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "log frame size")
|
|
||||||
assert.Contains(t, err.Error(), "exceeds maximum")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("total size limit should truncate", func(t *testing.T) {
|
|
||||||
// Create frames that exceed maxTotalLogSize (5MB)
|
|
||||||
// Use frames within maxLogFrameSize (1MB) to avoid single-frame rejection
|
|
||||||
frameSize := uint32(800 * 1024) // 800KB per frame
|
|
||||||
var input []byte
|
|
||||||
|
|
||||||
// Frames 1-6: 800KB each (total 4.8MB - within 5MB limit)
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
char := byte('A' + i)
|
|
||||||
frameHeader := []byte{
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
|
||||||
}
|
|
||||||
input = append(input, frameHeader...)
|
|
||||||
input = append(input, bytes.Repeat([]byte{char}, int(frameSize))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frame 7: 800KB (would bring total to 5.6MB, exceeding 5MB limit - should be truncated)
|
|
||||||
frame7Header := []byte{
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
|
||||||
}
|
|
||||||
input = append(input, frame7Header...)
|
|
||||||
input = append(input, bytes.Repeat([]byte{'Z'}, int(frameSize))...)
|
|
||||||
|
|
||||||
reader := bytes.NewReader(input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
// Should complete without error (graceful truncation)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
// Should have read 6 frames (4.8MB total, stopping before 7th would exceed 5MB limit)
|
|
||||||
expectedSize := int(frameSize) * 6
|
|
||||||
assert.Equal(t, expectedSize, builder.Len())
|
|
||||||
// Should contain A-F but not Z
|
|
||||||
result := builder.String()
|
|
||||||
assert.Contains(t, result, "A")
|
|
||||||
assert.Contains(t, result, "F")
|
|
||||||
assert.NotContains(t, result, "Z")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllocateBuffer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
currentCap int
|
|
||||||
needed int
|
|
||||||
expectedCap int
|
|
||||||
shouldRealloc bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "buffer has enough capacity",
|
|
||||||
currentCap: 1024,
|
|
||||||
needed: 512,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "buffer needs reallocation",
|
|
||||||
currentCap: 512,
|
|
||||||
needed: 1024,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "buffer needs exact size",
|
|
||||||
currentCap: 1024,
|
|
||||||
needed: 1024,
|
|
||||||
expectedCap: 1024,
|
|
||||||
shouldRealloc: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
current := make([]byte, 0, tt.currentCap)
|
|
||||||
result := allocateBuffer(current, tt.needed)
|
|
||||||
|
|
||||||
assert.Equal(t, tt.needed, len(result))
|
|
||||||
assert.GreaterOrEqual(t, cap(result), tt.expectedCap)
|
|
||||||
|
|
||||||
if tt.shouldRealloc {
|
|
||||||
// If reallocation was needed, capacity should be at least the needed size
|
|
||||||
assert.GreaterOrEqual(t, cap(result), tt.needed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldExcludeContainer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
containerName string
|
|
||||||
patterns []string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty patterns excludes nothing",
|
|
||||||
containerName: "any-container",
|
|
||||||
patterns: []string{},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match - excluded",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"test-web", "test-api"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match - not excluded",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-web", "test-api"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard prefix match - excluded",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard prefix match - not excluded",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-*"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard suffix match - excluded",
|
|
||||||
containerName: "myapp-staging",
|
|
||||||
patterns: []string{"*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard suffix match - not excluded",
|
|
||||||
containerName: "myapp-prod",
|
|
||||||
patterns: []string{"*-staging"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard both sides match - excluded",
|
|
||||||
containerName: "test-myapp-staging",
|
|
||||||
patterns: []string{"*-myapp-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard both sides match - not excluded",
|
|
||||||
containerName: "prod-yourapp-live",
|
|
||||||
patterns: []string{"*-myapp-*"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - matches first",
|
|
||||||
containerName: "test-container",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - matches second",
|
|
||||||
containerName: "myapp-staging",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - no match",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed exact and wildcard - exact match",
|
|
||||||
containerName: "temp-container",
|
|
||||||
patterns: []string{"temp-container", "test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed exact and wildcard - wildcard match",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"temp-container", "test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
dm := &dockerManager{
|
|
||||||
excludeContainers: tt.patterns,
|
|
||||||
}
|
|
||||||
result := dm.shouldExcludeContainer(tt.containerName)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,12 +49,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
// Build command arguments, optionally selecting a device via -d
|
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
||||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
|
||||||
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
|
||||||
args = append(args, "-d", dev)
|
|
||||||
}
|
|
||||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
|
||||||
// Avoid blocking if intel_gpu_top writes to stderr
|
// Avoid blocking if intel_gpu_top writes to stderr
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = io.Discard
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
@@ -134,9 +129,7 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
// Collect engine names from header1
|
// Collect engine names from header1
|
||||||
for _, col := range h1 {
|
for _, col := range h1 {
|
||||||
key := strings.TrimRightFunc(col, func(r rune) bool {
|
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
|
||||||
return (r >= '0' && r <= '9') || r == '/'
|
|
||||||
})
|
|
||||||
var friendly string
|
var friendly string
|
||||||
switch key {
|
switch key {
|
||||||
case "RCS":
|
case "RCS":
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1439,15 +1437,6 @@ func TestParseIntelHeaders(t *testing.T) {
|
|||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "basic headers with RCS BCS VCS using index in name",
|
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
|
||||||
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
|
||||||
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
|
||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "headers with only RCS",
|
name: "headers with only RCS",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
@@ -1635,42 +1624,3 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
|
|
||||||
// Prepare a file to capture args
|
|
||||||
argsFile := filepath.Join(dir, "args.txt")
|
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
|
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
|
||||||
script := fmt.Sprintf(`#!/bin/sh
|
|
||||||
echo "$@" > %s
|
|
||||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
|
||||||
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
|
||||||
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
|
||||||
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
|
||||||
`, argsFile)
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set device selector via prefixed env var
|
|
||||||
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
|
||||||
t.Fatalf("collectIntelStats error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that -d sriov was passed
|
|
||||||
data, err := os.ReadFile(argsFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed reading args file: %v", err)
|
|
||||||
}
|
|
||||||
argsStr := strings.TrimSpace(string(data))
|
|
||||||
require.Contains(t, argsStr, "-d sriov")
|
|
||||||
require.Contains(t, argsStr, "-s ")
|
|
||||||
require.Contains(t, argsStr, "-l")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlerContext provides context for request handlers
|
// HandlerContext provides context for request handlers
|
||||||
@@ -47,10 +43,6 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
|
|
||||||
registry.Register(common.GetData, &GetDataHandler{})
|
registry.Register(common.GetData, &GetDataHandler{})
|
||||||
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -107,99 +99,3 @@ type CheckFingerprintHandler struct{}
|
|||||||
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
||||||
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetContainerLogsHandler handles container log requests
|
|
||||||
type GetContainerLogsHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.dockerManager == nil {
|
|
||||||
return hctx.SendResponse("", hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.ContainerLogsRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(logContent, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetContainerInfoHandler handles container info requests
|
|
||||||
type GetContainerInfoHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.dockerManager == nil {
|
|
||||||
return hctx.SendResponse("", hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.ContainerInfoRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(string(info), hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetSmartDataHandler handles SMART data requests
|
|
||||||
type GetSmartDataHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.smartManager == nil {
|
|
||||||
// return empty map to indicate no data
|
|
||||||
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
|
||||||
}
|
|
||||||
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
|
||||||
slog.Debug("smart refresh failed", "err", err)
|
|
||||||
}
|
|
||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetSystemdInfoHandler handles detailed systemd service info requests
|
|
||||||
type GetSystemdInfoHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.systemdManager == nil {
|
|
||||||
return errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.SystemdInfoRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ServiceName == "" {
|
|
||||||
return errors.New("service name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -170,12 +168,6 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case *system.CombinedData:
|
case *system.CombinedData:
|
||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
default:
|
default:
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
}
|
}
|
||||||
|
|||||||
974
agent/smart.go
974
agent/smart.go
@@ -1,974 +0,0 @@
|
|||||||
//go:generate -command fetchsmartctl go run ./tools/fetchsmartctl
|
|
||||||
//go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SmartManager manages data collection for SMART devices
|
|
||||||
type SmartManager struct {
|
|
||||||
sync.Mutex
|
|
||||||
SmartDataMap map[string]*smart.SmartData
|
|
||||||
SmartDevices []*DeviceInfo
|
|
||||||
refreshMutex sync.Mutex
|
|
||||||
lastScanTime time.Time
|
|
||||||
binPath string
|
|
||||||
excludedDevices map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type scanOutput struct {
|
|
||||||
Devices []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
InfoName string `json:"info_name"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
} `json:"devices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
InfoName string `json:"info_name"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
// typeVerified reports whether we have already parsed SMART data for this device
|
|
||||||
// with the stored parserType. When true we can skip re-running the detection logic.
|
|
||||||
typeVerified bool
|
|
||||||
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
|
||||||
parserType string
|
|
||||||
}
|
|
||||||
|
|
||||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
|
||||||
|
|
||||||
// Refresh updates SMART data for all known devices
|
|
||||||
func (sm *SmartManager) Refresh(forceScan bool) error {
|
|
||||||
sm.refreshMutex.Lock()
|
|
||||||
defer sm.refreshMutex.Unlock()
|
|
||||||
|
|
||||||
scanErr := sm.ScanDevices(false)
|
|
||||||
if scanErr != nil {
|
|
||||||
slog.Debug("smartctl scan failed", "err", scanErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
devices := sm.devicesSnapshot()
|
|
||||||
var collectErr error
|
|
||||||
for _, deviceInfo := range devices {
|
|
||||||
if deviceInfo == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := sm.CollectSmart(deviceInfo); err != nil {
|
|
||||||
slog.Debug("smartctl collect failed", "device", deviceInfo.Name, "err", err)
|
|
||||||
collectErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sm.resolveRefreshError(scanErr, collectErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
|
||||||
// while holding the primary mutex for longer than necessary.
|
|
||||||
func (sm *SmartManager) devicesSnapshot() []*DeviceInfo {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
devices := make([]*DeviceInfo, len(sm.SmartDevices))
|
|
||||||
copy(devices, sm.SmartDevices)
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasSmartData reports whether any SMART data has been collected.
|
|
||||||
// func (sm *SmartManager) hasSmartData() bool {
|
|
||||||
// sm.Lock()
|
|
||||||
// defer sm.Unlock()
|
|
||||||
|
|
||||||
// return len(sm.SmartDataMap) > 0
|
|
||||||
// }
|
|
||||||
|
|
||||||
// resolveRefreshError determines the proper error to return after a refresh.
|
|
||||||
func (sm *SmartManager) resolveRefreshError(scanErr, collectErr error) error {
|
|
||||||
sm.Lock()
|
|
||||||
noDevices := len(sm.SmartDevices) == 0
|
|
||||||
noData := len(sm.SmartDataMap) == 0
|
|
||||||
sm.Unlock()
|
|
||||||
|
|
||||||
if noDevices {
|
|
||||||
if scanErr != nil {
|
|
||||||
return scanErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !noData {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if collectErr != nil {
|
|
||||||
return collectErr
|
|
||||||
}
|
|
||||||
if scanErr != nil {
|
|
||||||
return scanErr
|
|
||||||
}
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentData returns the current SMART data
|
|
||||||
func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
result := make(map[string]smart.SmartData, len(sm.SmartDataMap))
|
|
||||||
for key, value := range sm.SmartDataMap {
|
|
||||||
if value != nil {
|
|
||||||
result[key] = *value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanDevices scans for SMART devices
|
|
||||||
// Scan devices using `smartctl --scan -j`
|
|
||||||
// If scan fails, return error
|
|
||||||
// If scan succeeds, parse the output and update the SmartDevices slice
|
|
||||||
func (sm *SmartManager) ScanDevices(force bool) error {
|
|
||||||
if !force && time.Since(sm.lastScanTime) < 30*time.Minute {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sm.lastScanTime = time.Now()
|
|
||||||
currentDevices := sm.devicesSnapshot()
|
|
||||||
|
|
||||||
var configuredDevices []*DeviceInfo
|
|
||||||
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
|
|
||||||
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
|
||||||
config := strings.TrimSpace(configuredRaw)
|
|
||||||
if config == "" {
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedDevices, err := sm.parseConfiguredDevices(config)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configuredDevices = parsedDevices
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
|
|
||||||
var (
|
|
||||||
scanErr error
|
|
||||||
scannedDevices []*DeviceInfo
|
|
||||||
hasValidScan bool
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
scanErr = err
|
|
||||||
} else {
|
|
||||||
scannedDevices, hasValidScan = sm.parseScan(output)
|
|
||||||
if !hasValidScan {
|
|
||||||
scanErr = errNoValidSmartData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
|
||||||
finalDevices = sm.filterExcludedDevices(finalDevices)
|
|
||||||
sm.updateSmartDevices(finalDevices)
|
|
||||||
|
|
||||||
if len(finalDevices) == 0 {
|
|
||||||
if scanErr != nil {
|
|
||||||
slog.Debug("smartctl scan failed", "err", scanErr)
|
|
||||||
return scanErr
|
|
||||||
}
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
|
||||||
entries := strings.Split(config, ",")
|
|
||||||
devices := make([]*DeviceInfo, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
if entry == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(entry, ":", 2)
|
|
||||||
|
|
||||||
name := strings.TrimSpace(parts[0])
|
|
||||||
if name == "" {
|
|
||||||
return nil, fmt.Errorf("invalid SMART_DEVICES entry %q", entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
devType := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
devType = strings.ToLower(strings.TrimSpace(parts[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
devices = append(devices, &DeviceInfo{
|
|
||||||
Name: name,
|
|
||||||
Type: devType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(devices) == 0 {
|
|
||||||
return nil, errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
return devices, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) refreshExcludedDevices() {
|
|
||||||
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
|
||||||
sm.excludedDevices = make(map[string]struct{})
|
|
||||||
|
|
||||||
for entry := range strings.SplitSeq(rawValue, ",") {
|
|
||||||
device := strings.TrimSpace(entry)
|
|
||||||
if device == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sm.excludedDevices[device] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
|
|
||||||
_, exists := sm.excludedDevices[deviceName]
|
|
||||||
return exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
|
|
||||||
if devices == nil {
|
|
||||||
return []*DeviceInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
excluded := sm.excludedDevices
|
|
||||||
if len(excluded) == 0 {
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := make([]*DeviceInfo, 0, len(devices))
|
|
||||||
for _, device := range devices {
|
|
||||||
if device == nil || device.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, skip := excluded[device.Name]; skip {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, device)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectSmartOutputType inspects sections that are unique to each smartctl
|
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
|
||||||
// when the reported device type is ambiguous or missing.
|
|
||||||
func detectSmartOutputType(output []byte) string {
|
|
||||||
var hints struct {
|
|
||||||
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
|
|
||||||
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
|
|
||||||
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &hints); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
|
|
||||||
return "nvme"
|
|
||||||
case hasJSONValue(hints.AtaSmartAttributes):
|
|
||||||
return "sat"
|
|
||||||
case hasJSONValue(hints.ScsiErrorCounterLog):
|
|
||||||
return "scsi"
|
|
||||||
default:
|
|
||||||
return "sat"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasJSONValue reports whether a JSON payload contains a concrete value. The
|
|
||||||
// smartctl output often emits "null" for sections that do not apply, so we
|
|
||||||
// only treat non-null content as a hint.
|
|
||||||
func hasJSONValue(raw json.RawMessage) bool {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(string(raw))
|
|
||||||
return trimmed != "" && trimmed != "null"
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeParserType(value string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "nvme", "sntasmedia", "sntrealtek":
|
|
||||||
return "nvme"
|
|
||||||
case "sat", "ata":
|
|
||||||
return "sat"
|
|
||||||
case "scsi":
|
|
||||||
return "scsi"
|
|
||||||
default:
|
|
||||||
return strings.ToLower(strings.TrimSpace(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
|
||||||
// it is not provided, and updates the device info when a parser succeeds.
|
|
||||||
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
|
||||||
parsers := []struct {
|
|
||||||
Type string
|
|
||||||
Parse func([]byte) (bool, int)
|
|
||||||
}{
|
|
||||||
{Type: "nvme", Parse: sm.parseSmartForNvme},
|
|
||||||
{Type: "sat", Parse: sm.parseSmartForSata},
|
|
||||||
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceType := normalizeParserType(deviceInfo.parserType)
|
|
||||||
if deviceType == "" {
|
|
||||||
deviceType = normalizeParserType(deviceInfo.Type)
|
|
||||||
}
|
|
||||||
if deviceInfo.parserType == "" {
|
|
||||||
switch deviceType {
|
|
||||||
case "nvme", "sat", "scsi":
|
|
||||||
deviceInfo.parserType = deviceType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only run the type detection when we do not yet know which parser works
|
|
||||||
// or the previous attempt failed.
|
|
||||||
needsDetection := deviceType == "" || !deviceInfo.typeVerified
|
|
||||||
if needsDetection {
|
|
||||||
structureType := detectSmartOutputType(output)
|
|
||||||
if deviceType != structureType {
|
|
||||||
deviceType = structureType
|
|
||||||
deviceInfo.parserType = structureType
|
|
||||||
deviceInfo.typeVerified = false
|
|
||||||
}
|
|
||||||
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, structureType) {
|
|
||||||
deviceInfo.Type = structureType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the most likely parser first, but keep the remaining parsers in reserve
|
|
||||||
// so an incorrect hint never leaves the device unparsed.
|
|
||||||
selectedParsers := make([]struct {
|
|
||||||
Type string
|
|
||||||
Parse func([]byte) (bool, int)
|
|
||||||
}, 0, len(parsers))
|
|
||||||
if deviceType != "" {
|
|
||||||
for _, parser := range parsers {
|
|
||||||
if parser.Type == deviceType {
|
|
||||||
selectedParsers = append(selectedParsers, parser)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, parser := range parsers {
|
|
||||||
alreadySelected := false
|
|
||||||
for _, selected := range selectedParsers {
|
|
||||||
if selected.Type == parser.Type {
|
|
||||||
alreadySelected = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if alreadySelected {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selectedParsers = append(selectedParsers, parser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the selected parsers in order until we find one that succeeds.
|
|
||||||
for _, parser := range selectedParsers {
|
|
||||||
hasData, _ := parser.Parse(output)
|
|
||||||
if hasData {
|
|
||||||
deviceInfo.parserType = parser.Type
|
|
||||||
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, parser.Type) {
|
|
||||||
deviceInfo.Type = parser.Type
|
|
||||||
}
|
|
||||||
// Remember that this parser is valid so future refreshes can bypass
|
|
||||||
// detection entirely.
|
|
||||||
deviceInfo.typeVerified = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave verification false so the next pass will attempt detection again.
|
|
||||||
deviceInfo.typeVerified = false
|
|
||||||
slog.Debug("parsing failed", "device", deviceInfo.Name)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectSmart collects SMART data for a device
|
|
||||||
// Collect data using `smartctl -d <type> -aj /dev/<device>` when device type is known
|
|
||||||
// Always attempts to parse output even if command fails, as some data may still be available
|
|
||||||
// If collect fails, return error
|
|
||||||
// If collect succeeds, parse the output and update the SmartDataMap
|
|
||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
|
||||||
// for initial data collection when no cached data exists
|
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|
||||||
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
|
||||||
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Try with -n standby first if we have existing data
|
|
||||||
args := sm.smartctlArgs(deviceInfo, true)
|
|
||||||
cmd := exec.CommandContext(ctx, sm.binPath, args...)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
// Check if device is in standby (exit status 2)
|
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
|
||||||
if hasExistingData {
|
|
||||||
// Device is in standby and we have cached data, keep using cache
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// No cached data, need to collect initial data by bypassing standby
|
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel2()
|
|
||||||
args = sm.smartctlArgs(deviceInfo, false)
|
|
||||||
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
|
||||||
output, err = cmd.CombinedOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
|
||||||
|
|
||||||
if !hasValidData {
|
|
||||||
if err != nil {
|
|
||||||
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// smartctlArgs returns the arguments for the smartctl command
|
|
||||||
// based on the device type and whether to include standby mode
|
|
||||||
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
|
||||||
args := make([]string, 0, 7)
|
|
||||||
|
|
||||||
if deviceInfo != nil {
|
|
||||||
deviceType := strings.ToLower(deviceInfo.Type)
|
|
||||||
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
|
||||||
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
|
||||||
args = append(args, "-d", deviceInfo.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, "-a", "--json=c")
|
|
||||||
|
|
||||||
if includeStandby {
|
|
||||||
args = append(args, "-n", "standby")
|
|
||||||
}
|
|
||||||
|
|
||||||
if deviceInfo != nil {
|
|
||||||
args = append(args, deviceInfo.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasDataForDevice checks if we have cached SMART data for a specific device
|
|
||||||
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
// Check if any cached data has this device name
|
|
||||||
for _, data := range sm.SmartDataMap {
|
|
||||||
if data != nil && data.DiskName == deviceName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseScan parses the output of smartctl --scan -j and returns the discovered devices.
|
|
||||||
func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) {
|
|
||||||
scan := &scanOutput{}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(output, scan); err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(scan.Devices) == 0 {
|
|
||||||
slog.Debug("no devices found in smartctl scan")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
devices := make([]*DeviceInfo, 0, len(scan.Devices))
|
|
||||||
for _, device := range scan.Devices {
|
|
||||||
slog.Debug("smartctl scan", "name", device.Name, "type", device.Type, "protocol", device.Protocol)
|
|
||||||
devices = append(devices, &DeviceInfo{
|
|
||||||
Name: device.Name,
|
|
||||||
Type: device.Type,
|
|
||||||
InfoName: device.InfoName,
|
|
||||||
Protocol: device.Protocol,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return devices, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeDeviceLists combines scanned and configured SMART devices, preferring
|
|
||||||
// configured SMART_DEVICES when both sources reference the same device.
|
|
||||||
func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo {
|
|
||||||
if len(scanned) == 0 && len(configured) == 0 {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
// preserveVerifiedType copies the verified type/parser metadata from an existing
|
|
||||||
// device record so that subsequent scans/config updates never downgrade a
|
|
||||||
// previously verified device.
|
|
||||||
preserveVerifiedType := func(target, prev *DeviceInfo) {
|
|
||||||
if prev == nil || !prev.typeVerified {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target.Type = prev.Type
|
|
||||||
target.typeVerified = true
|
|
||||||
target.parserType = prev.parserType
|
|
||||||
}
|
|
||||||
|
|
||||||
existingIndex := make(map[string]*DeviceInfo, len(existing))
|
|
||||||
for _, dev := range existing {
|
|
||||||
if dev == nil || dev.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingIndex[dev.Name] = dev
|
|
||||||
}
|
|
||||||
|
|
||||||
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
|
|
||||||
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
|
|
||||||
|
|
||||||
// Start with the newly scanned devices so we always surface fresh metadata,
|
|
||||||
// but ensure we retain any previously verified parser assignment.
|
|
||||||
for _, dev := range scanned {
|
|
||||||
if dev == nil || dev.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Work on a copy so we can safely adjust metadata without mutating the
|
|
||||||
// input slices that may be reused elsewhere.
|
|
||||||
copyDev := *dev
|
|
||||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
|
||||||
preserveVerifiedType(©Dev, prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalDevices = append(finalDevices, ©Dev)
|
|
||||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge configured devices on top so users can override scan results (except
|
|
||||||
// for verified type information).
|
|
||||||
for _, dev := range configured {
|
|
||||||
if dev == nil || dev.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingDev, ok := deviceIndex[dev.Name]; ok {
|
|
||||||
// Only update the type if it has not been verified yet; otherwise we
|
|
||||||
// keep the existing verified metadata intact.
|
|
||||||
if dev.Type != "" && !existingDev.typeVerified {
|
|
||||||
newType := strings.TrimSpace(dev.Type)
|
|
||||||
existingDev.Type = newType
|
|
||||||
existingDev.typeVerified = false
|
|
||||||
existingDev.parserType = normalizeParserType(newType)
|
|
||||||
}
|
|
||||||
if dev.InfoName != "" {
|
|
||||||
existingDev.InfoName = dev.InfoName
|
|
||||||
}
|
|
||||||
if dev.Protocol != "" {
|
|
||||||
existingDev.Protocol = dev.Protocol
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
copyDev := *dev
|
|
||||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
|
||||||
preserveVerifiedType(©Dev, prev)
|
|
||||||
} else if copyDev.Type != "" {
|
|
||||||
copyDev.parserType = normalizeParserType(copyDev.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalDevices = append(finalDevices, ©Dev)
|
|
||||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalDevices
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateSmartDevices replaces the cached device list and prunes SMART data
|
|
||||||
// entries whose backing device no longer exists.
|
|
||||||
func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
sm.SmartDevices = devices
|
|
||||||
|
|
||||||
if len(sm.SmartDataMap) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
validNames := make(map[string]struct{}, len(devices))
|
|
||||||
for _, device := range devices {
|
|
||||||
if device == nil || device.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
validNames[device.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, data := range sm.SmartDataMap {
|
|
||||||
if data == nil {
|
|
||||||
delete(sm.SmartDataMap, key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := validNames[data.DiskName]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(sm.SmartDataMap, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
|
|
||||||
func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool {
|
|
||||||
vendorUpper := strings.ToUpper(data.ScsiVendor)
|
|
||||||
productUpper := strings.ToUpper(data.ScsiProduct)
|
|
||||||
modelUpper := strings.ToUpper(data.ModelName)
|
|
||||||
|
|
||||||
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out
|
|
||||||
func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool {
|
|
||||||
modelUpper := strings.ToUpper(data.ModelName)
|
|
||||||
|
|
||||||
return sm.isVirtualDeviceFromStrings(modelUpper)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out
|
|
||||||
func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool {
|
|
||||||
vendorUpper := strings.ToUpper(data.ScsiVendor)
|
|
||||||
productUpper := strings.ToUpper(data.ScsiProduct)
|
|
||||||
modelUpper := strings.ToUpper(data.ScsiModelName)
|
|
||||||
|
|
||||||
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device
|
|
||||||
func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
|
|
||||||
for _, field := range fields {
|
|
||||||
fieldUpper := strings.ToUpper(field)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target
|
|
||||||
strings.Contains(fieldUpper, "VIRTUAL"),
|
|
||||||
strings.Contains(fieldUpper, "QEMU"),
|
|
||||||
strings.Contains(fieldUpper, "VBOX"),
|
|
||||||
strings.Contains(fieldUpper, "VMWARE"),
|
|
||||||
strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
|
||||||
// Returns hasValidData and exitStatus
|
|
||||||
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
|
||||||
var data smart.SmartInfoForSata
|
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.SerialNumber == "" {
|
|
||||||
slog.Debug("no serial number", "device", data.Device.Name)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
|
||||||
if sm.isVirtualDevice(&data) {
|
|
||||||
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
keyName := data.SerialNumber
|
|
||||||
|
|
||||||
// if device does not exist in SmartDataMap, initialize it
|
|
||||||
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
|
||||||
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update SmartData
|
|
||||||
smartData := sm.SmartDataMap[keyName]
|
|
||||||
// smartData.ModelFamily = data.ModelFamily
|
|
||||||
smartData.ModelName = data.ModelName
|
|
||||||
smartData.SerialNumber = data.SerialNumber
|
|
||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
|
||||||
smartData.Temperature = data.Temperature.Current
|
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
|
||||||
smartData.DiskName = data.Device.Name
|
|
||||||
smartData.DiskType = data.Device.Type
|
|
||||||
|
|
||||||
// update SmartAttributes
|
|
||||||
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
|
||||||
for _, attr := range data.AtaSmartAttributes.Table {
|
|
||||||
rawValue := uint64(attr.Raw.Value)
|
|
||||||
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
|
|
||||||
rawValue = parsed
|
|
||||||
}
|
|
||||||
smartAttr := &smart.SmartAttribute{
|
|
||||||
ID: attr.ID,
|
|
||||||
Name: attr.Name,
|
|
||||||
Value: attr.Value,
|
|
||||||
Worst: attr.Worst,
|
|
||||||
Threshold: attr.Thresh,
|
|
||||||
RawValue: rawValue,
|
|
||||||
RawString: attr.Raw.String,
|
|
||||||
WhenFailed: attr.WhenFailed,
|
|
||||||
}
|
|
||||||
smartData.Attributes = append(smartData.Attributes, smartAttr)
|
|
||||||
}
|
|
||||||
sm.SmartDataMap[keyName] = smartData
|
|
||||||
|
|
||||||
return true, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSmartStatus(temperature uint8, passed bool) string {
|
|
||||||
if passed {
|
|
||||||
return "PASSED"
|
|
||||||
} else if temperature > 0 {
|
|
||||||
return "FAILED"
|
|
||||||
} else {
|
|
||||||
return "UNKNOWN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
|
||||||
var data smart.SmartInfoForScsi
|
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.SerialNumber == "" {
|
|
||||||
slog.Debug("no serial number", "device", data.Device.Name)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
|
||||||
if sm.isVirtualDeviceScsi(&data) {
|
|
||||||
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
keyName := data.SerialNumber
|
|
||||||
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
|
||||||
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
|
||||||
}
|
|
||||||
|
|
||||||
smartData := sm.SmartDataMap[keyName]
|
|
||||||
smartData.ModelName = data.ScsiModelName
|
|
||||||
smartData.SerialNumber = data.SerialNumber
|
|
||||||
smartData.FirmwareVersion = data.ScsiRevision
|
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
|
||||||
smartData.Temperature = data.Temperature.Current
|
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
|
||||||
smartData.DiskName = data.Device.Name
|
|
||||||
smartData.DiskType = data.Device.Type
|
|
||||||
|
|
||||||
attributes := make([]*smart.SmartAttribute, 0, 10)
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime})
|
|
||||||
|
|
||||||
readStats := data.ScsiErrorCounterLog.Read
|
|
||||||
writeStats := data.ScsiErrorCounterLog.Write
|
|
||||||
verifyStats := data.ScsiErrorCounterLog.Verify
|
|
||||||
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations})
|
|
||||||
if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 {
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)})
|
|
||||||
}
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations})
|
|
||||||
if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 {
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)})
|
|
||||||
}
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors})
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations})
|
|
||||||
if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 {
|
|
||||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)})
|
|
||||||
}
|
|
||||||
|
|
||||||
smartData.Attributes = attributes
|
|
||||||
sm.SmartDataMap[keyName] = smartData
|
|
||||||
|
|
||||||
return true, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseScsiGigabytesProcessed(value string) int64 {
|
|
||||||
if value == "" {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
normalized := strings.ReplaceAll(value, ",", "")
|
|
||||||
parsed, err := strconv.ParseInt(normalized, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
|
||||||
// Returns hasValidData and exitStatus
|
|
||||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|
||||||
data := &smart.SmartInfoForNvme{}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(output, &data); err != nil {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.SerialNumber == "" {
|
|
||||||
slog.Debug("no serial number", "device", data.Device.Name)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
|
||||||
if sm.isVirtualDeviceNvme(data) {
|
|
||||||
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
|
|
||||||
return false, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
keyName := data.SerialNumber
|
|
||||||
|
|
||||||
// if device does not exist in SmartDataMap, initialize it
|
|
||||||
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
|
||||||
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update SmartData
|
|
||||||
smartData := sm.SmartDataMap[keyName]
|
|
||||||
smartData.ModelName = data.ModelName
|
|
||||||
smartData.SerialNumber = data.SerialNumber
|
|
||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
|
||||||
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
|
||||||
smartData.DiskName = data.Device.Name
|
|
||||||
smartData.DiskType = data.Device.Type
|
|
||||||
|
|
||||||
// nvme attributes does not follow the same format as ata attributes,
|
|
||||||
// so we manually map each field to SmartAttributes
|
|
||||||
log := data.NVMeSmartHealthInformationLog
|
|
||||||
smartData.Attributes = []*smart.SmartAttribute{
|
|
||||||
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
|
|
||||||
{Name: "Temperature", RawValue: uint64(log.Temperature)},
|
|
||||||
{Name: "AvailableSpare", RawValue: uint64(log.AvailableSpare)},
|
|
||||||
{Name: "AvailableSpareThreshold", RawValue: uint64(log.AvailableSpareThreshold)},
|
|
||||||
{Name: "PercentageUsed", RawValue: uint64(log.PercentageUsed)},
|
|
||||||
{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
|
|
||||||
{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
|
|
||||||
{Name: "HostReads", RawValue: uint64(log.HostReads)},
|
|
||||||
{Name: "HostWrites", RawValue: uint64(log.HostWrites)},
|
|
||||||
{Name: "ControllerBusyTime", RawValue: uint64(log.ControllerBusyTime)},
|
|
||||||
{Name: "PowerCycles", RawValue: uint64(log.PowerCycles)},
|
|
||||||
{Name: "PowerOnHours", RawValue: uint64(log.PowerOnHours)},
|
|
||||||
{Name: "UnsafeShutdowns", RawValue: uint64(log.UnsafeShutdowns)},
|
|
||||||
{Name: "MediaErrors", RawValue: uint64(log.MediaErrors)},
|
|
||||||
{Name: "NumErrLogEntries", RawValue: uint64(log.NumErrLogEntries)},
|
|
||||||
{Name: "WarningTempTime", RawValue: uint64(log.WarningTempTime)},
|
|
||||||
{Name: "CriticalCompTime", RawValue: uint64(log.CriticalCompTime)},
|
|
||||||
}
|
|
||||||
|
|
||||||
sm.SmartDataMap[keyName] = smartData
|
|
||||||
|
|
||||||
return true, data.Smartctl.ExitStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
|
||||||
func (sm *SmartManager) detectSmartctl() (string, error) {
|
|
||||||
isWindows := runtime.GOOS == "windows"
|
|
||||||
|
|
||||||
// Load embedded smartctl.exe for Windows amd64 builds.
|
|
||||||
if isWindows && runtime.GOARCH == "amd64" {
|
|
||||||
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if path, err := exec.LookPath("smartctl"); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
locations := []string{}
|
|
||||||
if isWindows {
|
|
||||||
locations = append(locations,
|
|
||||||
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
|
||||||
}
|
|
||||||
for _, location := range locations {
|
|
||||||
if _, err := os.Stat(location); err == nil {
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errors.New("smartctl not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSmartManager creates and initializes a new SmartManager
|
|
||||||
func NewSmartManager() (*SmartManager, error) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
sm.refreshExcludedDevices()
|
|
||||||
path, err := sm.detectSmartctl()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug(err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
slog.Debug("smartctl", "path", path)
|
|
||||||
sm.binPath = path
|
|
||||||
return sm, nil
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
return "", errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseSmartForScsi(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed reading fixture: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
|
||||||
if !hasData {
|
|
||||||
t.Fatalf("expected SCSI data to parse successfully")
|
|
||||||
}
|
|
||||||
if exitStatus != 0 {
|
|
||||||
t.Fatalf("expected exit status 0, got %d", exitStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
|
|
||||||
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
|
|
||||||
assert.Equal(t, deviceData.FirmwareVersion, "C240")
|
|
||||||
assert.Equal(t, deviceData.DiskName, "/dev/sde")
|
|
||||||
assert.Equal(t, deviceData.DiskType, "scsi")
|
|
||||||
assert.EqualValues(t, deviceData.Temperature, 34)
|
|
||||||
assert.Equal(t, deviceData.SmartStatus, "PASSED")
|
|
||||||
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
|
|
||||||
|
|
||||||
if len(deviceData.Attributes) == 0 {
|
|
||||||
t.Fatalf("expected attributes to be populated")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSata(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 64, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
|
||||||
|
|
||||||
assert.Equal(t, "P3-2TB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/sda", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "sat", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(31), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
|
||||||
jsonPayload := []byte(`{
|
|
||||||
"smartctl": {"exit_status": 0},
|
|
||||||
"device": {"name": "/dev/sdz", "type": "sat"},
|
|
||||||
"model_name": "Example",
|
|
||||||
"serial_number": "PARENTHESES123",
|
|
||||||
"firmware_version": "1.0",
|
|
||||||
"user_capacity": {"bytes": 1024},
|
|
||||||
"smart_status": {"passed": true},
|
|
||||||
"temperature": {"current": 25},
|
|
||||||
"ata_smart_attributes": {
|
|
||||||
"table": [
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"name": "Power_On_Hours",
|
|
||||||
"value": 93,
|
|
||||||
"worst": 55,
|
|
||||||
"thresh": 0,
|
|
||||||
"when_failed": "",
|
|
||||||
"raw": {
|
|
||||||
"value": 57891864217128,
|
|
||||||
"string": "39925 (212 206 0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
data, ok := sm.SmartDataMap["PARENTHESES123"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, data.Attributes, 1)
|
|
||||||
|
|
||||||
attr := data.Attributes[0]
|
|
||||||
assert.Equal(t, uint64(39925), attr.RawValue)
|
|
||||||
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForNvme(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
|
||||||
|
|
||||||
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "nvme", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(61), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasDataForDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-1": {DiskName: "/dev/sda"},
|
|
||||||
"serial-2": nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
|
||||||
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
|
||||||
originalDevice := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: []*DeviceInfo{
|
|
||||||
originalDevice,
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot := sm.devicesSnapshot()
|
|
||||||
require.Len(t, snapshot, 2)
|
|
||||||
|
|
||||||
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
|
|
||||||
assert.Equal(t, "/dev/sda", snapshot[0].Name)
|
|
||||||
|
|
||||||
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
|
|
||||||
|
|
||||||
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
|
|
||||||
assert.Len(t, snapshot, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", ":sat")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", " ")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
assert.ErrorIs(t, err, errNoValidSmartData)
|
|
||||||
assert.Empty(t, sm.SmartDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgsWithoutType(t *testing.T) {
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
args := sm.smartctlArgs(device, true)
|
|
||||||
assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgs(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, true),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-a", "--json=c", "-n", "standby"},
|
|
||||||
sm.smartctlArgs(nil, true),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveRefreshError(t *testing.T) {
|
|
||||||
scanErr := errors.New("scan failed")
|
|
||||||
collectErr := errors.New("collect failed")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
devices []*DeviceInfo
|
|
||||||
data map[string]*smart.SmartData
|
|
||||||
scanErr error
|
|
||||||
collectErr error
|
|
||||||
expectedErr error
|
|
||||||
expectNoErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no devices returns scan error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "has data ignores errors",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: map[string]*smart.SmartData{"serial": {}},
|
|
||||||
scanErr: scanErr,
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectNoErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collect error preferred",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scan error returned when no data",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no errors returns sentinel",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
expectedErr: errNoValidSmartData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no devices collect error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: tt.devices,
|
|
||||||
SmartDataMap: tt.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
|
|
||||||
if tt.expectNoErr {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expectedErr == nil {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, tt.expectedErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseScan(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-active": {DiskName: "/dev/sda"},
|
|
||||||
"serial-stale": {DiskName: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanJSON := []byte(`{
|
|
||||||
"devices": [
|
|
||||||
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
|
|
||||||
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
|
|
||||||
devices, hasData := sm.parseScan(scanJSON)
|
|
||||||
assert.True(t, hasData)
|
|
||||||
|
|
||||||
sm.updateSmartDevices(devices)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
|
|
||||||
_, activeExists := sm.SmartDataMap["serial-active"]
|
|
||||||
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
|
|
||||||
|
|
||||||
_, staleExists := sm.SmartDataMap["serial-stale"]
|
|
||||||
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
||||||
{Name: "/dev/nvme0", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat-override"},
|
|
||||||
{Name: "/dev/sdb", Type: "sat"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 3)
|
|
||||||
|
|
||||||
byName := make(map[string]*DeviceInfo, len(merged))
|
|
||||||
for _, dev := range merged {
|
|
||||||
byName[dev.Name] = dev
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sda")
|
|
||||||
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
|
|
||||||
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/nvme0")
|
|
||||||
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sdb")
|
|
||||||
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "nvme", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
|
|
||||||
|
|
||||||
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
|
|
||||||
t.Helper()
|
|
||||||
attr := findAttr(attributes, name)
|
|
||||||
if attr == nil {
|
|
||||||
t.Fatalf("expected attribute %s to be present", name)
|
|
||||||
}
|
|
||||||
if attr.RawValue != expected {
|
|
||||||
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
|
|
||||||
for _, attr := range attributes {
|
|
||||||
if attr != nil && attr.Name == name {
|
|
||||||
return attr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForSata{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDevice(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceNvme(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
|
|
||||||
{"qemu virtual", "QEMU NVMe Ctrl", true},
|
|
||||||
{"virtualbox virtual", "VBOX NVMe", true},
|
|
||||||
{"vmware virtual", "VMWARE NVMe", true},
|
|
||||||
{"virtual in model", "Virtual NVMe Device", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForNvme{
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceNvme(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceScsi(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForScsi{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ScsiModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceScsi(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
envValue string
|
|
||||||
expectedDevs map[string]struct{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty env",
|
|
||||||
envValue: "",
|
|
||||||
expectedDevs: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single device",
|
|
||||||
envValue: "/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "devices with whitespace",
|
|
||||||
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty entries and whitespace",
|
|
||||||
envValue: "/dev/sda,, /dev/sdb , , ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envValue != "" {
|
|
||||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
|
||||||
} else {
|
|
||||||
// Ensure env var is not set for empty test
|
|
||||||
os.Unsetenv("EXCLUDE_SMART")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
sm.refreshExcludedDevices()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExcludedDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
deviceName string
|
|
||||||
expectedBool bool
|
|
||||||
}{
|
|
||||||
{"excluded device sda", "/dev/sda", true},
|
|
||||||
{"excluded device nvme0", "/dev/nvme0", true},
|
|
||||||
{"non-excluded device sdb", "/dev/sdb", false},
|
|
||||||
{"non-excluded device nvme1", "/dev/nvme1", false},
|
|
||||||
{"empty device name", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := sm.isExcludedDevice(tt.deviceName)
|
|
||||||
assert.Equal(t, tt.expectedBool, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
excludedDevs map[string]struct{}
|
|
||||||
inputDevices []*DeviceInfo
|
|
||||||
expectedDevs []*DeviceInfo
|
|
||||||
expectedLength int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no exclusions",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedLength: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "some devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedLength: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil devices",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: nil,
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter nil and empty name devices",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
nil,
|
|
||||||
{Name: ""},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedLength: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: tt.excludedDevs,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := sm.filterExcludedDevices(tt.inputDevices)
|
|
||||||
|
|
||||||
assert.Len(t, result, tt.expectedLength)
|
|
||||||
assert.Equal(t, tt.expectedDevs, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed smartmontools/smartctl.exe
|
|
||||||
var embeddedSmartctl []byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
smartctlOnce sync.Once
|
|
||||||
smartctlPath string
|
|
||||||
smartctlErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
smartctlOnce.Do(func() {
|
|
||||||
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
|
|
||||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, "smartctl.exe")
|
|
||||||
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
smartctlPath = destPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return smartctlPath, smartctlErr
|
|
||||||
}
|
|
||||||
@@ -78,29 +78,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
if battery.HasReadableBattery() {
|
||||||
systemStats.Battery[0] = batteryPercent
|
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
||||||
systemStats.Battery[1] = batteryState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu metrics
|
// cpu percent
|
||||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
cpuPercent, err := getCpuPercent(cacheTimeMs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
systemStats.Cpu = twoDecimals(cpuPercent)
|
||||||
systemStats.CpuBreakdown = []float64{
|
|
||||||
twoDecimals(cpuMetrics.User),
|
|
||||||
twoDecimals(cpuMetrics.System),
|
|
||||||
twoDecimals(cpuMetrics.Iowait),
|
|
||||||
twoDecimals(cpuMetrics.Steal),
|
|
||||||
twoDecimals(cpuMetrics.Idle),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting cpu metrics", "err", err)
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
}
|
|
||||||
|
|
||||||
// per-core cpu usage
|
|
||||||
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
|
||||||
systemStats.CpuCoresUsage = perCoreUsage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// load average
|
// load average
|
||||||
|
|||||||
273
agent/systemd.go
273
agent/systemd.go
@@ -1,273 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"maps"
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/coreos/go-systemd/v22/dbus"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errNoActiveTime = errors.New("no active time")
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
sync.Mutex
|
|
||||||
serviceStatsMap map[string]*systemd.Service
|
|
||||||
isRunning bool
|
|
||||||
hasFreshStats bool
|
|
||||||
patterns []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &systemdManager{
|
|
||||||
serviceStatsMap: make(map[string]*systemd.Service),
|
|
||||||
patterns: getServicePatterns(),
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.startWorker(conn)
|
|
||||||
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
|
|
||||||
if sm.isRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.isRunning = true
|
|
||||||
// prime the service stats map with the current services
|
|
||||||
_ = sm.getServiceStats(conn, true)
|
|
||||||
// update the services every 10 minutes
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(time.Minute * 10)
|
|
||||||
_ = sm.getServiceStats(nil, true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns the number of systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return len(sm.serviceStatsMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns the number of systemd services in a failed state.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
count := uint16(0)
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
if service.State == systemd.StatusFailed {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats collects statistics for all running systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
|
||||||
// start := time.Now()
|
|
||||||
// defer func() {
|
|
||||||
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
|
|
||||||
// }()
|
|
||||||
|
|
||||||
var services []*systemd.Service
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if !refresh {
|
|
||||||
// return nil
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = false
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn == nil || !conn.Connected() {
|
|
||||||
conn, err = dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error listing systemd service units", "err", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, unit := range units {
|
|
||||||
service, err := sm.updateServiceStats(conn, unit)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = true
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateServiceStats updates the statistics for a single systemd service.
|
|
||||||
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// if service has never been active (no active since time), skip it
|
|
||||||
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
|
|
||||||
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
|
|
||||||
return nil, errNoActiveTime
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
service, serviceExists := sm.serviceStatsMap[unit.Name]
|
|
||||||
if !serviceExists {
|
|
||||||
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
|
|
||||||
sm.serviceStatsMap[unit.Name] = service
|
|
||||||
}
|
|
||||||
|
|
||||||
memPeak := service.MemPeak
|
|
||||||
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
|
|
||||||
// If memPeak is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memPeak = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var memUsage uint64
|
|
||||||
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
|
|
||||||
// If memUsage is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.State = systemd.ParseServiceStatus(unit.ActiveState)
|
|
||||||
service.Sub = systemd.ParseServiceSubState(unit.SubState)
|
|
||||||
|
|
||||||
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
|
|
||||||
if memUsage > memPeak {
|
|
||||||
memPeak = memUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
var cpuUsage uint64
|
|
||||||
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
|
|
||||||
if v, ok := cpuProp.Value.Value().(uint64); ok {
|
|
||||||
cpuUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.Mem = memUsage
|
|
||||||
if memPeak > service.MemPeak {
|
|
||||||
service.MemPeak = memPeak
|
|
||||||
}
|
|
||||||
service.UpdateCPUPercent(cpuUsage)
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceDetails collects extended information for a specific systemd service.
|
|
||||||
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
unitName := serviceName
|
|
||||||
if !strings.HasSuffix(unitName, ".service") {
|
|
||||||
unitName += ".service"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with all unit properties
|
|
||||||
details := make(systemd.ServiceDetails)
|
|
||||||
maps.Copy(details, props)
|
|
||||||
|
|
||||||
// // Add service-specific properties
|
|
||||||
servicePropNames := []string{
|
|
||||||
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
|
|
||||||
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
|
|
||||||
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, propName := range servicePropNames {
|
|
||||||
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
|
|
||||||
value := variant.Value.Value()
|
|
||||||
// Check if the value is MaxUint64, which indicates unlimited/infinite
|
|
||||||
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
|
|
||||||
// Set to nil to indicate unlimited - frontend will handle this appropriately
|
|
||||||
details[propName] = nil
|
|
||||||
} else {
|
|
||||||
details[propName] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return details, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
|
|
||||||
func unescapeServiceName(name string) string {
|
|
||||||
if !strings.Contains(name, "\\x") {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
unescaped, err := strconv.Unquote("\"" + name + "\"")
|
|
||||||
if err != nil {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return unescaped
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServicePatterns returns the list of service patterns to match.
|
|
||||||
// It reads from the SERVICE_PATTERNS environment variable if set,
|
|
||||||
// otherwise defaults to "*service".
|
|
||||||
func getServicePatterns() []string {
|
|
||||||
patterns := []string{}
|
|
||||||
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
|
||||||
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
|
||||||
pattern = strings.TrimSpace(pattern)
|
|
||||||
if pattern == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(pattern, ".service") {
|
|
||||||
pattern += ".service"
|
|
||||||
}
|
|
||||||
patterns = append(patterns, pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(patterns) == 0 {
|
|
||||||
patterns = []string{"*.service"}
|
|
||||||
}
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
hasFreshStats bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
return &systemdManager{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats returns nil for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
|
||||||
return nil, errors.New("systemd manager unavailable")
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
//go:build !linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewSystemdManager(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceStats(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test with refresh = true
|
|
||||||
result := manager.getServiceStats(true)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with refresh = false
|
|
||||||
result = manager.getServiceStats(false)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceDetails(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := manager.getServiceDetails("any-service")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with empty service name
|
|
||||||
result, err = manager.getServiceDetails("")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerFields(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// The non-linux manager should be a simple struct with no special fields
|
|
||||||
// We can't test private fields directly, but we can test the methods work
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
//go:build linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUnescapeServiceName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"nginx.service", "nginx.service"}, // No escaping needed
|
|
||||||
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
|
|
||||||
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
|
|
||||||
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
|
|
||||||
{"no-escape-here", "no-escape-here"}, // No escape sequences
|
|
||||||
{"", ""}, // Empty string
|
|
||||||
{"\\x2d\\x2d", "--"}, // Multiple escapes
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
|
||||||
// Test invalid escape sequences - should return original string
|
|
||||||
invalidInputs := []string{
|
|
||||||
"invalid\\x", // Incomplete escape
|
|
||||||
"invalid\\xZZ", // Invalid hex
|
|
||||||
"invalid\\x2", // Incomplete hex
|
|
||||||
"invalid\\xyz", // Not a valid escape
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, input := range invalidInputs {
|
|
||||||
t.Run(input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(input)
|
|
||||||
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetServicePatterns(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
prefixedEnv string
|
|
||||||
unprefixedEnv string
|
|
||||||
expected []string
|
|
||||||
cleanupEnvVars bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "default when no env var set",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with prefixed env",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with unprefixed env",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "nginx",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "prefixed env takes precedence",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "apache",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns",
|
|
||||||
prefixedEnv: "nginx,apache,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with .service suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed patterns with and without suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache,postgresql.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with whitespace",
|
|
||||||
prefixedEnv: " nginx , apache , postgresql ",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty patterns are skipped",
|
|
||||||
prefixedEnv: "nginx,,apache, ,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard pattern",
|
|
||||||
prefixedEnv: "*nginx*,*apache*",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*nginx*.service", "*apache*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Clean up any existing env vars
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
|
|
||||||
// Set up environment variables
|
|
||||||
if tt.prefixedEnv != "" {
|
|
||||||
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
|
||||||
}
|
|
||||||
if tt.unprefixedEnv != "" {
|
|
||||||
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the function
|
|
||||||
result := getServicePatterns()
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
if tt.cleanupEnvVars {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/nvme0"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761507494,
|
|
||||||
"asctime": "Sun Oct 26 15:38:14 2025 EDT"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
},
|
|
||||||
"model_name": "PELADN 512GB",
|
|
||||||
"serial_number": "2024031600129",
|
|
||||||
"firmware_version": "VC2S038E",
|
|
||||||
"nvme_pci_vendor": {
|
|
||||||
"id": 4332,
|
|
||||||
"subsystem_id": 4332
|
|
||||||
},
|
|
||||||
"nvme_ieee_oui_identifier": 57420,
|
|
||||||
"nvme_controller_id": 1,
|
|
||||||
"nvme_version": {
|
|
||||||
"string": "1.4",
|
|
||||||
"value": 66560
|
|
||||||
},
|
|
||||||
"nvme_number_of_namespaces": 1,
|
|
||||||
"nvme_namespaces": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"size": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"utilization": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"formatted_lba_size": 512,
|
|
||||||
"eui64": {
|
|
||||||
"oui": 57420,
|
|
||||||
"ext_id": 112094110470
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"value": 0,
|
|
||||||
"thin_provisioning": false,
|
|
||||||
"na_fields": false,
|
|
||||||
"dealloc_or_unwritten_block_error": false,
|
|
||||||
"uid_reuse": false,
|
|
||||||
"np_fields": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"lba_formats": [
|
|
||||||
{
|
|
||||||
"formatted": true,
|
|
||||||
"data_bytes": 512,
|
|
||||||
"metadata_bytes": 0,
|
|
||||||
"relative_performance": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"logical_block_size": 512,
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"nvme_firmware_update_capabilities": {
|
|
||||||
"value": 2,
|
|
||||||
"slots": 1,
|
|
||||||
"first_slot_is_read_only": false,
|
|
||||||
"activiation_without_reset": false,
|
|
||||||
"multiple_update_detection": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_admin_commands": {
|
|
||||||
"value": 23,
|
|
||||||
"security_send_receive": true,
|
|
||||||
"format_nvm": true,
|
|
||||||
"firmware_download": true,
|
|
||||||
"namespace_management": false,
|
|
||||||
"self_test": true,
|
|
||||||
"directives": false,
|
|
||||||
"mi_send_receive": false,
|
|
||||||
"virtualization_management": false,
|
|
||||||
"doorbell_buffer_config": false,
|
|
||||||
"get_lba_status": false,
|
|
||||||
"command_and_feature_lockdown": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_nvm_commands": {
|
|
||||||
"value": 94,
|
|
||||||
"compare": false,
|
|
||||||
"write_uncorrectable": true,
|
|
||||||
"dataset_management": true,
|
|
||||||
"write_zeroes": true,
|
|
||||||
"save_select_feature_nonzero": true,
|
|
||||||
"reservations": false,
|
|
||||||
"timestamp": true,
|
|
||||||
"verify": false,
|
|
||||||
"copy": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_log_page_attributes": {
|
|
||||||
"value": 2,
|
|
||||||
"smart_health_per_namespace": false,
|
|
||||||
"commands_effects_log": true,
|
|
||||||
"extended_get_log_page_cmd": false,
|
|
||||||
"telemetry_log": false,
|
|
||||||
"persistent_event_log": false,
|
|
||||||
"supported_log_pages_log": false,
|
|
||||||
"telemetry_data_area_4": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_maximum_data_transfer_pages": 32,
|
|
||||||
"nvme_composite_temperature_threshold": {
|
|
||||||
"warning": 100,
|
|
||||||
"critical": 110
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"op_limit_max": 100,
|
|
||||||
"critical_limit_max": 110,
|
|
||||||
"current": 61
|
|
||||||
},
|
|
||||||
"nvme_power_states": [
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 0,
|
|
||||||
"relative_read_throughput": 0,
|
|
||||||
"relative_write_latency": 0,
|
|
||||||
"relative_write_throughput": 0,
|
|
||||||
"entry_latency_us": 230000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 800,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 1,
|
|
||||||
"relative_read_throughput": 1,
|
|
||||||
"relative_write_latency": 1,
|
|
||||||
"relative_write_throughput": 1,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 400,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 2,
|
|
||||||
"relative_read_throughput": 2,
|
|
||||||
"relative_write_latency": 2,
|
|
||||||
"relative_write_throughput": 2,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 250000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 3,
|
|
||||||
"relative_read_throughput": 3,
|
|
||||||
"relative_write_latency": 3,
|
|
||||||
"relative_write_throughput": 3,
|
|
||||||
"entry_latency_us": 5000,
|
|
||||||
"exit_latency_us": 10000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 4,
|
|
||||||
"relative_read_throughput": 4,
|
|
||||||
"relative_write_latency": 4,
|
|
||||||
"relative_write_throughput": 4,
|
|
||||||
"entry_latency_us": 54000,
|
|
||||||
"exit_latency_us": 45000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 50,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true,
|
|
||||||
"nvme": {
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nvme_smart_health_information_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"critical_warning": 0,
|
|
||||||
"temperature": 61,
|
|
||||||
"available_spare": 100,
|
|
||||||
"available_spare_threshold": 32,
|
|
||||||
"percentage_used": 0,
|
|
||||||
"data_units_read": 6573104,
|
|
||||||
"data_units_written": 16040567,
|
|
||||||
"host_reads": 63241130,
|
|
||||||
"host_writes": 253050006,
|
|
||||||
"controller_busy_time": 0,
|
|
||||||
"power_cycles": 430,
|
|
||||||
"power_on_hours": 4399,
|
|
||||||
"unsafe_shutdowns": 44,
|
|
||||||
"media_errors": 0,
|
|
||||||
"num_err_log_entries": 0,
|
|
||||||
"warning_temp_time": 0,
|
|
||||||
"critical_comp_time": 0
|
|
||||||
},
|
|
||||||
"spare_available": {
|
|
||||||
"current_percent": 100,
|
|
||||||
"threshold_percent": 32
|
|
||||||
},
|
|
||||||
"endurance_used": {
|
|
||||||
"current_percent": 0
|
|
||||||
},
|
|
||||||
"power_cycle_count": 430,
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 4399
|
|
||||||
},
|
|
||||||
"nvme_error_information_log": {
|
|
||||||
"size": 8,
|
|
||||||
"read": 8,
|
|
||||||
"unread": 0
|
|
||||||
},
|
|
||||||
"nvme_self_test_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"current_self_test_operation": {
|
|
||||||
"value": 0,
|
|
||||||
"string": "No self-test in progress"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"--scan",
|
|
||||||
"-j"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"name": "/dev/sda",
|
|
||||||
"info_name": "/dev/sda [SAT]",
|
|
||||||
"type": "sat",
|
|
||||||
"protocol": "ATA"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"svn_revision": "5338",
|
|
||||||
"platform_info": "x86_64-linux-6.12.43+deb12-amd64",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/sde"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761502142,
|
|
||||||
"asctime": "Sun Oct 21 21:09:02 2025 MSK"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/sde",
|
|
||||||
"info_name": "/dev/sde",
|
|
||||||
"type": "scsi",
|
|
||||||
"protocol": "SCSI"
|
|
||||||
},
|
|
||||||
"scsi_vendor": "YADRO",
|
|
||||||
"scsi_product": "WUH721414AL4204",
|
|
||||||
"scsi_model_name": "YADRO WUH721414AL4204",
|
|
||||||
"scsi_revision": "C240",
|
|
||||||
"scsi_version": "SPC-4",
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 3418095616,
|
|
||||||
"bytes": 14000519643136
|
|
||||||
},
|
|
||||||
"logical_block_size": 4096,
|
|
||||||
"scsi_lb_provisioning": {
|
|
||||||
"name": "fully provisioned",
|
|
||||||
"value": 0,
|
|
||||||
"management_enabled": {
|
|
||||||
"name": "LBPME",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"read_zeros": {
|
|
||||||
"name": "LBPRZ",
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rotation_rate": 7200,
|
|
||||||
"form_factor": {
|
|
||||||
"scsi_value": 2,
|
|
||||||
"name": "3.5 inches"
|
|
||||||
},
|
|
||||||
"logical_unit_id": "0x5000cca29063dc00",
|
|
||||||
"serial_number": "9YHSDH9B",
|
|
||||||
"device_type": {
|
|
||||||
"scsi_terminology": "Peripheral Device Type [PDT]",
|
|
||||||
"scsi_value": 0,
|
|
||||||
"name": "disk"
|
|
||||||
},
|
|
||||||
"scsi_transport_protocol": {
|
|
||||||
"name": "SAS (SPL-4)",
|
|
||||||
"value": 6
|
|
||||||
},
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"temperature_warning": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"current": 34,
|
|
||||||
"drive_trip": 85
|
|
||||||
},
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 458,
|
|
||||||
"minutes": 25
|
|
||||||
},
|
|
||||||
"scsi_start_stop_cycle_counter": {
|
|
||||||
"year_of_manufacture": "2022",
|
|
||||||
"week_of_manufacture": "41",
|
|
||||||
"specified_cycle_count_over_device_lifetime": 50000,
|
|
||||||
"accumulated_start_stop_cycles": 2,
|
|
||||||
"specified_load_unload_count_over_device_lifetime": 600000,
|
|
||||||
"accumulated_load_unload_cycles": 418
|
|
||||||
},
|
|
||||||
"scsi_grown_defect_list": 0,
|
|
||||||
"scsi_error_counter_log": {
|
|
||||||
"read": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 346,
|
|
||||||
"gigabytes_processed": "3,641",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 4052,
|
|
||||||
"gigabytes_processed": "2124,590",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"verify": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 223,
|
|
||||||
"gigabytes_processed": "0,000",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Download smartctl.exe from the given URL and save it to the given destination.
|
|
||||||
// This is used to embed smartctl.exe in the Windows build.
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
|
|
||||||
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
|
|
||||||
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
|
|
||||||
force := flag.Bool("force", false, "Force re-download even if destination exists")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *url == "" || *out == "" {
|
|
||||||
fatalf("-url and -out are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*force {
|
|
||||||
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
|
|
||||||
fmt.Println("smartctl.exe already present, skipping download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloadFile(*url, *out, *sha); err != nil {
|
|
||||||
fatalf("download failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(url, dest, shaHex string) error {
|
|
||||||
// Prepare destination
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP client
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("new request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("http get: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp := dest + ".tmp"
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
|
|
||||||
var hasher hash.Hash
|
|
||||||
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
|
|
||||||
switch len(cleanSha) {
|
|
||||||
case 40:
|
|
||||||
hasher = sha1.New()
|
|
||||||
case 64:
|
|
||||||
hasher = sha256.New()
|
|
||||||
default:
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mw io.Writer = f
|
|
||||||
if hasher != nil {
|
|
||||||
mw = io.MultiWriter(f, hasher)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(mw, resp.Body); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("write tmp: %w", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("close tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasher != nil && shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
|
|
||||||
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
|
|
||||||
if got != cleanSha {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make executable and move into place
|
|
||||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("chmod: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("rename: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("smartctl.exe downloaded to", dest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fatalf(format string, a ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.16.1"
|
Version = "0.13.2"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
49
go.mod
49
go.mod
@@ -1,25 +1,27 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.1
|
||||||
|
|
||||||
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.9.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0
|
|
||||||
github.com/distatus/battery v0.11.0
|
github.com/distatus/battery v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.0
|
github.com/nicholas-fedor/shoutrrr v0.10.0
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.33.0
|
github.com/pocketbase/pocketbase v0.30.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10
|
github.com/shirou/gopsutil/v4 v4.25.9
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.44.0
|
golang.org/x/crypto v0.42.0
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,38 +33,37 @@ require (
|
|||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.9.0 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.33.0 // indirect
|
golang.org/x/image v0.31.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.44.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.40.0 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
126
go.sum
126
go.sum
@@ -9,8 +9,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@@ -25,16 +23,16 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
@@ -51,44 +49,40 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.0 h1:8mwJdfU+uBEybSymwQJMGl/grG7lvVUKbVSNxn3XvUI=
|
github.com/nicholas-fedor/shoutrrr v0.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.0/go.mod h1:WYiRalR4C43Qmd2zhPWGIFIxu633NB1hDM6Ap/DQcsA=
|
github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -96,8 +90,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.33.0 h1:v2EfiY3hxigzRJ/BwFuwVn0vUv7d2QQoD5zUFPaKR9o=
|
github.com/pocketbase/pocketbase v0.30.1 h1:8lgfhH+HiSw1PyKVMq2sjtC4ZNvda2f/envTAzWMLOA=
|
||||||
github.com/pocketbase/pocketbase v0.33.0/go.mod h1:9BEs+CRV7CrS+X5LfBh4bdJQsbzQAIklft3ovGe/c5A=
|
github.com/pocketbase/pocketbase v0.30.1/go.mod h1:sUI+uekXZam5Wa0eh+DClc+HieKMCeqsHA7Ydd9vwyE=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -105,8 +99,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
@@ -118,77 +112,77 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
|
|
||||||
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -197,8 +191,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ type AlertManager struct {
|
|||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
UserID string
|
UserID string
|
||||||
SystemID string
|
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
Link string
|
Link string
|
||||||
@@ -41,18 +40,13 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
|
||||||
Usage float64 `json:"u"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -78,6 +72,7 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
|
"matrix": {},
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -106,81 +101,8 @@ func (am *AlertManager) bindEvents() {
|
|||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
|
||||||
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
|
||||||
// Query for quiet hours windows that match this user and system
|
|
||||||
// Include both global windows (system is null/empty) and system-specific windows
|
|
||||||
var filter string
|
|
||||||
var params dbx.Params
|
|
||||||
|
|
||||||
if systemID == "" {
|
|
||||||
// If no systemID provided, only check global windows
|
|
||||||
filter = "user={:user} AND system=''"
|
|
||||||
params = dbx.Params{"user": userID}
|
|
||||||
} else {
|
|
||||||
// Check both global and system-specific windows
|
|
||||||
filter = "user={:user} AND (system='' OR system={:system})"
|
|
||||||
params = dbx.Params{
|
|
||||||
"user": userID,
|
|
||||||
"system": systemID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
|
|
||||||
if err != nil || len(quietHourWindows) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
for _, window := range quietHourWindows {
|
|
||||||
windowType := window.GetString("type")
|
|
||||||
start := window.GetDateTime("start").Time()
|
|
||||||
end := window.GetDateTime("end").Time()
|
|
||||||
|
|
||||||
if windowType == "daily" {
|
|
||||||
// For daily recurring windows, extract just the time portion and compare
|
|
||||||
// The start/end are stored as full datetime but we only care about HH:MM
|
|
||||||
startHour, startMin, _ := start.Clock()
|
|
||||||
endHour, endMin, _ := end.Clock()
|
|
||||||
nowHour, nowMin, _ := now.Clock()
|
|
||||||
|
|
||||||
// Convert to minutes since midnight for easier comparison
|
|
||||||
startMinutes := startHour*60 + startMin
|
|
||||||
endMinutes := endHour*60 + endMin
|
|
||||||
nowMinutes := nowHour*60 + nowMin
|
|
||||||
|
|
||||||
// Handle case where window crosses midnight
|
|
||||||
if endMinutes < startMinutes {
|
|
||||||
// Window crosses midnight (e.g., 23:00 - 01:00)
|
|
||||||
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal case (e.g., 09:00 - 17:00)
|
|
||||||
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// One-time window: check if current time is within the date range
|
|
||||||
if (now.After(start) || now.Equal(start)) && now.Before(end) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// Check if alert is silenced
|
|
||||||
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
|
||||||
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAlertSilencedOneTime(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Create an alert
|
|
||||||
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Test that alert is silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced during active one-time window")
|
|
||||||
|
|
||||||
// Create a window that has already ended
|
|
||||||
pastStart := now.Add(-3 * time.Hour)
|
|
||||||
pastEnd := now.Add(-2 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": pastStart,
|
|
||||||
"end": pastEnd,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Should still be silenced because of the first window
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
|
|
||||||
|
|
||||||
// Clear all windows and create a future window
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
futureStart := now.Add(2 * time.Hour)
|
|
||||||
futureEnd := now.Add(3 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": futureStart,
|
|
||||||
"end": futureEnd,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should NOT be silenced (window hasn't started yet)
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
|
|
||||||
|
|
||||||
_ = alert
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedDaily(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Get current hour and create a window that includes current time
|
|
||||||
now := time.Now().UTC()
|
|
||||||
currentHour := now.Hour()
|
|
||||||
currentMin := now.Minute()
|
|
||||||
|
|
||||||
// Create a window from 1 hour ago to 1 hour from now
|
|
||||||
startHour := (currentHour - 1 + 24) % 24
|
|
||||||
endHour := (currentHour + 1) % 24
|
|
||||||
|
|
||||||
// Create times with just the hours/minutes we want (date doesn't matter for daily)
|
|
||||||
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
|
|
||||||
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should be silenced (current time is within the daily window)
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced during active daily window")
|
|
||||||
|
|
||||||
// Clear windows and create one that doesn't include current time
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a window from 6-12 hours from now
|
|
||||||
futureStartHour := (currentHour + 6) % 24
|
|
||||||
futureEndHour := (currentHour + 12) % 24
|
|
||||||
|
|
||||||
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
|
|
||||||
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a window that crosses midnight: 22:00 - 02:00
|
|
||||||
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
|
||||||
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test with a time at 23:00 (should be silenced)
|
|
||||||
// We can't control the actual current time, but we can verify the logic
|
|
||||||
// by checking if the window was created correctly
|
|
||||||
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, windows, 1, "Should have created 1 window")
|
|
||||||
|
|
||||||
window := windows[0]
|
|
||||||
assert.Equal(t, "daily", window.GetString("type"))
|
|
||||||
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
|
|
||||||
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedGlobal(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create multiple systems
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a global quiet hours window (no system specified)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
// system field is empty/null for global windows
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// All systems should be silenced
|
|
||||||
for _, system := range systems {
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even with a systemID that doesn't exist, should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedSystemSpecific(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create multiple systems
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system1 := systems[0]
|
|
||||||
system2 := systems[1]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a system-specific quiet hours window for system1 only
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system1.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// System1 should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for system1")
|
|
||||||
|
|
||||||
// System2 should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced for system2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedMultiUser(t *testing.T) {
|
|
||||||
hub, _ := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create two users
|
|
||||||
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a system accessible to both users
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "shared-system",
|
|
||||||
"users": []string{user1.Id, user2.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a quiet hours window for user1 only
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user1.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// User1 should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for user1")
|
|
||||||
|
|
||||||
// User2 should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced for user2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Create a status alert
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create user settings with email
|
|
||||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
|
||||||
if err != nil || userSettings == nil {
|
|
||||||
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"settings": map[string]any{
|
|
||||||
"emails": []string{"test@example.com"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a quiet hours window
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get initial email count
|
|
||||||
initialEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
|
|
||||||
// Trigger an alert by setting system to down
|
|
||||||
system.Set("status", "down")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed (1 minute + buffer)
|
|
||||||
time.Sleep(time.Second * 75)
|
|
||||||
synctest.Wait()
|
|
||||||
|
|
||||||
// Check that no email was sent (because alert is silenced)
|
|
||||||
finalEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
|
|
||||||
|
|
||||||
// Clear quiet hours windows
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Reset system to up, then down again
|
|
||||||
system.Set("status", "up")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
system.Set("status", "down")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(time.Second * 75)
|
|
||||||
synctest.Wait()
|
|
||||||
|
|
||||||
// Now an email should be sent
|
|
||||||
newEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedNoWindows(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Without any quiet hours windows, alert should NOT be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
|
|
||||||
}
|
|
||||||
@@ -161,15 +161,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// Get system ID for the link
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
systemID := alertRecord.GetString("system")
|
// return errs["user"]
|
||||||
|
// }
|
||||||
|
// user := alertRecord.ExpandedOne("user")
|
||||||
|
// if user == nil {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
SystemID: systemID,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "GPU":
|
|
||||||
val = data.Info.GpuPct
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
@@ -208,17 +206,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
case "GPU":
|
|
||||||
if len(stats.GPU) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
maxUsage := 0.0
|
|
||||||
for _, gpu := range stats.GPU {
|
|
||||||
if gpu.Usage > maxUsage {
|
|
||||||
maxUsage = gpu.Usage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val += maxUsage
|
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -281,9 +268,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU or GPU
|
// make title alert name lowercase if not CPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
if titleAlertName != "CPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +296,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
SystemID: alert.systemRecord.Id,
|
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSocketAction = uint8
|
type WebSocketAction = uint8
|
||||||
@@ -13,14 +11,6 @@ const (
|
|||||||
GetData WebSocketAction = iota
|
GetData WebSocketAction = iota
|
||||||
// Check the fingerprint of the agent
|
// Check the fingerprint of the agent
|
||||||
CheckFingerprint
|
CheckFingerprint
|
||||||
// Request container logs from agent
|
|
||||||
GetContainerLogs
|
|
||||||
// Request container info from agent
|
|
||||||
GetContainerInfo
|
|
||||||
// Request SMART data from agent
|
|
||||||
GetSmartData
|
|
||||||
// Request detailed systemd service info from agent
|
|
||||||
GetSystemdInfo
|
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,14 +23,10 @@ type HubRequest[T any] struct {
|
|||||||
|
|
||||||
// AgentResponse defines the structure for responses sent from agent to hub.
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
type AgentResponse struct {
|
type AgentResponse struct {
|
||||||
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
|
|
||||||
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
|
|
||||||
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,15 +47,3 @@ type DataRequestOptions struct {
|
|||||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContainerLogsRequest struct {
|
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerInfoRequest struct {
|
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemdInfoRequest struct {
|
|
||||||
ServiceName string `cbor:"0,keyasint"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . ./
|
|
||||||
|
|
||||||
# Build
|
|
||||||
ARG TARGETOS TARGETARCH
|
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
|
||||||
|
|
||||||
# --------------------------
|
|
||||||
# Final image: default scratch-based agent
|
|
||||||
# --------------------------
|
|
||||||
FROM alpine:latest
|
|
||||||
COPY --from=builder /agent /agent
|
|
||||||
|
|
||||||
RUN apk add --no-cache smartmontools
|
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
|
||||||
@@ -20,7 +20,7 @@ FROM alpine:edge
|
|||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY ../go.mod ../go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
@@ -12,24 +13,7 @@ COPY . ./
|
|||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
RUN rm -rf /tmp/*
|
||||||
# Smartmontools builder stage
|
|
||||||
# --------------------------
|
|
||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 AS smartmontools-builder
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
wget \
|
|
||||||
build-essential \
|
|
||||||
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
|
|
||||||
&& tar zxvf smartmontools-7.5.tar.gz \
|
|
||||||
&& cd smartmontools-7.5 \
|
|
||||||
&& ./configure --prefix=/usr --sysconfdir=/etc \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& rm -rf /smartmontools-7.5* \
|
|
||||||
&& apt-get remove -y wget build-essential \
|
|
||||||
&& apt-get autoremove -y \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: GPU-enabled agent with nvidia-smi
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
@@ -37,8 +21,8 @@ RUN apt-get update && apt-get install -y \
|
|||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
# Copy smartmontools binaries and config files
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import "time"
|
|||||||
|
|
||||||
// Docker container info from /containers/json
|
// Docker container info from /containers/json
|
||||||
type ApiInfo struct {
|
type ApiInfo struct {
|
||||||
Id string
|
Id string
|
||||||
IdShort string
|
IdShort string
|
||||||
Names []string
|
Names []string
|
||||||
Status string
|
Status string
|
||||||
State string
|
Health string `json:"Health,omitempty"` // Container health status
|
||||||
Image string
|
Created int64 `json:"Created,omitempty"` // Container creation timestamp
|
||||||
|
StartedAt int64 `json:"StartedAt,omitempty"` // Container start timestamp
|
||||||
|
FinishedAt int64 `json:"FinishedAt,omitempty"` // Container finish timestamp
|
||||||
|
State string `json:"State,omitempty"` // Container state (running, stopped, etc.)
|
||||||
|
// Image string
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
// Created int64
|
|
||||||
// Ports []Port
|
// Ports []Port
|
||||||
// SizeRw int64 `json:",omitempty"`
|
// SizeRw int64 `json:",omitempty"`
|
||||||
// SizeRootFs int64 `json:",omitempty"`
|
// SizeRootFs int64 `json:",omitempty"`
|
||||||
// Labels map[string]string
|
Labels map[string]string
|
||||||
|
// State string
|
||||||
// HostConfig struct {
|
// HostConfig struct {
|
||||||
// NetworkMode string `json:",omitempty"`
|
// NetworkMode string `json:",omitempty"`
|
||||||
// Annotations map[string]string `json:",omitempty"`
|
// Annotations map[string]string `json:",omitempty"`
|
||||||
// }
|
// }
|
||||||
// NetworkSettings *SummaryNetworkSettings
|
// NetworkSettings *SummaryNetworkSettings
|
||||||
// Mounts []MountPoint
|
Mounts []MountPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker container resources from /containers/{id}/stats
|
// Docker container resources from /containers/{id}/stats
|
||||||
@@ -32,6 +36,7 @@ type ApiStats struct {
|
|||||||
Networks map[string]NetworkStats
|
Networks map[string]NetworkStats
|
||||||
CPUStats CPUStats `json:"cpu_stats"`
|
CPUStats CPUStats `json:"cpu_stats"`
|
||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
|
BlkioStats BlkioStats `json:"blkio_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
@@ -98,42 +103,58 @@ type NetworkStats struct {
|
|||||||
TxBytes uint64 `json:"tx_bytes"`
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BlkioStats struct {
|
||||||
|
IoServiceBytesRecursive []BlkioStatEntry `json:"io_service_bytes_recursive"`
|
||||||
|
IoServicedRecursive []BlkioStatEntry `json:"io_serviced_recursive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlkioStatEntry struct {
|
||||||
|
Major uint64 `json:"major"`
|
||||||
|
Minor uint64 `json:"minor"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Value uint64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
type prevNetStats struct {
|
type prevNetStats struct {
|
||||||
Sent uint64
|
Sent uint64
|
||||||
Recv uint64
|
Recv uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type DockerHealth = uint8
|
type prevDiskStats struct {
|
||||||
|
Read uint64
|
||||||
const (
|
Write uint64
|
||||||
DockerHealthNone DockerHealth = iota
|
|
||||||
DockerHealthStarting
|
|
||||||
DockerHealthHealthy
|
|
||||||
DockerHealthUnhealthy
|
|
||||||
)
|
|
||||||
|
|
||||||
var DockerHealthStrings = map[string]DockerHealth{
|
|
||||||
"none": DockerHealthNone,
|
|
||||||
"starting": DockerHealthStarting,
|
|
||||||
"healthy": DockerHealthHealthy,
|
|
||||||
"unhealthy": DockerHealthUnhealthy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker container stats
|
// Docker container stats
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
||||||
|
DiskRead float64 `json:"dr" cbor:"5,keyasint"` // Disk read rate in MB/s
|
||||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
DiskWrite float64 `json:"dw" cbor:"6,keyasint"` // Disk write rate in MB/s
|
||||||
Status string `json:"-" cbor:"6,keyasint"`
|
Volumes map[string]float64 `json:"v,omitempty" cbor:"7,keyasint"` // Volume name to size mapping
|
||||||
Id string `json:"-" cbor:"7,keyasint"`
|
Health string `json:"h,omitempty" cbor:"8,keyasint"` // Container health status
|
||||||
Image string `json:"-" cbor:"8,keyasint"`
|
Status string `json:"s,omitempty" cbor:"9,keyasint"` // Container status (running, stopped, etc.)
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
Uptime float64 `json:"u,omitempty" cbor:"10,keyasint"` // Container uptime in seconds
|
||||||
CpuSystem uint64 `json:"-"`
|
Project string `json:"p,omitempty" cbor:"11,keyasint"` // Docker Compose project name
|
||||||
CpuContainer uint64 `json:"-"`
|
IdShort string `json:"idShort,omitempty" cbor:"12,keyasint"` // Container short ID for frontend
|
||||||
PrevNet prevNetStats `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
PrevReadTime time.Time `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
|
PrevNet prevNetStats `json:"-"`
|
||||||
|
PrevDisk prevDiskStats `json:"-"`
|
||||||
|
PrevReadTime time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountPoint represents a mount point in a container
|
||||||
|
type MountPoint struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Source string `json:"Source"`
|
||||||
|
Destination string `json:"Destination"`
|
||||||
|
Driver string `json:"Driver,omitempty"`
|
||||||
|
Mode string `json:"Mode"`
|
||||||
|
RW bool `json:"RW"`
|
||||||
|
Propagation string `json:"Propagation"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
package smart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Common types
|
|
||||||
type VersionInfo [2]int
|
|
||||||
|
|
||||||
type SmartctlInfo struct {
|
|
||||||
Version VersionInfo `json:"version"`
|
|
||||||
SvnRevision string `json:"svn_revision"`
|
|
||||||
PlatformInfo string `json:"platform_info"`
|
|
||||||
BuildInfo string `json:"build_info"`
|
|
||||||
Argv []string `json:"argv"`
|
|
||||||
ExitStatus int `json:"exit_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
InfoName string `json:"info_name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Protocol string `json:"protocol"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserCapacity struct {
|
|
||||||
Blocks uint64 `json:"blocks"`
|
|
||||||
Bytes uint64 `json:"bytes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type LocalTime struct {
|
|
||||||
// TimeT int64 `json:"time_t"`
|
|
||||||
// Asctime string `json:"asctime"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type WwnInfo struct {
|
|
||||||
// Naa int `json:"naa"`
|
|
||||||
// Oui int `json:"oui"`
|
|
||||||
// ID int `json:"id"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type FormFactorInfo struct {
|
|
||||||
// AtaValue int `json:"ata_value"`
|
|
||||||
// Name string `json:"name"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type TrimInfo struct {
|
|
||||||
// Supported bool `json:"supported"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type AtaVersionInfo struct {
|
|
||||||
// String string `json:"string"`
|
|
||||||
// MajorValue int `json:"major_value"`
|
|
||||||
// MinorValue int `json:"minor_value"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type VersionStringInfo struct {
|
|
||||||
// String string `json:"string"`
|
|
||||||
// Value int `json:"value"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type SpeedInfo struct {
|
|
||||||
// SataValue int `json:"sata_value"`
|
|
||||||
// String string `json:"string"`
|
|
||||||
// UnitsPerSecond int `json:"units_per_second"`
|
|
||||||
// BitsPerUnit int `json:"bits_per_unit"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type InterfaceSpeedInfo struct {
|
|
||||||
// Max SpeedInfo `json:"max"`
|
|
||||||
// Current SpeedInfo `json:"current"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type SmartStatusInfo struct {
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatusInfo struct {
|
|
||||||
Value int `json:"value"`
|
|
||||||
String string `json:"string"`
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PollingMinutes struct {
|
|
||||||
Short int `json:"short"`
|
|
||||||
Extended int `json:"extended"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CapabilitiesInfo struct {
|
|
||||||
Values []int `json:"values"`
|
|
||||||
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
|
|
||||||
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
|
|
||||||
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
|
|
||||||
SelfTestsSupported bool `json:"self_tests_supported"`
|
|
||||||
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
|
|
||||||
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
|
|
||||||
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
|
|
||||||
ErrorLoggingSupported bool `json:"error_logging_supported"`
|
|
||||||
GpLoggingSupported bool `json:"gp_logging_supported"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type AtaSmartData struct {
|
|
||||||
// OfflineDataCollection OfflineDataCollectionInfo `json:"offline_data_collection"`
|
|
||||||
// SelfTest SelfTestInfo `json:"self_test"`
|
|
||||||
// Capabilities CapabilitiesInfo `json:"capabilities"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type OfflineDataCollectionInfo struct {
|
|
||||||
// Status StatusInfo `json:"status"`
|
|
||||||
// CompletionSeconds int `json:"completion_seconds"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type SelfTestInfo struct {
|
|
||||||
// Status StatusInfo `json:"status"`
|
|
||||||
// PollingMinutes PollingMinutes `json:"polling_minutes"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type AtaSctCapabilities struct {
|
|
||||||
// Value int `json:"value"`
|
|
||||||
// ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
|
|
||||||
// FeatureControlSupported bool `json:"feature_control_supported"`
|
|
||||||
// DataTableSupported bool `json:"data_table_supported"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type SummaryInfo struct {
|
|
||||||
Revision int `json:"revision"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AtaSmartAttributes struct {
|
|
||||||
// Revision int `json:"revision"`
|
|
||||||
Table []AtaSmartAttribute `json:"table"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AtaSmartAttribute struct {
|
|
||||||
ID uint16 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Value uint16 `json:"value"`
|
|
||||||
Worst uint16 `json:"worst"`
|
|
||||||
Thresh uint16 `json:"thresh"`
|
|
||||||
WhenFailed string `json:"when_failed"`
|
|
||||||
// Flags AttributeFlags `json:"flags"`
|
|
||||||
Raw RawValue `json:"raw"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type AttributeFlags struct {
|
|
||||||
// Value int `json:"value"`
|
|
||||||
// String string `json:"string"`
|
|
||||||
// Prefailure bool `json:"prefailure"`
|
|
||||||
// UpdatedOnline bool `json:"updated_online"`
|
|
||||||
// Performance bool `json:"performance"`
|
|
||||||
// ErrorRate bool `json:"error_rate"`
|
|
||||||
// EventCount bool `json:"event_count"`
|
|
||||||
// AutoKeep bool `json:"auto_keep"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type RawValue struct {
|
|
||||||
Value SmartRawValue `json:"value"`
|
|
||||||
String string `json:"string"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RawValue) UnmarshalJSON(data []byte) error {
|
|
||||||
var tmp struct {
|
|
||||||
Value json.RawMessage `json:"value"`
|
|
||||||
String string `json:"string"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tmp.Value) > 0 {
|
|
||||||
if err := r.Value.UnmarshalJSON(tmp.Value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
r.Value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
r.String = tmp.String
|
|
||||||
|
|
||||||
if parsed, ok := ParseSmartRawValueString(tmp.String); ok {
|
|
||||||
r.Value = SmartRawValue(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartRawValue uint64
|
|
||||||
|
|
||||||
// handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours
|
|
||||||
func (v *SmartRawValue) UnmarshalJSON(data []byte) error {
|
|
||||||
trimmed := strings.TrimSpace(string(data))
|
|
||||||
if len(trimmed) == 0 || trimmed == "null" {
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed[0] == '"' {
|
|
||||||
valueStr, err := strconv.Unquote(trimmed)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, ok := ParseSmartRawValueString(valueStr)
|
|
||||||
if ok {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, err := strconv.ParseUint(trimmed, 0, 64); err == nil {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, ok := ParseSmartRawValueString(trimmed); ok {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSmartRawValueString attempts to extract a numeric value from the raw value
|
|
||||||
// strings emitted by smartctl, which sometimes include human-friendly annotations
|
|
||||||
// like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a
|
|
||||||
// boolean indicating success.
|
|
||||||
func ParseSmartRawValueString(value string) (uint64, bool) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, err := strconv.ParseUint(value, 0, 64); err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx := strings.IndexRune(value, 'h'); idx > 0 {
|
|
||||||
hoursPart := strings.TrimSpace(value[:idx])
|
|
||||||
if hoursPart != "" {
|
|
||||||
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
|
|
||||||
return uint64(parsed), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
if value[i] < '0' || value[i] > '9' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
end := i + 1
|
|
||||||
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
digits := value[i:end]
|
|
||||||
if parsed, err := strconv.ParseUint(digits, 10, 64); err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
i = end
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// type PowerOnTimeInfo struct {
|
|
||||||
// Hours uint32 `json:"hours"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type TemperatureInfo struct {
|
|
||||||
Current uint8 `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TemperatureInfoScsi struct {
|
|
||||||
Current uint8 `json:"current"`
|
|
||||||
DriveTrip uint8 `json:"drive_trip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type SelectiveSelfTestTable struct {
|
|
||||||
// LbaMin int `json:"lba_min"`
|
|
||||||
// LbaMax int `json:"lba_max"`
|
|
||||||
// Status StatusInfo `json:"status"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type SelectiveSelfTestFlags struct {
|
|
||||||
// Value int `json:"value"`
|
|
||||||
// RemainderScanEnabled bool `json:"remainder_scan_enabled"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type AtaSmartSelectiveSelfTestLog struct {
|
|
||||||
// Revision int `json:"revision"`
|
|
||||||
// Table []SelectiveSelfTestTable `json:"table"`
|
|
||||||
// Flags SelectiveSelfTestFlags `json:"flags"`
|
|
||||||
// PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// BaseSmartInfo contains common fields shared between SATA and NVMe drives
|
|
||||||
// type BaseSmartInfo struct {
|
|
||||||
// Device DeviceInfo `json:"device"`
|
|
||||||
// ModelName string `json:"model_name"`
|
|
||||||
// SerialNumber string `json:"serial_number"`
|
|
||||||
// FirmwareVersion string `json:"firmware_version"`
|
|
||||||
// UserCapacity UserCapacity `json:"user_capacity"`
|
|
||||||
// LogicalBlockSize int `json:"logical_block_size"`
|
|
||||||
// LocalTime LocalTime `json:"local_time"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type SmartctlInfoLegacy struct {
|
|
||||||
Version VersionInfo `json:"version"`
|
|
||||||
SvnRevision string `json:"svn_revision"`
|
|
||||||
PlatformInfo string `json:"platform_info"`
|
|
||||||
BuildInfo string `json:"build_info"`
|
|
||||||
Argv []string `json:"argv"`
|
|
||||||
ExitStatus int `json:"exit_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartInfoForSata struct {
|
|
||||||
// JSONFormatVersion VersionInfo `json:"json_format_version"`
|
|
||||||
Smartctl SmartctlInfoLegacy `json:"smartctl"`
|
|
||||||
Device DeviceInfo `json:"device"`
|
|
||||||
// ModelFamily string `json:"model_family"`
|
|
||||||
ModelName string `json:"model_name"`
|
|
||||||
SerialNumber string `json:"serial_number"`
|
|
||||||
// Wwn WwnInfo `json:"wwn"`
|
|
||||||
FirmwareVersion string `json:"firmware_version"`
|
|
||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
|
||||||
ScsiVendor string `json:"scsi_vendor"`
|
|
||||||
ScsiProduct string `json:"scsi_product"`
|
|
||||||
// LogicalBlockSize int `json:"logical_block_size"`
|
|
||||||
// PhysicalBlockSize int `json:"physical_block_size"`
|
|
||||||
// RotationRate int `json:"rotation_rate"`
|
|
||||||
// FormFactor FormFactorInfo `json:"form_factor"`
|
|
||||||
// Trim TrimInfo `json:"trim"`
|
|
||||||
// InSmartctlDatabase bool `json:"in_smartctl_database"`
|
|
||||||
// AtaVersion AtaVersionInfo `json:"ata_version"`
|
|
||||||
// SataVersion VersionStringInfo `json:"sata_version"`
|
|
||||||
// InterfaceSpeed InterfaceSpeedInfo `json:"interface_speed"`
|
|
||||||
// LocalTime LocalTime `json:"local_time"`
|
|
||||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
|
||||||
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
|
|
||||||
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
|
|
||||||
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
|
|
||||||
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
|
|
||||||
// PowerCycleCount uint16 `json:"power_cycle_count"`
|
|
||||||
Temperature TemperatureInfo `json:"temperature"`
|
|
||||||
// AtaSmartErrorLog AtaSmartErrorLog `json:"ata_smart_error_log"`
|
|
||||||
// AtaSmartSelfTestLog AtaSmartSelfTestLog `json:"ata_smart_self_test_log"`
|
|
||||||
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScsiErrorCounter struct {
|
|
||||||
ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"`
|
|
||||||
ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"`
|
|
||||||
ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"`
|
|
||||||
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
|
|
||||||
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
|
|
||||||
GigabytesProcessed string `json:"gigabytes_processed"`
|
|
||||||
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScsiErrorCounterLog struct {
|
|
||||||
Read ScsiErrorCounter `json:"read"`
|
|
||||||
Write ScsiErrorCounter `json:"write"`
|
|
||||||
Verify ScsiErrorCounter `json:"verify"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScsiStartStopCycleCounter struct {
|
|
||||||
YearOfManufacture string `json:"year_of_manufacture"`
|
|
||||||
WeekOfManufacture string `json:"week_of_manufacture"`
|
|
||||||
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
|
|
||||||
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
|
|
||||||
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
|
|
||||||
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PowerOnTimeScsi struct {
|
|
||||||
Hours uint64 `json:"hours"`
|
|
||||||
Minutes uint64 `json:"minutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartInfoForScsi struct {
|
|
||||||
Smartctl SmartctlInfoLegacy `json:"smartctl"`
|
|
||||||
Device DeviceInfo `json:"device"`
|
|
||||||
ScsiVendor string `json:"scsi_vendor"`
|
|
||||||
ScsiProduct string `json:"scsi_product"`
|
|
||||||
ScsiModelName string `json:"scsi_model_name"`
|
|
||||||
ScsiRevision string `json:"scsi_revision"`
|
|
||||||
ScsiVersion string `json:"scsi_version"`
|
|
||||||
SerialNumber string `json:"serial_number"`
|
|
||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
|
||||||
Temperature TemperatureInfoScsi `json:"temperature"`
|
|
||||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
|
||||||
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
|
|
||||||
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
|
|
||||||
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
|
|
||||||
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type AtaSmartErrorLog struct {
|
|
||||||
// Summary SummaryInfo `json:"summary"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type AtaSmartSelfTestLog struct {
|
|
||||||
// Standard SummaryInfo `json:"standard"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type SmartctlInfoNvme struct {
|
|
||||||
Version VersionInfo `json:"version"`
|
|
||||||
SVNRevision string `json:"svn_revision"`
|
|
||||||
PlatformInfo string `json:"platform_info"`
|
|
||||||
BuildInfo string `json:"build_info"`
|
|
||||||
Argv []string `json:"argv"`
|
|
||||||
ExitStatus int `json:"exit_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// type NVMePCIVendor struct {
|
|
||||||
// ID int `json:"id"`
|
|
||||||
// SubsystemID int `json:"subsystem_id"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type SizeCapacityInfo struct {
|
|
||||||
// Blocks uint64 `json:"blocks"`
|
|
||||||
// Bytes uint64 `json:"bytes"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type EUI64Info struct {
|
|
||||||
// OUI int `json:"oui"`
|
|
||||||
// ExtID int `json:"ext_id"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type NVMeNamespace struct {
|
|
||||||
// ID uint32 `json:"id"`
|
|
||||||
// Size SizeCapacityInfo `json:"size"`
|
|
||||||
// Capacity SizeCapacityInfo `json:"capacity"`
|
|
||||||
// Utilization SizeCapacityInfo `json:"utilization"`
|
|
||||||
// FormattedLBASize uint32 `json:"formatted_lba_size"`
|
|
||||||
// EUI64 EUI64Info `json:"eui64"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type SmartStatusInfoNvme struct {
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
NVMe SmartStatusNVMe `json:"nvme"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartStatusNVMe struct {
|
|
||||||
Value int `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NVMeSmartHealthInformationLog struct {
|
|
||||||
CriticalWarning uint `json:"critical_warning"`
|
|
||||||
Temperature uint8 `json:"temperature"`
|
|
||||||
AvailableSpare uint `json:"available_spare"`
|
|
||||||
AvailableSpareThreshold uint `json:"available_spare_threshold"`
|
|
||||||
PercentageUsed uint8 `json:"percentage_used"`
|
|
||||||
DataUnitsRead uint64 `json:"data_units_read"`
|
|
||||||
DataUnitsWritten uint64 `json:"data_units_written"`
|
|
||||||
HostReads uint `json:"host_reads"`
|
|
||||||
HostWrites uint `json:"host_writes"`
|
|
||||||
ControllerBusyTime uint `json:"controller_busy_time"`
|
|
||||||
PowerCycles uint16 `json:"power_cycles"`
|
|
||||||
PowerOnHours uint32 `json:"power_on_hours"`
|
|
||||||
UnsafeShutdowns uint16 `json:"unsafe_shutdowns"`
|
|
||||||
MediaErrors uint `json:"media_errors"`
|
|
||||||
NumErrLogEntries uint `json:"num_err_log_entries"`
|
|
||||||
WarningTempTime uint `json:"warning_temp_time"`
|
|
||||||
CriticalCompTime uint `json:"critical_comp_time"`
|
|
||||||
TemperatureSensors []uint8 `json:"temperature_sensors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartInfoForNvme struct {
|
|
||||||
// JSONFormatVersion VersionInfo `json:"json_format_version"`
|
|
||||||
Smartctl SmartctlInfoNvme `json:"smartctl"`
|
|
||||||
Device DeviceInfo `json:"device"`
|
|
||||||
ModelName string `json:"model_name"`
|
|
||||||
SerialNumber string `json:"serial_number"`
|
|
||||||
FirmwareVersion string `json:"firmware_version"`
|
|
||||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
|
||||||
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
|
||||||
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
|
||||||
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
|
||||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
|
||||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
|
||||||
// NVMeNumberOfNamespaces uint8 `json:"nvme_number_of_namespaces"`
|
|
||||||
// NVMeNamespaces []NVMeNamespace `json:"nvme_namespaces"`
|
|
||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
|
||||||
// LogicalBlockSize int `json:"logical_block_size"`
|
|
||||||
// LocalTime LocalTime `json:"local_time"`
|
|
||||||
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
|
|
||||||
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
|
|
||||||
Temperature TemperatureInfoNvme `json:"temperature"`
|
|
||||||
PowerCycleCount uint16 `json:"power_cycle_count"`
|
|
||||||
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TemperatureInfoNvme struct {
|
|
||||||
Current int `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PowerOnTimeInfoNvme struct {
|
|
||||||
Hours int `json:"hours"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartData struct {
|
|
||||||
// ModelFamily string `json:"mf,omitempty" cbor:"0,keyasint,omitempty"`
|
|
||||||
ModelName string `json:"mn,omitempty" cbor:"1,keyasint,omitempty"`
|
|
||||||
SerialNumber string `json:"sn,omitempty" cbor:"2,keyasint,omitempty"`
|
|
||||||
FirmwareVersion string `json:"fv,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
Capacity uint64 `json:"c,omitempty" cbor:"4,keyasint,omitempty"`
|
|
||||||
SmartStatus string `json:"s,omitempty" cbor:"5,keyasint,omitempty"`
|
|
||||||
DiskName string `json:"dn,omitempty" cbor:"6,keyasint,omitempty"`
|
|
||||||
DiskType string `json:"dt,omitempty" cbor:"7,keyasint,omitempty"`
|
|
||||||
Temperature uint8 `json:"t,omitempty" cbor:"8,keyasint,omitempty"`
|
|
||||||
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartAttribute struct {
|
|
||||||
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
|
|
||||||
Name string `json:"n" cbor:"1,keyasint"`
|
|
||||||
Value uint16 `json:"v,omitempty" cbor:"2,keyasint,omitempty"`
|
|
||||||
Worst uint16 `json:"w,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
Threshold uint16 `json:"t,omitempty" cbor:"4,keyasint,omitempty"`
|
|
||||||
RawValue uint64 `json:"rv" cbor:"5,keyasint"`
|
|
||||||
RawString string `json:"rs,omitempty" cbor:"6,keyasint,omitempty"`
|
|
||||||
WhenFailed string `json:"wf,omitempty" cbor:"7,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package smart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDuration(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 62312, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"7344","string":"7344"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 7344, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalParenthetical(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"39925 (212 206 0)","string":"39925 (212 206 0)"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 39925, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDurationWithFractions(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"2748h+31m+49.560s","string":"2748h+31m+49.560s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 2748, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalParentheticalRawValue(t *testing.T) {
|
|
||||||
input := []byte(`{"value":57891864217128,"string":"39925 (212 206 0)"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 39925, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDurationRawValue(t *testing.T) {
|
|
||||||
input := []byte(`{"value":57891864217128,"string":"2748h+31m+49.560s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 2748, raw.Value)
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,9 @@ package system
|
|||||||
// TODO: this is confusing, make common package with common/types common/helpers etc
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -43,28 +41,9 @@ type Stats struct {
|
|||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
|
|
||||||
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
|
||||||
// JSON: encodes as array of numbers (avoids base64 string).
|
|
||||||
// CBOR: falls back to default handling for []uint8 (byte string), keeping payload small.
|
|
||||||
type Uint8Slice []uint8
|
|
||||||
|
|
||||||
func (s Uint8Slice) MarshalJSON() ([]byte, error) {
|
|
||||||
if s == nil {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
// Convert to wider ints to force array-of-numbers encoding.
|
|
||||||
arr := make([]uint16, len(s))
|
|
||||||
for i, v := range s {
|
|
||||||
arr[i] = uint16(v)
|
|
||||||
}
|
|
||||||
return json.Marshal(arr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -144,16 +123,13 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
|
||||||
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package systemd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceState represents the status of a systemd service
|
|
||||||
type ServiceState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive ServiceState = iota
|
|
||||||
StatusInactive
|
|
||||||
StatusFailed
|
|
||||||
StatusActivating
|
|
||||||
StatusDeactivating
|
|
||||||
StatusReloading
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceSubState represents the sub status of a systemd service
|
|
||||||
type ServiceSubState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
SubStateDead ServiceSubState = iota
|
|
||||||
SubStateRunning
|
|
||||||
SubStateExited
|
|
||||||
SubStateFailed
|
|
||||||
SubStateUnknown
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseServiceStatus converts a string status to a ServiceStatus enum value
|
|
||||||
func ParseServiceStatus(status string) ServiceState {
|
|
||||||
switch status {
|
|
||||||
case "active":
|
|
||||||
return StatusActive
|
|
||||||
case "inactive":
|
|
||||||
return StatusInactive
|
|
||||||
case "failed":
|
|
||||||
return StatusFailed
|
|
||||||
case "activating":
|
|
||||||
return StatusActivating
|
|
||||||
case "deactivating":
|
|
||||||
return StatusDeactivating
|
|
||||||
case "reloading":
|
|
||||||
return StatusReloading
|
|
||||||
default:
|
|
||||||
return StatusInactive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
|
|
||||||
func ParseServiceSubState(subState string) ServiceSubState {
|
|
||||||
switch subState {
|
|
||||||
case "dead":
|
|
||||||
return SubStateDead
|
|
||||||
case "running":
|
|
||||||
return SubStateRunning
|
|
||||||
case "exited":
|
|
||||||
return SubStateExited
|
|
||||||
case "failed":
|
|
||||||
return SubStateFailed
|
|
||||||
default:
|
|
||||||
return SubStateUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service represents a single systemd service with its stats.
|
|
||||||
type Service struct {
|
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
|
||||||
State ServiceState `json:"s" cbor:"1,keyasint"`
|
|
||||||
Cpu float64 `json:"c" cbor:"2,keyasint"`
|
|
||||||
Mem uint64 `json:"m" cbor:"3,keyasint"`
|
|
||||||
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
|
|
||||||
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
|
|
||||||
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
|
|
||||||
PrevCpuUsage uint64 `json:"-"`
|
|
||||||
PrevReadTime time.Time `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCPUPercent calculates the CPU usage percentage for the service.
|
|
||||||
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
|
|
||||||
s.Cpu = 0
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := now.Sub(s.PrevReadTime).Nanoseconds()
|
|
||||||
if duration <= 0 {
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
coreCount := int64(runtime.NumCPU())
|
|
||||||
duration *= coreCount
|
|
||||||
|
|
||||||
usageDelta := cpuUsage - s.PrevCpuUsage
|
|
||||||
cpuPercent := float64(usageDelta) / float64(duration)
|
|
||||||
s.Cpu = twoDecimals(cpuPercent * 100)
|
|
||||||
|
|
||||||
if s.Cpu > s.CpuPeak {
|
|
||||||
s.CpuPeak = s.Cpu
|
|
||||||
}
|
|
||||||
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceDependency represents a unit that the service depends on.
|
|
||||||
type ServiceDependency struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
ActiveState string `json:"activeState,omitempty"`
|
|
||||||
SubState string `json:"subState,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceDetails contains extended information about a systemd service.
|
|
||||||
type ServiceDetails map[string]any
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package systemd_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseServiceStatus(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected systemd.ServiceState
|
|
||||||
}{
|
|
||||||
{"active", systemd.StatusActive},
|
|
||||||
{"inactive", systemd.StatusInactive},
|
|
||||||
{"failed", systemd.StatusFailed},
|
|
||||||
{"activating", systemd.StatusActivating},
|
|
||||||
{"deactivating", systemd.StatusDeactivating},
|
|
||||||
{"reloading", systemd.StatusReloading},
|
|
||||||
{"unknown", systemd.StatusInactive}, // default case
|
|
||||||
{"", systemd.StatusInactive}, // default case
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := systemd.ParseServiceStatus(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseServiceSubState(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected systemd.ServiceSubState
|
|
||||||
}{
|
|
||||||
{"dead", systemd.SubStateDead},
|
|
||||||
{"running", systemd.SubStateRunning},
|
|
||||||
{"exited", systemd.SubStateExited},
|
|
||||||
{"failed", systemd.SubStateFailed},
|
|
||||||
{"unknown", systemd.SubStateUnknown},
|
|
||||||
{"other", systemd.SubStateUnknown}, // default case
|
|
||||||
{"", systemd.SubStateUnknown}, // default case
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := systemd.ParseServiceSubState(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceUpdateCPUPercent(t *testing.T) {
|
|
||||||
t.Run("initial call sets CPU to 0", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.UpdateCPUPercent(1000)
|
|
||||||
assert.Equal(t, 0.0, service.Cpu)
|
|
||||||
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
|
|
||||||
assert.False(t, service.PrevReadTime.IsZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
|
|
||||||
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
|
|
||||||
|
|
||||||
// CPU usage should be positive and reasonable
|
|
||||||
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
|
|
||||||
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
|
|
||||||
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
|
|
||||||
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CPU peak updates only when higher", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
|
|
||||||
initialPeak := service.CpuPeak
|
|
||||||
|
|
||||||
// Now try with much lower CPU usage - should not update peak
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(1000000) // Much lower usage
|
|
||||||
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles zero duration", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
now := time.Now()
|
|
||||||
service.PrevReadTime = now
|
|
||||||
// Mock time.Now() to return the same time to ensure zero duration
|
|
||||||
// Since we can't mock time in Go easily, we'll check the logic manually
|
|
||||||
// The zero duration case happens when duration <= 0
|
|
||||||
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles CPU usage wraparound", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
// Simulate wraparound where new usage is less than previous
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(500) // Less than previous, should reset
|
|
||||||
assert.Equal(t, 0.0, service.Cpu)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -120,27 +120,18 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
if err := setCollectionAuthSettings(e.App); err != nil {
|
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCollectionAuthSettings sets up default authentication settings for the app
|
|
||||||
func setCollectionAuthSettings(app core.App) error {
|
|
||||||
usersCollection, err := app.FindCollectionByNameOrId("users")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
|
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
|
||||||
|
if usersCollection.OAuth2.Enabled {
|
||||||
|
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
|
||||||
|
}
|
||||||
// allow oauth user creation if USER_CREATION is set
|
// allow oauth user creation if USER_CREATION is set
|
||||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||||
cr := "@request.context = 'oauth2'"
|
cr := "@request.context = 'oauth2'"
|
||||||
@@ -148,52 +139,29 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
} else {
|
} else {
|
||||||
usersCollection.CreateRule = nil
|
usersCollection.CreateRule = nil
|
||||||
}
|
}
|
||||||
|
if err := e.App.Save(usersCollection); err != nil {
|
||||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
|
||||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
|
||||||
usersCollection.OTP.Length = 6
|
|
||||||
superusersCollection.OTP.Length = 6
|
|
||||||
usersCollection.OTP.Enabled = mfaOtp == "true"
|
|
||||||
usersCollection.MFA.Enabled = mfaOtp == "true"
|
|
||||||
superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
|
|
||||||
superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
|
|
||||||
if err := app.Save(superusersCollection); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := app.Save(usersCollection); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
|
||||||
|
|
||||||
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
|
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
|
||||||
systemsCollection, err := app.FindCollectionByNameOrId("systems")
|
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var systemsReadRule string
|
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
||||||
if shareAllSystems == "true" {
|
systemsReadRule := "@request.auth.id != \"\""
|
||||||
systemsReadRule = "@request.auth.id != \"\""
|
if shareAllSystems != "true" {
|
||||||
} else {
|
// default is to only show systems that the user id is assigned to
|
||||||
systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id"
|
systemsReadRule += " && users.id ?= @request.auth.id"
|
||||||
}
|
}
|
||||||
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
|
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
|
||||||
systemsCollection.ListRule = &systemsReadRule
|
systemsCollection.ListRule = &systemsReadRule
|
||||||
systemsCollection.ViewRule = &systemsReadRule
|
systemsCollection.ViewRule = &systemsReadRule
|
||||||
systemsCollection.UpdateRule = &updateDeleteRule
|
systemsCollection.UpdateRule = &updateDeleteRule
|
||||||
systemsCollection.DeleteRule = &updateDeleteRule
|
systemsCollection.DeleteRule = &updateDeleteRule
|
||||||
if err := app.Save(systemsCollection); err != nil {
|
if err := e.App.Save(systemsCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
// allow all users to access all containers if SHARE_ALL_SYSTEMS is set
|
|
||||||
containersCollection, err := app.FindCollectionByNameOrId("containers")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
|
|
||||||
containersCollection.ListRule = &containersListRule
|
|
||||||
return app.Save(containersCollection)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
@@ -268,17 +236,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// get SMART data
|
|
||||||
apiAuth.GET("/smart", h.getSmartData)
|
|
||||||
// get systemd service details
|
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
|
||||||
// /containers routes
|
|
||||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
|
||||||
// get container logs
|
|
||||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
|
||||||
// get container info
|
|
||||||
apiAuth.GET("/containers/info", h.getContainerInfo)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,80 +267,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, response)
|
return e.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// containerRequestHandler handles both container logs and info requests
|
|
||||||
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
|
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
|
||||||
containerID := e.Request.URL.Query().Get("container")
|
|
||||||
|
|
||||||
if systemID == "" || containerID == "" {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := fetchFunc(system, containerID)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSystemdInfo handles GET /api/beszel/systemd/info requests
|
|
||||||
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|
||||||
query := e.Request.URL.Query()
|
|
||||||
systemID := query.Get("system")
|
|
||||||
serviceName := query.Get("service")
|
|
||||||
|
|
||||||
if systemID == "" || serviceName == "" {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
|
|
||||||
}
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
|
||||||
}
|
|
||||||
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
|
||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSmartData handles GET /api/beszel/smart requests
|
|
||||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
|
||||||
if systemID == "" {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
|
||||||
}
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
|
||||||
}
|
|
||||||
data, err := system.FetchSmartDataFromAgent()
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
|
||||||
return e.JSON(http.StatusOK, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns signer
|
// generates key pair if it doesn't exist and returns signer
|
||||||
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||||
if h.signer != nil {
|
if h.signer != nil {
|
||||||
|
|||||||
@@ -449,47 +449,6 @@ 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 and container parameters are 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{"system and container parameters are required"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "GET /containers/logs - with auth but invalid system should fail",
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Authorization": userToken,
|
|
||||||
},
|
|
||||||
ExpectedStatus: 404,
|
|
||||||
ExpectedContent: []string{"system not found"},
|
|
||||||
TestAppFactory: testAppFactory,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Auth Optional Routes - Should work without authentication
|
// Auth Optional Routes - Should work without authentication
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,15 +13,12 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -139,128 +135,41 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hub := sys.manager.hub
|
hub := sys.manager.hub
|
||||||
err = hub.RunInTransaction(func(txApp core.App) error {
|
// add system_stats and container_stats records
|
||||||
// add system_stats and container_stats records
|
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
||||||
systemStatsCollection, err := txApp.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")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
||||||
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
containerStatsRecord.Set("system", systemRecord.Id)
|
||||||
systemStatsRecord.Set("system", systemRecord.Id)
|
containerStatsRecord.Set("stats", data.Containers)
|
||||||
systemStatsRecord.Set("stats", data.Stats)
|
containerStatsRecord.Set("type", "1m")
|
||||||
systemStatsRecord.Set("type", "1m")
|
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
|
return nil, err
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add new systemd_stats record
|
|
||||||
if len(data.SystemdServices) > 0 {
|
|
||||||
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// shared params for all records
|
|
||||||
params := dbx.Params{
|
|
||||||
"system": systemId,
|
|
||||||
"updated": time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
}
|
||||||
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
valueStrings := make([]string, 0, len(data))
|
systemRecord.Set("info", data.Info)
|
||||||
for i, service := range data {
|
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
return nil, err
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
|
||||||
params["id"+suffix] = getSystemdServiceId(systemId, service.Name)
|
|
||||||
params["name"+suffix] = service.Name
|
|
||||||
params["state"+suffix] = service.State
|
|
||||||
params["sub"+suffix] = service.Sub
|
|
||||||
params["cpu"+suffix] = service.Cpu
|
|
||||||
params["cpuPeak"+suffix] = service.CpuPeak
|
|
||||||
params["memory"+suffix] = service.Mem
|
|
||||||
params["memPeak"+suffix] = service.MemPeak
|
|
||||||
}
|
}
|
||||||
queryString := fmt.Sprintf(
|
return systemRecord, nil
|
||||||
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
|
|
||||||
strings.Join(valueStrings, ","),
|
|
||||||
)
|
|
||||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSystemdServiceId generates a deterministic unique id for a systemd service
|
|
||||||
func getSystemdServiceId(systemId string, serviceName string) string {
|
|
||||||
hash := fnv.New32a()
|
|
||||||
hash.Write([]byte(systemId + serviceName))
|
|
||||||
return fmt.Sprintf("%x", hash.Sum32())
|
|
||||||
}
|
|
||||||
|
|
||||||
// createContainerRecords creates container records
|
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// shared params for all records
|
|
||||||
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}, {:image%[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["image"+suffix] = container.Image
|
|
||||||
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, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
|
|
||||||
strings.Join(valueStrings, ","),
|
|
||||||
)
|
|
||||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRecord retrieves the system record from the database.
|
// getRecord retrieves the system record from the database.
|
||||||
@@ -333,159 +242,37 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
|||||||
return sys.data, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
|
|
||||||
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
|
||||||
var result string
|
|
||||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
req := common.HubRequest[any]{Action: action, Data: requestData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
err = cbor.NewDecoder(stdout).Decode(&resp)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.String == nil {
|
|
||||||
return false, errors.New(errorMsg)
|
|
||||||
}
|
|
||||||
result = *resp.String
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchContainerInfoFromAgent fetches container info from the agent
|
|
||||||
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestContainerInfo(ctx, containerID)
|
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchContainerLogsFromAgent fetches container logs from the agent
|
|
||||||
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestContainerLogs(ctx, containerID)
|
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
|
||||||
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, 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.RequestSystemdInfo(ctx, serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
err := sys.runSSHOperation(5*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: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
|
|
||||||
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.ServiceInfo == nil {
|
|
||||||
if resp.Error != "" {
|
|
||||||
return false, errors.New(resp.Error)
|
|
||||||
}
|
|
||||||
return false, errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
result = resp.ServiceInfo
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, 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.RequestSmartData(ctx)
|
|
||||||
}
|
|
||||||
// fetch via SSH
|
|
||||||
var result map[string]any
|
|
||||||
err := sys.runSSHOperation(5*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: common.GetSmartData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
// Convert to generic map for JSON response
|
|
||||||
result = make(map[string]any, len(resp.SmartData))
|
|
||||||
for k, v := range resp.SmartData {
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchDataViaSSH handles fetching data using SSH.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
// This function encapsulates the original SSH logic.
|
// This function encapsulates the original SSH logic.
|
||||||
// It updates sys.data directly upon successful fetch.
|
// It updates sys.data directly upon successful fetch.
|
||||||
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, 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()
|
||||||
|
|
||||||
stdout, err := session.StdoutPipe()
|
stdout, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stdin, stdinErr := session.StdinPipe()
|
stdin, stdinErr := session.StdinPipe()
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
*sys.data = system.CombinedData{}
|
*sys.data = system.CombinedData{}
|
||||||
@@ -493,82 +280,45 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
|||||||
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||||
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
|
// Close write side to signal end of request
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
|
|
||||||
var resp common.AgentResponse
|
var resp common.AgentResponse
|
||||||
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
||||||
*sys.data = *resp.SystemData
|
*sys.data = *resp.SystemData
|
||||||
|
// wait for the session to complete
|
||||||
if err := session.Wait(); err != nil {
|
if err := session.Wait(); err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return false, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
// If decoding failed, fall back below
|
||||||
}
|
}
|
||||||
|
|
||||||
var decodeErr error
|
|
||||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
|
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
} else {
|
} else {
|
||||||
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
|
err = json.NewDecoder(stdout).Decode(sys.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if decodeErr != nil {
|
|
||||||
return true, decodeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Wait(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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()
|
sys.closeSSHConnection()
|
||||||
continue
|
if attempt < maxRetries {
|
||||||
}
|
|
||||||
|
|
||||||
retry, opErr := func() (bool, error) {
|
|
||||||
defer session.Close()
|
|
||||||
return operation(session)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if opErr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if retry {
|
|
||||||
sys.closeSSHConnection()
|
|
||||||
if attempt < retries {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return opErr
|
// wait for the session to complete
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("ssh operation failed")
|
// this should never be reached due to the return in the loop
|
||||||
|
return nil, fmt.Errorf("failed to fetch data")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
// createSSHClient creates a new SSH client for the system
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystem returns a system by ID from the store
|
|
||||||
func (sm *SystemManager) GetSystem(systemID string) (*System, error) {
|
|
||||||
sys, ok := sm.systems.GetOk(systemID)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("system not found")
|
|
||||||
}
|
|
||||||
return sys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
||||||
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
||||||
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
||||||
|
|||||||
@@ -154,20 +154,19 @@ func (sm *SystemManager) startRealtimeWorker() {
|
|||||||
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||||
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||||
for systemId, info := range activeSubscriptions {
|
for systemId, info := range activeSubscriptions {
|
||||||
system, err := sm.GetSystem(systemId)
|
system, ok := sm.systems.GetOk(systemId)
|
||||||
if err != nil {
|
if ok {
|
||||||
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetSystemdServiceId(t *testing.T) {
|
|
||||||
t.Run("deterministic output", func(t *testing.T) {
|
|
||||||
systemId := "sys-123"
|
|
||||||
serviceName := "nginx.service"
|
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
|
||||||
id1 := getSystemdServiceId(systemId, serviceName)
|
|
||||||
id2 := getSystemdServiceId(systemId, serviceName)
|
|
||||||
id3 := getSystemdServiceId(systemId, serviceName)
|
|
||||||
|
|
||||||
assert.Equal(t, id1, id2)
|
|
||||||
assert.Equal(t, id2, id3)
|
|
||||||
assert.NotEmpty(t, id1)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("different inputs produce different ids", func(t *testing.T) {
|
|
||||||
systemId1 := "sys-123"
|
|
||||||
systemId2 := "sys-456"
|
|
||||||
serviceName1 := "nginx.service"
|
|
||||||
serviceName2 := "apache.service"
|
|
||||||
|
|
||||||
id1 := getSystemdServiceId(systemId1, serviceName1)
|
|
||||||
id2 := getSystemdServiceId(systemId2, serviceName1)
|
|
||||||
id3 := getSystemdServiceId(systemId1, serviceName2)
|
|
||||||
id4 := getSystemdServiceId(systemId2, serviceName2)
|
|
||||||
|
|
||||||
// All IDs should be different
|
|
||||||
assert.NotEqual(t, id1, id2)
|
|
||||||
assert.NotEqual(t, id1, id3)
|
|
||||||
assert.NotEqual(t, id1, id4)
|
|
||||||
assert.NotEqual(t, id2, id3)
|
|
||||||
assert.NotEqual(t, id2, id4)
|
|
||||||
assert.NotEqual(t, id3, id4)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("consistent length", func(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
systemId string
|
|
||||||
serviceName string
|
|
||||||
}{
|
|
||||||
{"short", "short.service"},
|
|
||||||
{"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
|
|
||||||
{"", "empty-system.service"},
|
|
||||||
{"empty-service", ""},
|
|
||||||
{"", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
id := getSystemdServiceId(tc.systemId, tc.serviceName)
|
|
||||||
// FNV-32 produces 8 hex characters
|
|
||||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("hexadecimal output", func(t *testing.T) {
|
|
||||||
id := getSystemdServiceId("test-system", "test-service")
|
|
||||||
assert.NotEmpty(t, id)
|
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
|
||||||
for _, char := range id {
|
|
||||||
assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
|
|
||||||
"ID should only contain hexadecimal characters, got: %s", id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -19,11 +18,11 @@ type ResponseHandler interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
||||||
type BaseHandler struct{}
|
// type BaseHandler struct{}
|
||||||
|
|
||||||
func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
// func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
||||||
return errors.New("legacy format not supported")
|
// return errors.New("legacy format not supported")
|
||||||
}
|
// }
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -64,136 +63,6 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
handler := &systemdInfoHandler{result: &result}
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemdInfoHandler parses ServiceDetails from AgentResponse
|
|
||||||
type systemdInfoHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *systemd.ServiceDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.ServiceInfo == nil {
|
|
||||||
return errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.ServiceInfo
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSmartData, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var result map[string]any
|
|
||||||
handler := ResponseHandler(&smartDataHandler{result: &result})
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// smartDataHandler parses SMART data map from AgentResponse
|
|
||||||
type smartDataHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.SmartData == nil {
|
|
||||||
return errors.New("no SMART data in response")
|
|
||||||
}
|
|
||||||
// convert to map[string]any for transport convenience in hub layer
|
|
||||||
out := make(map[string]any, len(agentResponse.SmartData))
|
|
||||||
for k, v := range agentResponse.SmartData {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
*h.result = out
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
type fingerprintHandler struct {
|
type fingerprintHandler struct {
|
||||||
result *common.FingerprintResponse
|
result *common.FingerprintResponse
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerSuccess(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test successful handling with valid ServiceInfo
|
|
||||||
testDetails := systemd.ServiceDetails{
|
|
||||||
"Id": "nginx.service",
|
|
||||||
"ActiveState": "active",
|
|
||||||
"SubState": "running",
|
|
||||||
"Description": "A high performance web server",
|
|
||||||
"ExecMainPID": 1234,
|
|
||||||
"MemoryCurrent": 1024000,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: testDetails,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testDetails, *handler.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerError(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test error handling when ServiceInfo is nil
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: nil,
|
|
||||||
Error: "service not found",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with completely empty response
|
|
||||||
response := common.AgentResponse{}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that legacy format is not supported
|
|
||||||
err := handler.HandleLegacy([]byte("some data"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "legacy format not supported", err.Error())
|
|
||||||
}
|
|
||||||
@@ -181,17 +181,6 @@ func TestCommonActions(t *testing.T) {
|
|||||||
// Test that the actions we use exist and have expected values
|
// Test that the actions we use exist and have expected values
|
||||||
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogsHandler(t *testing.T) {
|
|
||||||
h := &stringResponseHandler{errorMsg: "no logs in response"}
|
|
||||||
|
|
||||||
logValue := "test logs"
|
|
||||||
resp := common.AgentResponse{String: &logValue}
|
|
||||||
err := h.Handle(resp)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, logValue, h.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler tests that we can create a Handler
|
// TestHandler tests that we can create a Handler
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
@@ -75,7 +76,6 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
"GPU",
|
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15"
|
||||||
@@ -719,9 +719,7 @@ func init() {
|
|||||||
"type": "autodate"
|
"type": "autodate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [
|
"indexes": [],
|
||||||
"CREATE INDEX ` + "`" + `idx_systems_status` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `status` + "`" + `)"
|
|
||||||
],
|
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -862,387 +860,6 @@ func init() {
|
|||||||
"system": false,
|
"system": false,
|
||||||
"authRule": "verified=true",
|
"authRule": "verified=true",
|
||||||
"manageRule": null
|
"manageRule": null
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pbc_1864144027",
|
|
||||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"name": "containers",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-f0-9]{6}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 12,
|
|
||||||
"min": 6,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-f0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": false,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text1579384326",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text2063623452",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "status",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3470402323",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "health",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3128971310",
|
|
||||||
"max": 100,
|
|
||||||
"min": 0,
|
|
||||||
"name": "cpu",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3933025333",
|
|
||||||
"max": null,
|
|
||||||
"min": 0,
|
|
||||||
"name": "memory",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number4075427327",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "net",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3332085495",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "updated",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3309110367",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "image",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_JxWirjdhyO` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `updated` + "`" + `)",
|
|
||||||
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
|
||||||
],
|
|
||||||
"system": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{10}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 10,
|
|
||||||
"min": 6,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text1579384326",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number2063623452",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "state",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1476559580",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "sub",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3128971310",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cpu",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1052053287",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cpuPeak",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3933025333",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "memory",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1828797201",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "memPeak",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3332085495",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "updated",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_3494996990",
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
|
|
||||||
"CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"name": "systemd_services",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": null,
|
|
||||||
"viewRule": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{10}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 10,
|
|
||||||
"min": 10,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation2375276105",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "user",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "select2844932856",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "type",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": [
|
|
||||||
"one-time",
|
|
||||||
"daily"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "date2675529103",
|
|
||||||
"max": "",
|
|
||||||
"min": "",
|
|
||||||
"name": "start",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "date16528305",
|
|
||||||
"max": "",
|
|
||||||
"min": "",
|
|
||||||
"name": "end",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "date"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_451525641",
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)",
|
|
||||||
"CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"name": "quiet_hours",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id"
|
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -1251,6 +868,31 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all systems that don't have fingerprint records
|
||||||
|
var systemIds []string
|
||||||
|
err = app.DB().NewQuery(`
|
||||||
|
SELECT s.id FROM systems s
|
||||||
|
LEFT JOIN fingerprints f ON s.id = f.system
|
||||||
|
WHERE f.system IS NULL
|
||||||
|
`).Column(&systemIds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Create fingerprint records with unique UUID tokens for each system
|
||||||
|
for _, systemId := range systemIds {
|
||||||
|
token := uuid.New().String()
|
||||||
|
_, err = app.DB().NewQuery(`
|
||||||
|
INSERT INTO fingerprints (system, token)
|
||||||
|
VALUES ({:system}, {:token})
|
||||||
|
`).Bind(map[string]any{
|
||||||
|
"system": systemId,
|
||||||
|
"token": token,
|
||||||
|
}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, func(app core.App) error {
|
}, func(app core.App) error {
|
||||||
return nil
|
return nil
|
||||||
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This can be deleted after Nov 2025 or so
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
var systemIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
|
||||||
|
|
||||||
|
for _, systemId := range systemIds {
|
||||||
|
var statRecordIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
|
||||||
|
|
||||||
|
for _, statRecordId := range statRecordIds {
|
||||||
|
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var systemStats system.Stats
|
||||||
|
err = statRecord.UnmarshalJSONField("stats", &systemStats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// if mem buff cache is less than total mem, we don't need to fix it
|
||||||
|
if systemStats.MemBuffCache < systemStats.Mem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
systemStats.MemBuffCache = 0
|
||||||
|
statRecord.Set("stats", systemStats)
|
||||||
|
err = txApp.SaveNoValidate(statRecord)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, func(app core.App) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -177,10 +177,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
stats := &tempStats
|
stats := &tempStats
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
// accumulate per-core usage across records
|
|
||||||
var cpuCoresSums []uint64
|
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
|
||||||
var cpuBreakdownSums []float64
|
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
@@ -198,15 +194,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
}
|
}
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
|
||||||
if stats.CpuBreakdown != nil {
|
|
||||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
|
||||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
|
||||||
}
|
|
||||||
for i, v := range stats.CpuBreakdown {
|
|
||||||
cpuBreakdownSums[i] += v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
sum.MemUsed += stats.MemUsed
|
sum.MemUsed += stats.MemUsed
|
||||||
sum.MemPct += stats.MemPct
|
sum.MemPct += stats.MemPct
|
||||||
@@ -230,17 +217,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskIO[1] += stats.DiskIO[1]
|
sum.DiskIO[1] += stats.DiskIO[1]
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
|
|
||||||
// accumulate per-core usage if present
|
|
||||||
if stats.CpuCoresUsage != nil {
|
|
||||||
if len(cpuCoresSums) < len(stats.CpuCoresUsage) {
|
|
||||||
// extend slices to accommodate core count
|
|
||||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
|
||||||
}
|
|
||||||
for i, v := range stats.CpuCoresUsage {
|
|
||||||
cpuCoresSums[i] += uint64(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
@@ -293,10 +269,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
fs.DiskReadPs += value.DiskReadPs
|
fs.DiskReadPs += value.DiskReadPs
|
||||||
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
||||||
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
||||||
fs.DiskReadBytes += value.DiskReadBytes
|
|
||||||
fs.DiskWriteBytes += value.DiskWriteBytes
|
|
||||||
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
|
|
||||||
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +356,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
|
||||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,25 +379,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Average per-core usage
|
|
||||||
if len(cpuCoresSums) > 0 {
|
|
||||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
|
||||||
for i := range cpuCoresSums {
|
|
||||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
|
||||||
avg[i] = uint8(v)
|
|
||||||
}
|
|
||||||
sum.CpuCoresUsage = avg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average CPU breakdown
|
|
||||||
if len(cpuBreakdownSums) > 0 {
|
|
||||||
avg := make([]float64, len(cpuBreakdownSums))
|
|
||||||
for i := range cpuBreakdownSums {
|
|
||||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
|
||||||
}
|
|
||||||
sum.CpuBreakdown = avg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum
|
return sum
|
||||||
@@ -463,6 +414,8 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
sums[stat.Name].Mem += stat.Mem
|
sums[stat.Name].Mem += stat.Mem
|
||||||
sums[stat.Name].NetworkSent += stat.NetworkSent
|
sums[stat.Name].NetworkSent += stat.NetworkSent
|
||||||
sums[stat.Name].NetworkRecv += stat.NetworkRecv
|
sums[stat.Name].NetworkRecv += stat.NetworkRecv
|
||||||
|
sums[stat.Name].DiskRead += stat.DiskRead
|
||||||
|
sums[stat.Name].DiskWrite += stat.DiskWrite
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +427,8 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
Mem: twoDecimals(value.Mem / count),
|
Mem: twoDecimals(value.Mem / count),
|
||||||
NetworkSent: twoDecimals(value.NetworkSent / count),
|
NetworkSent: twoDecimals(value.NetworkSent / count),
|
||||||
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
NetworkRecv: twoDecimals(value.NetworkRecv / count),
|
||||||
|
DiskRead: twoDecimals(value.DiskRead / count),
|
||||||
|
DiskWrite: twoDecimals(value.DiskWrite / count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -486,22 +441,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = deleteOldContainerRecords(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldSystemdServiceRecords(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = deleteOldQuietHours(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -567,45 +510,6 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
|
||||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
|
||||||
|
|
||||||
// Delete systemd service records where updated < twentyMinutesAgo
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes old quiet hours records where end date has passed
|
|
||||||
func deleteOldQuietHours(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
@@ -351,83 +351,6 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
|
||||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
rm := records.NewRecordManager(hub)
|
|
||||||
|
|
||||||
// Create test user and system
|
|
||||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
|
||||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "nginx.service",
|
|
||||||
"state": 0, // Active
|
|
||||||
"sub": 1, // Running
|
|
||||||
"cpu": 5.0,
|
|
||||||
"cpuPeak": 10.0,
|
|
||||||
"memory": 1024000,
|
|
||||||
"memPeak": 2048000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 25 minutes ago (should be deleted)
|
|
||||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(oldRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
|
||||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "apache.service",
|
|
||||||
"state": 1, // Inactive
|
|
||||||
"sub": 0, // Dead
|
|
||||||
"cpu": 2.0,
|
|
||||||
"cpuPeak": 3.0,
|
|
||||||
"memory": 512000,
|
|
||||||
"memPeak": 1024000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 10 minutes ago (should be kept)
|
|
||||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(recentRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Count records before deletion
|
|
||||||
countBefore, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
|
||||||
|
|
||||||
// Run deletion via RecordManager
|
|
||||||
rm.DeleteOldRecords()
|
|
||||||
|
|
||||||
// Count records after deletion
|
|
||||||
countAfter, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
|
||||||
|
|
||||||
// Verify the correct record was kept
|
|
||||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
|
||||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecordManagerCreation tests RecordManager creation
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
func TestRecordManagerCreation(t *testing.T) {
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
|||||||
@@ -30,12 +30,11 @@
|
|||||||
"noUnusedFunctionParameters": "error",
|
"noUnusedFunctionParameters": "error",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedPrivateClassMembers": "error",
|
||||||
"useExhaustiveDependencies": {
|
"useExhaustiveDependencies": {
|
||||||
"level": "warn",
|
"level": "error",
|
||||||
"options": {
|
"options": {
|
||||||
"reportUnnecessaryDependencies": false
|
"reportUnnecessaryDependencies": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"useUniqueElementIds": "off",
|
|
||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
BIN
internal/site/bun.lockb
Executable file
BIN
internal/site/bun.lockb
Executable file
Binary file not shown.
@@ -11,10 +11,10 @@ export default defineConfig({
|
|||||||
"es",
|
"es",
|
||||||
"fa",
|
"fa",
|
||||||
"fr",
|
"fr",
|
||||||
"he",
|
|
||||||
"hr",
|
"hr",
|
||||||
"hu",
|
"hu",
|
||||||
"it",
|
"it",
|
||||||
|
"is",
|
||||||
"ja",
|
"ja",
|
||||||
"ko",
|
"ko",
|
||||||
"nl",
|
"nl",
|
||||||
|
|||||||
611
internal/site/package-lock.json
generated
611
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.16.1",
|
"version": "0.13.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.16.1",
|
"version": "0.13.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
@@ -42,12 +42,11 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.3",
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
@@ -333,9 +332,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.3.tgz",
|
||||||
"integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==",
|
"integrity": "sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -349,20 +348,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.2.4",
|
"@biomejs/cli-darwin-arm64": "2.2.3",
|
||||||
"@biomejs/cli-darwin-x64": "2.2.4",
|
"@biomejs/cli-darwin-x64": "2.2.3",
|
||||||
"@biomejs/cli-linux-arm64": "2.2.4",
|
"@biomejs/cli-linux-arm64": "2.2.3",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.2.4",
|
"@biomejs/cli-linux-arm64-musl": "2.2.3",
|
||||||
"@biomejs/cli-linux-x64": "2.2.4",
|
"@biomejs/cli-linux-x64": "2.2.3",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.2.4",
|
"@biomejs/cli-linux-x64-musl": "2.2.3",
|
||||||
"@biomejs/cli-win32-arm64": "2.2.4",
|
"@biomejs/cli-win32-arm64": "2.2.3",
|
||||||
"@biomejs/cli-win32-x64": "2.2.4"
|
"@biomejs/cli-win32-x64": "2.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.3.tgz",
|
||||||
"integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==",
|
"integrity": "sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -377,9 +376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.3.tgz",
|
||||||
"integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==",
|
"integrity": "sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -394,9 +393,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.3.tgz",
|
||||||
"integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==",
|
"integrity": "sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -411,9 +410,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.3.tgz",
|
||||||
"integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==",
|
"integrity": "sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -428,9 +427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.3.tgz",
|
||||||
"integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==",
|
"integrity": "sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -445,9 +444,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.3.tgz",
|
||||||
"integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==",
|
"integrity": "sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -462,9 +461,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.3.tgz",
|
||||||
"integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==",
|
"integrity": "sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -479,9 +478,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.3.tgz",
|
||||||
"integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==",
|
"integrity": "sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2747,73 +2746,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/core": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/types": "3.13.0",
|
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
|
||||||
"@types/hast": "^3.0.4",
|
|
||||||
"hast-util-to-html": "^9.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/engine-javascript": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/types": "3.13.0",
|
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
|
||||||
"oniguruma-to-es": "^4.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/engine-oniguruma": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/types": "3.13.0",
|
|
||||||
"@shikijs/vscode-textmate": "^10.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/langs": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/types": "3.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/themes": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/types": "3.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/types": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
|
||||||
"@types/hast": "^3.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/vscode-textmate": {
|
|
||||||
"version": "10.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
|
||||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.27.8",
|
"version": "0.27.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||||
@@ -3484,15 +3416,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/hast": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -3520,15 +3443,6 @@
|
|||||||
"@types/istanbul-lib-report": "*"
|
"@types/istanbul-lib-report": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/mdast": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.3.0",
|
"version": "24.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||||
@@ -3559,12 +3473,6 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
@@ -3582,12 +3490,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react-swc": {
|
"node_modules/@vitejs/plugin-react-swc": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.0.1.tgz",
|
||||||
@@ -3871,16 +3773,6 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/ccount": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -3898,26 +3790,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/character-entities-html4": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/character-entities-legacy": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||||
@@ -4043,16 +3915,6 @@
|
|||||||
"node": ">=0.1.90"
|
"node": ">=0.1.90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/comma-separated-tokens": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -4277,15 +4139,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dequal": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||||
@@ -4302,19 +4155,6 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/devlop": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -4553,52 +4393,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hast-util-to-html": {
|
|
||||||
"version": "9.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
|
|
||||||
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/hast": "^3.0.0",
|
|
||||||
"@types/unist": "^3.0.0",
|
|
||||||
"ccount": "^2.0.0",
|
|
||||||
"comma-separated-tokens": "^2.0.0",
|
|
||||||
"hast-util-whitespace": "^3.0.0",
|
|
||||||
"html-void-elements": "^3.0.0",
|
|
||||||
"mdast-util-to-hast": "^13.0.0",
|
|
||||||
"property-information": "^7.0.0",
|
|
||||||
"space-separated-tokens": "^2.0.0",
|
|
||||||
"stringify-entities": "^4.0.0",
|
|
||||||
"zwitch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hast-util-whitespace": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/hast": "^3.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/html-void-elements": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -4807,9 +4601,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5179,116 +4973,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
|
||||||
"version": "13.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/hast": "^3.0.0",
|
|
||||||
"@types/mdast": "^4.0.0",
|
|
||||||
"@ungap/structured-clone": "^1.0.0",
|
|
||||||
"devlop": "^1.0.0",
|
|
||||||
"micromark-util-sanitize-uri": "^2.0.0",
|
|
||||||
"trim-lines": "^3.0.0",
|
|
||||||
"unist-util-position": "^5.0.0",
|
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"vfile": "^6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-util-character": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "GitHub Sponsors",
|
|
||||||
"url": "https://github.com/sponsors/unifiedjs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "OpenCollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"micromark-util-symbol": "^2.0.0",
|
|
||||||
"micromark-util-types": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-util-encode": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "GitHub Sponsors",
|
|
||||||
"url": "https://github.com/sponsors/unifiedjs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "OpenCollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/micromark-util-sanitize-uri": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "GitHub Sponsors",
|
|
||||||
"url": "https://github.com/sponsors/unifiedjs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "OpenCollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"micromark-util-character": "^2.0.0",
|
|
||||||
"micromark-util-encode": "^2.0.0",
|
|
||||||
"micromark-util-symbol": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromark-util-symbol": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "GitHub Sponsors",
|
|
||||||
"url": "https://github.com/sponsors/unifiedjs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "OpenCollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/micromark-util-types": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "GitHub Sponsors",
|
|
||||||
"url": "https://github.com/sponsors/unifiedjs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "OpenCollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -5454,23 +5138,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oniguruma-parser": {
|
|
||||||
"version": "0.12.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
|
|
||||||
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/oniguruma-to-es": {
|
|
||||||
"version": "4.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz",
|
|
||||||
"integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"oniguruma-parser": "^0.12.1",
|
|
||||||
"regex": "^6.0.1",
|
|
||||||
"regex-recursion": "^6.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ora": {
|
"node_modules/ora": {
|
||||||
"version": "5.4.1",
|
"version": "5.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
|
||||||
@@ -5684,16 +5351,6 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/property-information": {
|
|
||||||
"version": "7.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
|
||||||
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pseudolocale": {
|
"node_modules/pseudolocale": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pseudolocale/-/pseudolocale-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pseudolocale/-/pseudolocale-2.1.0.tgz",
|
||||||
@@ -5910,30 +5567,6 @@
|
|||||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/regex": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"regex-utilities": "^2.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/regex-recursion": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"regex-utilities": "^2.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/regex-utilities": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -6065,22 +5698,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shiki": {
|
|
||||||
"version": "3.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz",
|
|
||||||
"integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@shikijs/core": "3.13.0",
|
|
||||||
"@shikijs/engine-javascript": "3.13.0",
|
|
||||||
"@shikijs/engine-oniguruma": "3.13.0",
|
|
||||||
"@shikijs/langs": "3.13.0",
|
|
||||||
"@shikijs/themes": "3.13.0",
|
|
||||||
"@shikijs/types": "3.13.0",
|
|
||||||
"@shikijs/vscode-textmate": "^10.0.2",
|
|
||||||
"@types/hast": "^3.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -6117,16 +5734,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/space-separated-tokens": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -6201,20 +5808,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stringify-entities": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"character-entities-html4": "^2.0.0",
|
|
||||||
"character-entities-legacy": "^3.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||||
@@ -6373,16 +5966,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/trim-lines": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.6.3",
|
"version": "2.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
|
||||||
@@ -6420,74 +6003,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unist-util-is": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unist-util-position": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unist-util-stringify-position": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unist-util-visit": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0",
|
|
||||||
"unist-util-is": "^6.0.0",
|
|
||||||
"unist-util-visit-parents": "^6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unist-util-visit-parents": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0",
|
|
||||||
"unist-util-is": "^6.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||||
@@ -6583,34 +6098,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vfile": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0",
|
|
||||||
"vfile-message": "^4.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vfile-message": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/unist": "^3.0.0",
|
|
||||||
"unist-util-stringify-position": "^4.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/unified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/victory-vendor": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
@@ -6634,9 +6121,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.11",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
|
||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6854,16 +6341,6 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
|
||||||
"node_modules/zwitch": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.16.1",
|
"version": "0.13.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -49,12 +49,11 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.3",
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { alertInfo } from "@/lib/alerts"
|
|
||||||
import { $alerts, $allSystemsById } from "@/lib/stores"
|
|
||||||
import type { AlertRecord } from "@/types"
|
|
||||||
import { Plural, Trans } from "@lingui/react/macro"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import { $router, Link } from "./router"
|
|
||||||
import { Alert, AlertTitle, AlertDescription } from "./ui/alert"
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
|
|
||||||
|
|
||||||
export const ActiveAlerts = () => {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const systems = useStore($allSystemsById)
|
|
||||||
|
|
||||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
|
||||||
const activeAlerts: AlertRecord[] = []
|
|
||||||
// key to prevent re-rendering if alerts change but active alerts didn't
|
|
||||||
const alertsKey: string[] = []
|
|
||||||
|
|
||||||
for (const systemId of Object.keys(alerts)) {
|
|
||||||
for (const alert of alerts[systemId].values()) {
|
|
||||||
if (alert.triggered && alert.name in alertInfo) {
|
|
||||||
activeAlerts.push(alert)
|
|
||||||
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeAlerts, alertsKey }
|
|
||||||
}, [alerts])
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
|
||||||
return useMemo(() => {
|
|
||||||
if (activeAlerts.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle>
|
|
||||||
<Trans>Active Alerts</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="max-sm:p-2">
|
|
||||||
{activeAlerts.length > 0 && (
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
|
||||||
{activeAlerts.map((alert) => {
|
|
||||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
key={alert.id}
|
|
||||||
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
|
||||||
>
|
|
||||||
<info.icon className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{alert.name === "Status" ? (
|
|
||||||
<Trans>Connection is down</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
Exceeds {alert.value}
|
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}, [alertsKey.join("")])
|
|
||||||
}
|
|
||||||
@@ -36,31 +36,28 @@ import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/i
|
|||||||
import { InputCopy } from "./ui/input-copy"
|
import { InputCopy } from "./ui/input-copy"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
if (isReadOnlyUser()) {
|
const [open, setOpen] = useState(false)
|
||||||
return null
|
const opened = useRef(false)
|
||||||
}
|
if (open) {
|
||||||
const [open, setOpen] = useState(false)
|
opened.current = true
|
||||||
const opened = useRef(false)
|
}
|
||||||
if (open) {
|
|
||||||
opened.current = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}
|
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
<Trans>
|
<Trans>
|
||||||
Add <span className="hidden sm:inline">System</span>
|
Add <span className="hidden sm:inline">System</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
{opened.current && <SystemDialog setOpen={setOpen} />}
|
{opened.current && <SystemDialog setOpen={setOpen} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
|
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import {
|
|||||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
import { AxisDomain } from "recharts/types/util/types"
|
|
||||||
|
|
||||||
export type DataPoint = {
|
export type DataPoint = {
|
||||||
label: string
|
label: string
|
||||||
dataKey: (data: SystemStatsRecord) => number | undefined
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
color: number | string
|
color: number | string
|
||||||
opacity: number
|
opacity: number
|
||||||
stackId?: string | number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
@@ -31,25 +29,19 @@ export default function AreaChartDefault({
|
|||||||
domain,
|
domain,
|
||||||
legend,
|
legend,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
showTotal = false,
|
|
||||||
reverseStackOrder = false,
|
|
||||||
hideYAxis = false,
|
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
maxToggled?: boolean
|
maxToggled?: boolean
|
||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
domain?: AxisDomain
|
domain?: [number, number]
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
showTotal?: boolean
|
itemSorter?: (a: any, b: any) => number
|
||||||
itemSorter?: (a: any, b: any) => number
|
// logRender?: boolean
|
||||||
reverseStackOrder?: boolean
|
}) {
|
||||||
hideYAxis?: boolean
|
|
||||||
// logRender?: boolean
|
|
||||||
}) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
|
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
|
||||||
@@ -64,29 +56,21 @@ export default function AreaChartDefault({
|
|||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
"opacity-100": yAxisWidth || hideYAxis,
|
"opacity-100": yAxisWidth,
|
||||||
"ps-4": hideYAxis,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
reverseStackOrder={reverseStackOrder}
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData.systemStats}
|
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{!hideYAxis && (
|
<YAxis
|
||||||
<YAxis
|
direction="ltr"
|
||||||
direction="ltr"
|
orientation={chartData.orientation}
|
||||||
orientation={chartData.orientation}
|
className="tracking-tighter"
|
||||||
className="tracking-tighter"
|
width={yAxisWidth}
|
||||||
width={yAxisWidth}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
domain={domain ?? [0, max ?? "auto"]}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
@@ -97,7 +81,6 @@ export default function AreaChartDefault({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
showTotal={showTotal}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -116,14 +99,13 @@ export default function AreaChartDefault({
|
|||||||
fillOpacity={dataPoint.opacity}
|
fillOpacity={dataPoint.opacity}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
stackId={dataPoint.stackId}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
// import Spinner from '../spinner'
|
// import Spinner from '../spinner'
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import React from "react"
|
||||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
|
import { Area, AreaChart, CartesianGrid, Line, LineChart, YAxis } from "recharts"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { ChartType, Unit } from "@/lib/enums"
|
import { ChartType, Unit } from "@/lib/enums"
|
||||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
import { $containerColors, $containerFilter, $stackFilter, $userSettings } from "@/lib/stores"
|
||||||
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
import {
|
||||||
|
chartMargin,
|
||||||
|
cn,
|
||||||
|
decimalString,
|
||||||
|
formatBytes,
|
||||||
|
formatShortDate,
|
||||||
|
generateFallbackColor,
|
||||||
|
getSizeAndUnit,
|
||||||
|
toFixedFloat,
|
||||||
|
toFixedWithoutTrailingZeros,
|
||||||
|
} from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "../ui/separator"
|
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function ContainerChart({
|
export default memo(function ContainerChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
chartData,
|
chartData,
|
||||||
chartType,
|
chartType,
|
||||||
chartConfig,
|
chartConfig: propChartConfig,
|
||||||
unit = "%",
|
unit = "%",
|
||||||
}: {
|
}: {
|
||||||
dataKey: string
|
dataKey: string
|
||||||
@@ -23,13 +35,168 @@ export default memo(function ContainerChart({
|
|||||||
chartConfig: ChartConfig
|
chartConfig: ChartConfig
|
||||||
unit?: string
|
unit?: string
|
||||||
}) {
|
}) {
|
||||||
const filter = useStore($containerFilter)
|
const containerFilter = useStore($containerFilter)
|
||||||
|
const stackFilter = useStore($stackFilter)
|
||||||
|
const containerColors = useStore($containerColors)
|
||||||
const userSettings = useStore($userSettings)
|
const userSettings = useStore($userSettings)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
const { containerData } = chartData
|
const { containerData } = chartData
|
||||||
|
|
||||||
const isNetChart = chartType === ChartType.Network
|
const isNetChart = chartType === ChartType.Network
|
||||||
|
const isVolumeChart = chartType === ChartType.Volume
|
||||||
|
const isHealthChart = chartType === ChartType.Health
|
||||||
|
const isUptimeChart = chartType === ChartType.Uptime
|
||||||
|
const isHealthUptimeChart = chartType === ChartType.HealthUptime
|
||||||
|
const isDiskIOChart = chartType === ChartType.DiskIO
|
||||||
|
|
||||||
|
// Centralized data processing for all chart types
|
||||||
|
const chartDatasets = useMemo(() => {
|
||||||
|
const volumeChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const healthChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const uptimeChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const healthUptimeChartData = { data: [], colors: {} } as {
|
||||||
|
data: Record<string, number | string>[]
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
const containerChartConfig = {} as Record<string, { label: string; color: string }>
|
||||||
|
|
||||||
|
const volumeSums: Record<string, number> = {}
|
||||||
|
const volumeContainers: Record<string, string[]> = {}
|
||||||
|
const allContainerNames = new Set<string>()
|
||||||
|
const healthUptimeContainerNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const containerStats of containerData) {
|
||||||
|
if (!containerStats.created) {
|
||||||
|
// For gaps in data
|
||||||
|
volumeChartData.data.push({ created: "" })
|
||||||
|
healthChartData.data.push({ created: "" })
|
||||||
|
uptimeChartData.data.push({ created: "" })
|
||||||
|
healthUptimeChartData.data.push({ created: "" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const volumeData = { created: containerStats.created } as Record<string, number | string>
|
||||||
|
const healthData = { created: containerStats.created } as Record<string, number | string>
|
||||||
|
const uptimeData = { created: containerStats.created } as Record<string, number | string>
|
||||||
|
const healthUptimeData = { created: containerStats.created } as Record<string, number | string>
|
||||||
|
|
||||||
|
for (const [containerName, containerDataObj] of Object.entries(containerStats)) {
|
||||||
|
if (containerName === "created") continue
|
||||||
|
|
||||||
|
// Apply container filter
|
||||||
|
if (containerFilter.length > 0 && !containerFilter.includes(containerName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply stack filter
|
||||||
|
if (stackFilter.length > 0 && typeof containerDataObj === "object" && containerDataObj) {
|
||||||
|
const stackName = (containerDataObj as any).p || "—"
|
||||||
|
if (!stackFilter.includes(stackName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allContainerNames.add(containerName)
|
||||||
|
|
||||||
|
if (typeof containerDataObj === "object" && containerDataObj) {
|
||||||
|
// Volume
|
||||||
|
if ("v" in containerDataObj && containerDataObj.v) {
|
||||||
|
for (const [volumeName, volumeSize] of Object.entries(containerDataObj.v)) {
|
||||||
|
if (typeof volumeSize === "number" && volumeSize > 0) {
|
||||||
|
volumeData[volumeName] = ((volumeData[volumeName] as number) || 0) + volumeSize
|
||||||
|
volumeSums[volumeName] = (volumeSums[volumeName] ?? 0) + volumeSize
|
||||||
|
if (!volumeContainers[volumeName]) volumeContainers[volumeName] = []
|
||||||
|
if (!volumeContainers[volumeName].includes(containerName))
|
||||||
|
volumeContainers[volumeName].push(containerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Health
|
||||||
|
if ("h" in containerDataObj) {
|
||||||
|
const healthStatus = ((containerDataObj.h as string) || "").toLowerCase()
|
||||||
|
let healthValue = 0
|
||||||
|
switch (healthStatus) {
|
||||||
|
case "healthy":
|
||||||
|
healthValue = 3
|
||||||
|
break
|
||||||
|
case "starting":
|
||||||
|
healthValue = 2
|
||||||
|
break
|
||||||
|
case "unhealthy":
|
||||||
|
healthValue = 1
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
healthValue = 0
|
||||||
|
}
|
||||||
|
healthData[containerName] = healthValue
|
||||||
|
// Health+Uptime
|
||||||
|
healthUptimeData[`${containerName}_health`] = healthValue
|
||||||
|
healthUptimeContainerNames.add(containerName)
|
||||||
|
}
|
||||||
|
// Uptime
|
||||||
|
if ("u" in containerDataObj && containerDataObj.u) {
|
||||||
|
uptimeData[containerName] = (containerDataObj.u as number) / 3600
|
||||||
|
// Health+Uptime
|
||||||
|
healthUptimeData[`${containerName}_uptime`] = (containerDataObj.u as number) / 3600
|
||||||
|
healthUptimeContainerNames.add(containerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumeChartData.data.push(volumeData)
|
||||||
|
healthChartData.data.push(healthData)
|
||||||
|
uptimeChartData.data.push(uptimeData)
|
||||||
|
healthUptimeChartData.data.push(healthUptimeData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process volumes attached to containers
|
||||||
|
const volumeKeys = Object.keys(volumeSums)
|
||||||
|
.filter((key) => (volumeContainers[key] || []).length > 0)
|
||||||
|
.sort((a, b) => volumeSums[b] - volumeSums[a])
|
||||||
|
for (const key of volumeKeys) {
|
||||||
|
const containers = volumeContainers[key] || []
|
||||||
|
const firstContainer = containers[0]
|
||||||
|
volumeChartData.colors[key] =
|
||||||
|
containerColors[firstContainer] || generateFallbackColor(firstContainer)
|
||||||
|
}
|
||||||
|
const healthKeys = Object.keys(healthChartData.data[0] || {}).filter((key) => key !== "created")
|
||||||
|
for (const key of healthKeys) {
|
||||||
|
healthChartData.colors[key] = containerColors[key] || generateFallbackColor(key)
|
||||||
|
}
|
||||||
|
const uptimeKeys = Object.keys(uptimeChartData.data[0] || {}).filter((key) => key !== "created")
|
||||||
|
for (const key of uptimeKeys) {
|
||||||
|
uptimeChartData.colors[key] = containerColors[key] || generateFallbackColor(key)
|
||||||
|
}
|
||||||
|
for (const containerName of healthUptimeContainerNames) {
|
||||||
|
const color = containerColors[containerName] || generateFallbackColor(containerName)
|
||||||
|
healthUptimeChartData.colors[`${containerName}_uptime`] = color
|
||||||
|
healthUptimeChartData.colors[`${containerName}_health`] = color
|
||||||
|
}
|
||||||
|
for (const containerName of allContainerNames) {
|
||||||
|
const color = containerColors[containerName] || generateFallbackColor(containerName)
|
||||||
|
containerChartConfig[containerName] = { label: containerName, color }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
volumeChartData,
|
||||||
|
healthChartData,
|
||||||
|
uptimeChartData,
|
||||||
|
healthUptimeChartData,
|
||||||
|
containerChartConfig,
|
||||||
|
}
|
||||||
|
}, [containerData, containerColors, containerFilter, stackFilter])
|
||||||
|
|
||||||
|
const { volumeChartData, healthChartData, uptimeChartData, healthUptimeChartData, containerChartConfig } =
|
||||||
|
chartDatasets
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
|
||||||
@@ -41,9 +208,40 @@ export default memo(function ContainerChart({
|
|||||||
// tick formatter
|
// tick formatter
|
||||||
if (chartType === ChartType.CPU) {
|
if (chartType === ChartType.CPU) {
|
||||||
obj.tickFormatter = (value) => {
|
obj.tickFormatter = (value) => {
|
||||||
const val = `${toFixedFloat(value, 2)}%`
|
const val = `${toFixedWithoutTrailingZeros(value, 2)}${unit}`
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
}
|
}
|
||||||
|
} else if (isHealthChart) {
|
||||||
|
obj.tickFormatter = (value) => {
|
||||||
|
let healthLabel = "Unknown"
|
||||||
|
switch (value) {
|
||||||
|
case 3:
|
||||||
|
healthLabel = "Healthy"
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
healthLabel = "Starting"
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
healthLabel = "Unhealthy"
|
||||||
|
break
|
||||||
|
case 0:
|
||||||
|
healthLabel = "None"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return updateYAxisWidth(healthLabel)
|
||||||
|
}
|
||||||
|
} else if (isUptimeChart) {
|
||||||
|
obj.tickFormatter = (value) => {
|
||||||
|
const hours = Math.floor(value)
|
||||||
|
const minutes = Math.floor((value - hours) * 60)
|
||||||
|
const label = `${hours}h ${minutes}m`
|
||||||
|
return updateYAxisWidth(label)
|
||||||
|
}
|
||||||
|
} else if (isVolumeChart || isDiskIOChart) {
|
||||||
|
obj.tickFormatter = (value) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value, false)
|
||||||
|
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isDiskIOChart ? "/s" : ""}`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
|
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
|
||||||
obj.tickFormatter = (val) => {
|
obj.tickFormatter = (val) => {
|
||||||
@@ -57,7 +255,12 @@ export default memo(function ContainerChart({
|
|||||||
try {
|
try {
|
||||||
const sent = item?.payload?.[key]?.ns ?? 0
|
const sent = item?.payload?.[key]?.ns ?? 0
|
||||||
const received = item?.payload?.[key]?.nr ?? 0
|
const received = item?.payload?.[key]?.nr ?? 0
|
||||||
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
|
const { value: receivedValue, unit: receivedUnit } = formatBytes(
|
||||||
|
received,
|
||||||
|
true,
|
||||||
|
userSettings.unitNet,
|
||||||
|
true
|
||||||
|
)
|
||||||
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
|
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
|
||||||
return (
|
return (
|
||||||
<span className="flex">
|
<span className="flex">
|
||||||
@@ -72,17 +275,74 @@ export default memo(function ContainerChart({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (isDiskIOChart) {
|
||||||
|
obj.toolTipFormatter = (item: any, key: string) => {
|
||||||
|
try {
|
||||||
|
const read = item?.payload?.[key]?.dr ?? 0
|
||||||
|
const write = item?.payload?.[key]?.dw ?? 0
|
||||||
|
return (
|
||||||
|
<span className="flex">
|
||||||
|
{decimalString(read)} MB/s
|
||||||
|
<span className="opacity-70 ms-0.5"> read </span>
|
||||||
|
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
|
||||||
|
{decimalString(write)} MB/s
|
||||||
|
<span className="opacity-70 ms-0.5"> write</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (chartType === ChartType.Memory) {
|
} else if (chartType === ChartType.Memory) {
|
||||||
obj.toolTipFormatter = (item: any) => {
|
obj.toolTipFormatter = (item: any) => {
|
||||||
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
|
||||||
return `${decimalString(value)} ${unit}`
|
return `${decimalString(value)} ${unit}`
|
||||||
}
|
}
|
||||||
|
} else if (isVolumeChart) {
|
||||||
|
obj.toolTipFormatter = (item: any) => {
|
||||||
|
const { v, u } = getSizeAndUnit(item.value, false)
|
||||||
|
return `${decimalString(v, 2)}${u}`
|
||||||
|
}
|
||||||
|
} else if (isHealthChart) {
|
||||||
|
obj.toolTipFormatter = (item: any) => {
|
||||||
|
let healthLabel = "Unknown"
|
||||||
|
switch (item.value) {
|
||||||
|
case 3:
|
||||||
|
healthLabel = "Healthy"
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
healthLabel = "Starting"
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
healthLabel = "Unhealthy"
|
||||||
|
break
|
||||||
|
case 0:
|
||||||
|
healthLabel = "None"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return healthLabel
|
||||||
|
}
|
||||||
|
} else if (isUptimeChart) {
|
||||||
|
obj.toolTipFormatter = (item: any) => {
|
||||||
|
const hours = Math.floor(item.value)
|
||||||
|
const minutes = Math.floor((item.value - hours) * 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const remainingHours = hours % 24
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${remainingHours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
return `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
|
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
|
||||||
}
|
}
|
||||||
// data function
|
// data function
|
||||||
if (isNetChart) {
|
if (isNetChart) {
|
||||||
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
|
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
|
||||||
|
} else if (isDiskIOChart) {
|
||||||
|
obj.dataFunction = (key: string, data: any) =>
|
||||||
|
data[key] ? (data[key].dr || 0) + (data[key].dw || 0) : null
|
||||||
} else {
|
} else {
|
||||||
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
|
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
|
||||||
}
|
}
|
||||||
@@ -91,15 +351,11 @@ export default memo(function ContainerChart({
|
|||||||
|
|
||||||
// Filter with set lookup
|
// Filter with set lookup
|
||||||
const filteredKeys = useMemo(() => {
|
const filteredKeys = useMemo(() => {
|
||||||
if (!filter) {
|
if (!containerFilter || containerFilter.length === 0) {
|
||||||
return new Set<string>()
|
return new Set<string>()
|
||||||
}
|
}
|
||||||
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
|
return new Set(Object.keys(containerChartConfig).filter((key) => !containerFilter.includes(key)))
|
||||||
return new Set(Object.keys(chartConfig).filter((key) => {
|
}, [containerChartConfig, containerFilter])
|
||||||
const keyLower = key.toLowerCase()
|
|
||||||
return !filterTerms.some(term => keyLower.includes(term))
|
|
||||||
}))
|
|
||||||
}, [chartConfig, filter])
|
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
@@ -107,8 +363,228 @@ export default memo(function ContainerChart({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For volume charts, check if we have volume data
|
||||||
|
if (isVolumeChart) {
|
||||||
|
if (!volumeChartData || Object.keys(volumeChartData.colors).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For health charts, check if we have health data
|
||||||
|
if (isHealthChart) {
|
||||||
|
if (!healthChartData || Object.keys(healthChartData.colors).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For uptime charts, check if we have uptime data
|
||||||
|
if (isUptimeChart) {
|
||||||
|
if (!uptimeChartData || Object.keys(uptimeChartData.colors).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For combined health+uptime chart
|
||||||
|
if (isHealthUptimeChart) {
|
||||||
|
if (!healthUptimeChartData || healthUptimeChartData.data.length === 0) return null
|
||||||
|
return (
|
||||||
|
<HealthUptimeTable
|
||||||
|
containerData={containerData}
|
||||||
|
healthUptimeChartData={healthUptimeChartData}
|
||||||
|
containerColors={containerColors}
|
||||||
|
filter={containerFilter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show selected containers, or all if none selected
|
||||||
|
const filterableKeys = isVolumeChart
|
||||||
|
? Object.keys(containerChartConfig)
|
||||||
|
: Object.keys(containerChartConfig).filter(
|
||||||
|
(key) =>
|
||||||
|
!(
|
||||||
|
containerChartConfig[key] &&
|
||||||
|
containerChartConfig[key].label &&
|
||||||
|
containerChartConfig[key].label.startsWith("(orphaned volume)")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render volume chart
|
||||||
|
if (isVolumeChart) {
|
||||||
|
const colors = Object.keys(volumeChartData!.colors)
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={volumeChartData!.data}
|
||||||
|
margin={chartMargin}
|
||||||
|
reverseStackOrder={true}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const { v, u } = getSizeAndUnit(value, false)
|
||||||
|
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}`)
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-expect-error
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
truncate={true}
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={toolTipFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{colors.map((key) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
fill={volumeChartData!.colors[key]}
|
||||||
|
fillOpacity={0.4}
|
||||||
|
stroke={volumeChartData!.colors[key]}
|
||||||
|
strokeOpacity={1}
|
||||||
|
activeDot={{ opacity: 1 }}
|
||||||
|
stackId="a"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render health chart
|
||||||
|
if (isHealthChart) {
|
||||||
|
const colors = Object.keys(healthChartData!.colors)
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={healthChartData!.data} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, 3]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={tickFormatter}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-expect-error
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
truncate={true}
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={toolTipFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{colors.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={healthChartData!.colors[key]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render uptime chart
|
||||||
|
if (isUptimeChart) {
|
||||||
|
const colors = Object.keys(uptimeChartData!.colors)
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<ChartContainer
|
||||||
|
className={cn("h-full w-full absolute bg-card opacity-0 transition-opacity", {
|
||||||
|
"opacity-100": yAxisWidth,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LineChart accessibilityLayer data={uptimeChartData!.data} margin={chartMargin}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
direction="ltr"
|
||||||
|
orientation={chartData.orientation}
|
||||||
|
className="tracking-tighter"
|
||||||
|
domain={[0, "auto"]}
|
||||||
|
width={yAxisWidth}
|
||||||
|
tickFormatter={tickFormatter}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
{xAxis(chartData)}
|
||||||
|
<ChartTooltip
|
||||||
|
animationEasing="ease-out"
|
||||||
|
animationDuration={150}
|
||||||
|
// @ts-expect-error
|
||||||
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
truncate={true}
|
||||||
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
|
contentFormatter={toolTipFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{colors.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotoneX"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={uptimeChartData!.colors[key]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular container chart (Area chart)
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-full h-full">
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
"opacity-100": yAxisWidth,
|
"opacity-100": yAxisWidth,
|
||||||
@@ -124,7 +600,6 @@ export default memo(function ContainerChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
domain={pinnedAxisDomain()}
|
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
@@ -140,9 +615,14 @@ export default memo(function ContainerChart({
|
|||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
filter={containerFilter.length > 0 ? containerFilter.join(",") : undefined}
|
||||||
|
contentFormatter={toolTipFormatter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{filterableKeys.map((key) => {
|
||||||
const filtered = filteredKeys.has(key)
|
const filtered = filteredKeys.has(key)
|
||||||
const fillOpacity = filtered ? 0.05 : 0.4
|
const fillOpacity = filtered ? 0.05 : 0.4
|
||||||
const strokeOpacity = filtered ? 0.1 : 1
|
const strokeOpacity = filtered ? 0.1 : 1
|
||||||
@@ -153,11 +633,11 @@ export default memo(function ContainerChart({
|
|||||||
dataKey={dataFunction.bind(null, key)}
|
dataKey={dataFunction.bind(null, key)}
|
||||||
name={key}
|
name={key}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
fill={chartConfig[key].color}
|
fill={containerChartConfig[key].color}
|
||||||
fillOpacity={fillOpacity}
|
fillOpacity={fillOpacity}
|
||||||
stroke={chartConfig[key].color}
|
stroke={containerChartConfig[key].color}
|
||||||
strokeOpacity={strokeOpacity}
|
strokeOpacity={strokeOpacity}
|
||||||
activeDot={{ opacity: filtered ? 0 : 1 }}
|
activeDot={{ opacity: 1 }}
|
||||||
stackId="a"
|
stackId="a"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -167,3 +647,358 @@ export default memo(function ContainerChart({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Extracted HealthUptimeTable component
|
||||||
|
const HealthUptimeTable = React.memo(function HealthUptimeTable({
|
||||||
|
containerData,
|
||||||
|
healthUptimeChartData,
|
||||||
|
containerColors,
|
||||||
|
filter,
|
||||||
|
}: {
|
||||||
|
containerData: any[]
|
||||||
|
healthUptimeChartData: { data: any[]; colors: Record<string, string> }
|
||||||
|
containerColors: Record<string, string>
|
||||||
|
filter: string[]
|
||||||
|
}) {
|
||||||
|
const stackFilter = useStore($stackFilter)
|
||||||
|
// Get the latest data point for table display
|
||||||
|
const latestData = healthUptimeChartData.data[healthUptimeChartData.data.length - 1]
|
||||||
|
if (!latestData) return null
|
||||||
|
|
||||||
|
// Extract container data for table
|
||||||
|
const containerTableData = React.useMemo(() => {
|
||||||
|
const containerNames = new Set<string>()
|
||||||
|
for (const key of Object.keys(latestData)) {
|
||||||
|
if (key === "created") continue
|
||||||
|
const containerName = key.replace(/_uptime$/, "").replace(/_health$/, "")
|
||||||
|
// Skip orphaned volumes
|
||||||
|
if (containerName.startsWith("(orphaned volume)")) continue
|
||||||
|
containerNames.add(containerName)
|
||||||
|
}
|
||||||
|
const tableData = []
|
||||||
|
for (const containerName of containerNames) {
|
||||||
|
const uptimeKey = `${containerName}_uptime`
|
||||||
|
const healthKey = `${containerName}_health`
|
||||||
|
const uptimeHours = latestData[uptimeKey]
|
||||||
|
const healthValue = latestData[healthKey] || 0
|
||||||
|
let healthStatus = "Unknown"
|
||||||
|
switch (healthValue) {
|
||||||
|
case 3:
|
||||||
|
healthStatus = "Healthy"
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
healthStatus = "Starting"
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
healthStatus = "Unhealthy"
|
||||||
|
break
|
||||||
|
case 0:
|
||||||
|
healthStatus = "None"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let uptimeDisplay = "N/A"
|
||||||
|
if (typeof uptimeHours === "number" && !Number.isNaN(uptimeHours)) {
|
||||||
|
const hours = Math.floor(uptimeHours)
|
||||||
|
const minutes = Math.floor((uptimeHours - hours) * 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const remainingHours = hours % 24
|
||||||
|
if (days > 0) {
|
||||||
|
uptimeDisplay = `${days}d ${remainingHours}h ${minutes}m`
|
||||||
|
} else {
|
||||||
|
uptimeDisplay = `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stackName = "—"
|
||||||
|
let statusInfo = "—"
|
||||||
|
let idShort = ""
|
||||||
|
for (let i = containerData.length - 1; i >= 0; i--) {
|
||||||
|
const containerStats = containerData[i]
|
||||||
|
if (containerStats.created && containerStats[containerName]) {
|
||||||
|
const containerDataObj = containerStats[containerName]
|
||||||
|
if (typeof containerDataObj === "object" && containerDataObj) {
|
||||||
|
if ("p" in containerDataObj) {
|
||||||
|
stackName = containerDataObj.p as string
|
||||||
|
}
|
||||||
|
if ("s" in containerDataObj) {
|
||||||
|
statusInfo = containerDataObj.s as string
|
||||||
|
}
|
||||||
|
if ("idShort" in containerDataObj) {
|
||||||
|
idShort = containerDataObj.idShort as string
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tableData.push({
|
||||||
|
name: containerName,
|
||||||
|
idShort,
|
||||||
|
health: healthStatus,
|
||||||
|
status: statusInfo,
|
||||||
|
uptime: uptimeDisplay,
|
||||||
|
uptimeHours: uptimeHours,
|
||||||
|
healthValue: healthValue,
|
||||||
|
stack: stackName,
|
||||||
|
color: containerColors[containerName] || generateFallbackColor(containerName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tableData
|
||||||
|
}, [containerData, latestData, containerColors])
|
||||||
|
|
||||||
|
// Sort and filter state
|
||||||
|
const [sortField, setSortField] = React.useState<"name" | "idShort" | "stack" | "health" | "status" | "uptime">(
|
||||||
|
"uptime"
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = React.useState<"asc" | "desc">("desc")
|
||||||
|
const [currentPage, setCurrentPage] = React.useState(1)
|
||||||
|
const containersPerPage = 4
|
||||||
|
|
||||||
|
// Filtered data
|
||||||
|
const filteredContainerData = React.useMemo(() => {
|
||||||
|
let filtered = containerTableData
|
||||||
|
|
||||||
|
// Apply container filter
|
||||||
|
if (Array.isArray(filter) && filter.length > 0) {
|
||||||
|
filtered = filtered.filter((container) => filter.includes(container.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply stack filter
|
||||||
|
if (Array.isArray(stackFilter) && stackFilter.length > 0) {
|
||||||
|
filtered = filtered.filter((container) => stackFilter.includes(container.stack))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [containerTableData, filter, stackFilter])
|
||||||
|
|
||||||
|
// Sorted data
|
||||||
|
const sortedContainerData = React.useMemo(() => {
|
||||||
|
return [...filteredContainerData].sort((a, b) => {
|
||||||
|
let aValue: string | number
|
||||||
|
let bValue: string | number
|
||||||
|
switch (sortField) {
|
||||||
|
case "name":
|
||||||
|
aValue = a.name?.toLowerCase?.() || ""
|
||||||
|
bValue = b.name?.toLowerCase?.() || ""
|
||||||
|
break
|
||||||
|
case "idShort":
|
||||||
|
aValue = a.idShort || ""
|
||||||
|
bValue = b.idShort || ""
|
||||||
|
break
|
||||||
|
case "stack":
|
||||||
|
aValue = a.stack?.toLowerCase?.() || ""
|
||||||
|
bValue = b.stack?.toLowerCase?.() || ""
|
||||||
|
break
|
||||||
|
case "health":
|
||||||
|
aValue = typeof a.healthValue === "number" ? a.healthValue : 0
|
||||||
|
bValue = typeof b.healthValue === "number" ? b.healthValue : 0
|
||||||
|
break
|
||||||
|
case "status":
|
||||||
|
aValue = a.status?.toLowerCase?.() || ""
|
||||||
|
bValue = b.status?.toLowerCase?.() || ""
|
||||||
|
break
|
||||||
|
case "uptime":
|
||||||
|
aValue = typeof a.uptimeHours === "number" ? a.uptimeHours : 0
|
||||||
|
bValue = typeof b.uptimeHours === "number" ? b.uptimeHours : 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1
|
||||||
|
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}, [filteredContainerData, sortField, sortDirection])
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalPages = Math.ceil(sortedContainerData.length / containersPerPage)
|
||||||
|
const startIndex = (currentPage - 1) * containersPerPage
|
||||||
|
const endIndex = startIndex + containersPerPage
|
||||||
|
const currentContainers = sortedContainerData.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleSort = (field: "name" | "idShort" | "stack" | "health" | "status" | "uptime") => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
||||||
|
} else {
|
||||||
|
setSortField(field)
|
||||||
|
setSortDirection("asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getSortIcon = (field: "name" | "idShort" | "stack" | "health" | "status" | "uptime") => {
|
||||||
|
if (sortField !== field) return "↑↓"
|
||||||
|
return sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [filteredContainerData, sortField, sortDirection])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col opacity-100">
|
||||||
|
<div className="flex-1 p-2 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto h-full">
|
||||||
|
<table className="w-full text-xs table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("idShort")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
ID
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("idShort")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/4 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("name")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Container
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("name")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("stack")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Stack
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("stack")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("health")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Health
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("health")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("status")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("status")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left font-medium p-1 w-1/6 cursor-pointer hover:bg-muted/50 transition-colors select-none"
|
||||||
|
onClick={() => handleSort("uptime")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Uptime
|
||||||
|
<span className="text-xs opacity-60">{getSortIcon("uptime")}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentContainers.map((container) => (
|
||||||
|
<tr key={container.name} className="border-b border-border/30 hover:bg-muted/30">
|
||||||
|
<td className="p-1 w-1/6 font-mono text-xs text-muted-foreground" title={container.idShort}>
|
||||||
|
{container.idShort}
|
||||||
|
</td>
|
||||||
|
<td className="p-1 w-1/4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: container.color }} />
|
||||||
|
<span className="text-xs truncate">{container.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-1 w-1/6">
|
||||||
|
<span className="text-xs text-muted-foreground truncate" title={container.stack}>
|
||||||
|
{container.stack}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-1 w-1/6">
|
||||||
|
<Badge
|
||||||
|
className={cn("px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0", {
|
||||||
|
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400":
|
||||||
|
container.healthValue === 3,
|
||||||
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400":
|
||||||
|
container.healthValue === 2,
|
||||||
|
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400": container.healthValue === 1,
|
||||||
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": container.healthValue === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{container.health}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-1 w-1/6">
|
||||||
|
<Badge
|
||||||
|
className={cn("px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0", {
|
||||||
|
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400":
|
||||||
|
container.status?.toLowerCase() === "running",
|
||||||
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400":
|
||||||
|
container.status?.toLowerCase() === "paused" || container.status?.toLowerCase() === "restarting",
|
||||||
|
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400":
|
||||||
|
container.status?.toLowerCase().includes("exited") ||
|
||||||
|
container.status?.toLowerCase().includes("dead") ||
|
||||||
|
container.status?.toLowerCase().includes("removing"),
|
||||||
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400":
|
||||||
|
!container.status || container.status?.toLowerCase() === "created",
|
||||||
|
})}
|
||||||
|
title={container.status}
|
||||||
|
>
|
||||||
|
{container.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-1 w-1/6 text-xs whitespace-nowrap">{container.uptime}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-2 py-2 border-t border-border bg-muted/20">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Showing {startIndex + 1}-{Math.min(endIndex, sortedContainerData.length)} of{" "}
|
||||||
|
{sortedContainerData.length} containers
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs rounded border transition-colors",
|
||||||
|
"hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
"border-border hover:border-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs rounded border transition-colors min-w-[28px]",
|
||||||
|
page === currentPage
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "border-border hover:bg-muted hover:border-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs rounded border transition-colors",
|
||||||
|
"hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
"border-border hover:border-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
257
internal/site/src/components/charts/container-health-table.tsx
Normal file
257
internal/site/src/components/charts/container-health-table.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
useReactTable,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { ArrowUpDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type DockerHealthRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stack: string
|
||||||
|
health: string
|
||||||
|
healthValue: number
|
||||||
|
status: string
|
||||||
|
uptime: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: DockerHealthRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContainerHealthTable({ data }: Props) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({})
|
||||||
|
|
||||||
|
const columns = React.useMemo<ColumnDef<DockerHealthRow>[]>(() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
ID
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground" title={row.original.id}>{row.original.id}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Container
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: row.original.color }} />
|
||||||
|
<span className="text-xs truncate">{row.original.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "stack",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Stack
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground truncate" title={row.original.stack}>{row.original.stack}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "health",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Health
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge className={cn(
|
||||||
|
"px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0",
|
||||||
|
{
|
||||||
|
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400": row.original.healthValue === 3,
|
||||||
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400": row.original.healthValue === 2,
|
||||||
|
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400": row.original.healthValue === 1,
|
||||||
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": row.original.healthValue === 0,
|
||||||
|
}
|
||||||
|
)}>{row.original.health}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge className={cn(
|
||||||
|
"px-1.5 py-0.5 text-xs font-medium whitespace-nowrap border-0",
|
||||||
|
{
|
||||||
|
"bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400": row.original.status?.toLowerCase() === "running",
|
||||||
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400": row.original.status?.toLowerCase() === "paused" || row.original.status?.toLowerCase() === "restarting",
|
||||||
|
"bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400":
|
||||||
|
row.original.status?.toLowerCase().includes("exited") ||
|
||||||
|
row.original.status?.toLowerCase().includes("dead") ||
|
||||||
|
row.original.status?.toLowerCase().includes("removing"),
|
||||||
|
"bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400": !row.original.status || row.original.status?.toLowerCase() === "created",
|
||||||
|
}
|
||||||
|
)} title={row.original.status}>{row.original.status}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "uptime",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Uptime
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs whitespace-nowrap">{row.original.uptime}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
table.setPageSize(5)
|
||||||
|
}, [table])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-1">
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
|
|||||||
const hue = ((i * 360) / count) % 360
|
const hue = ((i * 360) / count) % 360
|
||||||
chartConfig[containerName] = {
|
chartConfig[containerName] = {
|
||||||
label: containerName,
|
label: containerName,
|
||||||
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
|
|||||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
direction="ltr"
|
direction="ltr"
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={["auto", "auto"]}
|
domain={[0, "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
@@ -91,8 +91,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{colors.map((key) => {
|
{colors.map((key) => {
|
||||||
const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
|
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||||
const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term))
|
|
||||||
const strokeOpacity = filtered ? 0.1 : 1
|
const strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { DialogDescription } from "@radix-ui/react-dialog"
|
|||||||
import {
|
import {
|
||||||
AlertOctagonIcon,
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
ContainerIcon,
|
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -81,7 +80,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home", t`All Systems`]}
|
keywords={["home"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -95,20 +94,6 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(getPagePath($router, "containers"))
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ContainerIcon className="me-2 size-4" />
|
|
||||||
<span>
|
|
||||||
<Trans>All Containers</Trans>
|
|
||||||
</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Page</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
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,
|
|
||||||
LayersIcon,
|
|
||||||
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"
|
|
||||||
|
|
||||||
// Unit names and their corresponding number of seconds for converting docker status strings
|
|
||||||
const unitSeconds = [["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000]] as const
|
|
||||||
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
|
|
||||||
function getStatusValue(status: string): number {
|
|
||||||
const [_, num, unit] = status.split(" ")
|
|
||||||
const numValue = Number(num)
|
|
||||||
for (const [unitName, value] of unitSeconds) {
|
|
||||||
if (unit.startsWith(unitName)) {
|
|
||||||
return numValue * value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
|
||||||
accessorFn: (record) => record.name,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={ContainerIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
return <span className="ms-1.5 xl:w-48 block truncate">{getValue() as string}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "system",
|
|
||||||
accessorFn: (record) => record.system,
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const allSystems = $allSystemsById.get()
|
|
||||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
|
||||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
|
||||||
return systemNameA.localeCompare(systemNameB)
|
|
||||||
},
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const allSystems = useStore($allSystemsById)
|
|
||||||
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// id: "id",
|
|
||||||
// accessorFn: (record) => record.id,
|
|
||||||
// sortingFn: (a, b) => a.original.id.localeCompare(b.original.id),
|
|
||||||
// header: ({ column }) => <HeaderButton column={column} name="ID" Icon={HashIcon} />,
|
|
||||||
// cell: ({ getValue }) => {
|
|
||||||
// return <span className="ms-1.5 me-3 font-mono">{getValue() as string}</span>
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
id: "cpu",
|
|
||||||
accessorFn: (record) => record.cpu,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "memory",
|
|
||||||
accessorFn: (record) => record.memory,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
const formatted = formatBytes(val, false, undefined, true)
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "net",
|
|
||||||
accessorFn: (record) => record.net,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
const formatted = formatBytes(val, true, undefined, true)
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "health",
|
|
||||||
invertSorting: true,
|
|
||||||
accessorFn: (record) => record.health,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const healthValue = getValue() as number
|
|
||||||
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="dark:border-white/12">
|
|
||||||
<span className={cn("size-2 me-1.5 rounded-full", {
|
|
||||||
"bg-green-500": healthValue === ContainerHealth.Healthy,
|
|
||||||
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
|
|
||||||
"bg-yellow-500": healthValue === ContainerHealth.Starting,
|
|
||||||
"bg-zinc-500": healthValue === ContainerHealth.None,
|
|
||||||
})}>
|
|
||||||
</span>
|
|
||||||
{healthStatus}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "image",
|
|
||||||
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
|
|
||||||
accessorFn: (record) => record.image,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
accessorFn: (record) => record.status,
|
|
||||||
invertSorting: true,
|
|
||||||
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
return <span className="ms-1.5 w-25 block truncate">{getValue() as string}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "updated",
|
|
||||||
invertSorting: true,
|
|
||||||
accessorFn: (record) => record.updated,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const timestamp = getValue() as number
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">
|
|
||||||
{hourWithSeconds(new Date(timestamp).toISOString())}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) {
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="size-4" />}
|
|
||||||
{name}
|
|
||||||
<ArrowUpDownIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
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, XIcon } from "lucide-react"
|
|
||||||
import { Separator } from "../ui/separator"
|
|
||||||
import { $router, Link } from "../router"
|
|
||||||
import { listenKeys } from "nanostores"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
|
|
||||||
const syntaxTheme = "github-dark-dimmed"
|
|
||||||
|
|
||||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|
||||||
const loadTime = Date.now()
|
|
||||||
const [data, setData] = useState<ContainerRecord[]>([])
|
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
|
||||||
`sort-c-${systemId ? 1 : 0}`,
|
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
|
||||||
sessionStorage
|
|
||||||
)
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function fetchData(systemId?: string) {
|
|
||||||
pb.collection<ContainerRecord>("containers")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
.then(({ items }) => items.length && setData((curItems) => {
|
|
||||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
|
||||||
const containerIds = new Set()
|
|
||||||
const newItems = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
|
||||||
containerIds.add(item.id)
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of curItems) {
|
|
||||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newItems
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial load
|
|
||||||
fetchData(systemId)
|
|
||||||
|
|
||||||
// if no systemId, pull system containers after every system update
|
|
||||||
if (!systemId) {
|
|
||||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
|
||||||
// exclude initial load of systems
|
|
||||||
if (Date.now() - loadTime > 500) {
|
|
||||||
fetchData(systemId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// if systemId, fetch containers after the system is updated
|
|
||||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
|
||||||
fetchData(systemId)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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 image = container.image ?? ""
|
|
||||||
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase()
|
|
||||||
|
|
||||||
return (filterValue as string)
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.every((term) => searchString.includes(term))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = table.getRowModel().rows
|
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-6 @container w-full">
|
|
||||||
<CardHeader className="p-0 mb-4">
|
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle className="mb-2">
|
|
||||||
<Trans>All Containers</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex">
|
|
||||||
<Trans>Click on a container to view more information.</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="ps-4 pe-10 w-full"
|
|
||||||
/>
|
|
||||||
{globalFilter && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t`Clear filter`}
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
|
||||||
onClick={() => setGlobalFilter("")}
|
|
||||||
>
|
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="rounded-md">
|
|
||||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AllContainersTable = memo(function AllContainersTable({
|
|
||||||
table,
|
|
||||||
rows,
|
|
||||||
colLength,
|
|
||||||
}: {
|
|
||||||
table: TableType<ContainerRecord>
|
|
||||||
rows: Row<ContainerRecord>[]
|
|
||||||
colLength: number
|
|
||||||
}) {
|
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const activeContainer = useRef<ContainerRecord | null>(null)
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const openSheet = (container: ContainerRecord) => {
|
|
||||||
activeContainer.current = container
|
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
|
||||||
count: rows.length,
|
|
||||||
estimateSize: () => 54,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
overscan: 5,
|
|
||||||
})
|
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
|
||||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
|
||||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
|
||||||
)}
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
{/* add header height to table size */}
|
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
||||||
<table className="text-sm w-full h-full text-nowrap">
|
|
||||||
<ContainersTableHead table={table} />
|
|
||||||
<TableBody>
|
|
||||||
{rows.length ? (
|
|
||||||
virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return <ContainerTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
|
||||||
<Trans>No results.</Trans>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
|
||||||
try {
|
|
||||||
const [{ highlighter }, logsHtml] = await Promise.all([
|
|
||||||
import("@/lib/shiki"),
|
|
||||||
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
|
||||||
system: container.system,
|
|
||||||
container: container.id,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
|
||||||
try {
|
|
||||||
let [{ highlighter }, { info }] = await Promise.all([
|
|
||||||
import("@/lib/shiki"),
|
|
||||||
pb.send<{ info: string }>("/api/beszel/containers/info", {
|
|
||||||
system: container.system,
|
|
||||||
container: container.id,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
try {
|
|
||||||
info = JSON.stringify(JSON.parse(info), null, 2)
|
|
||||||
} catch (_) { }
|
|
||||||
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContainerSheet({
|
|
||||||
sheetOpen,
|
|
||||||
setSheetOpen,
|
|
||||||
activeContainer,
|
|
||||||
}: {
|
|
||||||
sheetOpen: boolean
|
|
||||||
setSheetOpen: (open: boolean) => void
|
|
||||||
activeContainer: RefObject<ContainerRecord | null>
|
|
||||||
}) {
|
|
||||||
const container = activeContainer.current
|
|
||||||
if (!container) return null
|
|
||||||
|
|
||||||
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
|
||||||
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
|
||||||
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
|
||||||
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
|
||||||
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
|
|
||||||
const logsContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
function scrollLogsToBottom() {
|
|
||||||
if (logsContainerRef.current) {
|
|
||||||
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshLogs = async () => {
|
|
||||||
setIsRefreshingLogs(true)
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const logsHtml = await getLogsHtml(container)
|
|
||||||
setLogsDisplay(logsHtml)
|
|
||||||
setTimeout(scrollLogsToBottom, 20)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
// Ensure minimum spin duration of 800ms
|
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
const remaining = Math.max(0, 500 - elapsed)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsRefreshingLogs(false)
|
|
||||||
}, remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLogsDisplay("")
|
|
||||||
setInfoDisplay("")
|
|
||||||
if (!container) return
|
|
||||||
; (async () => {
|
|
||||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
|
||||||
setLogsDisplay(logsHtml)
|
|
||||||
setInfoDisplay(infoHtml)
|
|
||||||
setTimeout(scrollLogsToBottom, 20)
|
|
||||||
})()
|
|
||||||
}, [container])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<LogsFullscreenDialog
|
|
||||||
open={logsFullscreenOpen}
|
|
||||||
onOpenChange={setLogsFullscreenOpen}
|
|
||||||
logsDisplay={logsDisplay}
|
|
||||||
containerName={container.name}
|
|
||||||
onRefresh={refreshLogs}
|
|
||||||
isRefreshing={isRefreshingLogs}
|
|
||||||
/>
|
|
||||||
<InfoFullscreenDialog
|
|
||||||
open={infoFullscreenOpen}
|
|
||||||
onOpenChange={setInfoFullscreenOpen}
|
|
||||||
infoDisplay={infoDisplay}
|
|
||||||
containerName={container.name}
|
|
||||||
/>
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
||||||
<SheetContent className="w-full sm:max-w-220 p-2">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>{container.name}</SheetTitle>
|
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
|
|
||||||
{$allSystemsById.get()[container.system]?.name ?? ""}
|
|
||||||
</Link>
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
{container.status}
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
{container.image}
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
{container.id}
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
{ContainerHealthLabels[container.health as ContainerHealth]}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<h3>{t`Logs`}</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={refreshLogs}
|
|
||||||
className="h-8 w-8 p-0 ms-auto"
|
|
||||||
disabled={isRefreshingLogs}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon
|
|
||||||
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
|
|
||||||
<MaximizeIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={logsContainerRef}
|
|
||||||
className={cn(
|
|
||||||
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
|
||||||
!logsDisplay && ["animate-pulse", "h-full"]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<h3>{t`Detail`}</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setInfoFullscreenOpen(true)}
|
|
||||||
className="h-8 w-8 p-0 ms-auto"
|
|
||||||
>
|
|
||||||
<MaximizeIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
|
||||||
!infoDisplay && "animate-pulse"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
|
||||||
return (
|
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead className="px-2" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContainerTableRow = memo(function ContainerTableRow({
|
|
||||||
row,
|
|
||||||
virtualRow,
|
|
||||||
openSheet,
|
|
||||||
}: {
|
|
||||||
row: Row<ContainerRecord>
|
|
||||||
virtualRow: VirtualItem
|
|
||||||
openSheet: (container: ContainerRecord) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="cursor-pointer transition-opacity"
|
|
||||||
onClick={() => openSheet(row.original)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className="py-0"
|
|
||||||
style={{
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function LogsFullscreenDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
logsDisplay,
|
|
||||||
containerName,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
logsDisplay: string
|
|
||||||
containerName: string
|
|
||||||
onRefresh: () => void | Promise<void>
|
|
||||||
isRefreshing: boolean
|
|
||||||
}) {
|
|
||||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && logsDisplay) {
|
|
||||||
// Scroll the outer container to bottom
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
if (outerContainerRef.current) {
|
|
||||||
outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(scrollToBottom, 50)
|
|
||||||
}
|
|
||||||
}, [open, logsDisplay])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
|
||||||
<DialogTitle className="sr-only">{containerName} logs</DialogTitle>
|
|
||||||
<div ref={outerContainerRef} className="h-full overflow-auto">
|
|
||||||
<div className="h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
|
|
||||||
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void onRefresh()
|
|
||||||
}}
|
|
||||||
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
title={t`Refresh`}
|
|
||||||
aria-label={t`Refresh`}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className={`size-4 transition-transform duration-300 ${isRefreshing ? "animate-spin" : ""}`} />
|
|
||||||
</button>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoFullscreenDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
infoDisplay,
|
|
||||||
containerName,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
infoDisplay: string
|
|
||||||
containerName: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
|
||||||
<DialogTitle className="sr-only">{containerName} info</DialogTitle>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<div className="h-full w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm leading-relaxed">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { GithubIcon } from "lucide-react"
|
|
||||||
import { Separator } from "./ui/separator"
|
|
||||||
|
|
||||||
export function FooterRepoLink() {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
|
||||||
<a
|
|
||||||
href="https://github.com/henrygd/beszel"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<GithubIcon className="h-3 w-3" /> GitHub
|
|
||||||
</a>
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
<a
|
|
||||||
href="https://github.com/henrygd/beszel/releases"
|
|
||||||
target="_blank"
|
|
||||||
className="text-muted-foreground hover:text-foreground duration-75"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Beszel {globalThis.BESZEL.HUB_VERSION}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export function LangToggle() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
|
<Button variant={"ghost"} size="icon" className="hidden 450:flex">
|
||||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
||||||
<span className="sr-only">Language</span>
|
<span className="sr-only">Language</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,27 +1,16 @@
|
|||||||
import { useId } from "react"
|
|
||||||
|
|
||||||
const d = "M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
|
||||||
|
|
||||||
export function Logo({ className }: { className?: string }) {
|
export function Logo({ className }: { className?: string }) {
|
||||||
const id = useId()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Righteous font from Google Fonts
|
// Righteous
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
||||||
<defs>
|
{/* <defs>
|
||||||
<linearGradient id={id} x1="0%" y1="20%" x2="100%" y2="120%">
|
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
<stop offset="10%" style={{ stopColor: "#747bff" }} />
|
<stop offset="0%" style={{ stopColor: "#747bff" }} />
|
||||||
<stop offset="90%" style={{ stopColor: "#24eb5c" }} />
|
<stop offset="100%" style={{ stopColor: "#24eb5c" }} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs> */}
|
||||||
<path
|
<path
|
||||||
className="duration-250 group-hover:opacity-0 group-hover:ease-in ease-out"
|
// fill="url(#gradient)"
|
||||||
d={d}
|
d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-0 duration-250 group-hover:opacity-100 ease-in-out"
|
|
||||||
fill={`url(#${id})`}
|
|
||||||
d={d}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
ContainerIcon,
|
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
@@ -40,7 +39,7 @@ export default function Navbar() {
|
|||||||
<Link
|
<Link
|
||||||
href={basePath}
|
href={basePath}
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
className="p-2 ps-0 me-3 group"
|
className="p-2 ps-0 me-3"
|
||||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||||
>
|
>
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||||
@@ -48,25 +47,18 @@ export default function Navbar() {
|
|||||||
<SearchButton />
|
<SearchButton />
|
||||||
|
|
||||||
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "containers")}
|
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
|
||||||
aria-label="Containers"
|
|
||||||
>
|
|
||||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
|
||||||
</Link>
|
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "settings", { name: "general" })}
|
href={getPagePath($router, "settings", { name: "general" })}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
</Link>
|
</Link>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
<button aria-label="User Actions" className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -120,7 +112,7 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AddSystemButton className="ms-2 hidden 450:flex" />
|
<AddSystemButton className="ms-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createRouter } from "@nanostores/router"
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { memo, useEffect, useMemo } from "react"
|
|
||||||
import ContainersTable from "@/components/containers-table/containers-table"
|
|
||||||
import { ActiveAlerts } from "@/components/active-alerts"
|
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
|
||||||
|
|
||||||
export default memo(() => {
|
|
||||||
const { t } = useLingui()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${t`All Containers`} / Beszel`
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<ActiveAlerts />
|
|
||||||
<ContainersTable />
|
|
||||||
</div>
|
|
||||||
<FooterRepoLink />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,28 +1,128 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { GithubIcon } from "lucide-react"
|
||||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||||
|
import { $router, Link } from "@/components/router"
|
||||||
import SystemsTable from "@/components/systems-table/systems-table"
|
import SystemsTable from "@/components/systems-table/systems-table"
|
||||||
import { ActiveAlerts } from "@/components/active-alerts"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
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"
|
||||||
|
|
||||||
export default memo(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t`All Systems`} / Beszel`
|
document.title = `${t`Dashboard`} / Beszel`
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<ActiveAlerts />
|
||||||
<ActiveAlerts />
|
<Suspense>
|
||||||
<Suspense>
|
<SystemsTable />
|
||||||
<SystemsTable />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
|
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
||||||
|
<a
|
||||||
|
href="https://github.com/henrygd/beszel"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<GithubIcon className="h-3 w-3" /> GitHub
|
||||||
|
</a>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<a
|
||||||
|
href="https://github.com/henrygd/beszel/releases"
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-foreground duration-75"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Beszel {globalThis.BESZEL.HUB_VERSION}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<FooterRepoLink />
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ActiveAlerts = () => {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const systems = useStore($allSystemsById)
|
||||||
|
|
||||||
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
|
const activeAlerts: AlertRecord[] = []
|
||||||
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
|
const alertsKey: string[] = []
|
||||||
|
|
||||||
|
for (const systemId of Object.keys(alerts)) {
|
||||||
|
for (const alert of alerts[systemId].values()) {
|
||||||
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
|
activeAlerts.push(alert)
|
||||||
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeAlerts, alertsKey }
|
||||||
|
}, [alerts])
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
||||||
|
return useMemo(() => {
|
||||||
|
if (activeAlerts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Active Alerts</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-sm:p-2">
|
||||||
|
{activeAlerts.length > 0 && (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||||
|
{activeAlerts.map((alert) => {
|
||||||
|
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
||||||
|
>
|
||||||
|
<info.icon className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{alert.name === "Status" ? (
|
||||||
|
<Trans>Connection is down</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Exceeds {alert.value}
|
||||||
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}, [alertsKey.join("")])
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
@@ -41,7 +40,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { alertInfo } from "@/lib/alerts"
|
import { alertInfo } from "@/lib/alerts"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
|
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
|
||||||
import type { AlertsHistoryRecord } from "@/types"
|
import type { AlertsHistoryRecord } from "@/types"
|
||||||
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||||
|
|
||||||
@@ -68,12 +67,6 @@ export default function AlertsHistoryDataTable() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Store pagination preference in local storage
|
|
||||||
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
const pbOptions = {
|
const pbOptions = {
|
||||||
@@ -143,14 +136,12 @@ export default function AlertsHistoryDataTable() {
|
|||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
pagination,
|
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
@@ -327,10 +318,10 @@ export default function AlertsHistoryDataTable() {
|
|||||||
<Select
|
<Select
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
table.setPageSize(Number(value));
|
table.setPageSize(Number(value))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-18" id="rows-per-page">
|
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top">
|
<SelectContent side="top">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function ConfigYaml() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
||||||
<AlertCircleIcon className="size-4.5 stroke-destructive" />
|
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Caution - potential data loss</Trans>
|
<Trans>Caution - potential data loss</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import Slider from "@/components/ui/slider"
|
|
||||||
import { HourFormat, Unit } from "@/lib/enums"
|
import { HourFormat, Unit } from "@/lib/enums"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { $userSettings } from "@/lib/stores"
|
|
||||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
@@ -20,8 +17,6 @@ import { saveSettings } from "./layout"
|
|||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
const currentUserSettings = useStore($userSettings)
|
|
||||||
const layoutWidth = currentUserSettings.layoutWidth ?? 1480
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -78,27 +73,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
|
||||||
<Trans>Layout width</Trans>
|
|
||||||
</h3>
|
|
||||||
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
id="layoutWidth"
|
|
||||||
name="layoutWidth"
|
|
||||||
value={[layoutWidth]}
|
|
||||||
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
|
|
||||||
min={1000}
|
|
||||||
max={2000}
|
|
||||||
step={10}
|
|
||||||
className="w-full mb-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { toast } from "@/components/ui/use-toast"
|
|||||||
import { isAdmin, pb } from "@/lib/api"
|
import { isAdmin, pb } from "@/lib/api"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
import { QuietHours } from "./quiet-hours"
|
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -121,32 +120,19 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
<div>
|
||||||
<div>
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<Trans>Webhook / Push notifications</Trans>
|
||||||
<Trans>Webhook / Push notifications</Trans>
|
</h3>
|
||||||
</h3>
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<Trans>
|
||||||
<Trans>
|
Beszel uses{" "}
|
||||||
Beszel uses{" "}
|
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
Shoutrrr
|
||||||
Shoutrrr
|
</a>{" "}
|
||||||
</a>{" "}
|
to integrate with popular notification services.
|
||||||
to integrate with popular notification services.
|
</Trans>
|
||||||
</Trans>
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 shrink-0"
|
|
||||||
onClick={addWebhook}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" />
|
|
||||||
<span className="ms-1">
|
|
||||||
<Trans>Add URL</Trans>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{webhooks.length > 0 && (
|
{webhooks.length > 0 && (
|
||||||
<div className="grid gap-2.5" id="webhooks">
|
<div className="grid gap-2.5" id="webhooks">
|
||||||
@@ -160,10 +146,16 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<Button
|
||||||
<Separator />
|
type="button"
|
||||||
<div className="space-y-3">
|
variant="outline"
|
||||||
<QuietHours />
|
size="sm"
|
||||||
|
className="mt-2 flex items-center gap-1"
|
||||||
|
onClick={addWebhook}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
||||||
|
<Trans>Add URL</Trans>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Button
|
<Button
|
||||||
@@ -202,7 +194,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-table-header p-2 md:p-3">
|
<Card className="bg-muted/40 p-2 md:p-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { MoreHorizontalIcon, PlusIcon, Trash2Icon, ServerIcon, ClockIcon, CalendarIcon, ActivityIcon, PenSquareIcon } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { $systems } from "@/lib/stores"
|
|
||||||
import { formatShortDate } from "@/lib/utils"
|
|
||||||
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
|
||||||
|
|
||||||
export function QuietHours() {
|
|
||||||
const [data, setData] = useState<QuietHoursRecord[]>([])
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const systems = useStore($systems)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let unsubscribe: (() => void) | undefined
|
|
||||||
const pbOptions = {
|
|
||||||
expand: "system",
|
|
||||||
fields: "id,user,system,type,start,end,expand.system.name",
|
|
||||||
}
|
|
||||||
// Initial load
|
|
||||||
pb.collection<QuietHoursRecord>("quiet_hours")
|
|
||||||
.getList(0, 200, {
|
|
||||||
...pbOptions,
|
|
||||||
sort: "system",
|
|
||||||
})
|
|
||||||
.then(({ items }) => setData(items))
|
|
||||||
|
|
||||||
// Subscribe to changes
|
|
||||||
; (async () => {
|
|
||||||
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
|
||||||
"*",
|
|
||||||
(e) => {
|
|
||||||
if (e.action === "create") {
|
|
||||||
setData((current) => [e.record as QuietHoursRecord, ...current])
|
|
||||||
}
|
|
||||||
if (e.action === "update") {
|
|
||||||
setData((current) =>
|
|
||||||
current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (e.action === "delete") {
|
|
||||||
setData((current) => current.filter((r) => r.id !== e.record.id))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pbOptions
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
// Unsubscribe on unmount
|
|
||||||
return () => unsubscribe?.()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pb.collection("quiet_hours").delete(id)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t`Error`,
|
|
||||||
description: (e as Error).message || "Failed to delete quiet hours.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditDialog = (record: QuietHoursRecord) => {
|
|
||||||
setEditingRecord(record)
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDialog = () => {
|
|
||||||
setDialogOpen(false)
|
|
||||||
setEditingRecord(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTime = (record: QuietHoursRecord) => {
|
|
||||||
if (record.type === "daily") {
|
|
||||||
// For daily windows, show only time
|
|
||||||
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
||||||
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
||||||
return `${startTime} - ${endTime}`
|
|
||||||
}
|
|
||||||
// For one-time windows, show full date and time
|
|
||||||
const start = formatShortDate(record.start)
|
|
||||||
const end = formatShortDate(record.end)
|
|
||||||
return `${start} - ${end}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "future" => {
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
if (record.type === "daily") {
|
|
||||||
// For daily windows, check if current time is within the window
|
|
||||||
const startDate = new Date(record.start)
|
|
||||||
const endDate = new Date(record.end)
|
|
||||||
|
|
||||||
// Get current time in local timezone
|
|
||||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
|
||||||
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
|
||||||
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
|
||||||
|
|
||||||
// Convert UTC to local time offset
|
|
||||||
const offset = now.getTimezoneOffset()
|
|
||||||
const localStartMinutes = (startMinutes - offset + 1440) % 1440
|
|
||||||
const localEndMinutes = (endMinutes - offset + 1440) % 1440
|
|
||||||
|
|
||||||
// Handle cases where window spans midnight
|
|
||||||
if (localStartMinutes <= localEndMinutes) {
|
|
||||||
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "future"
|
|
||||||
} else {
|
|
||||||
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "future"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For one-time windows
|
|
||||||
const startDate = new Date(record.start)
|
|
||||||
const endDate = new Date(record.end)
|
|
||||||
|
|
||||||
if (now >= startDate && now < endDate) {
|
|
||||||
return "active"
|
|
||||||
} else if (now >= endDate) {
|
|
||||||
return "past"
|
|
||||||
} else {
|
|
||||||
return "future"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
|
||||||
<Trans>Quiet hours</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Trans>Schedule quiet hours where notifications will not be sent, such as during maintenance periods.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
|
||||||
<PlusIcon className="size-4" />
|
|
||||||
<span className="ms-1">
|
|
||||||
<Trans>Add Quiet Hours</Trans>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<QuietHoursDialog
|
|
||||||
editingRecord={editingRecord}
|
|
||||||
systems={systems}
|
|
||||||
onClose={closeDialog}
|
|
||||||
toast={toast}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
{data.length > 0 && (
|
|
||||||
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="border-border/50">
|
|
||||||
<TableHead className="px-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<ServerIcon className="size-4" />
|
|
||||||
<Trans>System</Trans>
|
|
||||||
</span>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="px-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<ClockIcon className="size-4" />
|
|
||||||
<Trans>Type</Trans>
|
|
||||||
</span>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="px-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<ActivityIcon className="size-4" />
|
|
||||||
<Trans>State</Trans>
|
|
||||||
</span>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="px-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="size-4" />
|
|
||||||
<Trans>Schedule</Trans>
|
|
||||||
</span>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="px-4 text-right sr-only">
|
|
||||||
<Trans>Actions</Trans>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.map((record) => (
|
|
||||||
<TableRow key={record.id}>
|
|
||||||
<TableCell className="px-4 py-3">
|
|
||||||
{record.system ? (record.expand?.system?.name || record.system) : <Trans>All Systems</Trans>}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-3">
|
|
||||||
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-3">
|
|
||||||
{(() => {
|
|
||||||
const state = getWindowState(record)
|
|
||||||
const stateConfig = {
|
|
||||||
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
|
||||||
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
|
||||||
future: { label: <Trans>Future</Trans>, variant: "default" as const },
|
|
||||||
}
|
|
||||||
const config = stateConfig[state]
|
|
||||||
return (
|
|
||||||
<Badge variant={config.variant}>
|
|
||||||
{config.label}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
|
||||||
<TableCell className="px-4 py-3 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
|
||||||
<span className="sr-only"><Trans>Open menu</Trans></span>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
|
||||||
<PenSquareIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
|
||||||
function formatDateTimeLocal(date: Date): string {
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuietHoursDialog({
|
|
||||||
editingRecord,
|
|
||||||
systems,
|
|
||||||
onClose,
|
|
||||||
toast,
|
|
||||||
}: {
|
|
||||||
editingRecord: QuietHoursRecord | null
|
|
||||||
systems: SystemRecord[]
|
|
||||||
onClose: () => void
|
|
||||||
toast: any
|
|
||||||
}) {
|
|
||||||
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
|
||||||
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
|
||||||
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
|
|
||||||
const [startDateTime, setStartDateTime] = useState("")
|
|
||||||
const [endDateTime, setEndDateTime] = useState("")
|
|
||||||
const [startTime, setStartTime] = useState("")
|
|
||||||
const [endTime, setEndTime] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingRecord) {
|
|
||||||
setSelectedSystem(editingRecord.system || "")
|
|
||||||
setIsGlobal(!editingRecord.system)
|
|
||||||
setWindowType(editingRecord.type)
|
|
||||||
if (editingRecord.type === "daily") {
|
|
||||||
// Extract time from datetime
|
|
||||||
const start = new Date(editingRecord.start)
|
|
||||||
const end = editingRecord.end ? new Date(editingRecord.end) : null
|
|
||||||
setStartTime(start.toTimeString().slice(0, 5))
|
|
||||||
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
|
|
||||||
} else {
|
|
||||||
// For one-time, format as datetime-local (local time, not UTC)
|
|
||||||
const startDate = new Date(editingRecord.start)
|
|
||||||
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
|
|
||||||
|
|
||||||
setStartDateTime(formatDateTimeLocal(startDate))
|
|
||||||
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Reset form with default dates: today at 12pm and 1pm
|
|
||||||
const today = new Date()
|
|
||||||
const noon = new Date(today)
|
|
||||||
noon.setHours(12, 0, 0, 0)
|
|
||||||
const onePm = new Date(today)
|
|
||||||
onePm.setHours(13, 0, 0, 0)
|
|
||||||
|
|
||||||
setSelectedSystem("")
|
|
||||||
setIsGlobal(true)
|
|
||||||
setWindowType("one-time")
|
|
||||||
setStartDateTime(formatDateTimeLocal(noon))
|
|
||||||
setEndDateTime(formatDateTimeLocal(onePm))
|
|
||||||
setStartTime("12:00")
|
|
||||||
setEndTime("13:00")
|
|
||||||
}
|
|
||||||
}, [editingRecord])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
try {
|
|
||||||
let startValue: string
|
|
||||||
let endValue: string | undefined
|
|
||||||
|
|
||||||
if (windowType === "daily") {
|
|
||||||
// For daily windows, convert local time to UTC
|
|
||||||
// Create a date with the time in local timezone, then convert to UTC
|
|
||||||
const startDate = new Date(`2000-01-01T${startTime}:00`)
|
|
||||||
startValue = startDate.toISOString()
|
|
||||||
|
|
||||||
if (endTime) {
|
|
||||||
const endDate = new Date(`2000-01-01T${endTime}:00`)
|
|
||||||
endValue = endDate.toISOString()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For one-time windows, use the datetime values
|
|
||||||
startValue = new Date(startDateTime).toISOString()
|
|
||||||
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
user: pb.authStore.record?.id,
|
|
||||||
system: isGlobal ? undefined : selectedSystem,
|
|
||||||
type: windowType,
|
|
||||||
start: startValue,
|
|
||||||
end: endValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingRecord) {
|
|
||||||
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
|
||||||
toast({
|
|
||||||
title: t`Updated`,
|
|
||||||
description: t`Quiet hours have been updated.`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await pb.collection("quiet_hours").create(data)
|
|
||||||
toast({
|
|
||||||
title: t`Created`,
|
|
||||||
description: t`Quiet hours have been created.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose()
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t`Error`,
|
|
||||||
description: t`Failed to save quiet hours.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editingRecord ? <Trans>Edit Quiet Hours</Trans> : <Trans>Add Quiet Hours</Trans>}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure quiet hours where notifications will not be sent.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="global">
|
|
||||||
<Trans>All Systems</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="system">
|
|
||||||
<Trans>Specific System</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="system" className="mt-4 space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="system">
|
|
||||||
<Trans>System</Trans>
|
|
||||||
</Label>
|
|
||||||
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
|
||||||
<SelectTrigger id="system">
|
|
||||||
<SelectValue placeholder={t`Select a system`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{systems.map((system) => (
|
|
||||||
<SelectItem key={system.id} value={system.id}>
|
|
||||||
{system.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{/* Hidden input for native form validation */}
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="text"
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
value={selectedSystem}
|
|
||||||
onChange={() => { }}
|
|
||||||
required={!isGlobal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="type">
|
|
||||||
<Trans>Type</Trans>
|
|
||||||
</Label>
|
|
||||||
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
|
||||||
<SelectTrigger id="type">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="one-time">
|
|
||||||
<Trans>One-time</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="daily">
|
|
||||||
<Trans>Daily</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{windowType === "one-time" ? (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="start-datetime">
|
|
||||||
<Trans>Start Date & Time</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="start-datetime"
|
|
||||||
type="datetime-local"
|
|
||||||
value={startDateTime}
|
|
||||||
onChange={(e) => setStartDateTime(e.target.value)}
|
|
||||||
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
|
||||||
required
|
|
||||||
className="tabular-nums tracking-tighter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="end-datetime">
|
|
||||||
<Trans>End Date & Time</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="end-datetime"
|
|
||||||
type="datetime-local"
|
|
||||||
value={endDateTime}
|
|
||||||
onChange={(e) => setEndDateTime(e.target.value)}
|
|
||||||
min={startDateTime || formatDateTimeLocal(new Date())}
|
|
||||||
required
|
|
||||||
className="tabular-nums tracking-tighter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="start-time">
|
|
||||||
<Trans>Start Time</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="start-time"
|
|
||||||
type="time"
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="end-time">
|
|
||||||
<Trans>End Time</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input id="end-time" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import {
|
import {
|
||||||
ChevronRightSquareIcon,
|
ChevronRightSquareIcon,
|
||||||
ClockArrowUp,
|
ClockArrowUp,
|
||||||
|
Container as ContainerIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
|
Server as ServerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { subscribeKeys } from "nanostores"
|
import { subscribeKeys } from "nanostores"
|
||||||
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||||
import ContainerChart from "@/components/charts/container-chart"
|
import ContainerChart from "@/components/charts/container-chart"
|
||||||
import DiskChart from "@/components/charts/disk-chart"
|
import DiskChart from "@/components/charts/disk-chart"
|
||||||
@@ -30,9 +32,11 @@ import {
|
|||||||
$allSystemsById,
|
$allSystemsById,
|
||||||
$allSystemsByName,
|
$allSystemsByName,
|
||||||
$chartTime,
|
$chartTime,
|
||||||
|
$containerColors,
|
||||||
$containerFilter,
|
$containerFilter,
|
||||||
$direction,
|
$direction,
|
||||||
$maxValues,
|
$maxValues,
|
||||||
|
$stackFilter,
|
||||||
$systems,
|
$systems,
|
||||||
$temperatureFilter,
|
$temperatureFilter,
|
||||||
$userSettings,
|
$userSettings,
|
||||||
@@ -41,10 +45,9 @@ import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
|||||||
import {
|
import {
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
compareSemVer,
|
debounce,
|
||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
secondsToString,
|
|
||||||
getHostDisplayValue,
|
getHostDisplayValue,
|
||||||
listen,
|
listen,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
@@ -70,11 +73,10 @@ import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocke
|
|||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import NetworkSheet from "./system/network-sheet"
|
import NetworkSheet from "./system/network-sheet"
|
||||||
import CpuCoresSheet from "./system/cpu-sheet"
|
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
import { pinnedAxisDomain } from "../ui/chart"
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -96,8 +98,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// const buffer = chartTime === "1m" ? 400 : 20_000
|
const buffer = chartTime === "1m" ? 400 : 20_000
|
||||||
const now = new Date(Date.now())
|
const now = new Date(Date.now() + buffer)
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
const data = {
|
const data = {
|
||||||
@@ -171,7 +173,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||||
const temperatureChartRef = useRef<HTMLDivElement>(null)
|
const netCardRef = useRef<HTMLDivElement>(null)
|
||||||
const persistChartTime = useRef(false)
|
const persistChartTime = useRef(false)
|
||||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
@@ -217,7 +219,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
// subscribe to realtime metrics if chart time is 1m
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsub = () => { }
|
let unsub = () => {}
|
||||||
if (!system.id || chartTime !== "1m") {
|
if (!system.id || chartTime !== "1m") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -358,13 +360,21 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
value: system.info.k,
|
value: system.info.k,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let uptime: string
|
let uptime: React.ReactNode
|
||||||
if (system.info.u < 3600) {
|
if (system.info.u < 3600) {
|
||||||
uptime = secondsToString(system.info.u, "minute")
|
uptime = (
|
||||||
} else if (system.info.u < 360000) {
|
<Plural
|
||||||
uptime = secondsToString(system.info.u, "hour")
|
value={Math.trunc(system.info.u / 60)}
|
||||||
|
one="# minute"
|
||||||
|
few="# minutes"
|
||||||
|
many="# minutes"
|
||||||
|
other="# minutes"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (system.info.u < 172800) {
|
||||||
|
uptime = <Plural value={Math.trunc(system.info.u / 3600)} one="# hour" other="# hours" />
|
||||||
} else {
|
} else {
|
||||||
uptime = secondsToString(system.info.u, "day")
|
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||||
@@ -390,20 +400,76 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}[]
|
}[]
|
||||||
}, [system, t])
|
}, [system, t])
|
||||||
|
|
||||||
/** Space for tooltip if more than 10 sensors and no containers table */
|
/** Space for tooltip if more than 12 containers */
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: filters accessed via .get()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
const calculateSpacing = () => {
|
||||||
if (!temperatureChartRef.current || sensors.length < 10 || containerData.length > 0) {
|
if (!netCardRef.current || !containerData.length) {
|
||||||
setBottomSpacing(0)
|
setBottomSpacing(0)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count visible containers after applying filters
|
||||||
|
const containerFilter = $containerFilter.get()
|
||||||
|
const stackFilter = $stackFilter.get()
|
||||||
|
let visibleCount = 0
|
||||||
|
|
||||||
|
if (containerData[0]) {
|
||||||
|
for (const [key, value] of Object.entries(containerData[0])) {
|
||||||
|
if (key === "created") continue
|
||||||
|
|
||||||
|
// Apply container filter
|
||||||
|
if (containerFilter.length > 0 && !containerFilter.includes(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply stack filter
|
||||||
|
if (stackFilter.length > 0 && typeof value === "object" && value) {
|
||||||
|
const stackName = (value as any).p || "—"
|
||||||
|
if (!stackFilter.includes(stackName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add spacing if there are more than 12 visible containers
|
||||||
|
if (visibleCount > 12) {
|
||||||
|
const tooltipHeight = (visibleCount - 11) * 17.8 - 40
|
||||||
|
const wrapperEl = chartWrapRef.current as HTMLDivElement
|
||||||
|
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||||
|
const chartRect = netCardRef.current.getBoundingClientRect()
|
||||||
|
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||||
|
const spacing = Math.max(0, tooltipHeight - distanceToBottom)
|
||||||
|
setBottomSpacing(spacing)
|
||||||
|
} else {
|
||||||
|
setBottomSpacing(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const tooltipHeight = (sensors.length - 10) * 17.8 - 40
|
|
||||||
const wrapperEl = chartWrapRef.current as HTMLDivElement
|
// Initial calculation
|
||||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
calculateSpacing()
|
||||||
const chartRect = temperatureChartRef.current.getBoundingClientRect()
|
|
||||||
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
// Subscribe to filter changes to recalculate
|
||||||
setBottomSpacing(tooltipHeight - distanceToBottom)
|
// Use requestAnimationFrame to wait for chart re-render, then add small delay for layout
|
||||||
}, [])
|
const unsubContainer = $containerFilter.subscribe(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(calculateSpacing, 100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const unsubStack = $stackFilter.subscribe(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(calculateSpacing, 100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubContainer()
|
||||||
|
unsubStack()
|
||||||
|
}
|
||||||
|
}, [containerData])
|
||||||
|
|
||||||
// keyboard navigation between systems
|
// keyboard navigation between systems
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -450,7 +516,14 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
const showMax = maxValues && isLongerChart
|
const showMax = maxValues && isLongerChart
|
||||||
|
|
||||||
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
const containerFilterBar = containerData.length ? <FilterBar containerData={containerData} /> : null
|
||||||
|
const stackFilterBar = containerData.length ? <FilterBar containerData={containerData} store={$stackFilter} mode="stack" /> : null
|
||||||
|
const combinedFilterBar = containerData.length ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FilterBar containerData={containerData} />
|
||||||
|
<FilterBar containerData={containerData} store={$stackFilter} mode="stack" />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||||
@@ -566,17 +639,21 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
{/* <Tabs defaultValue="overview" className="w-full">
|
{/* Tabbed interface for system and Docker stats */}
|
||||||
<TabsList className="w-full h-11">
|
<Tabs defaultValue="system" className="w-full">
|
||||||
<TabsTrigger value="overview" className="w-full h-9">Overview</TabsTrigger>
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
<TabsTrigger value="containers" className="w-full h-9">Containers</TabsTrigger>
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<TabsTrigger value="smart" className="w-full h-9">S.M.A.R.T.</TabsTrigger>
|
<ServerIcon className="h-4 w-4" />
|
||||||
</TabsList>
|
{t`System Stats`}
|
||||||
<TabsContent value="smart">
|
</TabsTrigger>
|
||||||
</TabsContent>
|
<TabsTrigger value="docker" className="flex items-center gap-2">
|
||||||
</Tabs> */}
|
<ContainerIcon className="h-4 w-4" />
|
||||||
|
{dockerOrPodman(t`Docker Stats`, system)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* System Stats Tab */}
|
||||||
|
<TabsContent value="system" className="space-y-4">
|
||||||
{/* main charts */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -584,12 +661,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`CPU Usage`}
|
title={t`CPU Usage`}
|
||||||
description={t`Average system-wide CPU utilization`}
|
description={t`Average system-wide CPU utilization`}
|
||||||
cornerEl={
|
cornerEl={maxValSelect}
|
||||||
<div className="flex gap-2">
|
|
||||||
{maxValSelect}
|
|
||||||
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
@@ -604,27 +676,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
]}
|
]}
|
||||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||||
domain={pinnedAxisDomain()}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
|
||||||
<ChartCard
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
|
||||||
description={t`Average CPU utilization of containers`}
|
|
||||||
cornerEl={containerFilterBar}
|
|
||||||
>
|
|
||||||
<ContainerChart
|
|
||||||
chartData={chartData}
|
|
||||||
dataKey="c"
|
|
||||||
chartType={ChartType.CPU}
|
|
||||||
chartConfig={containerChartConfigs.cpu}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
@@ -635,23 +689,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<MemChart chartData={chartData} showMax={showMax} />
|
<MemChart chartData={chartData} showMax={showMax} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && (
|
|
||||||
<ChartCard
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
|
||||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
|
||||||
cornerEl={containerFilterBar}
|
|
||||||
>
|
|
||||||
<ContainerChart
|
|
||||||
chartData={chartData}
|
|
||||||
dataKey="m"
|
|
||||||
chartType={ChartType.Memory}
|
|
||||||
chartConfig={containerChartConfigs.memory}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||||
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -698,7 +735,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -752,27 +788,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
|
||||||
<ChartCard
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
|
||||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
|
||||||
cornerEl={containerFilterBar}
|
|
||||||
>
|
|
||||||
<ContainerChart
|
|
||||||
chartData={chartData}
|
|
||||||
chartType={ChartType.Network}
|
|
||||||
dataKey="n"
|
|
||||||
chartConfig={containerChartConfigs.network}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Swap chart */}
|
{/* Swap chart */}
|
||||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -800,21 +818,16 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
{/* Temperature chart */}
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<div
|
<ChartCard
|
||||||
ref={temperatureChartRef}
|
empty={dataEmpty}
|
||||||
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
|
grid={grid}
|
||||||
|
title={t`Temperature`}
|
||||||
|
description={t`Temperatures of system sensors`}
|
||||||
|
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||||
|
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
|
||||||
>
|
>
|
||||||
<ChartCard
|
<TemperatureChart chartData={chartData} />
|
||||||
empty={dataEmpty}
|
</ChartCard>
|
||||||
grid={grid}
|
|
||||||
title={t`Temperature`}
|
|
||||||
description={t`Temperatures of system sensors`}
|
|
||||||
cornerEl={<FilterBar store={$temperatureFilter} />}
|
|
||||||
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
|
|
||||||
>
|
|
||||||
<TemperatureChart chartData={chartData} />
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Battery chart */}
|
{/* Battery chart */}
|
||||||
@@ -965,9 +978,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
label: t`Write`,
|
label: t`Write`,
|
||||||
dataKey: ({ stats }) => {
|
dataKey: ({ stats }) => {
|
||||||
if (showMax) {
|
if (showMax) {
|
||||||
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
||||||
}
|
}
|
||||||
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
||||||
},
|
},
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
@@ -1002,21 +1015,128 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
|
{/* Docker Stats Tab */}
|
||||||
<LazySmartTable systemId={system.id} />
|
<TabsContent value="docker" className="space-y-4">
|
||||||
|
{/* Centralized filter bar for all Docker charts */}
|
||||||
|
{combinedFilterBar && (
|
||||||
|
<div className="flex justify-end gap-2 pb-2">
|
||||||
|
{combinedFilterBar}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
|
{containerFilterBar && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
||||||
|
description={t`Average CPU utilization of containers`}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
dataKey="c"
|
||||||
|
chartType={ChartType.CPU}
|
||||||
|
chartConfig={containerChartConfigs.cpu}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
{containerFilterBar && (
|
||||||
<LazyContainersTable systemId={system.id} />
|
<ChartCard
|
||||||
)}
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
||||||
|
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
dataKey="m"
|
||||||
|
chartType={ChartType.Memory}
|
||||||
|
chartConfig={containerChartConfigs.memory}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
<LazySystemdTable systemId={system.id} />
|
<div
|
||||||
)}
|
ref={netCardRef}
|
||||||
|
className={cn({
|
||||||
|
"col-span-full": !grid,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
||||||
|
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
chartType={ChartType.Network}
|
||||||
|
dataKey="n"
|
||||||
|
chartConfig={containerChartConfigs.network}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Docker Disk I/O chart */}
|
||||||
|
{containerFilterBar && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker Disk I/O`, system)}
|
||||||
|
description={dockerOrPodman(t`Disk read/write rates of docker containers`, system)}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
chartType={ChartType.DiskIO}
|
||||||
|
dataKey="d"
|
||||||
|
chartConfig={{}}
|
||||||
|
unit=" MB/s"
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Docker Volumes chart */}
|
||||||
|
{containerFilterBar && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker Volumes`, system)}
|
||||||
|
description={dockerOrPodman(t`Volume usage of docker containers`, system)}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
chartType={ChartType.Volume}
|
||||||
|
dataKey="v"
|
||||||
|
chartConfig={{}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Docker Health & Uptime chart */}
|
||||||
|
{containerFilterBar && (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={dockerOrPodman(t`Docker Health & Uptime`, system)}
|
||||||
|
description={dockerOrPodman(t`Container health status and uptime`, system)}
|
||||||
|
>
|
||||||
|
<ContainerChart
|
||||||
|
chartData={chartData}
|
||||||
|
chartType={ChartType.HealthUptime}
|
||||||
|
dataKey="h"
|
||||||
|
chartConfig={{}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if lots of sensors */}
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1044,57 +1164,121 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({
|
||||||
const storeValue = useStore(store)
|
store = $containerFilter,
|
||||||
const [inputValue, setInputValue] = useState(storeValue)
|
containerData,
|
||||||
|
mode = "container"
|
||||||
|
}: {
|
||||||
|
store?: typeof $containerFilter
|
||||||
|
containerData?: ChartData["containerData"]
|
||||||
|
mode?: "container" | "stack"
|
||||||
|
}) {
|
||||||
|
const selected = useStore(store)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
// Extract all unique container names or stack names from current data
|
||||||
setInputValue(storeValue)
|
const availableItems = useMemo(() => {
|
||||||
}, [storeValue])
|
const items = new Set<string>()
|
||||||
|
if (containerData) {
|
||||||
useEffect(() => {
|
for (const dataPoint of containerData) {
|
||||||
if (inputValue === storeValue) {
|
if (dataPoint.created) {
|
||||||
return
|
for (const [key, value] of Object.entries(dataPoint)) {
|
||||||
|
if (key !== "created") {
|
||||||
|
if (mode === "stack" && typeof value === "object" && value) {
|
||||||
|
// Extract stack/project name
|
||||||
|
const stackName = (value as any).p || "—"
|
||||||
|
items.add(stackName)
|
||||||
|
} else if (mode === "container") {
|
||||||
|
// Add container name
|
||||||
|
items.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const handle = window.setTimeout(() => store.set(inputValue), 80)
|
return Array.from(items).sort()
|
||||||
return () => clearTimeout(handle)
|
}, [containerData, mode])
|
||||||
}, [inputValue, storeValue, store])
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const toggleItem = useCallback((item: string) => {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
const current = store.get()
|
||||||
const value = e.target.value
|
if (current.includes(item)) {
|
||||||
setInputValue(value)
|
store.set(current.filter(i => i !== item))
|
||||||
},
|
} else {
|
||||||
[]
|
store.set([...current, item])
|
||||||
)
|
}
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setInputValue("")
|
|
||||||
store.set("")
|
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
store.set([])
|
||||||
|
setOpen(false)
|
||||||
|
}, [store])
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('[data-filter-dropdown]')) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative" data-filter-dropdown>
|
||||||
<Input
|
<Button
|
||||||
placeholder={t`Filter...`}
|
variant="outline"
|
||||||
className="ps-4 pe-8 w-full sm:w-44"
|
size="sm"
|
||||||
onChange={handleChange}
|
className="h-9 w-full sm:w-44 justify-between"
|
||||||
value={inputValue}
|
onClick={() => setOpen(!open)}
|
||||||
/>
|
>
|
||||||
{inputValue && (
|
<span className="truncate">
|
||||||
<Button
|
{selected.length === 0
|
||||||
type="button"
|
? (mode === "stack" ? t`Filter stacks...` : t`Filter containers...`)
|
||||||
variant="ghost"
|
: `${selected.length} selected`
|
||||||
size="icon"
|
}
|
||||||
aria-label="Clear"
|
</span>
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
<ChevronRightSquareIcon className="ml-2 h-4 w-4 opacity-50" />
|
||||||
onClick={handleClear}
|
</Button>
|
||||||
>
|
{open && (
|
||||||
<XIcon className="h-4 w-4" />
|
<div className="absolute z-50 mt-1 w-full sm:w-64 bg-popover border rounded-md shadow-md">
|
||||||
</Button>
|
<div className="p-2 space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{availableItems.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t`No items available`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 cursor-pointer hover:bg-accent"
|
||||||
|
onClick={() => toggleItem(item)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(item)}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate flex-1">{item}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<Button variant="ghost" size="sm" className="w-full" onClick={clearAll}>
|
||||||
|
{t`Clear all`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,7 +1333,7 @@ export function ChartCard({
|
|||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
|
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className={cn("ps-0 w-[calc(100%-1.3em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
<div className={cn("ps-0 w-[calc(100%-1.5em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
||||||
{
|
{
|
||||||
<Spinner
|
<Spinner
|
||||||
msg={empty ? t`Waiting for enough records to display` : undefined}
|
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||||
@@ -1162,36 +1346,3 @@ export function ChartCard({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainersTable = lazy(() => import("../containers-table/containers-table"))
|
|
||||||
|
|
||||||
function LazyContainersTable({ systemId }: { systemId: string }) {
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && <ContainersTable systemId={systemId} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SmartTable = lazy(() => import("./system/smart-table"))
|
|
||||||
|
|
||||||
function LazySmartTable({ systemId }: { systemId: string }) {
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && <SmartTable systemId={systemId} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemdTable = lazy(() => import("../systemd-table/systemd-table"))
|
|
||||||
|
|
||||||
function LazySystemdTable({ systemId }: { systemId: string }) {
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && <SystemdTable systemId={systemId} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { MoreHorizontalIcon } from "lucide-react"
|
|
||||||
import { memo, useRef, useState } from "react"
|
|
||||||
import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart"
|
|
||||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
|
||||||
import { DialogTitle } from "@/components/ui/dialog"
|
|
||||||
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
|
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
|
||||||
import { ChartCard } from "../system"
|
|
||||||
|
|
||||||
const minAgentVersion = parseSemVer("0.15.3")
|
|
||||||
|
|
||||||
export default memo(function CpuCoresSheet({
|
|
||||||
chartData,
|
|
||||||
dataEmpty,
|
|
||||||
grid,
|
|
||||||
maxValues,
|
|
||||||
}: {
|
|
||||||
chartData: ChartData
|
|
||||||
dataEmpty: boolean
|
|
||||||
grid: boolean
|
|
||||||
maxValues: boolean
|
|
||||||
}) {
|
|
||||||
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
|
|
||||||
const hasOpened = useRef(false)
|
|
||||||
|
|
||||||
const supportsBreakdown = compareSemVer(chartData.agentVersion, minAgentVersion) >= 0
|
|
||||||
|
|
||||||
if (!supportsBreakdown) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cpuCoresOpen && !hasOpened.current) {
|
|
||||||
hasOpened.current = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Latest stats snapshot
|
|
||||||
const latest = chartData.systemStats.at(-1)?.stats
|
|
||||||
const cpus = latest?.cpus ?? []
|
|
||||||
const numCores = cpus.length
|
|
||||||
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
|
|
||||||
|
|
||||||
const breakdownDataPoints = [
|
|
||||||
{
|
|
||||||
label: "System",
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
|
|
||||||
color: 3,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "User",
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
|
|
||||||
color: 1,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "IOWait",
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
|
|
||||||
color: 4,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Steal",
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
|
|
||||||
color: 5,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Idle",
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
|
|
||||||
color: 2,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Other`,
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
|
||||||
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
|
|
||||||
return total > 0 ? 100 - total : null
|
|
||||||
},
|
|
||||||
color: `hsl(80, 65%, 52%)`,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
},
|
|
||||||
] as DataPoint[]
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
|
|
||||||
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button
|
|
||||||
title={t`View more`}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
{hasOpened.current && (
|
|
||||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
|
||||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
|
||||||
{hasBreakdown && (
|
|
||||||
<ChartCard
|
|
||||||
key="cpu-breakdown"
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={t`CPU Time Breakdown`}
|
|
||||||
description={t`Percentage of time spent in each state`}
|
|
||||||
legend={true}
|
|
||||||
className="min-h-auto"
|
|
||||||
>
|
|
||||||
<AreaChartDefault
|
|
||||||
chartData={chartData}
|
|
||||||
maxToggled={maxValues}
|
|
||||||
legend={true}
|
|
||||||
dataPoints={breakdownDataPoints}
|
|
||||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
|
||||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
|
||||||
reverseStackOrder={true}
|
|
||||||
itemSorter={() => 1}
|
|
||||||
domain={[0, 100]}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{numCores > 0 && (
|
|
||||||
<ChartCard
|
|
||||||
key="cpu-cores-all"
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={t`CPU Cores`}
|
|
||||||
legend={numCores < 10}
|
|
||||||
description={t`Per-core average utilization`}
|
|
||||||
className="min-h-auto"
|
|
||||||
>
|
|
||||||
<AreaChartDefault
|
|
||||||
hideYAxis={true}
|
|
||||||
chartData={chartData}
|
|
||||||
maxToggled={maxValues}
|
|
||||||
legend={numCores < 10}
|
|
||||||
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
|
|
||||||
label: `CPU ${i}`,
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
|
|
||||||
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
|
|
||||||
opacity: 0.35,
|
|
||||||
stackId: "a"
|
|
||||||
}))}
|
|
||||||
tickFormatter={(val) => `${val}%`}
|
|
||||||
contentFormatter={({ value }) => `${value}%`}
|
|
||||||
reverseStackOrder={true}
|
|
||||||
itemSorter={() => 1}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Array.from({ length: numCores }).map((_, i) => (
|
|
||||||
<ChartCard
|
|
||||||
key={`cpu-core-${i}`}
|
|
||||||
empty={dataEmpty}
|
|
||||||
grid={grid}
|
|
||||||
title={`CPU ${i}`}
|
|
||||||
description={t`Per-core average utilization`}
|
|
||||||
legend={false}
|
|
||||||
className="min-h-auto"
|
|
||||||
>
|
|
||||||
<AreaChartDefault
|
|
||||||
chartData={chartData}
|
|
||||||
maxToggled={maxValues}
|
|
||||||
legend={false}
|
|
||||||
dataPoints={[
|
|
||||||
{
|
|
||||||
label: t`Usage`,
|
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
|
|
||||||
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
|
|
||||||
opacity: 0.35,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
tickFormatter={(val) => `${val}%`}
|
|
||||||
contentFormatter={({ value }) => `${value}%`}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
))}
|
|
||||||
</SheetContent>
|
|
||||||
)}
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{hasOpened.current && (
|
{hasOpened.current && (
|
||||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
ColumnFiltersState,
|
|
||||||
Column,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
SortingState,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react"
|
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { SmartData, SmartAttribute } from "@/types"
|
|
||||||
import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { ThermometerIcon } from "@/components/ui/icons"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
// Column definition for S.M.A.R.T. attributes table
|
|
||||||
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "id",
|
|
||||||
header: "ID",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (row) => row.n,
|
|
||||||
header: "Name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: (row) => row.rs || row.rv?.toString(),
|
|
||||||
header: "Value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "v",
|
|
||||||
header: "Normalized",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "w",
|
|
||||||
header: "Worst",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "t",
|
|
||||||
header: "Threshold",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// accessorFn: (row) => row.wf,
|
|
||||||
accessorKey: "wf",
|
|
||||||
header: "Failing",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type DiskInfo = {
|
|
||||||
device: string
|
|
||||||
model: string
|
|
||||||
serialNumber: string
|
|
||||||
firmwareVersion: string
|
|
||||||
capacity: string
|
|
||||||
status: string
|
|
||||||
temperature: number
|
|
||||||
deviceType: string
|
|
||||||
powerOnHours?: number
|
|
||||||
powerCycles?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to format capacity display
|
|
||||||
function formatCapacity(bytes: number): string {
|
|
||||||
const { value, unit } = formatBytes(bytes)
|
|
||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to convert SmartData to DiskInfo
|
|
||||||
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
|
|
||||||
const unknown = "Unknown"
|
|
||||||
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
|
|
||||||
device: smartData.dn || key,
|
|
||||||
model: smartData.mn || unknown,
|
|
||||||
serialNumber: smartData.sn || unknown,
|
|
||||||
firmwareVersion: smartData.fv || unknown,
|
|
||||||
capacity: smartData.c ? formatCapacity(smartData.c) : unknown,
|
|
||||||
status: smartData.s || unknown,
|
|
||||||
temperature: smartData.t || 0,
|
|
||||||
deviceType: smartData.dt || unknown,
|
|
||||||
// These fields need to be extracted from SmartAttribute if available
|
|
||||||
powerOnHours: smartData.a?.find(attr => {
|
|
||||||
const name = attr.n.toLowerCase();
|
|
||||||
return name.includes("poweronhours") || name.includes("power_on_hours");
|
|
||||||
})?.rv,
|
|
||||||
powerCycles: smartData.a?.find(attr => {
|
|
||||||
const name = attr.n.toLowerCase();
|
|
||||||
return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles");
|
|
||||||
})?.rv,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const columns: ColumnDef<DiskInfo>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "device",
|
|
||||||
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium max-w-50 truncate ms-1.5" title={row.getValue("device")}>
|
|
||||||
{row.getValue("device")}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "model",
|
|
||||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="max-w-50 truncate ms-1.5" title={row.getValue("model")}>
|
|
||||||
{row.getValue("model")}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "capacity",
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "temperature",
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const { value, unit } = formatTemperature(getValue() as number)
|
|
||||||
return <span className="ms-1.5">{`${value} ${unit}`}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const status = getValue() as string
|
|
||||||
return (
|
|
||||||
<div className="ms-1.5">
|
|
||||||
<Badge
|
|
||||||
variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "deviceType",
|
|
||||||
sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<div className="ms-1.5">
|
|
||||||
<Badge variant="outline" className="uppercase">
|
|
||||||
{getValue() as string}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "powerOnHours",
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const hours = (getValue() ?? 0) as number
|
|
||||||
if (!hours && hours !== 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground ms-1.5">
|
|
||||||
N/A
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const seconds = hours * 3600
|
|
||||||
return (
|
|
||||||
<div className="text-sm ms-1.5">
|
|
||||||
<div>{secondsToString(seconds, "hour")}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "powerCycles",
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const cycles = getValue() as number | undefined
|
|
||||||
if (!cycles && cycles !== 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-muted-foreground ms-1.5">
|
|
||||||
N/A
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <span className="ms-1.5">{cycles}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "serialNumber",
|
|
||||||
sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "firmwareVersion",
|
|
||||||
sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="size-4" />}
|
|
||||||
{name}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DisksTable({ systemId }: { systemId: string }) {
|
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "device", desc: false }])
|
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({})
|
|
||||||
const [smartData, setSmartData] = React.useState<Record<string, SmartData> | undefined>(undefined)
|
|
||||||
const [activeDisk, setActiveDisk] = React.useState<DiskInfo | null>(null)
|
|
||||||
const [sheetOpen, setSheetOpen] = React.useState(false)
|
|
||||||
|
|
||||||
const openSheet = (disk: DiskInfo) => {
|
|
||||||
setActiveDisk(disk)
|
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch smart data when component mounts or systemId changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (systemId) {
|
|
||||||
pb.send<Record<string, SmartData>>("/api/beszel/smart", { query: { system: systemId } })
|
|
||||||
.then((data) => {
|
|
||||||
setSmartData(data)
|
|
||||||
})
|
|
||||||
.catch(() => setSmartData({}))
|
|
||||||
}
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
// Convert SmartData to DiskInfo, if no data use empty array
|
|
||||||
const diskData = React.useMemo(() => {
|
|
||||||
return smartData ? convertSmartDataToDiskInfo(smartData) : []
|
|
||||||
}, [smartData])
|
|
||||||
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: diskData,
|
|
||||||
columns: columns,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!diskData.length && !columnFilters.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Card className="p-6 @container w-full">
|
|
||||||
<CardHeader className="p-0 mb-4">
|
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle className="mb-2">
|
|
||||||
S.M.A.R.T.
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex">
|
|
||||||
<Trans>Click on a device to view more information.</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
table.getColumn("device")?.setFilterValue(event.target.value)
|
|
||||||
}
|
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="rounded-md border text-nowrap">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead key={header.id} className="px-2">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => openSheet(row.original)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="md:ps-5">
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
{smartData ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
|
|
||||||
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<DiskSheet disk={activeDisk} smartData={smartData?.[activeDisk?.serialNumber ?? ""]} open={sheetOpen} onOpenChange={setSheetOpen} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
||||||
if (!disk) return null
|
|
||||||
|
|
||||||
const smartAttributes = smartData?.a || []
|
|
||||||
|
|
||||||
// Find all attributes where when failed is not empty
|
|
||||||
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '')
|
|
||||||
|
|
||||||
// Filter columns to only show those that have values in at least one row
|
|
||||||
const visibleColumns = React.useMemo(() => {
|
|
||||||
return smartColumns.filter(column => {
|
|
||||||
const accessorKey = (column as any).accessorKey as keyof SmartAttribute
|
|
||||||
if (!accessorKey) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Check if any row has a non-empty value for this column
|
|
||||||
return smartAttributes.some(attr => {
|
|
||||||
return attr[accessorKey] !== undefined
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [smartAttributes])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: smartAttributes,
|
|
||||||
columns: visibleColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent className="w-full sm:max-w-220 gap-0">
|
|
||||||
<SheetHeader className="mb-0 border-b">
|
|
||||||
<SheetTitle><Trans>S.M.A.R.T. Details</Trans> - {disk.device}</SheetTitle>
|
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{disk.model} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
{disk.serialNumber}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
|
|
||||||
<Alert className="pb-3">
|
|
||||||
{smartData?.s === "PASSED" ? (
|
|
||||||
<CheckCircle2Icon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
<AlertTitle><Trans>S.M.A.R.T. Self-Test</Trans>: {smartData?.s}</AlertTitle>
|
|
||||||
{failedAttributes.length > 0 && (
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>Failed Attributes:</Trans> {failedAttributes.map(attr => attr.n).join(", ")}
|
|
||||||
</AlertDescription>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
{smartAttributes.length > 0 ? (
|
|
||||||
<div className="rounded-md border overflow-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.map((row) => {
|
|
||||||
// Check if the attribute is failed
|
|
||||||
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
|
||||||
import type { SystemdRecord } from "@/types"
|
|
||||||
import { ServiceStatus, ServiceStatusLabels, ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
|
||||||
import {
|
|
||||||
ActivityIcon,
|
|
||||||
ArrowUpDownIcon,
|
|
||||||
ClockIcon,
|
|
||||||
CpuIcon,
|
|
||||||
MemoryStickIcon,
|
|
||||||
TerminalSquareIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Badge } from "../ui/badge"
|
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
// import { $allSystemsById } from "@/lib/stores"
|
|
||||||
// import { useStore } from "@nanostores/react"
|
|
||||||
|
|
||||||
function getSubStateColor(subState: ServiceSubState) {
|
|
||||||
switch (subState) {
|
|
||||||
case ServiceSubState.Running:
|
|
||||||
return "bg-green-500"
|
|
||||||
case ServiceSubState.Failed:
|
|
||||||
return "bg-red-500"
|
|
||||||
case ServiceSubState.Dead:
|
|
||||||
return "bg-yellow-500"
|
|
||||||
default:
|
|
||||||
return "bg-zinc-500"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const systemdTableCols: ColumnDef<SystemdRecord>[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
|
||||||
accessorFn: (record) => record.name,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TerminalSquareIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
return <span className="ms-1.5 xl:w-50 block truncate">{getValue() as string}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// id: "system",
|
|
||||||
// accessorFn: (record) => record.system,
|
|
||||||
// sortingFn: (a, b) => {
|
|
||||||
// const allSystems = $allSystemsById.get()
|
|
||||||
// const systemNameA = allSystems[a.original.system]?.name ?? ""
|
|
||||||
// const systemNameB = allSystems[b.original.system]?.name ?? ""
|
|
||||||
// return systemNameA.localeCompare(systemNameB)
|
|
||||||
// },
|
|
||||||
// header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
|
||||||
// cell: ({ getValue }) => {
|
|
||||||
// const allSystems = useStore($allSystemsById)
|
|
||||||
// return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
id: "state",
|
|
||||||
accessorFn: (record) => record.state,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`State`} Icon={ActivityIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const statusValue = getValue() as ServiceStatus
|
|
||||||
const statusLabel = ServiceStatusLabels[statusValue] || "Unknown"
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="dark:border-white/12">
|
|
||||||
<span className={cn("size-2 me-1.5 rounded-full", getStatusColor(statusValue))} />
|
|
||||||
{statusLabel}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sub",
|
|
||||||
accessorFn: (record) => record.sub,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Sub State`} Icon={ActivityIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const subState = getValue() as ServiceSubState
|
|
||||||
const subStateLabel = ServiceSubStateLabels[subState] || "Unknown"
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className="dark:border-white/12 text-xs capitalize">
|
|
||||||
<span className={cn("size-2 me-1.5 rounded-full", getSubStateColor(subState))} />
|
|
||||||
{subStateLabel}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cpu",
|
|
||||||
accessorFn: (record) => {
|
|
||||||
if (record.sub !== ServiceSubState.Running) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return record.cpu
|
|
||||||
},
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={`${t`CPU`} (10m)`} Icon={CpuIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
if (val < 0) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
|
||||||
}
|
|
||||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cpuPeak",
|
|
||||||
accessorFn: (record) => {
|
|
||||||
if (record.sub !== ServiceSubState.Running) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return record.cpuPeak ?? 0
|
|
||||||
},
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU Peak`} Icon={CpuIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
if (val < 0) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
|
||||||
}
|
|
||||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "memory",
|
|
||||||
accessorFn: (record) => record.memory,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
|
||||||
}
|
|
||||||
const formatted = formatBytes(val, false, undefined, false)
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "memPeak",
|
|
||||||
accessorFn: (record) => record.memPeak,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory Peak`} Icon={MemoryStickIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const val = getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
|
||||||
}
|
|
||||||
const formatted = formatBytes(val, false, undefined, false)
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "updated",
|
|
||||||
invertSorting: true,
|
|
||||||
accessorFn: (record) => record.updated,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const timestamp = getValue() as number
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums">
|
|
||||||
{hourWithSeconds(new Date(timestamp).toISOString())}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<SystemdRecord>; name: string; Icon: React.ElementType }) {
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="size-4" />}
|
|
||||||
{name}
|
|
||||||
<ArrowUpDownIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusColor(status: ServiceStatus) {
|
|
||||||
switch (status) {
|
|
||||||
case ServiceStatus.Active:
|
|
||||||
return "bg-green-500"
|
|
||||||
case ServiceStatus.Failed:
|
|
||||||
return "bg-red-500"
|
|
||||||
case ServiceStatus.Reloading:
|
|
||||||
case ServiceStatus.Activating:
|
|
||||||
case ServiceStatus.Deactivating:
|
|
||||||
return "bg-yellow-500"
|
|
||||||
default:
|
|
||||||
return "bg-zinc-500"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
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 { LoaderCircleIcon } from "lucide-react"
|
|
||||||
import { listenKeys } from "nanostores"
|
|
||||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
|
||||||
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
|
|
||||||
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
|
|
||||||
import { Separator } from "../ui/separator"
|
|
||||||
|
|
||||||
export default function SystemdTable({ systemId }: { systemId?: string }) {
|
|
||||||
const loadTime = Date.now()
|
|
||||||
const [data, setData] = useState<SystemdRecord[]>([])
|
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
|
||||||
`sort-sd-${systemId ? 1 : 0}`,
|
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
|
||||||
sessionStorage
|
|
||||||
)
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
|
||||||
|
|
||||||
// clear old data when systemId changes
|
|
||||||
useEffect(() => {
|
|
||||||
return setData([])
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lastUpdated = data[0]?.updated ?? 0
|
|
||||||
|
|
||||||
function fetchData(systemId?: string) {
|
|
||||||
pb.collection<SystemdRecord>("systemd_services")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: "name,state,sub,cpu,cpuPeak,memory,memPeak,updated",
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
({ items }) =>
|
|
||||||
items.length &&
|
|
||||||
setData((curItems) => {
|
|
||||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
|
||||||
const systemdNames = new Set()
|
|
||||||
const newItems: SystemdRecord[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
|
||||||
systemdNames.add(item.name)
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of curItems) {
|
|
||||||
if (!systemdNames.has(item.name) && lastUpdated - item.updated < 70_000) {
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newItems
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial load
|
|
||||||
fetchData(systemId)
|
|
||||||
|
|
||||||
// if no systemId, pull system containers after every system update
|
|
||||||
if (!systemId) {
|
|
||||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
|
||||||
// exclude initial load of systems
|
|
||||||
if (Date.now() - loadTime > 500) {
|
|
||||||
fetchData(systemId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// if systemId, fetch containers after the system is updated
|
|
||||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
|
||||||
// don't fetch data if the last update is less than 9.5 minutes
|
|
||||||
if (lastUpdated > Date.now() - 9.5 * 60 * 1000) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetchData(systemId)
|
|
||||||
})
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
// columns: systemdTableCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
|
||||||
columns: systemdTableCols,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
defaultColumn: {
|
|
||||||
sortUndefined: "last",
|
|
||||||
size: 100,
|
|
||||||
minSize: 0,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
|
||||||
globalFilter,
|
|
||||||
},
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
|
||||||
const service = row.original
|
|
||||||
const systemName = $allSystemsById.get()[service.system]?.name ?? ""
|
|
||||||
const name = service.name ?? ""
|
|
||||||
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
|
||||||
const subState = service.sub ?? ""
|
|
||||||
const searchString = `${systemName} ${name} ${statusLabel} ${subState}`.toLowerCase()
|
|
||||||
|
|
||||||
return (filterValue as string)
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.every((term) => searchString.includes(term))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = table.getRowModel().rows
|
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
|
||||||
|
|
||||||
const statusTotals = useMemo(() => {
|
|
||||||
const totals = [0, 0, 0, 0, 0, 0]
|
|
||||||
for (const service of data) {
|
|
||||||
totals[service.state]++
|
|
||||||
}
|
|
||||||
return totals
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
if (!data.length && !globalFilter) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-6 @container w-full">
|
|
||||||
<CardHeader className="p-0 mb-4">
|
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle className="mb-2">
|
|
||||||
<Trans>Systemd Services</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex items-center">
|
|
||||||
<Trans>Total: {data.length}</Trans>
|
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
|
||||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
|
||||||
<Trans>Updated every 10 minutes.</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="rounded-md">
|
|
||||||
<AllSystemdTable table={table} rows={rows} colLength={visibleColumns.length} systemId={systemId} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AllSystemdTable = memo(function AllSystemdTable({
|
|
||||||
table,
|
|
||||||
rows,
|
|
||||||
colLength,
|
|
||||||
systemId,
|
|
||||||
}: {
|
|
||||||
table: TableType<SystemdRecord>
|
|
||||||
rows: Row<SystemdRecord>[]
|
|
||||||
colLength: number
|
|
||||||
systemId?: string
|
|
||||||
}) {
|
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const activeService = useRef<SystemdRecord | null>(null)
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const openSheet = (service: SystemdRecord) => {
|
|
||||||
activeService.current = service
|
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
|
||||||
count: rows.length,
|
|
||||||
estimateSize: () => 54,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
overscan: 5,
|
|
||||||
})
|
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
|
||||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
|
||||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
|
||||||
)}
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
{/* add header height to table size */}
|
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
||||||
<table className="text-sm w-full h-full text-nowrap">
|
|
||||||
<SystemdTableHead table={table} />
|
|
||||||
<TableBody>
|
|
||||||
{rows.length ? (
|
|
||||||
virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return <SystemdTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
|
||||||
<Trans>No results.</Trans>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<SystemdSheet
|
|
||||||
sheetOpen={sheetOpen}
|
|
||||||
setSheetOpen={setSheetOpen}
|
|
||||||
activeService={activeService}
|
|
||||||
systemId={systemId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function SystemdSheet({
|
|
||||||
sheetOpen,
|
|
||||||
setSheetOpen,
|
|
||||||
activeService,
|
|
||||||
systemId,
|
|
||||||
}: {
|
|
||||||
sheetOpen: boolean
|
|
||||||
setSheetOpen: (open: boolean) => void
|
|
||||||
activeService: React.RefObject<SystemdRecord | null>
|
|
||||||
systemId?: string
|
|
||||||
}) {
|
|
||||||
const service = activeService.current
|
|
||||||
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sheetOpen || !service) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
setDetails(null)
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
|
|
||||||
query: {
|
|
||||||
system: systemId,
|
|
||||||
service: service.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(({ details }) => {
|
|
||||||
if (cancelled) return
|
|
||||||
if (details) {
|
|
||||||
setDetails(details)
|
|
||||||
} else {
|
|
||||||
setDetails(null)
|
|
||||||
setError(t`No results found.`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return
|
|
||||||
setError(err?.message ?? "Failed to load service details")
|
|
||||||
setDetails(null)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [sheetOpen, service, systemId])
|
|
||||||
|
|
||||||
if (!service) return null
|
|
||||||
|
|
||||||
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
|
||||||
const subStateLabel = ServiceSubStateLabels[service.sub as ServiceSubState] ?? ""
|
|
||||||
|
|
||||||
const notAvailable = <span className="text-muted-foreground">N/A</span>
|
|
||||||
|
|
||||||
const formatMemory = (value?: number | null) => {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return value === null ? t`Unlimited` : undefined
|
|
||||||
}
|
|
||||||
const { value: convertedValue, unit } = formatBytes(value, false, undefined, false)
|
|
||||||
const digits = convertedValue >= 10 ? 1 : 2
|
|
||||||
return `${decimalString(convertedValue, digits)} ${unit}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCpuTime = (ns?: number) => {
|
|
||||||
if (!ns) return undefined
|
|
||||||
const seconds = ns / 1_000_000_000
|
|
||||||
if (seconds >= 3600) {
|
|
||||||
const hours = Math.floor(seconds / 3600)
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
if (seconds >= 60) {
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
return `${minutes}m ${secs}s`
|
|
||||||
}
|
|
||||||
if (seconds >= 1) {
|
|
||||||
return `${decimalString(seconds, 2)}s`
|
|
||||||
}
|
|
||||||
return `${decimalString(seconds * 1000, 2)}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTasks = (current?: number, max?: number) => {
|
|
||||||
const hasCurrent = typeof current === "number" && current >= 0
|
|
||||||
const hasMax = typeof max === "number" && max > 0 && max !== null
|
|
||||||
if (!hasCurrent && !hasMax) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasCurrent ? current : notAvailable}
|
|
||||||
{hasMax && (
|
|
||||||
<span className="text-muted-foreground ms-1.5">
|
|
||||||
{`(${t`limit`}: ${max})`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{max === null && (
|
|
||||||
<span className="text-muted-foreground ms-1.5">
|
|
||||||
{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp?: number) => {
|
|
||||||
if (!timestamp) return undefined
|
|
||||||
// systemd timestamps are in microseconds, convert to milliseconds for JavaScript Date
|
|
||||||
const date = new Date(timestamp / 1000)
|
|
||||||
if (Number.isNaN(date.getTime())) return undefined
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeStateValue = (() => {
|
|
||||||
const stateText = details?.ActiveState
|
|
||||||
? details.SubState
|
|
||||||
? `${details.ActiveState} (${details.SubState})`
|
|
||||||
: details.ActiveState
|
|
||||||
: subStateLabel
|
|
||||||
? `${statusLabel} (${subStateLabel})`
|
|
||||||
: statusLabel
|
|
||||||
|
|
||||||
for (const [index, status] of ServiceStatusLabels.entries()) {
|
|
||||||
if (details?.ActiveState?.toLowerCase() === status.toLowerCase()) {
|
|
||||||
service.state = index as ServiceStatus
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor(service.state))} />
|
|
||||||
{stateText}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const statusTextValue = details?.Result
|
|
||||||
|
|
||||||
const cpuTime = formatCpuTime(details?.CPUUsageNSec)
|
|
||||||
const tasks = formatTasks(details?.TasksCurrent, details?.TasksMax)
|
|
||||||
const memoryCurrent = formatMemory(details?.MemoryCurrent)
|
|
||||||
const memoryPeak = formatMemory(details?.MemoryPeak)
|
|
||||||
const memoryLimit = formatMemory(details?.MemoryLimit)
|
|
||||||
const restartsValue = typeof details?.NRestarts === "number" ? details.NRestarts : undefined
|
|
||||||
const mainPidValue = typeof details?.MainPID === "number" && details.MainPID > 0 ? details.MainPID : undefined
|
|
||||||
const execMainPidValue =
|
|
||||||
typeof details?.ExecMainPID === "number" && details.ExecMainPID > 0 && details.ExecMainPID !== details?.MainPID
|
|
||||||
? details.ExecMainPID
|
|
||||||
: undefined
|
|
||||||
const activeEnterTimestamp = formatTimestamp(details?.ActiveEnterTimestamp)
|
|
||||||
const activeExitTimestamp = formatTimestamp(details?.ActiveExitTimestamp)
|
|
||||||
const inactiveEnterTimestamp = formatTimestamp(details?.InactiveEnterTimestamp)
|
|
||||||
const execMainStartTimestamp = undefined // Property not available in current systemd interface
|
|
||||||
|
|
||||||
const renderRow = (key: string, label: ReactNode, value?: ReactNode, alwaysShow = false) => {
|
|
||||||
if (!alwaysShow && (value === undefined || value === null || value === "")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<tr key={key} className="border-b last:border-b-0">
|
|
||||||
<td className="px-3 py-2 font-medium bg-muted dark:bg-muted/40 align-top w-35">{label}</td>
|
|
||||||
<td className="px-3 py-2">{value ?? notAvailable}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const capitalize = (str: string) => `${str.charAt(0).toUpperCase()}${str.slice(1).toLowerCase()}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
||||||
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
|
||||||
<SheetHeader className="p-0">
|
|
||||||
<SheetTitle>
|
|
||||||
<Trans>Service Details</Trans>
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="grid gap-6">
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
|
||||||
<Trans>Loading...</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
|
|
||||||
<AlertTitle>
|
|
||||||
<Trans>Error</Trans>
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{renderRow("name", t`Name`, service.name, true)}
|
|
||||||
{renderRow("description", t`Description`, details?.Description, true)}
|
|
||||||
{renderRow("loadState", t`Load state`, details?.LoadState, true)}
|
|
||||||
{renderRow(
|
|
||||||
"bootState",
|
|
||||||
t`Boot state`,
|
|
||||||
<div className="flex items-center">
|
|
||||||
{details?.UnitFileState}
|
|
||||||
{details?.UnitFilePreset && (
|
|
||||||
<span className="text-muted-foreground ms-1.5">(preset: {details?.UnitFilePreset})</span>
|
|
||||||
)}
|
|
||||||
</div>,
|
|
||||||
true
|
|
||||||
)}
|
|
||||||
{renderRow("unitFile", t`Unit file`, details?.FragmentPath, true)}
|
|
||||||
{renderRow("active", t`Active state`, activeStateValue, true)}
|
|
||||||
{renderRow("status", t`Status`, statusTextValue, true)}
|
|
||||||
{renderRow(
|
|
||||||
"documentation",
|
|
||||||
t`Documentation`,
|
|
||||||
Array.isArray(details?.Documentation) && details.Documentation.length > 0
|
|
||||||
? details.Documentation.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-3">
|
|
||||||
<Trans>Runtime Metrics</Trans>
|
|
||||||
</h3>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
|
|
||||||
{renderRow("execMainPid", t`Exec main PID`, execMainPidValue)}
|
|
||||||
{renderRow("tasks", t`Tasks`, tasks, true)}
|
|
||||||
{renderRow("cpuTime", t`CPU time`, cpuTime)}
|
|
||||||
{renderRow("memory", t`Memory`, memoryCurrent, true)}
|
|
||||||
{renderRow("memoryPeak", capitalize(t`Memory Peak`), memoryPeak)}
|
|
||||||
{renderRow("memoryLimit", t`Memory limit`, memoryLimit)}
|
|
||||||
{renderRow("restarts", t`Restarts`, restartsValue, true)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden has-[tr]:block">
|
|
||||||
<h3 className="text-sm font-medium mb-3">
|
|
||||||
<Trans>Relationships</Trans>
|
|
||||||
</h3>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{renderRow(
|
|
||||||
"wants",
|
|
||||||
t`Wants`,
|
|
||||||
Array.isArray(details?.Wants) && details.Wants.length > 0 ? details.Wants.join(", ") : undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"requires",
|
|
||||||
t`Requires`,
|
|
||||||
Array.isArray(details?.Requires) && details.Requires.length > 0
|
|
||||||
? details.Requires.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"requiredBy",
|
|
||||||
t`Required by`,
|
|
||||||
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
|
|
||||||
? details.RequiredBy.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"conflicts",
|
|
||||||
t`Conflicts`,
|
|
||||||
Array.isArray(details?.Conflicts) && details.Conflicts.length > 0
|
|
||||||
? details.Conflicts.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"before",
|
|
||||||
t`Before`,
|
|
||||||
Array.isArray(details?.Before) && details.Before.length > 0 ? details.Before.join(", ") : undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"after",
|
|
||||||
t`After`,
|
|
||||||
Array.isArray(details?.After) && details.After.length > 0 ? details.After.join(", ") : undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"triggers",
|
|
||||||
t`Triggers`,
|
|
||||||
Array.isArray(details?.Triggers) && details.Triggers.length > 0
|
|
||||||
? details.Triggers.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
{renderRow(
|
|
||||||
"triggeredBy",
|
|
||||||
t`Triggered by`,
|
|
||||||
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
|
|
||||||
? details.TriggeredBy.join(", ")
|
|
||||||
: undefined
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden has-[tr]:block">
|
|
||||||
<h3 className="text-sm font-medium mb-3">
|
|
||||||
<Trans>Lifecycle</Trans>
|
|
||||||
</h3>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{renderRow("activeSince", t`Became active`, activeEnterTimestamp)}
|
|
||||||
{service.state !== ServiceStatus.Active &&
|
|
||||||
renderRow("lastActive", t`Exited active`, activeExitTimestamp)}
|
|
||||||
{renderRow("inactiveSince", t`Became inactive`, inactiveEnterTimestamp)}
|
|
||||||
{renderRow("execMainStart", t`Process started`, execMainStartTimestamp)}
|
|
||||||
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
|
|
||||||
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden has-[tr]:block">
|
|
||||||
<h3 className="text-sm font-medium mb-3">
|
|
||||||
<Trans>Capabilities</Trans>
|
|
||||||
</h3>
|
|
||||||
<div className="border rounded-md">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody>
|
|
||||||
{renderRow("canStart", t`Can start`, details?.CanStart ? t`Yes` : t`No`)}
|
|
||||||
{renderRow("canStop", t`Can stop`, details?.CanStop ? t`Yes` : t`No`)}
|
|
||||||
{renderRow("canReload", t`Can reload`, details?.CanReload ? t`Yes` : t`No`)}
|
|
||||||
{/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? t`Yes` : t`No`)}
|
|
||||||
{renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
|
|
||||||
return (
|
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead className="px-2" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemdTableRow = memo(function SystemdTableRow({
|
|
||||||
row,
|
|
||||||
virtualRow,
|
|
||||||
openSheet,
|
|
||||||
}: {
|
|
||||||
row: Row<SystemdRecord>
|
|
||||||
virtualRow: VirtualItem
|
|
||||||
openSheet: (service: SystemdRecord) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="cursor-pointer transition-opacity"
|
|
||||||
onClick={() => openSheet(row.original)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className="py-0"
|
|
||||||
style={{
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -16,12 +16,10 @@ import {
|
|||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
TerminalSquareIcon,
|
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
@@ -70,7 +68,7 @@ const STATUS_COLORS = {
|
|||||||
* @param viewMode - "table" or "grid"
|
* @param viewMode - "table" or "grid"
|
||||||
* @returns - Column definitions for the systems table
|
* @returns - Column definitions for the systems table
|
||||||
*/
|
*/
|
||||||
export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
// size: 200,
|
// size: 200,
|
||||||
@@ -135,7 +133,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.cpu || undefined,
|
accessorFn: ({ info }) => info.cpu,
|
||||||
id: "cpu",
|
id: "cpu",
|
||||||
name: () => t`CPU`,
|
name: () => t`CPU`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -144,7 +142,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// accessorKey: "info.mp",
|
// accessorKey: "info.mp",
|
||||||
accessorFn: ({ info }) => info.mp || undefined,
|
accessorFn: ({ info }) => info.mp,
|
||||||
id: "memory",
|
id: "memory",
|
||||||
name: () => t`Memory`,
|
name: () => t`Memory`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -152,15 +150,15 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.dp || undefined,
|
accessorFn: ({ info }) => info.dp,
|
||||||
id: "disk",
|
id: "disk",
|
||||||
name: () => t`Disk`,
|
name: () => t`Disk`,
|
||||||
cell: DiskCellWithMultiple,
|
cell: TableCellWithMeter,
|
||||||
Icon: HardDriveIcon,
|
Icon: HardDriveIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.g || undefined,
|
accessorFn: ({ info }) => info.g,
|
||||||
id: "gpu",
|
id: "gpu",
|
||||||
name: () => "GPU",
|
name: () => "GPU",
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -173,9 +171,9 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
// TODO: remove this in future release in favor of la array
|
// TODO: remove this in future release in favor of la array
|
||||||
if (!sum) {
|
if (!sum) {
|
||||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
||||||
}
|
}
|
||||||
return sum || undefined
|
return sum
|
||||||
},
|
},
|
||||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -218,7 +216,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => (info.bb || (info.b || 0) * 1024 * 1024) || undefined,
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
id: "net",
|
id: "net",
|
||||||
name: () => t`Net`,
|
name: () => t`Net`,
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -230,7 +228,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
if (sys.status === SystemStatus.Paused) {
|
if (sys.status === SystemStatus.Paused) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
@@ -260,46 +258,11 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.sv?.[0],
|
|
||||||
id: "services",
|
|
||||||
name: () => t`Services`,
|
|
||||||
size: 50,
|
|
||||||
Icon: TerminalSquareIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
hideSort: true,
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
// sort priorities: 1) failed services, 2) total services
|
|
||||||
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
|
|
||||||
const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0]
|
|
||||||
if (numFailedA !== numFailedB) {
|
|
||||||
return numFailedA - numFailedB
|
|
||||||
}
|
|
||||||
return totalCountA - totalCountB
|
|
||||||
},
|
|
||||||
cell(info) {
|
|
||||||
const sys = info.row.original
|
|
||||||
const [totalCount, numFailed] = sys.info.sv ?? [0, 0]
|
|
||||||
if (sys.status !== SystemStatus.Up || totalCount === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
|
|
||||||
<span
|
|
||||||
className={cn("block size-2 rounded-full", {
|
|
||||||
[STATUS_COLORS[SystemStatus.Down]]: numFailed > 0,
|
|
||||||
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{totalCount} <span className="text-muted-foreground text-sm -ms-0.5">({t`Failed`.toLowerCase()}: {numFailed})</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.v,
|
accessorFn: ({ info }) => info.v,
|
||||||
id: "agent",
|
id: "agent",
|
||||||
name: () => t`Agent`,
|
name: () => t`Agent`,
|
||||||
|
// invertSorting: true,
|
||||||
size: 50,
|
size: 50,
|
||||||
Icon: WifiIcon,
|
Icon: WifiIcon,
|
||||||
hideSort: true,
|
hideSort: true,
|
||||||
@@ -377,9 +340,9 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
const meterClass = cn(
|
const meterClass = cn(
|
||||||
"h-full",
|
"h-full",
|
||||||
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
STATUS_COLORS.down
|
STATUS_COLORS.down
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
||||||
@@ -391,85 +354,12 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const { info: sysInfo, status, id } = info.row.original
|
|
||||||
const extraFs = Object.entries(sysInfo.efs ?? {})
|
|
||||||
|
|
||||||
// No extra disks - show basic meter
|
|
||||||
if (extraFs.length === 0) {
|
|
||||||
return TableCellWithMeter(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDiskPct = sysInfo.dp
|
|
||||||
|
|
||||||
// sort extra disks by percentage descending
|
|
||||||
extraFs.sort((a, b) => b[1] - a[1])
|
|
||||||
|
|
||||||
function getMeterClass(pct: number) {
|
|
||||||
const threshold = getMeterState(pct)
|
|
||||||
return cn(
|
|
||||||
"h-full",
|
|
||||||
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
|
||||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
|
||||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
|
||||||
STATUS_COLORS.down
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
|
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
|
||||||
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
|
||||||
{/* Root disk */}
|
|
||||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
|
||||||
{/* Extra disks */}
|
|
||||||
{extraFs.map(([_name, pct], index) => (
|
|
||||||
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<div className="grid gap-0.5">
|
|
||||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums"><Trans context="Root disk label">Root</Trans></div>
|
|
||||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
|
||||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
|
||||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{extraFs.map(([name, pct]) => {
|
|
||||||
return (
|
|
||||||
<div key={name} className="grid gap-0.5">
|
|
||||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
|
||||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
|
||||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
|
||||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("shrink-0 size-2 rounded-full", className)}
|
className={cn("shrink-0 size-2 rounded-full", className)}
|
||||||
// style={{ marginBottom: "-1px" }}
|
// style={{ marginBottom: "-1px" }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
XIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -48,7 +47,7 @@ import type { SystemRecord } from "@/types"
|
|||||||
import AlertButton from "../alerts/alert-button"
|
import AlertButton from "../alerts/alert-button"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | SystemRecord["status"]
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
@@ -61,7 +60,7 @@ export default function SystemsTable() {
|
|||||||
const upSystems = $upSystems.get()
|
const upSystems = $upSystems.get()
|
||||||
const pausedSystems = $pausedSystems.get()
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>("")
|
const [filter, setFilter] = useState<string>()
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
"sortMode",
|
"sortMode",
|
||||||
@@ -146,26 +145,7 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<div className="flex gap-2 ms-auto w-full md:w-80">
|
||||||
<div className="relative flex-1">
|
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
value={filter}
|
|
||||||
className="ps-4 pe-10 w-full"
|
|
||||||
/>
|
|
||||||
{filter && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Clear filter"
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
|
||||||
onClick={() => setFilter("")}
|
|
||||||
>
|
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
@@ -298,7 +278,6 @@ export default function SystemsTable() {
|
|||||||
upSystemsLength,
|
upSystemsLength,
|
||||||
downSystemsLength,
|
downSystemsLength,
|
||||||
pausedSystemsLength,
|
pausedSystemsLength,
|
||||||
filter,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ const badgeVariants = cva(
|
|||||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
success: "border-transparent bg-green-200 text-green-800",
|
|
||||||
danger: "border-transparent bg-red-200 text-red-800",
|
|
||||||
warning: "border-transparent bg-yellow-200 text-yellow-800",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -23,7 +20,7 @@ const badgeVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> { }
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RechartsPrimitive from "recharts"
|
import * as RechartsPrimitive from "recharts"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "./separator"
|
|
||||||
import { AxisDomain } from "recharts/types/util/types"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -94,18 +91,16 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
indicator?: "line" | "dot" | "dashed"
|
indicator?: "line" | "dot" | "dashed"
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
labelKey?: string
|
labelKey?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
filter?: string
|
filter?: string | string[]
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
showTotal?: boolean
|
}
|
||||||
totalLabel?: React.ReactNode
|
|
||||||
}
|
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -126,100 +121,27 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
itemSorter,
|
itemSorter,
|
||||||
contentFormatter: content = undefined,
|
contentFormatter: content = undefined,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
showTotal = false,
|
|
||||||
totalLabel,
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
const config = {}
|
const config = {}
|
||||||
const { t } = useLingui()
|
|
||||||
const totalLabelNode = totalLabel ?? t`Total`
|
|
||||||
const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total`
|
|
||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
|
if (Array.isArray(filter)) {
|
||||||
payload = payload?.filter((item) => {
|
// Array filter: only show items that are in the filter array
|
||||||
const itemName = (item.name as string)?.toLowerCase()
|
payload = payload?.filter((item) => filter.includes(item.name as string))
|
||||||
return filterTerms.some(term => itemName?.includes(term))
|
} else {
|
||||||
})
|
// String filter: show items that match the string (backward compatibility)
|
||||||
|
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (itemSorter) {
|
if (itemSorter) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
payload?.sort(itemSorter)
|
payload?.sort(itemSorter)
|
||||||
}
|
}
|
||||||
}, [itemSorter, payload])
|
}, [itemSorter, payload, filter])
|
||||||
|
|
||||||
const totalValueDisplay = React.useMemo(() => {
|
|
||||||
if (!showTotal || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalValue = 0
|
|
||||||
let hasNumericValue = false
|
|
||||||
const aggregatedNestedValues: Record<string, number> = {}
|
|
||||||
|
|
||||||
for (const item of payload) {
|
|
||||||
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
|
|
||||||
if (Number.isFinite(numericValue)) {
|
|
||||||
totalValue += numericValue
|
|
||||||
hasNumericValue = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content && item?.payload) {
|
|
||||||
const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
|
|
||||||
const nestedPayload = (item.payload as Record<string, unknown> | undefined)?.[payloadKey]
|
|
||||||
|
|
||||||
if (nestedPayload && typeof nestedPayload === "object") {
|
|
||||||
for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) {
|
|
||||||
if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
|
|
||||||
aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNumericValue) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalKey = "__total__"
|
|
||||||
const totalItem: any = {
|
|
||||||
value: totalValue,
|
|
||||||
name: totalName,
|
|
||||||
dataKey: totalKey,
|
|
||||||
color,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
const basePayload =
|
|
||||||
payload[0]?.payload && typeof payload[0].payload === "object"
|
|
||||||
? { ...(payload[0].payload as Record<string, unknown>) }
|
|
||||||
: {}
|
|
||||||
totalItem.payload = {
|
|
||||||
...basePayload,
|
|
||||||
[totalKey]: aggregatedNestedValues,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof formatter === "function") {
|
|
||||||
return formatter(
|
|
||||||
totalValue,
|
|
||||||
totalName,
|
|
||||||
totalItem,
|
|
||||||
payload.length,
|
|
||||||
totalItem.payload ?? payload[0]?.payload
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
return content(totalItem, totalKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${totalValue.toLocaleString()}${unit ?? ""}`
|
|
||||||
}, [color, content, formatter, nameKey, payload, showTotal, totalName, unit])
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
@@ -322,15 +244,6 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{totalValueDisplay ? (
|
|
||||||
<>
|
|
||||||
<Separator className="mt-0.5" />
|
|
||||||
<div className="flex items-center justify-between gap-2 -mt-0.75 font-medium">
|
|
||||||
<span className="text-muted-foreground ps-3">{totalLabelNode}</span>
|
|
||||||
<span>{totalValueDisplay}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -343,20 +256,17 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
reverse?: boolean
|
}
|
||||||
}
|
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
|
||||||
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
|
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const reversedPayload = reverse ? [...payload].reverse() : payload
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -366,7 +276,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{reversedPayload.map((item) => {
|
{payload.map((item) => {
|
||||||
// const key = `${nameKey || item.dataKey || 'value'}`
|
// const key = `${nameKey || item.dataKey || 'value'}`
|
||||||
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
@@ -455,15 +365,3 @@ export {
|
|||||||
xAxis,
|
xAxis,
|
||||||
// ChartStyle,
|
// ChartStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pinnedAxisDomain(): AxisDomain {
|
|
||||||
return [0, (dataMax: number) => {
|
|
||||||
if (dataMax > 10) {
|
|
||||||
return Math.round(dataMax)
|
|
||||||
}
|
|
||||||
if (dataMax > 1) {
|
|
||||||
return Math.round(dataMax / 0.1) * 0.1
|
|
||||||
}
|
|
||||||
return dataMax
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
@@ -32,9 +32,6 @@
|
|||||||
--chart-4: hsl(280 65% 60%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--table-header: hsl(225, 6%, 97%);
|
--table-header: hsl(225, 6%, 97%);
|
||||||
--chart-saturation: 65%;
|
|
||||||
--chart-lightness: 50%;
|
|
||||||
--container: 1480px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -54,13 +51,11 @@
|
|||||||
--accent: hsl(220 5% 15.5%);
|
--accent: hsl(220 5% 15.5%);
|
||||||
--accent-foreground: hsl(220 2% 98%);
|
--accent-foreground: hsl(220 2% 98%);
|
||||||
--destructive: hsl(0 62% 46%);
|
--destructive: hsl(0 62% 46%);
|
||||||
--border: hsl(220 3% 17%);
|
--border: hsl(220 3% 16%);
|
||||||
--input: hsl(220 4% 22%);
|
--input: hsl(220 4% 22%);
|
||||||
--ring: hsl(220 4% 80%);
|
--ring: hsl(220 4% 80%);
|
||||||
--table-header: hsl(220, 6%, 13%);
|
--table-header: hsl(220, 6%, 13%);
|
||||||
--radius: 0.8rem;
|
--radius: 0.8rem;
|
||||||
--chart-saturation: 60%;
|
|
||||||
--chart-lightness: 55%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -87,8 +82,6 @@
|
|||||||
--color-green-900: hsl(140 54% 12%);
|
--color-green-900: hsl(140 54% 12%);
|
||||||
--color-green-950: hsl(140 57% 6%);
|
--color-green-950: hsl(140 57% 6%);
|
||||||
|
|
||||||
--color-gh-dark: #22272e;
|
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -117,14 +110,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, InterVariable, sans-serif;
|
font-family: Inter, InterVariable, sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: InterVariable;
|
font-family: InterVariable;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -139,20 +130,16 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-variant-ligatures: no-contextual;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility container {
|
@utility container {
|
||||||
max-width: var(--container);
|
@apply max-w-360 mx-auto px-4;
|
||||||
@apply mx-auto px-4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility link {
|
@utility link {
|
||||||
@@ -162,14 +149,13 @@
|
|||||||
@utility ns-dialog {
|
@utility ns-dialog {
|
||||||
/* New system dialog width */
|
/* New system dialog width */
|
||||||
min-width: 30.3rem;
|
min-width: 30.3rem;
|
||||||
|
|
||||||
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
||||||
min-width: 27.9rem;
|
min-width: 27.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
z-index: 51;
|
z-index: 1;
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user