Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
2ab27ab642 chore(deps): bump github.com/mdlayher/wifi from 0.8.0 to 0.9.0
Bumps [github.com/mdlayher/wifi](https://github.com/mdlayher/wifi) from 0.8.0 to 0.9.0.
- [Release notes](https://github.com/mdlayher/wifi/releases)
- [Commits](https://github.com/mdlayher/wifi/compare/v0.8.0...v0.9.0)

---
updated-dependencies:
- dependency-name: github.com/mdlayher/wifi
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-27 02:12:23 +00:00
57 changed files with 661 additions and 2746 deletions

View File

@@ -151,9 +151,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
if err != nil {
slog.Debug("Systemd", "err", err)
}
if agent.systemdManager != nil {
agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled
}
agent.smartManager, err = NewSmartManager()
if err != nil {

View File

@@ -2,9 +2,7 @@ package agent
import (
"bufio"
"encoding/json"
"io"
"log/slog"
"os/exec"
"strconv"
"strings"
@@ -51,10 +49,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
return true
}
// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output.
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-J"}
args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev)
}
@@ -82,64 +80,48 @@ func (gm *GPUManager) collectIntelStats() (err error) {
}
}()
if err := gm.parseIntelJSONStream(stdout); err != nil {
return err
}
// The closing "]" is printed as the process exits, so read to EOF to let
// it finish instead of killing it.
_, _ = io.Copy(io.Discard, stdout)
return nil
}
// parseIntelJSONStream decodes samples from intel_gpu_top -J output and
// aggregates them. Since v1.28 the samples are wrapped in an array ("[", then
// comma separated objects, and "]" only when the process exits). Older
// versions print the same comma separated objects without the opening "[", so
// it is added here to let both formats decode as an array.
func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
er := &eofReader{r: r}
br := bufio.NewReader(er)
first, err := peekNonSpace(br)
if err != nil {
if err == io.EOF {
return errNoValidData
}
return err
}
var src io.Reader = br
if first != '[' {
src = io.MultiReader(strings.NewReader("["), br)
}
dec := json.NewDecoder(src)
if _, err := dec.Token(); err != nil { // opening "["
return err
}
scanner := bufio.NewScanner(stdout)
var header1 string
var engineNames []string
var friendlyNames []string
var preEngineCols int
var powerIndex int
var hadDataRow bool
// skip first data row because it sometimes has erroneous data
var skippedFirstDataRow bool
// Decode reads one object and skips the commas between them. The array is
// usually never closed, so output ending mid-array or mid-sample (the
// process was killed) is the normal end of the stream rather than an error.
for dec.More() {
var sample intelGpuJSONSample
if err := dec.Decode(&sample); err != nil {
if er.eof {
break
}
return err
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// first header line
if strings.HasPrefix(line, "Freq") {
header1 = line
continue
}
// second header line
if strings.HasPrefix(line, "req") {
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
continue
}
// Data row
if !skippedFirstDataRow {
skippedFirstDataRow = true
continue
}
stats := parseIntelJSONSample(sample)
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) {
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg)
continue
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
if err != nil {
return err
}
hadDataRow = true
gm.updateIntelFromStats(&stats)
gm.updateIntelFromStats(&sample)
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
if !hadDataRow {
return errNoValidData
@@ -147,82 +129,80 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
return nil
}
// eofReader records whether the underlying reader has returned io.EOF. The
// json decoder reports a stream ending mid-value as a syntax error, so this
// is how a truncated final sample is told apart from invalid output.
type eofReader struct {
r io.Reader
eof bool
}
func (e *eofReader) Read(p []byte) (int, error) {
n, err := e.r.Read(p)
if err == io.EOF {
e.eof = true
}
return n, err
}
// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it.
func peekNonSpace(br *bufio.Reader) (byte, error) {
for {
b, err := br.Peek(1)
if err != nil {
return 0, err
}
switch b[0] {
case ' ', '\t', '\n', '\r':
_, _ = br.ReadByte()
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
// Build indexes
h1 := strings.Fields(header1)
h2 := strings.Fields(header2)
powerIndex = -1 // Initialize to -1, will be set to actual index if found
// Collect engine names from header1
for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '/'
})
var friendly string
switch key {
case "RCS":
friendly = "Render/3D"
case "BCS":
friendly = "Blitter"
case "VCS":
friendly = "Video"
case "VECS":
friendly = "VideoEnhance"
case "CCS":
friendly = "Compute"
default:
return b[0], nil
continue
}
engineNames = append(engineNames, key)
friendlyNames = append(friendlyNames, friendly)
}
// find power gpu index among pre-engine columns
if n := len(engineNames); n > 0 {
preEngineCols = max(len(h2)-3*n, 0)
limit := min(len(h2), preEngineCols)
for i := range limit {
if strings.EqualFold(h2[i], "gpu") {
powerIndex = i
break
}
}
}
return engineNames, friendlyNames, powerIndex, preEngineCols
}
// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the
// needed fields are mapped.
type intelGpuJSONSample struct {
Power *struct {
GPU float64 `json:"GPU"`
Package float64 `json:"Package"`
} `json:"power"`
Engines map[string]struct {
Busy float64 `json:"busy"`
} `json:"engines"`
}
// validIntelPower reports whether a power reading from intel_gpu_top is plausible.
func validIntelPower(watts float64) bool {
// 5000 is well above any real GPU or package draw. intel_gpu_top
// computes power from unsigned energy counter deltas, so a counter that reads
// lower than the previous sample produces an enormous value for that period.
return watts >= 0 && watts <= 5000
}
// parseIntelJSONSample converts one intel_gpu_top JSON sample into intelGpuStats.
func parseIntelJSONSample(sample intelGpuJSONSample) (stats intelGpuStats) {
if sample.Power != nil {
stats.PowerGPU = sample.Power.GPU
stats.PowerPkg = sample.Power.Package
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
fields := strings.Fields(line)
if len(fields) == 0 {
return sample, errNoValidData
}
if len(sample.Engines) > 0 {
stats.Engines = make(map[string]float64, len(sample.Engines))
for key, engine := range sample.Engines {
stats.Engines[intelEngineClass(key)] += engine.Busy
// Make sure row has enough columns for engines
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
return sample, errNoValidData
}
if powerIndex >= 0 && powerIndex < len(fields) {
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
sample.PowerGPU = v
}
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
sample.PowerPkg = v
}
}
return stats
}
// intelEngineClass returns the engine class name for an engine key. Keys are
// class names ("Render/3D", "Video") in class view, which JSON output uses by
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in
// physical view, which older versions use.
func intelEngineClass(key string) string {
if i := strings.LastIndexByte(key, '/'); i >= 0 {
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil {
return key[:i]
if len(engineNames) > 0 {
sample.Engines = make(map[string]float64, len(engineNames))
for k := range engineNames {
base := preEngineCols + 3*k
if base < len(fields) {
busy := 0.0
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
busy = v
}
cur := sample.Engines[friendlyNames[k]]
sample.Engines[friendlyNames[k]] = cur + busy
} else {
sample.Engines[friendlyNames[k]] = 0
}
}
}
return key
return sample, nil
}

View File

@@ -3,11 +3,9 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -1433,10 +1431,12 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
intelPath := filepath.Join(dir, "intel_gpu_top")
intelScript := "#!/bin/sh\necho '" + intelJSONStream(true,
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
) + "'\n"
intelScript := `#!/bin/sh
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"
`
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
rocmPath := filepath.Join(dir, "rocm-smi")
@@ -1752,61 +1752,19 @@ func TestIntelUpdateFromStats(t *testing.T) {
assert.Equal(t, float64(2), gpu.Count)
}
// intelJSONSample returns one sample object formatted like intel_gpu_top -J output
func intelJSONSample(powerGPU, powerPkg float64, engines map[string]float64) string {
var sb strings.Builder
sb.WriteString("{\n\t\"period\": {\n\t\t\"duration\": 3300.123456,\n\t\t\"unit\": \"ms\"\n\t},\n")
sb.WriteString("\t\"frequency\": {\n\t\t\"requested\": 373.000000,\n\t\t\"actual\": 373.000000,\n\t\t\"unit\": \"MHz\"\n\t},\n")
fmt.Fprintf(&sb, "\t\"power\": {\n\t\t\"GPU\": %f,\n\t\t\"Package\": %f,\n\t\t\"unit\": \"W\"\n\t},\n", powerGPU, powerPkg)
sb.WriteString("\t\"engines\": {")
names := make([]string, 0, len(engines))
for name := range engines {
names = append(names, name)
}
slices.Sort(names)
for i, name := range names {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "\n\t\t%q: {\n\t\t\t\"busy\": %f,\n\t\t\t\"sema\": 0.000000,\n\t\t\t\"wait\": 0.000000,\n\t\t\t\"unit\": \"%%\"\n\t\t}", name, engines[name])
}
sb.WriteString("\n\t}\n}")
return sb.String()
}
// intelJSONStream joins samples as intel_gpu_top -J prints them. Since v1.28
// the output starts with "[" (withArray); older versions omit it.
func intelJSONStream(withArray bool, samples ...string) string {
var sb strings.Builder
if withArray {
sb.WriteString("[\n")
}
for i, s := range samples {
if i > 0 {
sb.WriteString(",\n")
}
sb.WriteString(s)
}
return sb.String()
}
func TestIntelCollectorStreaming(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
engines := func(render, blitter, video float64) map[string]float64 {
return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video}
}
output := intelJSONStream(true,
intelJSONSample(1.5, 4.13, engines(12.34, 0, 5)),
intelJSONSample(2.0, 2.69, engines(0, 0, 0)),
intelJSONSample(1.8, 2.45, engines(8.5, 15, 22)),
intelJSONSample(2.2, 3.12, engines(5.75, 9.5, 12)),
) + "\n]"
// Create a fake intel_gpu_top that prints -J output with four samples (first will be skipped) and exits
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
scriptPath := filepath.Join(dir, "intel_gpu_top")
script := "#!/bin/sh\necho '" + output + "'\n"
script := `#!/bin/sh
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 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 22.00 0 1"
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
@@ -1823,168 +1781,229 @@ func TestIntelCollectorStreaming(t *testing.T) {
gpu := gm.GpuDataMap["i0"]
require.NotNil(t, gpu)
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
assert.InDelta(t, 6.0, gpu.Power, 0.001)
assert.EqualValues(t, 6.0, gpu.Power)
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
// Engines aggregated from samples 2-4
assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00
assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
// Count should be 3 samples (first is skipped)
assert.Equal(t, float64(3), gpu.Count)
}
func TestParseIntelJSONStream(t *testing.T) {
first := intelJSONSample(9, 9, map[string]float64{"Render/3D": 99, "Compute": 99})
classView := []string{
intelJSONSample(2, 3, map[string]float64{"Render/3D": 10, "Blitter": 1, "Video": 5, "VideoEnhance": 0, "Compute": 40}),
intelJSONSample(1, 2, map[string]float64{"Render/3D": 20, "Blitter": 0, "Video": 5, "VideoEnhance": 3, "Compute": 60}),
}
classViewWant := map[string]float64{"Render/3D": 30, "Blitter": 1, "Video": 10, "VideoEnhance": 3, "Compute": 100}
func TestParseIntelHeaders(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
wantAnyErr bool
wantCount float64
wantPower float64
wantPkg float64
wantEngines map[string]float64
name string
header1 string
header2 string
wantEngineNames []string
wantFriendlyNames []string
wantPowerIndex int
wantPreEngineCols int
}{
{
name: "array still open while process runs",
input: intelJSONStream(true, first, classView[0], classView[1]),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "basic headers with RCS BCS VCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
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: "closed array",
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n",
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
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: "truncated final sample",
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "headers with only RCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
header2: " req act /s % gpu pkg rd wr % se wa",
wantEngineNames: []string{"RCS"},
wantFriendlyNames: []string{"Render/3D"},
wantPowerIndex: 4,
wantPreEngineCols: 8, // 11 total - 3*1 = 8
},
{
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names
name: "legacy output without array and with engine instances",
input: intelJSONStream(false,
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}),
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}),
),
wantCount: 1,
wantPower: 1.5,
wantPkg: 2.5,
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
name: "headers with VECS and CCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
wantEngineNames: []string{"VECS", "CCS"},
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
wantPowerIndex: 4,
wantPreEngineCols: 8, // 14 total - 3*2 = 8
},
{
// energy counter read lower than the previous sample in intel_gpu_top
name: "sample with invalid power is skipped",
input: intelJSONStream(true, first, classView[0],
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}),
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}),
classView[1],
),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "no engines",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
header2: " req act /s % gpu pkg rd wr",
wantEngineNames: nil, // no engines found, slices remain nil
wantFriendlyNames: nil,
wantPowerIndex: -1, // no engines, so no search
wantPreEngineCols: 0,
},
{
name: "only samples with invalid power",
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})),
wantErr: errNoValidData,
name: "power index not found",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
wantEngineNames: []string{"RCS"},
wantFriendlyNames: []string{"Render/3D"},
wantPowerIndex: -1, // "gpu" not found
wantPreEngineCols: 8, // 11 total - 3*1 = 8
},
{
name: "empty output",
input: "",
wantErr: errNoValidData,
},
{
name: "only first sample, which is skipped",
input: intelJSONStream(true, first),
wantErr: errNoValidData,
},
{
name: "invalid output",
input: "intel_gpu_top: command failed",
wantAnyErr: true,
name: "empty headers",
header1: "",
header2: "",
wantEngineNames: nil, // empty input, slices remain nil
wantFriendlyNames: nil,
wantPowerIndex: -1,
wantPreEngineCols: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
err := gm.parseIntelJSONStream(strings.NewReader(tt.input))
if tt.wantAnyErr {
assert.Error(t, err)
return
}
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Empty(t, gm.GpuDataMap)
return
}
require.NoError(t, err)
gm := &GPUManager{}
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
gpu := gm.GpuDataMap["i0"]
require.NotNil(t, gpu)
assert.Equal(t, tt.wantCount, gpu.Count)
assert.InDelta(t, tt.wantPower, gpu.Power, 0.001)
assert.InDelta(t, tt.wantPkg, gpu.PowerPkg, 0.001)
assert.Len(t, gpu.Engines, len(tt.wantEngines))
for name, want := range tt.wantEngines {
assert.InDelta(t, want, gpu.Engines[name], 0.001, name)
}
assert.Equal(t, tt.wantEngineNames, engineNames)
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
assert.Equal(t, tt.wantPowerIndex, powerIndex)
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
})
}
}
func TestParseIntelJSONSample(t *testing.T) {
t.Run("without power", func(t *testing.T) {
var sample intelGpuJSONSample
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample))
stats := parseIntelJSONSample(sample)
assert.Zero(t, stats.PowerGPU)
assert.Zero(t, stats.PowerPkg)
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines)
})
t.Run("without engines", func(t *testing.T) {
var sample intelGpuJSONSample
require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample))
stats := parseIntelJSONSample(sample)
assert.Equal(t, 1.25, stats.PowerGPU)
assert.Equal(t, 4.5, stats.PowerPkg)
assert.Nil(t, stats.Engines)
})
}
func TestIntelEngineClass(t *testing.T) {
tests := map[string]string{
"Render/3D": "Render/3D",
"Render/3D/0": "Render/3D",
"Blitter": "Blitter",
"Blitter/0": "Blitter",
"Video/1": "Video",
"VideoEnhance/0": "VideoEnhance",
"Compute/3": "Compute",
"[unknown]": "[unknown]",
"[unknown]/0": "[unknown]",
"Video/": "Video/",
func TestParseIntelData(t *testing.T) {
tests := []struct {
name string
line string
engineNames []string
friendlyNames []string
powerIndex int
preEngineCols int
wantPowerGPU float64
wantEngines map[string]float64
wantErr error
}{
{
name: "basic data with power and engines",
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with zero power",
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.00,
wantEngines: map[string]float64{
"Render/3D": 0.00,
"Blitter": 0.00,
"Video": 0.00,
},
},
{
name: "data with no power index",
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: -1,
preEngineCols: 8,
wantPowerGPU: 0.0, // no power parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with insufficient columns",
line: "373 373 224 45 1.50", // too few columns
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil, // empty sample returned
wantErr: errNoValidData,
},
{
name: "empty line",
line: "",
engineNames: []string{"RCS"},
friendlyNames: []string{"Render/3D"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil,
wantErr: errNoValidData,
},
{
name: "data with invalid power value",
line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0, // N/A can't be parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with invalid engine value",
line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: map[string]float64{
"Render/3D": 0.0, // N/A becomes 0
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with no engines",
line: "373 373 224 45 1.50 4.13 2554 714",
engineNames: []string{},
friendlyNames: []string{},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: nil,
},
}
for key, want := range tests {
assert.Equal(t, want, intelEngineClass(key), key)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{}
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
assert.Equal(t, tt.wantErr, err)
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
assert.Equal(t, tt.wantEngines, sample.Engines)
})
}
}
@@ -1997,11 +2016,13 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
scriptPath := filepath.Join(dir, "intel_gpu_top")
output := intelJSONStream(true,
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
)
script := fmt.Sprintf("#!/bin/sh\necho \"$@\" > %s\necho '%s'\n", argsFile, output)
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)
}
@@ -2022,5 +2043,5 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
argsStr := strings.TrimSpace(string(data))
require.Contains(t, argsStr, "-d sriov")
require.Contains(t, argsStr, "-s ")
require.Contains(t, argsStr, "-J")
require.Contains(t, argsStr, "-l")
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"log/slog"
)
@@ -53,10 +52,8 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{})
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
return registry
}
@@ -201,20 +198,6 @@ func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetPackageUpdatesHandler returns the pending package updates found by the
// last background check. It never runs a check itself.
type GetPackageUpdatesHandler struct{}
func (h *GetPackageUpdatesHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.packageUpdates == nil {
return hctx.SendResponse(system.PackageUpdates{}, hctx.RequestID)
}
return hctx.SendResponse(hctx.Agent.packageUpdates.list(), hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
@@ -246,33 +229,6 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdLogsHandler handles recent systemd service log requests.
type GetSystemdLogsHandler struct{}
func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(logs, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
type SyncNetworkMonitorsHandler struct{}

View File

@@ -15,7 +15,6 @@ import (
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
@@ -26,25 +25,16 @@ const (
pacmanSyncInterval = 12 * time.Hour
)
// packageUpdatesResult is the outcome of one package manager check.
type packageUpdatesResult struct {
// counts is [total] or [total, security] pending package updates.
counts []uint16
packages []system.PackageUpdate
// securityKnown is true if packages carry per-package security flags.
securityKnown bool
}
type packageUpdatesCheck func(ctx context.Context) (packageUpdatesResult, error)
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
// packageUpdatesManager periodically checks the host package manager for pending
// updates in the background and caches the result, so checks never delay metrics.
type packageUpdatesManager struct {
sync.Mutex
name string
check packageUpdatesCheck
interval time.Duration
result packageUpdatesResult
counts []uint16
checkedAt time.Time
running bool
}
@@ -56,37 +46,24 @@ func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
if runtime.GOOS != "linux" || runningInContainer() {
return nil
}
interval, enabled := packageUpdatesInterval()
if !enabled {
return nil
interval := defaultPackageUpdatesInterval
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
return nil
case err == nil && duration > 0:
interval = duration
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
}
}
name, check := detectPackageManager(dataDir)
if check == nil {
return nil
}
slog.Debug("Package updates", "manager", name, "interval", interval)
return &packageUpdatesManager{name: name, check: check, interval: interval}
}
// packageUpdatesInterval reads PACKAGE_UPDATES_INTERVAL as a Go duration such as
// "30m" or "6h". "0" disables checks. Invalid or negative values keep the default.
func packageUpdatesInterval() (interval time.Duration, enabled bool) {
env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL")
if !exists {
return defaultPackageUpdatesInterval, true
}
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", "disabled")
return 0, false
case err == nil && duration > 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", duration)
return duration, true
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
return defaultPackageUpdatesInterval, true
}
return &packageUpdatesManager{check: check, interval: interval}
}
// get returns the last cached counts and starts a background check if they are stale.
@@ -97,34 +74,19 @@ func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
pm.running = true
go pm.refresh()
}
return pm.result.counts
}
// list returns the per-package details of the last check. It never starts a check.
func (pm *packageUpdatesManager) list() system.PackageUpdates {
pm.Lock()
defer pm.Unlock()
data := system.PackageUpdates{
Manager: pm.name,
SecurityKnown: pm.result.securityKnown,
Packages: pm.result.packages,
}
if !pm.checkedAt.IsZero() {
data.CheckedAt = pm.checkedAt.Unix()
}
return data
return pm.counts
}
func (pm *packageUpdatesManager) refresh() {
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
defer cancel()
result, err := pm.check(ctx)
counts, err := pm.check(ctx)
if err != nil {
slog.Debug("Package updates check failed", "err", err)
result = packageUpdatesResult{}
counts = nil
}
pm.Lock()
pm.result = result
pm.counts = counts
pm.checkedAt = time.Now()
pm.running = false
pm.Unlock()
@@ -181,92 +143,42 @@ func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name
return string(out), err
}
// countSecurity returns the number of packages flagged as security updates.
func countSecurity(packages []system.PackageUpdate) (count uint16) {
for _, pkg := range packages {
if pkg.Security {
count++
}
}
return count
}
// checkApt simulates a full upgrade against the current package lists.
// It never refreshes the lists; apt-daily or the user does that.
func checkApt(ctx context.Context) (packageUpdatesResult, error) {
func checkApt(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
if err != nil {
return packageUpdatesResult{}, err
return nil, err
}
packages := parseAptSimulate(out)
return packageUpdatesResult{
counts: []uint16{uint16(len(packages)), countSecurity(packages)},
packages: packages,
securityKnown: true,
}, nil
total, security := parseAptSimulate(out)
return []uint16{total, security}, nil
}
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
// check-update lists only available versions, so installed versions come from rpm.
func checkDnf(ctx context.Context) (packageUpdatesResult, error) {
func checkDnf(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
if err != nil {
return packageUpdatesResult{}, err
return nil, err
}
packages := parseDnfCheckUpdate(out)
result := packageUpdatesResult{packages: packages}
if len(packages) > 0 {
args := []string{"-q", "--qf", rpmInstalledQueryFormat}
for _, pkg := range packages {
args = append(args, pkg.Name)
}
// rpm exits non-zero if any package is not installed; keep what it printed
out, _ = runPackageCommand(ctx, nil, "rpm", args...)
installed := parseRpmInstalled(out)
for i := range packages {
packages[i].Current = installed[packages[i].Name]
}
}
total := parseDnfCheckUpdate(out)
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
if err == nil {
// --security lists the lowest version that fixes an advisory, which may be
// older than the version check-update offers, so match on name.arch only
security := make(map[string]struct{})
for _, pkg := range parseDnfCheckUpdate(out) {
security[pkg.Name] = struct{}{}
}
for i := range packages {
_, packages[i].Security = security[packages[i].Name]
}
result.securityKnown = true
if err != nil {
return []uint16{total}, nil
}
for i := range packages {
packages[i].Name = trimRpmArch(packages[i].Name)
}
result.counts = []uint16{uint16(len(packages))}
if result.securityKnown {
result.counts = append(result.counts, countSecurity(packages))
}
return result, nil
return []uint16{total, parseDnfCheckUpdate(out)}, nil
}
// checkZypper lists package updates. Security updates come from patches, which
// zypper does not map to packages here, so only the security count is known.
func checkZypper(ctx context.Context) (packageUpdatesResult, error) {
func checkZypper(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
if err != nil {
return packageUpdatesResult{}, err
return nil, err
}
packages := parseZypperListUpdates(out)
result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}}
total := parseZypperTable(out)
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
if err == nil {
result.counts = append(result.counts, parseZypperTable(out))
if err != nil {
return []uint16{total}, nil
}
return result, nil
return []uint16{total, parseZypperTable(out)}, nil
}
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
@@ -285,7 +197,7 @@ func newPacmanCheck(dataDir string) packageUpdatesCheck {
}
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
return func(ctx context.Context) (packageUpdatesResult, error) {
return func(ctx context.Context) ([]uint16, error) {
// -n with a missing database reports no updates rather than failing,
// so always sync first and whenever the private copy is missing
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
@@ -300,55 +212,47 @@ func newPacmanCheck(dataDir string) packageUpdatesCheck {
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return packageUpdatesResult{}, err
return nil, err
}
if sync {
lastSync = time.Now()
}
packages := parsePacmanCheckUpdates(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
return []uint16{parsePacmanCheckUpdates(out)}, nil
}
}
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
func checkApk(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
if err != nil {
return packageUpdatesResult{}, err
return nil, err
}
packages := parseApkUpgradable(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
return []uint16{parseApkUpgradable(out)}, nil
}
// parseAptSimulate parses upgrades in `apt-get -s` output. Upgrade lines look like
// parseAptSimulate counts upgrades in `apt-get -s` output. Upgrade lines look like
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
// New dependencies have no "[old version]" and are skipped.
func parseAptSimulate(out string) (packages []system.PackageUpdate) {
// New dependencies have no "[old version]" and are not counted.
func parseAptSimulate(out string) (total, security uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") || !strings.HasPrefix(fields[3], "(") {
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") {
continue
}
pkg := system.PackageUpdate{
Name: fields[1],
Current: strings.Trim(fields[2], "[]"),
Available: strings.TrimPrefix(fields[3], "("),
}
total++
start := strings.IndexByte(line, '(')
end := strings.IndexByte(line, ')')
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
packages = append(packages, pkg)
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
security++
}
}
return packages
return total, security
}
// parseDnfCheckUpdate parses "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not listed twice. Names keep the
// arch so they can be matched with rpm output. dnf4 wraps a long name.arch onto
// its own line, with the version and repo on the next line.
func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
var wrappedName string
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not counted twice.
func parseDnfCheckUpdate(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
@@ -356,44 +260,11 @@ func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
break
}
fields := strings.Fields(line)
if wrappedName != "" && len(fields) == 2 {
fields = []string{wrappedName, fields[0], fields[1]}
}
wrappedName = ""
switch {
case len(fields) == 3 && strings.Contains(fields[0], "."):
packages = append(packages, system.PackageUpdate{Name: fields[0], Available: fields[1]})
case len(fields) == 1 && strings.Contains(fields[0], ".") && !strings.HasPrefix(line, " "):
wrappedName = fields[0]
if len(fields) == 3 && strings.Contains(fields[0], ".") {
count++
}
}
return packages
}
// rpmInstalledQueryFormat prints "name.arch [epoch:]version-release", matching
// the version format of dnf check-update.
const rpmInstalledQueryFormat = `%{NAME}.%{ARCH} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`
// parseRpmInstalled maps name.arch to its installed version. For packages with
// several installed versions, such as kernels, the last one listed wins.
func parseRpmInstalled(out string) map[string]string {
installed := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
// "package foo.x86_64 is not installed" has more than two fields
if fields := strings.Fields(scanner.Text()); len(fields) == 2 {
installed[fields[0]] = fields[1]
}
}
return installed
}
// trimRpmArch removes the ".arch" suffix from a dnf package name.
func trimRpmArch(name string) string {
if i := strings.LastIndexByte(name, '.'); i > 0 {
return name[:i]
}
return name
return count
}
// parseZypperTable counts the data rows of a zypper table (the lines after the
@@ -415,94 +286,25 @@ func parseZypperTable(out string) (count uint16) {
return count
}
// parseZypperListUpdates parses the `zypper list-updates` table, locating the
// columns by their header names.
func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
nameCol, currentCol, availableCol := -1, -1, -1
var header []string
inTable := false
// parsePacmanCheckUpdates counts "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
switch {
case !inTable && strings.HasPrefix(line, "--") && strings.Contains(line, "-+-"):
for i, col := range header {
switch strings.TrimSpace(col) {
case "Name":
nameCol = i
case "Current Version":
currentCol = i
case "Available Version":
availableCol = i
}
}
if nameCol < 0 || availableCol < 0 {
return nil
}
inTable = true
case !inTable:
header = strings.Split(line, "|")
case strings.Contains(line, "|"):
cols := strings.Split(line, "|")
if len(cols) != len(header) {
continue
}
pkg := system.PackageUpdate{
Name: strings.TrimSpace(cols[nameCol]),
Available: strings.TrimSpace(cols[availableCol]),
}
if currentCol >= 0 {
pkg.Current = strings.TrimSpace(cols[currentCol])
}
packages = append(packages, pkg)
default:
return packages
if strings.Contains(scanner.Text(), " -> ") {
count++
}
}
return packages
return count
}
// parsePacmanCheckUpdates parses "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) >= 4 && fields[2] == "->" {
packages = append(packages, system.PackageUpdate{Name: fields[0], Current: fields[1], Available: fields[3]})
}
}
return packages
}
// parseApkUpgradable parses lines of `apk -u list`, which look like
// parseApkUpgradable counts lines of `apk -u list`, which look like
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
func parseApkUpgradable(out string) (packages []system.PackageUpdate) {
const marker = "[upgradable from:"
func parseApkUpgradable(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
i := strings.Index(line, marker)
fields := strings.Fields(line)
if i < 0 || len(fields) == 0 {
continue
if strings.Contains(scanner.Text(), "[upgradable from:") {
count++
}
name, available := splitApkNameVersion(fields[0])
_, current := splitApkNameVersion(strings.TrimSuffix(strings.TrimSpace(line[i+len(marker):]), "]"))
packages = append(packages, system.PackageUpdate{Name: name, Current: current, Available: available})
}
return packages
}
// splitApkNameVersion splits "name-version-rN" into name and "version-rN".
// Names may contain dashes, but versions do not.
func splitApkNameVersion(s string) (name, version string) {
rel := strings.LastIndexByte(s, '-')
if rel <= 0 || !strings.HasPrefix(s[rel+1:], "r") {
return s, ""
}
ver := strings.LastIndexByte(s[:rel], '-')
if ver <= 0 {
return s, ""
}
return s[:ver], s[ver+1:]
return count
}

View File

@@ -12,7 +12,6 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -26,125 +25,44 @@ func readPackageUpdatesTestData(t *testing.T, name string) string {
// Test data files are real command outputs captured in containers.
// findPackage returns the named package from a parsed list.
func findPackage(t *testing.T, packages []system.PackageUpdate, name string) system.PackageUpdate {
t.Helper()
for _, pkg := range packages {
if pkg.Name == name {
return pkg
}
}
t.Fatalf("package %q not found", name)
return system.PackageUpdate{}
}
// fakeCommands puts shell scripts named after package manager commands first on PATH.
func fakeCommands(t *testing.T, scripts map[string]string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("requires shell scripts on PATH")
}
binDir := t.TempDir()
for name, script := range scripts {
require.NoError(t, os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\n"+script), 0o755))
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func testDataPath(t *testing.T, name string) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("test-data", "package_updates", name))
require.NoError(t, err)
return path
}
func TestPackageUpdatesInterval(t *testing.T) {
tests := []struct {
name string
value *string
interval time.Duration
enabled bool
}{
{"unset", nil, time.Hour, true},
{"duration", new("30m"), 30 * time.Minute, true},
{"compound duration", new("1h30m"), 90 * time.Minute, true},
{"zero disables", new("0"), 0, false},
{"zero with unit disables", new("0s"), 0, false},
{"negative keeps default", new("-5m"), time.Hour, true},
{"no unit keeps default", new("60"), time.Hour, true},
{"invalid keeps default", new("hourly"), time.Hour, true},
{"empty keeps default", new(""), time.Hour, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL"))
t.Setenv("PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("PACKAGE_UPDATES_INTERVAL"))
if tt.value != nil {
t.Setenv("PACKAGE_UPDATES_INTERVAL", *tt.value)
}
interval, enabled := packageUpdatesInterval()
assert.Equal(t, tt.interval, interval)
assert.Equal(t, tt.enabled, enabled)
})
}
t.Run("prefixed variable takes precedence", func(t *testing.T) {
t.Setenv("PACKAGE_UPDATES_INTERVAL", "0")
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "6h")
interval, enabled := packageUpdatesInterval()
assert.Equal(t, 6*time.Hour, interval)
assert.True(t, enabled)
})
}
func TestParseAptSimulate(t *testing.T) {
tests := []struct {
file string
total, security int
total, security uint16
}{
{"apt_debian12.txt", 44, 5},
{"apt_ubuntu2204.txt", 58, 45},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
packages := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Len(t, packages, tt.total)
assert.EqualValues(t, tt.security, countSecurity(packages))
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Equal(t, tt.total, total)
assert.Equal(t, tt.security, security)
})
}
t.Run("versions", func(t *testing.T) {
packages := parseAptSimulate(readPackageUpdatesTestData(t, "apt_ubuntu2204.txt"))
assert.Equal(t, system.PackageUpdate{Name: "libc6", Current: "2.35-0ubuntu3.4", Available: "2.35-0ubuntu3.15", Security: true}, findPackage(t, packages, "libc6"))
assert.Equal(t, system.PackageUpdate{Name: "base-files", Current: "12ubuntu4.4", Available: "12ubuntu4.7"}, findPackage(t, packages, "base-files"))
packages = parseAptSimulate(readPackageUpdatesTestData(t, "apt_debian12.txt"))
assert.Equal(t, system.PackageUpdate{Name: "tzdata", Current: "2023c-5+deb12u1", Available: "2026b-0+deb12u1"}, findPackage(t, packages, "tzdata"))
})
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Remv oldpkg [1.0]`
assert.Equal(t, []system.PackageUpdate{
{Name: "linux-image-generic", Current: "6.8.0-49.49", Available: "6.8.0-50.50", Security: true},
{Name: "gcc-12-base", Current: "12.3.0-1ubuntu1~22.04", Available: "12.3.0-1ubuntu1~22.04.3"},
}, parseAptSimulate(out))
total, security := parseAptSimulate(out)
assert.Equal(t, uint16(2), total)
assert.Equal(t, uint16(1), security)
})
t.Run("no updates", func(t *testing.T) {
assert.Empty(t, parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n"))
total, security := parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n")
assert.Zero(t, total)
assert.Zero(t, security)
})
}
func TestParseDnfCheckUpdate(t *testing.T) {
tests := []struct {
file string
count int
count uint16
}{
{"dnf4_rocky9_check_update.txt", 110},
{"dnf4_rocky9_check_update_security.txt", 53},
@@ -153,95 +71,19 @@ func TestParseDnfCheckUpdate(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Len(t, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)), tt.count)
assert.Equal(t, tt.count, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)))
})
}
t.Run("versions keep epoch and arch", func(t *testing.T) {
packages := parseDnfCheckUpdate(readPackageUpdatesTestData(t, "dnf5_fedora42_check_update.txt"))
assert.Equal(t, system.PackageUpdate{Name: "openssl-libs.aarch64", Available: "1:3.2.6-4.fc42"}, findPackage(t, packages, "openssl-libs.aarch64"))
})
t.Run("obsoletes section, notices and wrapped names", func(t *testing.T) {
t.Run("obsoletes section and notices", func(t *testing.T) {
out := `
kernel.x86_64 5.14.0-503.el9 baseos
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
python3-some-very-long-package-name-that-wraps.noarch
1.2.3-4.el9 appstream
Obsoleting Packages
grub2-tools.x86_64 1:2.06-80.el9 baseos
grub2-tools.x86_64 1:2.06-77.el9 @baseos
`
assert.Equal(t, []system.PackageUpdate{
{Name: "kernel.x86_64", Available: "5.14.0-503.el9"},
{Name: "python3-some-very-long-package-name-that-wraps.noarch", Available: "1.2.3-4.el9"},
}, parseDnfCheckUpdate(out))
})
}
func TestParseRpmInstalled(t *testing.T) {
installed := parseRpmInstalled(readPackageUpdatesTestData(t, "dnf4_rocky9_rpm_installed.txt"))
assert.Len(t, installed, 110)
assert.Equal(t, "2.34-83.el9.7", installed["glibc.aarch64"])
assert.Equal(t, "1:3.0.7-24.el9", installed["openssl-libs.aarch64"])
assert.NotContains(t, installed, "package")
// several installed kernels: the last one wins
installed = parseRpmInstalled("kernel.x86_64 5.14.0-427.el9\nkernel.x86_64 5.14.0-503.el9\n")
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
}
func TestCheckDnf(t *testing.T) {
tests := []struct {
name, updates, security, installed string
total, securityCount int
pkg system.PackageUpdate
}{
{
name: "dnf4",
updates: "dnf4_rocky9_check_update.txt", security: "dnf4_rocky9_check_update_security.txt", installed: "dnf4_rocky9_rpm_installed.txt",
total: 110, securityCount: 53,
pkg: system.PackageUpdate{Name: "vim-minimal", Current: "2:8.2.2637-20.el9_1", Available: "2:8.2.2637-26.el9_8.21", Security: true},
},
{
name: "dnf5",
updates: "dnf5_fedora42_check_update.txt", security: "dnf5_fedora42_check_update_security.txt", installed: "dnf5_fedora42_rpm_installed.txt",
total: 20, securityCount: 5,
pkg: system.PackageUpdate{Name: "openssl-libs", Current: "1:3.2.6-3.fc42", Available: "1:3.2.6-4.fc42", Security: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) cat "` + testDataPath(t, tt.security) + `" ;; *) cat "` + testDataPath(t, tt.updates) + `" ;; esac
exit 100`,
"rpm": `cat "` + testDataPath(t, tt.installed) + `"
exit 1`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{uint16(tt.total), uint16(tt.securityCount)}, result.counts)
assert.True(t, result.securityKnown)
assert.Len(t, result.packages, tt.total)
assert.Equal(t, tt.pkg, findPackage(t, result.packages, tt.pkg.Name))
for _, pkg := range result.packages {
assert.NotEmpty(t, pkg.Current, pkg.Name)
}
})
}
t.Run("security query fails", func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) exit 1 ;; esac
echo "bash.x86_64 5.1.8-9.el9 baseos"
exit 100`,
"rpm": `echo "bash.x86_64 5.1.8-6.el9_1"`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.False(t, result.securityKnown)
assert.Equal(t, []system.PackageUpdate{{Name: "bash", Current: "5.1.8-6.el9_1", Available: "5.1.8-9.el9"}}, result.packages)
assert.Equal(t, uint16(1), parseDnfCheckUpdate(out))
})
}
@@ -261,76 +103,23 @@ func TestParseZypperTable(t *testing.T) {
}
}
func TestParseZypperListUpdates(t *testing.T) {
packages := parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_updates.txt"))
assert.Len(t, packages, 22)
assert.Equal(t, system.PackageUpdate{Name: "zypper", Current: "1.14.76-150500.6.6.15", Available: "1.14.78-150500.6.14.1"}, findPackage(t, packages, "zypper"))
assert.Equal(t, system.PackageUpdate{Name: "aaa_base", Current: "84.87+git20180409.04c9dae-150300.10.20.1", Available: "84.87+git20180409.04c9dae-150300.10.23.1"}, findPackage(t, packages, "aaa_base"))
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap156_list_updates_none.txt")))
// patch tables have no version columns
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_patches_security.txt")))
}
func TestCheckZypper(t *testing.T) {
fakeCommands(t, map[string]string{
"zypper": `case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`,
})
result, err := checkZypper(context.Background())
require.NoError(t, err)
// security patches don't map to packages, so only the count is known
assert.Equal(t, []uint16{22, 4}, result.counts)
assert.False(t, result.securityKnown)
assert.Len(t, result.packages, 22)
}
func TestParsePacmanCheckUpdates(t *testing.T) {
assert.Equal(t, []system.PackageUpdate{
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
{Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"},
{Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"},
{Name: "tzdata", Current: "2026c-1", Available: "2026d-1"},
}, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Empty(t, parsePacmanCheckUpdates(""))
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Zero(t, parsePacmanCheckUpdates(""))
}
func TestParseApkUpgradable(t *testing.T) {
packages := parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt"))
assert.Len(t, packages, 10)
assert.Equal(t, system.PackageUpdate{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}, packages[6])
// names with dashes and digits
assert.Equal(t, system.PackageUpdate{Name: "busybox-binsh", Current: "1.36.1-r28", Available: "1.36.1-r31"}, packages[2])
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates-bundle", Current: "20240226-r0", Available: "20260413-r0"}, packages[3])
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Current: "3.3.0-r2", Available: "3.3.7-r0"}, packages[4])
assert.Empty(t, parseApkUpgradable(""))
}
func TestSplitApkNameVersion(t *testing.T) {
tests := []struct{ in, name, version string }{
{"musl-1.2.5-r3", "musl", "1.2.5-r3"},
{"py3-foo-bar-2.0_rc1-r0", "py3-foo-bar", "2.0_rc1-r0"},
{"apk-tools-2.14.4-r1", "apk-tools", "2.14.4-r1"},
// unexpected formats keep the whole string as the name
{"noversion", "noversion", ""},
{"name-1.0", "name-1.0", ""},
{"-1.0-r0", "-1.0-r0", ""},
}
for _, tt := range tests {
name, version := splitApkNameVersion(tt.in)
assert.Equal(t, tt.name, name, tt.in)
assert.Equal(t, tt.version, version, tt.in)
}
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
assert.Zero(t, parseApkUpgradable(""))
}
func TestPackageUpdatesManagerCaching(t *testing.T) {
calls := make(chan struct{}, 10)
packages := []system.PackageUpdate{{Name: "libc6", Current: "1", Available: "2", Security: true}}
result := packageUpdatesResult{counts: []uint16{3, 1}, packages: packages, securityKnown: true}
result := []uint16{3, 1}
var resultErr error
pm := &packageUpdatesManager{
name: "apt",
interval: time.Hour,
check: func(context.Context) (packageUpdatesResult, error) {
check: func(context.Context) ([]uint16, error) {
calls <- struct{}{}
return result, resultErr
},
@@ -343,9 +132,6 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
}, time.Second, time.Millisecond)
}
// no check has finished yet
assert.Equal(t, system.PackageUpdates{Manager: "apt"}, pm.list())
now := time.Now()
// first call starts a background check and returns nothing yet
assert.Nil(t, pm.get(now))
@@ -355,49 +141,15 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
// cached result within interval, no new check
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
assert.Len(t, calls, 1)
list := pm.list()
assert.Equal(t, "apt", list.Manager)
assert.True(t, list.SecurityKnown)
assert.Equal(t, packages, list.Packages)
assert.NotZero(t, list.CheckedAt)
// list never starts a check
assert.Len(t, calls, 1)
// stale after interval: returns cached value and refreshes in background
result, resultErr = packageUpdatesResult{}, errors.New("boom")
result, resultErr = nil, errors.New("boom")
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
waitIdle()
assert.Len(t, calls, 2)
// failed check clears the counts and the list
// failed check clears the counts
assert.Nil(t, pm.get(time.Now()))
assert.Nil(t, pm.list().Packages)
assert.False(t, pm.list().SecurityKnown)
}
func TestGetPackageUpdatesHandler(t *testing.T) {
var sent any
hctx := &HandlerContext{
Agent: &Agent{},
SendResponse: func(data any, _ *uint32) error {
sent = data
return nil
},
}
handler := &GetPackageUpdatesHandler{}
// no supported package manager
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{}, sent)
packages := []system.PackageUpdate{{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}}
hctx.Agent.packageUpdates = &packageUpdatesManager{
name: "apk",
result: packageUpdatesResult{counts: []uint16{1}, packages: packages},
checkedAt: time.Unix(1700000000, 0),
}
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent)
}
func TestPacmanCheckSync(t *testing.T) {
@@ -425,10 +177,9 @@ echo "linux 6.1-1 -> 6.2-1"
}
// first check syncs
result, err := check(context.Background())
counts, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
assert.Equal(t, []uint16{1}, counts)
// later checks reuse the synced copy
_, err = check(context.Background())
require.NoError(t, err)

View File

@@ -3,15 +3,12 @@
package agent
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"maps"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
@@ -23,50 +20,6 @@ import (
)
var errNoActiveTime = errors.New("no active time")
var errSystemdLogLimitReached = errors.New("systemd log size limit reached")
const systemdLogsTail = 200
// canReadSystemJournal probes whether the agent's current credentials can read
// the system journal. A successful empty result is still readable: entries may
// be written after the agent starts.
func canReadSystemJournal() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
_, err := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--lines", "1").Output()
return err == nil
}
// systemdLogsEnabled reports whether service logs can be served to the hub.
func systemdLogsEnabled() bool {
if skip, _ := utils.GetEnv("SKIP_SYSTEMD_LOGS"); skip == "true" {
return false
}
return canReadSystemJournal()
}
// limitedBuffer bounds command output before it is sent over the agent connection.
type limitedBuffer struct {
buffer bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
return 0, errSystemdLogLimitReached
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
return remaining, errSystemdLogLimitReached
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string {
return b.buffer.String()
}
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
@@ -74,7 +27,6 @@ type systemdManager struct {
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS
patterns []string
}
@@ -116,7 +68,6 @@ func newSystemdManager() (*systemdManager, error) {
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
logsEnabled: systemdLogsEnabled(),
patterns: getServicePatterns(),
}
@@ -281,14 +232,6 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat
return service, nil
}
// serviceUnitName preserves monitored timer units and defaults bare names to services.
func serviceUnitName(name string) string {
if strings.HasSuffix(name, ".service") || strings.HasSuffix(name, ".timer") {
return name
}
return name + ".service"
}
// getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
@@ -297,7 +240,10 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
}
defer conn.Close()
unitName := serviceUnitName(serviceName)
unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
@@ -332,63 +278,6 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
return details, nil
}
// monitoredUnitName resolves a service name to the unit name of a monitored
// service. Only monitored units are accepted so a request can't read other
// journal entries (journalctl --unit also accepts glob patterns).
func (sm *systemdManager) monitoredUnitName(serviceName string) (string, bool) {
sm.Lock()
defer sm.Unlock()
unitName := serviceUnitName(serviceName)
if _, ok := sm.serviceStatsMap[unitName]; ok {
return unitName, true
}
// Service names are unescaped, so match against the stored name as well.
for unitName, service := range sm.serviceStatsMap {
if service.Name == serviceName {
return unitName, true
}
}
return "", false
}
// getServiceLogs returns the newest journal entries for a monitored service.
// journalctl receives the unit name as an argument (rather than through a
// shell), so a service name can never alter the command being run.
func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) {
if !sm.logsEnabled {
return "", errors.New("systemd logs disabled")
}
unitName, ok := sm.monitoredUnitName(serviceName)
if !ok {
return "", fmt.Errorf("service %q is not monitored", serviceName)
}
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
output := limitedBuffer{limit: maxTotalLogSize}
cmd.Stdout = &output
stderr := limitedBuffer{limit: 1024}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, errSystemdLogLimitReached) {
return output.String(), nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("journalctl failed: %s", message)
}
return "", fmt.Errorf("journalctl failed: %w", err)
}
return output.String(), nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") {

View File

@@ -11,7 +11,6 @@ import (
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
logsEnabled bool
}
// newSystemdManager creates a new systemdManager.
@@ -37,7 +36,3 @@ func (sm *systemdManager) getFailedServiceCount() uint16 {
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}
func (sm *systemdManager) getServiceLogs(string) (string, error) {
return "", errors.New("systemd manager unavailable")
}

View File

@@ -3,14 +3,10 @@
package agent
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
@@ -36,127 +32,6 @@ func TestUnescapeServiceName(t *testing.T) {
}
}
func TestLimitedBuffer(t *testing.T) {
buffer := limitedBuffer{limit: 5}
n, err := buffer.Write([]byte("abcdef"))
assert.Equal(t, 5, n)
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
n, err = buffer.Write([]byte("g"))
assert.Zero(t, n)
assert.True(t, errors.Is(err, errSystemdLogLimitReached))
}
func TestLimitedBufferCapsExecOutput(t *testing.T) {
buffer := limitedBuffer{limit: 5}
cmd := exec.Command("sh", "-c", "printf 'abcdef'")
cmd.Stdout = &buffer
err := cmd.Run()
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
}
func TestServiceUnitName(t *testing.T) {
tests := map[string]string{
"nginx": "nginx.service",
"nginx.service": "nginx.service",
"backup.timer": "backup.timer",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, serviceUnitName(input))
})
}
}
func TestCanReadSystemJournal(t *testing.T) {
tests := []struct {
name string
script string
want bool
}{
{"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true},
{"empty", "#!/bin/sh\nexit 0\n", true},
{"denied", "#!/bin/sh\nexit 1\n", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.Equal(t, test.want, canReadSystemJournal())
})
}
}
func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) {
// Fake journalctl prints the unit it was asked for
dir := t.TempDir()
script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n"
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{
"nginx.service": {Name: "nginx"},
"backup.timer": {Name: "backup.timer"},
"foo\\x2dbar.service": {Name: "foo-bar"},
"getty@tty1.service": {Name: "getty@tty1"},
}}
tests := []struct {
name string
want string
}{
{"nginx", "nginx.service"},
{"nginx.service", "nginx.service"},
{"backup.timer", "backup.timer"},
{"foo-bar", "foo\\x2dbar.service"},
{"getty@tty1", "getty@tty1.service"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
logs, err := sm.getServiceLogs(test.name)
assert.NoError(t, err)
assert.Equal(t, test.want, logs)
})
}
for _, name := range []string{"sshd", "*", "*.service", "nginx*"} {
t.Run("rejects "+name, func(t *testing.T) {
logs, err := sm.getServiceLogs(name)
assert.Error(t, err)
assert.Empty(t, logs)
})
}
t.Run("disabled", func(t *testing.T) {
sm.logsEnabled = false
logs, err := sm.getServiceLogs("nginx")
assert.Error(t, err)
assert.Empty(t, logs)
})
}
func TestSystemdLogsEnabled(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.True(t, systemdLogsEnabled())
t.Setenv("SKIP_SYSTEMD_LOGS", "true")
assert.False(t, systemdLogsEnabled())
}
func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string
invalidInputs := []string{

View File

@@ -1,111 +0,0 @@
alternatives.aarch64 1.24-1.el9
audit-libs.aarch64 3.0.7-104.el9
basesystem.noarch 11-13.el9
bash.aarch64 5.1.8-6.el9_1
binutils.aarch64 2.35.2-42.el9
binutils-gold.aarch64 2.35.2-42.el9
bzip2-libs.aarch64 1.0.8-8.el9
ca-certificates.noarch 2023.2.60_v7.0.306-90.1.el9_2
coreutils-single.aarch64 8.32-34.el9
cracklib.aarch64 2.9.6-27.el9
cracklib-dicts.aarch64 2.9.6-27.el9
crypto-policies.noarch 20230731-1.git94f0e2c.el9_3.1
crypto-policies-scripts.noarch 20230731-1.git94f0e2c.el9_3.1
curl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
cyrus-sasl-lib.aarch64 2.1.27-21.el9
dnf.noarch 4.14.0-8.el9
dnf-data.noarch 4.14.0-8.el9
elfutils-debuginfod-client.aarch64 0.189-3.el9
elfutils-default-yama-scope.noarch 0.189-3.el9
elfutils-libelf.aarch64 0.189-3.el9
elfutils-libs.aarch64 0.189-3.el9
expat.aarch64 2.5.0-1.el9
file-libs.aarch64 5.39-14.el9
filesystem.aarch64 3.16-2.el9
findutils.aarch64 1:4.8.0-6.el9
gdbm-libs.aarch64 1:1.19-4.el9
glib2.aarch64 2.68.4-11.el9
glibc.aarch64 2.34-83.el9.7
glibc-common.aarch64 2.34-83.el9.7
glibc-minimal-langpack.aarch64 2.34-83.el9.7
gnupg2.aarch64 2.3.3-4.el9
gnutls.aarch64 3.7.6-23.el9
gzip.aarch64 1.12-1.el9
ima-evm-utils.aarch64 1.4-4.el9
krb5-libs.aarch64 1.21.1-1.el9
less.aarch64 590-2.el9_2
libacl.aarch64 2.3.1-3.el9
libarchive.aarch64 3.5.3-4.el9
libatomic.aarch64 11.4.1-2.1.el9
libattr.aarch64 2.5.1-3.el9
libblkid.aarch64 2.37.4-15.el9
libcap.aarch64 2.48-9.el9_2
libcom_err.aarch64 1.46.5-3.el9
libcurl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
libdb.aarch64 5.3.28-53.el9
libdnf.aarch64 0.69.0-6.el9_3
libeconf.aarch64 0.4.1-3.el9_2
libevent.aarch64 2.1.12-6.el9
libfdisk.aarch64 2.37.4-15.el9
libgcc.aarch64 11.4.1-2.1.el9
libgcrypt.aarch64 1.10.0-10.el9_2
libgomp.aarch64 11.4.1-2.1.el9
libksba.aarch64 1.5.1-6.el9_1
libmount.aarch64 2.37.4-15.el9
libnghttp2.aarch64 1.43.0-5.el9_3.1
librepo.aarch64 1.14.5-1.el9
libselinux.aarch64 3.5-1.el9
libsemanage.aarch64 3.5-2.el9
libsepol.aarch64 3.5-1.el9
libsmartcols.aarch64 2.37.4-15.el9
libsolv.aarch64 0.7.24-2.el9
libstdc++.aarch64 11.4.1-2.1.el9
libtasn1.aarch64 4.16.0-8.el9_1
libusbx.aarch64 1.0.26-1.el9
libuser.aarch64 0.63-13.el9
libuuid.aarch64 2.37.4-15.el9
libxml2.aarch64 2.9.13-4.el9
libzstd.aarch64 1.5.1-2.el9
mpfr.aarch64 4.1.0-7.el9
ncurses-base.noarch 6.2-10.20210508.el9
ncurses-libs.aarch64 6.2-10.20210508.el9
nettle.aarch64 3.8-3.el9_0
openldap.aarch64 2.6.3-1.el9
openssl.aarch64 1:3.0.7-24.el9
openssl-libs.aarch64 1:3.0.7-24.el9
p11-kit.aarch64 0.24.1-2.el9
p11-kit-trust.aarch64 0.24.1-2.el9
pam.aarch64 1.5.1-15.el9
pcre.aarch64 8.44-3.el9.3
pcre2.aarch64 10.40-2.el9
pcre2-syntax.noarch 10.40-2.el9
python3.aarch64 3.9.18-1.el9_3
python3-dnf.noarch 4.14.0-8.el9
python3-hawkey.aarch64 0.69.0-6.el9_3
python3-libdnf.aarch64 0.69.0-6.el9_3
python3-libs.aarch64 3.9.18-1.el9_3
python3-pip-wheel.noarch 21.2.3-7.el9
python3-rpm.aarch64 4.16.1.3-25.el9
python3-setuptools-wheel.noarch 53.0.0-12.el9
rocky-gpg-keys.noarch 9.3-1.1.el9
rocky-release.noarch 9.3-1.1.el9
rocky-repos.noarch 9.3-1.1.el9
rootfiles.noarch 8.1-31.el9
rpm.aarch64 4.16.1.3-25.el9
rpm-build-libs.aarch64 4.16.1.3-25.el9
rpm-libs.aarch64 4.16.1.3-25.el9
rpm-sign-libs.aarch64 4.16.1.3-25.el9
sed.aarch64 4.8-9.el9
setup.noarch 2.13.7-9.el9
shadow-utils.aarch64 2:4.9-8.el9
sqlite-libs.aarch64 3.34.1-6.el9_1
systemd-libs.aarch64 252-18.el9
tar.aarch64 2:1.34-6.el9_1
tpm2-tss.aarch64 3.2.2-2.el9
tzdata.noarch 2023c-1.el9
usermode.aarch64 1.114-4.el9
util-linux.aarch64 2.37.4-15.el9
util-linux-core.aarch64 2.37.4-15.el9
vim-minimal.aarch64 2:8.2.2637-20.el9_1
yum.noarch 4.14.0-8.el9
package nonexistent-pkg.x86_64 is not installed

View File

@@ -1,21 +0,0 @@
dnf5.aarch64 5.2.18.0-2.fc42
dnf5-plugins.aarch64 5.2.18.0-2.fc42
elfutils-default-yama-scope.noarch 0.194-1.fc42
elfutils-libelf.aarch64 0.194-1.fc42
elfutils-libs.aarch64 0.194-1.fc42
fedora-release-common.noarch 42-30
fedora-release-container.noarch 42-30
fedora-release-identity-container.noarch 42-30
glibc.aarch64 2.41-16.fc42
glibc-common.aarch64 2.41-16.fc42
glibc-minimal-langpack.aarch64 2.41-16.fc42
krb5-libs.aarch64 1.21.3-6.fc42
libdnf5.aarch64 5.2.18.0-2.fc42
libdnf5-cli.aarch64 5.2.18.0-2.fc42
libsolv.aarch64 0.7.36-2.fc42
openssl-libs.aarch64 1:3.2.6-3.fc42
rpm-sequoia.aarch64 1.10.1-1.fc42
tzdata.noarch 2025c-1.fc42
vim-data.noarch 2:9.2.280-1.fc42
vim-minimal.aarch64 2:9.2.280-1.fc42
package nonexistent-pkg.x86_64 is not installed

2
go.mod
View File

@@ -12,7 +12,7 @@ require (
github.com/lxzan/gws v1.10.2
github.com/mdlayher/genetlink v1.4.0
github.com/mdlayher/netlink v1.11.2
github.com/mdlayher/wifi v0.8.0
github.com/mdlayher/wifi v0.9.0
github.com/nicholas-fedor/shoutrrr v0.21.0
github.com/opencontainers/go-digest v1.0.0
github.com/pocketbase/dbx v1.12.0

4
go.sum
View File

@@ -89,8 +89,8 @@ github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4Yb
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mdlayher/wifi v0.8.0 h1:qi73hVANXCYJEsT6t147dMILsx9V6UBNipZw0mPYdu0=
github.com/mdlayher/wifi v0.8.0/go.mod h1:QHQ211ZKtZKSKssCznixGUOqBcoyBQAuQWSAOnanY4A=
github.com/mdlayher/wifi v0.9.0 h1:d5mmqw9S2U4f95dcW5wnnUagpI6GJh328/AchVSP4ko=
github.com/mdlayher/wifi v0.9.0/go.mod h1:Bfkrz+VncrVPaOLcFG/bR9tSY2PbmYNQicWvAZlUZlI=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=

View File

@@ -26,10 +26,6 @@ const (
GetZfsData
// Sync network monitor configuration to agent
SyncNetworkMonitors
// Request the list of pending package updates from agent
GetPackageUpdates
// Request recent logs for a systemd service from the agent.
GetSystemdLogs
// Add new actions here...
)
@@ -87,7 +83,3 @@ type ContainerInfoRequest struct {
type SystemdInfoRequest struct {
ServiceName string `cbor:"0,keyasint"`
}
type SystemdLogsRequest struct {
ServiceName string `cbor:"0,keyasint"`
}

View File

@@ -1,20 +0,0 @@
package system
// PackageUpdate is one pending package update on the host.
type PackageUpdate struct {
Name string `json:"name" cbor:"0,keyasint"`
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
Available string `json:"available" cbor:"2,keyasint"`
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
}
// PackageUpdates is the detail payload returned by the agent for the
// GetPackageUpdates action. The counts in Info.PackageUpdates come from the same check.
type PackageUpdates struct {
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
// SecurityKnown is true if the package manager flags security updates per package.
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
}

View File

@@ -194,7 +194,6 @@ type Info struct {
RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name)
PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown)
WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces
SystemdLogs bool `json:"jl,omitempty" cbor:"27,keyasint,omitempty"` // agent can read the system journal
}
// Data that does not change during process lifetime and is not needed in All Systems table

View File

@@ -15,7 +15,6 @@ import (
"github.com/blang/semver"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
systementity "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems"
@@ -203,10 +202,6 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// get recent logs for a systemd service
apiAuth.GET("/systemd/logs", h.getSystemdLogs)
// get pending package updates
apiAuth.GET("/package-updates", h.getPackageUpdates)
// /containers routes
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -450,61 +445,6 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// getSystemdLogs handles GET /api/beszel/systemd/logs requests.
func (h *Hub) getSystemdLogs(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.BadRequestError("Invalid system or service parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
// Only fetch logs for services that are currently monitored on this system.
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
"system": systemID,
"name": serviceName,
})
if err != nil {
return e.NotFoundError("", err)
}
// Old agents and agents without journal access do not advertise this capability.
systemRecord, err := e.App.FindRecordById("systems", systemID)
if err != nil {
return e.NotFoundError("", err)
}
var info systementity.Info
if err := systemRecord.UnmarshalJSONField("info", &info); err != nil || !info.SystemdLogs {
return e.JSON(http.StatusOK, map[string]string{"logs": ""})
}
logs, err := system.FetchSystemdLogsFromAgent(serviceName)
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, map[string]string{"logs": logs})
}
// getPackageUpdates handles GET /api/beszel/package-updates requests
func (h *Hub) getPackageUpdates(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.BadRequestError("Invalid system parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
updates, err := system.FetchPackageUpdatesFromAgent()
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, updates)
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {

View File

@@ -548,59 +548,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /package-updates route
{
Name: "GET /package-updates - no auth should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/package-updates",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/package-updates?system=invalid-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /package-updates - good user should pass validation",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /systemd routes
{
Name: "GET /systemd/info - no auth should fail",
@@ -684,25 +631,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
})
},
},
{
Name: "GET /systemd/logs - old agent without capability returns empty logs",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/logs?system=%s&service=nginx.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"logs":""`},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0,
"sub": 1,
})
},
},
// Auth Optional Routes - Should work without authentication
{

View File

@@ -3,17 +3,13 @@
package systems
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"net"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
@@ -95,120 +91,3 @@ func TestCreateSessionDuringClose(t *testing.T) {
wg.Wait()
}
}
// TestDialSSHHandshakeTimeout covers a peer that accepts the TCP connection but
// never sends an SSH banner. Without a handshake deadline the dial blocks the
// updater forever (GHSA-h9jh-29rh-w464).
func TestDialSSHHandshakeTimeout(t *testing.T) {
prev := sshHandshakeTimeout
sshHandshakeTimeout = 200 * time.Millisecond
t.Cleanup(func() { sshHandshakeTimeout = prev })
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
accepted := make(chan net.Conn, 1)
go func() {
conn, err := ln.Accept()
if err == nil {
accepted <- conn
}
}()
config := &ssh.ClientConfig{
User: "u",
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
done := make(chan error, 1)
go func() {
client, err := dialSSHWithKeepAlive("tcp", ln.Addr().String(), config)
if client != nil {
client.Close()
}
done <- err
}()
select {
case err := <-done:
assert.Error(t, err, "a silent peer must fail the handshake")
case <-time.After(5 * time.Second):
t.Fatal("dial blocked on a peer that never sends an SSH banner")
}
// the hub must close its side of the connection
conn := <-accepted
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = conn.Read(make([]byte, 256))
for err == nil {
_, err = conn.Read(make([]byte, 256))
}
var netErr net.Error
assert.False(t, errors.As(err, &netErr) && netErr.Timeout(), "hub should close the connection, got %v", err)
}
// TestDialSSHClearsHandshakeDeadline ensures the handshake deadline does not
// carry over to the established connection, which is reused for many updates.
func TestDialSSHClearsHandshakeDeadline(t *testing.T) {
prev := sshHandshakeTimeout
sshHandshakeTimeout = 200 * time.Millisecond
t.Cleanup(func() { sshHandshakeTimeout = prev })
_, hostPriv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
hostSigner, err := ssh.NewSignerFromKey(hostPriv)
require.NoError(t, err)
_, clientPriv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
clientSigner, err := ssh.NewSignerFromKey(clientPriv)
require.NoError(t, err)
serverConfig := &ssh.ServerConfig{
PublicKeyCallback: func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return nil, nil },
}
serverConfig.AddHostKey(hostSigner)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
_, chans, reqs, err := ssh.NewServerConn(conn, serverConfig)
if err != nil {
return
}
go ssh.DiscardRequests(reqs)
for newChan := range chans {
ch, chReqs, err := newChan.Accept()
if err != nil {
continue
}
go ssh.DiscardRequests(chReqs)
ch.Close()
}
}()
config := &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{ssh.PublicKeys(clientSigner)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
client, err := dialSSHWithKeepAlive("tcp", ln.Addr().String(), config)
require.NoError(t, err)
defer client.Close()
// wait past the handshake deadline; the connection must still be usable
time.Sleep(3 * sshHandshakeTimeout)
session, err := client.NewSession()
require.NoError(t, err, "connection should outlive the handshake deadline")
session.Close()
}

View File

@@ -777,15 +777,6 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return result, err
}
// FetchSystemdLogsFromAgent fetches recent journal entries for a systemd service from the agent.
func (sys *System) FetchSystemdLogsFromAgent(serviceName string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetSystemdLogs, common.SystemdLogsRequest{ServiceName: serviceName}, &result)
return result, err
}
// FetchSmartDataFromAgent fetches SMART data from the agent.
func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -800,15 +791,6 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
return result, err
}
// FetchPackageUpdatesFromAgent fetches the list of pending package updates from the agent.
func (sys *System) FetchPackageUpdatesFromAgent() (system.PackageUpdates, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result system.PackageUpdates
err := sys.request(ctx, common.GetPackageUpdates, nil, &result)
return result, err
}
// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -1001,12 +983,6 @@ func (s *System) createSSHClient() error {
// per-operation timeout in runSSHOperation instead (see issue #2041).
const sshKeepAliveInterval = 30 * time.Second
// sshHandshakeTimeout bounds the SSH handshake after the TCP connection is
// established. ssh.ClientConfig.Timeout only covers the TCP connect, so a peer
// that accepts the connection but never sends an SSH banner would otherwise
// block the updater forever.
var sshHandshakeTimeout = 10 * time.Second
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
// keep-alive on the underlying connection so half-open connections are
// eventually detected by the operating system.
@@ -1019,14 +995,11 @@ func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.
if err != nil {
return nil, err
}
_ = conn.SetDeadline(time.Now().Add(sshHandshakeTimeout))
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, err
}
// clear the handshake deadline so it doesn't apply to the long-lived connection
_ = conn.SetDeadline(time.Time{})
return ssh.NewClient(sshConn, chans, reqs), nil
}

View File

@@ -115,16 +115,6 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA
}
*d = resp.ServiceInfo
return nil
case common.GetSystemdLogs:
d, ok := dest.(*string)
if !ok {
return fmt.Errorf("unexpected dest type for GetSystemdLogs: %T", dest)
}
if resp.String == nil {
return errors.New("no systemd logs in response")
}
*d = *resp.String
return nil
}
return fmt.Errorf("unsupported action: %d", action)
}

View File

@@ -31,12 +31,3 @@ func TestUnmarshalSmartDataResponse(t *testing.T) {
})
}
}
func TestUnmarshalSystemdLogsResponse(t *testing.T) {
logs := "2026-09-27T12:00:00+00:00 host nginx[1]: started"
response := common.AgentResponse{String: &logs}
var result string
require.NoError(t, UnmarshalResponse(response, common.GetSystemdLogs, &result))
assert.Equal(t, logs, result)
}

View File

@@ -4,55 +4,17 @@ import type { AlertRecord } from "@/types"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { useMemo, useState } from "react"
import { AlertBannerSheet, AlertBannerSheetItem } from "./alert-banner-sheet"
import { $router } from "./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"
function AlertTriggeredDesc({ alert }: { alert: AlertRecord }) {
const info = alertInfo[alert.name as keyof typeof alertInfo]
if (info.triggeredDesc) {
return info.triggeredDesc()
}
if (alert.name === "NetworkMonitorLoss") {
return <Trans>One or more monitors exceed {alert.value}% loss</Trans>
}
if (alert.name === "Status") {
return <Trans>Connection is down</Trans>
}
if (info.invert) {
return (
<Trans>
Below {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)
}
return (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)
}
function AlertLabel({ alert, systemName }: { alert: AlertRecord; systemName?: string }) {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<>
{systemName} <span className="opacity-60 font-normal">·</span> {info.name()}
</>
)
}
/** Banner showing the number of triggered alerts, with a sheet listing them. */
export const ActiveAlerts = ({ className }: { className?: string }) => {
export const ActiveAlerts = () => {
const alerts = useStore($alerts)
const systems = useStore($allSystemsById)
const [open, setOpen] = useState(false)
const { activeAlerts, systemCount, alertsKey } = useMemo(() => {
const { activeAlerts, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = []
const systemIds = new Set<string>()
// key to prevent re-rendering if alerts change but active alerts didn't
const alertsKey: string[] = []
@@ -60,62 +22,73 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
for (const alert of alerts[systemId].values()) {
if (alert.triggered && alert.name in alertInfo) {
activeAlerts.push(alert)
systemIds.add(alert.system)
alertsKey.push(`${alert.id}${alert.value}${alert.min}`)
}
}
}
return { activeAlerts, systemCount: systemIds.size, alertsKey: alertsKey.join("") }
return { activeAlerts, alertsKey }
}, [alerts])
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
return useMemo(() => {
const alertCount = activeAlerts.length
if (alertCount === 0) {
if (activeAlerts.length === 0) {
return null
}
// name the alert directly in the banner when there is only one
const [firstAlert] = activeAlerts
return (
<AlertBannerSheet
open={open}
onOpenChange={setOpen}
className={className}
title={
alertCount === 1 ? (
<AlertLabel alert={firstAlert} systemName={systems[firstAlert.system]?.name} />
) : (
<Plural value={alertCount} one="# active alert" other="# active alerts" />
)
}
description={
alertCount === 1 ? (
<AlertTriggeredDesc alert={firstAlert} />
) : (
<Plural value={systemCount} one="Across # system" other="Across # systems" />
)
}
buttonLabel={<Trans>View alerts</Trans>}
sheetTitle={<Trans>Active Alerts</Trans>}
sheetDescription={
<Plural value={alertCount} one="# alert is currently triggered" other="# alerts are currently triggered" />
}
>
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
const system = systems[alert.system]
return (
<AlertBannerSheetItem
key={alert.id}
href={getPagePath($router, "system", { id: system?.id })}
onClick={() => setOpen(false)}
icon={info.icon}
title={<AlertLabel alert={alert} systemName={system?.name} />}
description={<AlertTriggeredDesc alert={alert} />}
/>
)
})}
</AlertBannerSheet>
<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()}
</AlertTitle>
<AlertDescription>
{info.triggeredDesc ? (
info.triggeredDesc()
) : alert.name === "NetworkMonitorLoss" ? (
<Trans>One or more monitors exceed {alert.value}% loss</Trans>
) : alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : info.invert ? (
<Trans>
Below {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</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, systemCount, systems, open, className])
}, [alertsKey.join("")])
}

View File

@@ -1,101 +0,0 @@
import { Trans } from "@lingui/react/macro"
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { Link } from "./router"
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"
import { Button } from "./ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"
/**
* Destructive alert banner with a button that opens a sheet containing details.
* Content agnostic so it can be reused for system alerts, network monitors, etc.
*/
export function AlertBannerSheet({
open,
onOpenChange,
title,
description,
buttonLabel,
sheetTitle,
sheetDescription,
icon: Icon = TriangleAlertIcon,
className,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
description?: ReactNode
buttonLabel?: ReactNode
sheetTitle: ReactNode
sheetDescription?: ReactNode
icon?: LucideIcon
className?: string
children: ReactNode
}) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Alert variant="destructive" className={cn("flex items-center gap-3 py-3 max-sm:flex-wrap", className)}>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icon className="size-5 shrink-0" />
<div className="min-w-0">
<AlertTitle className="m-0">{title}</AlertTitle>
{description && (
<AlertDescription className="text-destructive/80 dark:text-red-500/80">{description}</AlertDescription>
)}
</div>
</div>
<SheetTrigger asChild>
<Button
variant="outline"
size="sm"
className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-500 dark:hover:bg-destructive/15 dark:hover:text-red-500 max-sm:w-full"
>
{buttonLabel ?? <Trans>View details</Trans>}
<ChevronRightIcon className="size-4 ms-1 -me-1" />
</Button>
</SheetTrigger>
</Alert>
<SheetContent className="w-140 !max-w-full gap-0">
<SheetHeader className="p-4 sm:p-6 pb-3 sm:pb-4 border-b">
<SheetTitle>{sheetTitle}</SheetTitle>
{sheetDescription && <SheetDescription>{sheetDescription}</SheetDescription>}
</SheetHeader>
<div className="flex-1 overflow-auto p-4 sm:p-6 pt-3 sm:pt-4 flex flex-col gap-2.5">{children}</div>
</SheetContent>
</Sheet>
)
}
/** Clickable row for use inside AlertBannerSheet. */
export function AlertBannerSheetItem({
href,
onClick,
icon: Icon,
title,
description,
}: {
href: string
onClick?: () => void
icon: LucideIcon | React.FC<{ className?: string }>
title: ReactNode
description?: ReactNode
}) {
return (
<Link
href={href}
onClick={onClick}
className="group flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-accent/60"
>
<div className="rounded-md bg-destructive/10 p-2 text-destructive shrink-0">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium leading-tight truncate">{title}</div>
{description && <div className="text-sm text-muted-foreground mt-1">{description}</div>}
</div>
<ChevronRightIcon className="size-4 self-center shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
)
}

View File

@@ -6,7 +6,6 @@ import {
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
fixedDomainTicks,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
@@ -144,7 +143,6 @@ export default function AreaChartDefault({
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
ticks={fixedDomainTicks(domain ?? [0, max ?? "auto"])}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}

View File

@@ -6,7 +6,6 @@ import {
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
fixedDomainTicks,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
@@ -23,34 +22,12 @@ export type DataPoint<T = SystemStatsRecord> = {
order?: number
strokeOpacity?: number
activeDot?: boolean
dot?: boolean | typeof isolatedDot
dot?: boolean
/** Which Y axis this series plots against. Defaults to "left". */
yAxisId?: "left" | "right"
strokeDasharray?: string
}
type IsolatedDotProps = {
key: string
cx: number
cy: number
stroke: string
index: number
points: { value: unknown }[]
}
const hasValue = (point?: { value: unknown }) => typeof point?.value === "number"
/**
* Dot renderer that only draws points with no value on either side. Without connectNulls
* those points have no line segment, so they would otherwise only be visible on hover.
*/
export function isolatedDot({ key, cx, cy, stroke, index, points }: IsolatedDotProps) {
if (!hasValue(points[index]) || hasValue(points[index - 1]) || hasValue(points[index + 1])) {
return <g key={key} />
}
return <circle key={key} cx={cx} cy={cy} r={2} fill={stroke} />
}
export default function LineChartDefault({
chartData,
customData,
@@ -187,7 +164,6 @@ export default function LineChartDefault({
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
ticks={fixedDomainTicks(domain ?? [0, max ?? "auto"])}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
@@ -201,7 +177,6 @@ export default function LineChartDefault({
className="tracking-tighter"
width={rightAxisWidth}
domain={domain2 ?? [0, max2 ?? "auto"]}
ticks={fixedDomainTicks(domain2 ?? [0, max2 ?? "auto"])}
tickFormatter={tickFormatter2 ?? tickFormatter}
tickLine={false}
axisLine={false}

View File

@@ -32,7 +32,6 @@ import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { listenKeys } from "nanostores"
import { getPagePath } from "@nanostores/router"
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
const syntaxTheme = "github-dark-dimmed"
@@ -373,7 +372,7 @@ function ContainerSheet({
open={logsFullscreenOpen}
onOpenChange={setLogsFullscreenOpen}
logsDisplay={logsDisplay}
name={container.name}
containerName={container.name}
onRefresh={refreshLogs}
isRefreshing={isRefreshingLogs}
/>
@@ -425,7 +424,15 @@ function ContainerSheet({
<MaximizeIcon className="size-4" />
</Button>
</div>
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} />
<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
@@ -501,6 +508,58 @@ const ContainerTableRow = memo(function ContainerTableRow({
)
})
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={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,

View File

@@ -1,76 +0,0 @@
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */
import { t } from "@lingui/core/macro"
import { RefreshCwIcon } from "lucide-react"
import { type RefObject, useEffect, useRef } from "react"
import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
type LogsDisplayProps = {
logsDisplay: string
containerRef: RefObject<HTMLDivElement | null>
}
// Shared by Docker and systemd service sheets so logs behave identically.
export function LogsDisplay({ logsDisplay, containerRef }: LogsDisplayProps) {
return (
<div
ref={containerRef}
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>
)
}
export function LogsFullscreenDialog({
open,
onOpenChange,
logsDisplay,
name,
onRefresh,
isRefreshing,
}: {
open: boolean
onOpenChange: (open: boolean) => void
logsDisplay: string
name: string
onRefresh: () => void | Promise<void>
isRefreshing: boolean
}) {
const outerContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open && logsDisplay) {
setTimeout(() => {
if (outerContainerRef.current) {
outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight
}
}, 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">{name} logs</DialogTitle>
<div ref={outerContainerRef} className="absolute inset-x-0 bottom-0 top-12 overflow-auto">
<div className="min-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={onRefresh}
className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)}
disabled={isRefreshing}
title={t`Refresh`}
aria-label={t`Refresh`}
>
<RefreshCwIcon className={cn("size-4 transition-transform duration-300", isRefreshing && "animate-spin")} />
</button>
</DialogContent>
</Dialog>
)
}

View File

@@ -706,7 +706,6 @@ function NetworkMonitorSheetContent({
const monitorStats = useNetworkMonitorStats({
systemId: monitor.system,
monitorId: monitor.id,
interval: monitor.interval,
chartTime,
enabled: open,
})

View File

@@ -1,6 +1,7 @@
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(() => {
@@ -13,7 +14,10 @@ export default memo(() => {
return useMemo(
() => (
<>
<ContainersTable />
<div className="grid gap-4">
<ActiveAlerts />
<ContainersTable />
</div>
<FooterRepoLink />
</>
),

View File

@@ -1,6 +1,7 @@
import { useLingui } from "@lingui/react/macro"
import { memo, Suspense, useEffect, useMemo } from "react"
import SystemsTable from "@/components/systems-table/systems-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default memo(() => {
@@ -13,9 +14,12 @@ export default memo(() => {
return useMemo(
() => (
<>
<Suspense>
<SystemsTable />
</Suspense>
<div className="flex flex-col gap-4">
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
</div>
<FooterRepoLink />
</>
),

View File

@@ -1,6 +1,7 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect } from "react"
import NetworkMonitorsTableNew from "@/components/network-monitors-table/network-monitors-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { useNetworkMonitors } from "@/lib/use-network-monitors"
import { $allSystemsById } from "@/lib/stores"
@@ -22,7 +23,10 @@ export default memo(() => {
return (
<>
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
<div className="grid gap-4">
<ActiveAlerts />
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
</div>
<FooterRepoLink />
</>
)

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react"
import SmartTable from "@/components/routes/system/smart-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
export default function Smart() {
@@ -9,7 +10,10 @@ export default function Smart() {
return (
<>
<SmartTable />
<div className="grid gap-4">
<ActiveAlerts />
<SmartTable />
</div>
<FooterRepoLink />
</>
)

View File

@@ -1,7 +1,6 @@
import { memo, useState } from "react"
import { Trans } from "@lingui/react/macro"
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
import { SystemStatus } from "@/lib/enums"
import type { GPUData } from "@/types"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import InfoBar from "./system/info-bar"
@@ -17,13 +16,12 @@ import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
import {
LazyContainersTable,
LazyNetworkMonitorsTable,
LazyPackageUpdatesTable,
LazySmartTable,
LazySystemdTable,
LazyZfsTable,
} from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, PackageIcon, TerminalSquareIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
import SystemdTable from "../systemd-table/systemd-table"
import ContainersTable from "../containers-table/containers-table"
@@ -75,15 +73,12 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpu = hasGpuData || hasGpuPowerData
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
const hasNetworkMonitors = supportsNetworkMonitors(system)
// counts key the table so it refetches the list only after a new check
const packageUpdates = system.status === SystemStatus.Up && system.info.pu?.[0] ? system.info.pu.join(",") : ""
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "network", "disk"]
if (hasGpu) tabs.push("gpu")
if (hasContainers) tabs.push("containers")
if (hasSystemd) tabs.push("services")
if (packageUpdates) tabs.push("updates")
tabsRef.current = tabs
// shared chart props
@@ -168,8 +163,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasSystemd && <LazySystemdTable systemId={system.id} />}
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)
@@ -209,12 +202,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<Trans>Services</Trans>
</TabsTrigger>
)}
{packageUpdates && (
<TabsTrigger value="updates" className="w-full flex items-center gap-2">
<PackageIcon className="size-3.5" />
<Trans>Updates</Trans>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
@@ -308,12 +295,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{mountedTabs.has("services") && <SystemdTable systemId={system.id} />}
</TabsContent>
)}
{packageUpdates && (
<TabsContent value="updates" forceMount className={activeTab === "updates" ? "contents" : "hidden"}>
{mountedTabs.has("updates") && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
</TabsContent>
)}
</Tabs>
)
}

View File

@@ -4,6 +4,7 @@ import { useContainerDataPoints } from "@/components/charts/hooks"
import { decimalString, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData } from "@/types"
import { pinnedAxisDomain } from "@/components/ui/chart"
import CpuCoresSheet from "../cpu-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
@@ -51,6 +52,7 @@ export function CpuChart({
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
/>
</ChartCard>
)
@@ -85,6 +87,7 @@ export function ContainerCpuChart({
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}

View File

@@ -4,6 +4,7 @@ import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { SystemStatsRecord } from "@/types"
import { ChartCard, SelectAvgMax } from "../chart-card"
import { Unit } from "@/lib/enums"
import { pinnedAxisDomain } from "@/components/ui/chart"
import DiskIoSheet from "../disk-io-sheet"
import type { SystemData } from "../use-system-data"
import { useStore } from "@nanostores/react"
@@ -193,6 +194,7 @@ export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemDat
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
// domain={pinnedAxisDomain(true)}
showTotal={true}
dataPoints={[
{
@@ -248,6 +250,7 @@ export function DiskUtilizationChart({ systemData, extraFsName }: { systemData:
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
maxToggled={showMax}

View File

@@ -7,6 +7,7 @@ import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { pinnedAxisDomain } from "@/components/ui/chart"
export function MemoryChart({
chartData,
@@ -113,6 +114,7 @@ export function ContainerMemoryChart({
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}

View File

@@ -1,5 +1,5 @@
import { getMonitorTarget, monitorGapRecord } from "@/lib/network-monitor-utils"
import LineChartDefault, { isolatedDot } from "@/components/charts/line-chart"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-chart"
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
import { $monitorFilter } from "@/lib/stores"
@@ -77,14 +77,10 @@ function MonitorChart({
return { dataPoints: points, visibleKeys: visibleIDs }
}, [monitors, filter, metric, chartData.chartTime, color])
// Monitors with different intervals don't share timestamps, so multiple lines need connectNulls.
// A single monitor's stats already contain empty records at real gaps, so the line breaks there.
const multipleMonitors = visibleKeys.length > 1
const filteredMonitorStats = useMemo(() => {
if (!multipleMonitors) return monitorStats
if (!visibleKeys.length) return monitorStats
return monitorStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [monitorStats, visibleKeys, multipleMonitors])
}, [monitorStats, visibleKeys])
const legend = dataPoints.length < 10 && showFilter
@@ -103,7 +99,7 @@ function MonitorChart({
customData={filteredMonitorStats}
dataPoints={dataPoints}
domain={domain ?? ["auto", "auto"]}
connectNulls={multipleMonitors}
connectNulls
tickFormatter={tickFormatter}
contentFormatter={contentFormatter}
legend={legend}
@@ -129,10 +125,9 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
// only one monitor is relevant for this chart
const dataPoints: DataPoint<NetworkMonitorStatsRecord>[] = useMemo(() => {
const dataFn = (metric: keyof MonitorStats) => (record: NetworkMonitorStatsRecord) =>
record.stats?.[monitor?.id ?? ""]?.[metric] ?? null
record.stats?.[monitor?.id ?? ""]?.[metric] ?? "-"
const avgPoint = {
label: "Avg",
dot: isolatedDot,
dataKey: dataFn("res_avg"),
color: 1,
order: 0,
@@ -144,7 +139,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
return [
{
label: "Max",
dot: isolatedDot,
dataKey: dataFn("res_max"),
color: 3,
order: 0,
@@ -152,7 +146,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
avgPoint,
{
label: "Min",
dot: isolatedDot,
dataKey: dataFn("res_min"),
color: 2,
order: 2,
@@ -160,14 +153,10 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
]
}, [chartTime, hasLongInterval, monitor?.id])
// Replace records where every probe failed with gap markers, so the line breaks there without
// leaving points that have no response time for the tooltip to show.
const data = useMemo(() => {
const id = monitor?.id ?? ""
return monitorStats.map((record) =>
record.stats?.[id] && record.stats[id].res_avg == null ? monitorGapRecord : record
)
}, [monitorStats, monitor?.id])
if (!monitor) return []
return monitorStats.filter((record) => record.stats && monitor.id in record.stats)
}, [monitor, monitorStats])
const legend = dataPoints.length > 1
@@ -185,6 +174,7 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
customData={data}
dataPoints={dataPoints}
domain={["auto", "auto"]}
connectNulls
legend={legend}
tickFormatter={(value) => formatMicroseconds(value, false)}
contentFormatter={({ value }) => {

View File

@@ -5,6 +5,7 @@ import { useContainerDataPoints } from "@/components/charts/hooks"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { Separator } from "@/components/ui/separator"
import NetworkSheet from "../network-sheet"
@@ -170,6 +171,7 @@ export function ContainerNetworkChart({
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={contentFormatter}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}

View File

@@ -195,7 +195,7 @@ export default memo(function CpuCoresSheet({
opacity: 0.35,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>

View File

@@ -12,6 +12,7 @@ import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartCard, SelectAvgMax } from "@/components/routes/system/chart-card"
import type { SystemData } from "@/components/routes/system/use-system-data"
import { diskDataFns, DiskUtilizationChart } from "./charts/disk-charts"
import { pinnedAxisDomain } from "@/components/ui/chart"
export default memo(function DiskIOSheet({
systemData,
@@ -128,6 +129,7 @@ export default memo(function DiskIOSheet({
maxToggled={showMax}
chartProps={chartProps}
showTotal={true}
domain={pinnedAxisDomain()}
itemSorter={(a, b) => a.order - b.order}
reverseStackOrder={true}
dataPoints={[
@@ -174,6 +176,7 @@ export default memo(function DiskIOSheet({
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
maxToggled={showMax}
@@ -213,6 +216,7 @@ export default memo(function DiskIOSheet({
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}`}
contentFormatter={({ value }) => decimalString(value, value < 10 ? 3 : 2)}
maxToggled={showMax}
@@ -244,6 +248,7 @@ export default memo(function DiskIOSheet({
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)} ms`}
contentFormatter={({ value }) => `${decimalString(value)} ms`}
maxToggled={showMax}

View File

@@ -47,17 +47,6 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
)
}
const PackageUpdatesTable = lazy(() => import("./package-updates-table"))
export function LazyPackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <PackageUpdatesTable systemId={systemId} counts={counts} />}
</div>
)
}
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {

View File

@@ -1,307 +0,0 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type Column,
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table"
import {
ArrowUpDownIcon,
GitCompareArrowsIcon,
PackageCheckIcon,
PackageIcon,
PackageOpenIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Badge, type BadgeProps } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import { classifyVersionChange, type VersionChange } from "@/lib/package-updates"
import { cn, formatShortDate } from "@/lib/utils"
import type { PackageUpdate, PackageUpdates } from "@/types"
interface PackageUpdateRow extends PackageUpdate {
change: VersionChange
}
/** Sort order of version changes, so ascending puts major first. */
const changeRank: Record<VersionChange, number> = { major: 0, minor: 1, patch: 2, revision: 3, other: 4 }
const changeVariant: Record<VersionChange, BadgeProps["variant"]> = {
major: "danger",
minor: "warning",
patch: "success",
revision: "secondary",
other: "outline",
}
function changeLabel(change: VersionChange) {
switch (change) {
case "major":
return t({ message: "Major", context: "Version change" })
case "minor":
return t({ message: "Minor", context: "Version change" })
case "patch":
return t({ message: "Patch", context: "Version change" })
case "revision":
return t({ message: "Revision", context: "Version change" })
default:
return t({ message: "Other", context: "Version change" })
}
}
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<PackageUpdateRow>
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 className="size-4" />
{name}
<ArrowUpDownIcon className="size-4" />
</Button>
)
}
function getColumns(securityKnown: boolean): ColumnDef<PackageUpdateRow>[] {
const columns: ColumnDef<PackageUpdateRow>[] = [
{
id: "name",
accessorFn: (pkg) => pkg.name,
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Package`} Icon={PackageIcon} />,
cell: ({ getValue }) => <span className="ms-1.5 block">{getValue() as string}</span>,
},
{
id: "current",
accessorFn: (pkg) => pkg.current ?? "",
enableSorting: false,
header: () => (
<span className="flex items-center gap-2 px-3">
<PackageCheckIcon className="size-4" />
<Trans context="Installed package version">Current</Trans>
</span>
),
cell: ({ getValue }) => (
<span className="ms-1.5 block font-mono text-sm text-muted-foreground">{(getValue() as string) || "-"}</span>
),
},
{
id: "available",
accessorFn: (pkg) => pkg.available,
enableSorting: false,
header: () => (
<span className="flex items-center gap-2 px-3">
<PackageOpenIcon className="size-4" />
<Trans context="Package version available to install">Available</Trans>
</span>
),
cell: ({ getValue }) => <span className="ms-1.5 block font-mono text-sm">{getValue() as string}</span>,
},
{
id: "change",
accessorFn: (pkg) => changeRank[pkg.change],
header: ({ column }) => <HeaderButton column={column} name={t`Change`} Icon={GitCompareArrowsIcon} />,
cell: ({ row }) => (
<Badge variant={changeVariant[row.original.change]} className="ms-1.5">
{changeLabel(row.original.change)}
</Badge>
),
},
]
if (securityKnown) {
columns.push({
id: "security",
accessorFn: (pkg) => (pkg.security ? 1 : 0),
header: ({ column }) => <HeaderButton column={column} name={t`Security`} Icon={ShieldAlertIcon} />,
cell: ({ row }) =>
row.original.security ? (
<span className="ms-1.5 flex items-center gap-1.5 text-red-600 dark:text-red-400">
<ShieldAlertIcon className="size-4" />
<Trans>Security</Trans>
</span>
) : null,
})
}
return columns
}
/**
* Lists pending package updates reported by the agent. The agent caches the result of
* its background check, so this refetches only when the update counts change.
*/
export default function PackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
const [data, setData] = useState<PackageUpdates | null>(null)
const [error, setError] = useState<string | null>(null)
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
let cancelled = false
pb.send<PackageUpdates>("/api/beszel/package-updates", { query: { system: systemId } })
.then((result) => {
if (cancelled) return
setData(result)
setError(null)
})
.catch((err) => {
if (cancelled) return
setError(err?.message || t`Failed to load package updates`)
})
return () => {
cancelled = true
}
}, [systemId, counts])
const rows = useMemo<PackageUpdateRow[]>(
() => (data?.packages ?? []).map((pkg) => ({ ...pkg, change: classifyVersionChange(pkg.current, pkg.available) })),
[data]
)
const securityKnown = !!data?.securityKnown
const columns = useMemo(() => getColumns(securityKnown), [securityKnown])
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
state: { sorting, globalFilter },
globalFilterFn: (row, _columnId, filterValue: string) => {
const pkg = row.original
const searchString = `${pkg.name} ${pkg.current ?? ""} ${pkg.available} ${changeLabel(pkg.change)}`.toLowerCase()
return filterValue
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
if (!data && !error) {
return null
}
const securityCount = rows.filter((pkg) => pkg.security).length
const tableRows = table.getRowModel().rows
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Package Updates</Trans>
</CardTitle>
<CardDescription className="flex items-center flex-wrap">
{data?.manager && (
<>
<span className="font-mono">{data.manager}</span>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
</>
)}
<Trans>Total: {rows.length}</Trans>
{securityKnown && (
<>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Security: {securityCount}</Trans>
</>
)}
{!!data?.checkedAt && (
<>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Checked {formatShortDate(new Date(data.checkedAt * 1000).toISOString())}</Trans>
</>
)}
</CardDescription>
</div>
{rows.length > 0 && (
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
className="px-4 w-full max-w-full md:w-64"
/>
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
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>
{error ? (
<p className="px-2 sm:px-1 text-sm text-muted-foreground">{error}</p>
) : (
<div className="h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md">
<table className="text-sm w-full text-nowrap">
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</tr>
))}
</TableHeader>
<TableBody>
{tableRows.length ? (
tableRows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center pointer-events-none">
{rows.length ? <Trans>No results.</Trans> : <Trans>Up to date</Trans>}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
)}
</Card>
)
}

View File

@@ -13,16 +13,14 @@ import {
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon } from "lucide-react"
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, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
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"
@@ -31,19 +29,6 @@ import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
import { Separator } from "../ui/separator"
const syntaxTheme = "github-dark-dimmed"
async function getSystemdLogsHtml(systemId: string, serviceName: string): Promise<string> {
const [{ highlighter }, { logs }] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ logs: string }>("/api/beszel/systemd/logs", {
requestKey: null,
query: { system: systemId, service: serviceName },
}),
])
return logs ? highlighter.codeToHtml(logs, { lang: "log", theme: syntaxTheme }) : ""
}
export default function SystemdTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<SystemdRecord[]>([])
@@ -214,10 +199,8 @@ const AllSystemdTable = memo(function AllSystemdTable({
const scrollRef = useRef<HTMLDivElement>(null)
const activeService = useRef<SystemdRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetSession, setSheetSession] = useState(0)
const openSheet = (service: SystemdRecord) => {
activeService.current = service
setSheetSession((session) => session + 1)
setSheetOpen(true)
}
@@ -262,7 +245,6 @@ const AllSystemdTable = memo(function AllSystemdTable({
</table>
</div>
<SystemdSheet
key={sheetSession}
sheetOpen={sheetOpen}
setSheetOpen={setSheetOpen}
activeService={activeService}
@@ -284,23 +266,9 @@ function SystemdSheet({
systemId?: string
}) {
const service = activeService.current
const targetSystemId = systemId ?? service?.system
const canReadLogs = !!targetSystemId && !!$allSystemsById.get()[targetSystemId]?.info?.jl
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState("")
const [logsStatus, setLogsStatus] = useState<"loading" | "ready" | "empty" | "error">("loading")
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false)
const logsContainerRef = useRef<HTMLDivElement>(null)
const logsRequestId = useRef(0)
const scrollLogsToBottom = () => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
}
}
useEffect(() => {
if (!sheetOpen || !service) {
@@ -315,7 +283,7 @@ function SystemdSheet({
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
query: {
system: targetSystemId,
system: systemId,
service: service.name,
},
})
@@ -342,46 +310,7 @@ function SystemdSheet({
return () => {
cancelled = true
}
}, [sheetOpen, service, targetSystemId])
const loadLogs = async () => {
if (!service || !targetSystemId || !canReadLogs) return
const requestId = ++logsRequestId.current
setLogsStatus(logs ? "ready" : "loading")
setIsLoadingLogs(true)
try {
const logs = await getSystemdLogsHtml(targetSystemId, service.name)
if (requestId !== logsRequestId.current) return
setLogs(logs)
setLogsStatus(logs ? "ready" : "empty")
} catch (err) {
if (requestId !== logsRequestId.current) return
console.error(err)
setLogsStatus("error")
} finally {
if (requestId === logsRequestId.current) setIsLoadingLogs(false)
}
}
useEffect(() => {
if (sheetOpen && canReadLogs) {
loadLogs()
}
return () => {
logsRequestId.current++
}
}, [sheetOpen, service, targetSystemId, canReadLogs])
useEffect(() => {
if (!sheetOpen) setLogsFullscreenOpen(false)
}, [sheetOpen])
useEffect(() => {
if (logs) {
setTimeout(scrollLogsToBottom, 20)
}
}, [logs])
}, [sheetOpen, service, systemId])
if (!service) return null
@@ -504,75 +433,19 @@ function SystemdSheet({
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<LogsFullscreenDialog
open={logsFullscreenOpen && !!logs}
onOpenChange={setLogsFullscreenOpen}
logsDisplay={logs}
name={service.name}
onRefresh={loadLogs}
isRefreshing={isLoadingLogs}
/>
<SheetContent className="w-full min-w-0 sm:max-w-220 p-6 overflow-y-auto">
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
<SheetHeader className="p-0">
<SheetTitle>
<Trans>Service Details</Trans>
</SheetTitle>
<SheetDescription className="sr-only">{service.name}</SheetDescription>
</SheetHeader>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-6">
{canReadLogs && (
<div className="min-w-0">
<div className="flex items-center mb-3">
<h3 className="text-sm font-medium">
<Trans>Logs</Trans>
</h3>
<Button
variant="ghost"
size="sm"
onClick={loadLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isLoadingLogs}
aria-label={t`Refresh`}
>
<RefreshCwIcon
className={cn("size-4 transition-transform duration-300", isLoadingLogs && "animate-spin")}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
aria-label={t`Logs`}
disabled={!logs}
>
<MaximizeIcon className="size-4" />
</Button>
</div>
{logs ? (
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} />
) : logsStatus === "loading" ? (
<>
<div className="h-28" aria-busy="true">
<LogsDisplay logsDisplay="" containerRef={logsContainerRef} />
</div>
<output className="sr-only">
<Trans>Loading...</Trans>
</output>
</>
) : (
<output className="flex min-h-28 items-center justify-center rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
{logsStatus === "error" ? <Trans>Failed to load logs.</Trans> : <Trans>No logs found.</Trans>}
</output>
)}
{logs && logsStatus === "error" && (
<output className="mt-2 block text-sm text-destructive">
<Trans>Failed to load logs.</Trans>
</output>
)}
<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>
@@ -581,12 +454,6 @@ function SystemdSheet({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
<Trans>Loading...</Trans>
</div>
)}
<div>
<div className="border rounded-md">

View File

@@ -524,11 +524,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
return null
}
const system = info.row.original
let color = "text-red-500"
if (system.status === SystemStatus.Up) {
color = version === globalThis.BESZEL.HUB_VERSION ? "text-green-500" : "text-yellow-500"
} else if (system.status === SystemStatus.Paused) {
color = "text-primary/40"
const color = {
"text-green-500": version === globalThis.BESZEL.HUB_VERSION,
"text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION,
"text-red-500": system.status !== SystemStatus.Up,
}
return (
<Link

View File

@@ -1,29 +1,39 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
// import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4",
{
variants: {
variant: {
default: "bg-background text-foreground [&>svg]:text-foreground",
destructive:
"bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-500 dark:border-destructive/70 [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
// const alertVariants = cva(
// "relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
// {
// variants: {
// variant: {
// default: "bg-background text-foreground",
// destructive:
// "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
// },
// },
// defaultVariants: {
// variant: "default",
// },
// }
// )
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
// >(({ className, variant, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground",
className
)}
{...props}
/>
))
Alert.displayName = "Alert"

View File

@@ -474,19 +474,17 @@ export {
// ChartStyle,
}
const roundFloat = (value: number) => Number(value.toPrecision(12))
/**
* Four equal intervals for a fixed [0, max] domain (e.g. total memory or disk size), with max as the top tick.
* Recharts stops one full step before max, which leaves a large gap below the top tick.
*/
export function fixedDomainTicks(domain?: AxisDomain): number[] | undefined {
if (!Array.isArray(domain)) {
return undefined
}
const [min, max] = domain
if (min !== 0 || typeof max !== "number" || !(max > 0) || !Number.isFinite(max)) {
return undefined
}
return [0, 1, 2, 3, 4].map((i) => roundFloat((max * i) / 4))
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
},
]
}

View File

@@ -12,8 +12,6 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const dialogIconButtonClassName = "inline-flex size-6 items-center justify-center"
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -44,12 +42,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
"absolute end-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
dialogIconButtonClassName
)}
>
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -89,7 +82,6 @@ const DialogDescription = React.forwardRef<
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
dialogIconButtonClassName,
Dialog,
DialogPortal,
DialogOverlay,

View File

@@ -2,7 +2,6 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import type * as React from "react"
import { dialogIconButtonClassName } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
@@ -62,12 +61,7 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close
className={cn(
"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-3 end-3 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none",
dialogIconButtonClassName
)}
>
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>

View File

@@ -1,19 +1,12 @@
import type {
MonitorCertInfo,
MonitorStats,
NetworkMonitorRecord,
NetworkMonitorStatsRecord,
RawMonitorStatsRecord,
} from "@/types"
import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
import { toFixedFloat } from "./utils"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
const success = record.success_count > 0
return {
res_avg: success ? toFixedFloat(record.res_sum / record.success_count, 2) : null,
res_min: success ? record.res_min : null,
res_max: success ? record.res_max : null,
res_avg: record.success_count > 0 ? toFixedFloat(record.res_sum / record.success_count, 2) : 0,
res_min: record.res_min,
res_max: record.res_max,
loss:
record.total_count > 0
? toFixedFloat(((record.total_count - record.success_count) / record.total_count) * 100, 2)
@@ -21,47 +14,6 @@ export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
}
}
/**
* Realtime stats come from the agent without counts and report 0 response times when every
* probe failed; clear them to match stored stats.
*/
export function clearFailedResponse(stats: MonitorStats): MonitorStats {
if (stats.loss < 100) return stats
return { ...stats, res_avg: null, res_min: null, res_max: null }
}
/**
* Gap marker in the same form appendData uses. Without a timestamp it can't become the active
* tooltip point, which would otherwise have no values and make the tooltip jump to the corner.
*/
export const monitorGapRecord = { created: null, stats: null } as unknown as NetworkMonitorStatsRecord
/**
* Return the records that have stats for one monitor, with a gap marker inserted wherever
* consecutive records are further apart than expected (e.g. while the agent was disconnected),
* so charts break the line there instead of drawing across the missing time.
*/
export function withMonitorGaps(
records: NetworkMonitorStatsRecord[],
monitor: Pick<NetworkMonitorRecord, "id" | "interval">,
expectedInterval: number
): NetworkMonitorStatsRecord[] {
// long-interval monitors only get a record when a new probe completes
const maxGap = Math.max(expectedInterval, monitor.interval * 1000) * 1.5
const result: NetworkMonitorStatsRecord[] = []
let prevTime = 0
for (const record of records) {
// skip appendData's gap markers (created: null) and records without this monitor
if (record.created == null || !record.stats?.[monitor.id]) continue
if (prevTime && record.created - prevTime > maxGap) {
result.push(monitorGapRecord)
}
prevTime = record.created
result.push(record)
}
return result
}
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target

View File

@@ -1,61 +0,0 @@
import { expect, test } from "bun:test"
import { classifyVersionChange } from "./package-updates"
// version pairs are taken from real apt, dnf, zypper, pacman and apk output
test("major, minor and patch use the first differing upstream component", () => {
expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major")
expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor")
expect(classifyVersionChange("3.0.7-104.el9", "3.1.5-8.el9")).toBe("minor")
expect(classifyVersionChange("1.3.7-1", "1.3.8-1")).toBe("patch")
expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch")
expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch")
expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch")
// missing components count as zero
expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch")
expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor")
// components compare as numbers, not strings
expect(classifyVersionChange("1.9.0", "1.10.0")).toBe("minor")
// dotted versions without a revision, such as Ubuntu kernel metapackages
expect(classifyVersionChange("5.15.0.91.88", "5.15.0.92.89")).toBe("patch")
})
test("epochs are stripped when equal", () => {
expect(classifyVersionChange("2:9.2.280-1.fc42", "2:9.2.390-1.fc42")).toBe("patch")
expect(classifyVersionChange("1:2.3.4-1ubuntu1", "1:2.4.0-1ubuntu1")).toBe("minor")
expect(classifyVersionChange("1:3.2.6-3.fc42", "1:3.2.6-4.fc42")).toBe("revision")
})
test("revision-only changes", () => {
expect(classifyVersionChange("2.35-0ubuntu3.4", "2.35-0ubuntu3.15")).toBe("revision")
expect(classifyVersionChange("5.15.0-91.101", "5.15.0-92.102")).toBe("revision")
expect(classifyVersionChange("12.3.0-1ubuntu1~22.04", "12.3.0-1ubuntu1~22.04.3")).toBe("revision")
expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision")
expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision")
expect(classifyVersionChange("42-30", "42-31")).toBe("revision")
expect(
classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1")
).toBe("revision")
})
test("falls back to other when the change can't be classified safely", () => {
// unknown or identical versions
expect(classifyVersionChange(undefined, "1.0-1")).toBe("other")
expect(classifyVersionChange("", "1.0-1")).toBe("other")
expect(classifyVersionChange("1.0-1", "1.0-1")).toBe("other")
// epoch changes reset the version scheme
expect(classifyVersionChange("1.5-1", "1:1.0-1")).toBe("other")
expect(classifyVersionChange("1:2.0-1", "2:2.0-1")).toBe("other")
// calendar versions
expect(classifyVersionChange("2025c-1.fc42", "2026b-1.fc42")).toBe("other")
expect(classifyVersionChange("2026c-1", "2026d-1")).toBe("other")
expect(classifyVersionChange("20240226-r0", "20260413-r0")).toBe("other")
expect(classifyVersionChange("2023.2.60_v7.0.306-90.1.el9_2", "2025.2.80_v9.0.305-91.el9")).toBe("other")
expect(classifyVersionChange("20230731-1.git94f0e2c.el9_3.1", "20250905-1.git377cc42.el9_7")).toBe("other")
// pre-release and suffix-only changes
expect(classifyVersionChange("2.0~rc1-1", "2.0-1")).toBe("other")
expect(classifyVersionChange("1.2.3+dfsg-1", "1.2.3+dfsg2-1")).toBe("other")
// non-numeric versions
expect(classifyVersionChange("git20240101-1", "git20240301-1")).toBe("other")
// downgrades
expect(classifyVersionChange("1.3.0-1", "1.2.9-1")).toBe("other")
})

View File

@@ -1,81 +0,0 @@
/**
* Kind of version change between an installed and an available package version.
* - major / minor / patch: first differing numeric component of the upstream version
* - revision: same upstream version, only the distro packaging revision changed
* - other: anything that can't be classified safely (unknown current version,
* epoch change, calendar versions, pre-release suffixes, downgrades)
*/
export type VersionChange = "major" | "minor" | "patch" | "revision" | "other"
interface SplitVersion {
epoch: number
upstream: string
revision: string
}
/**
* Splits a distro version string into epoch, upstream version and packaging revision.
* Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1")
* and apk ("1.2.5-r3") formats. The revision follows the last "-".
*/
function splitVersion(version: string): SplitVersion {
let epoch = 0
const epochMatch = /^(\d+):/.exec(version)
if (epochMatch) {
epoch = Number(epochMatch[1])
version = version.slice(epochMatch[0].length)
}
const dash = version.lastIndexOf("-")
if (dash > 0) {
return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) }
}
return { epoch, upstream: version, revision: "" }
}
/**
* Leading components at or above this look like dates or years (20240226, 2026b,
* 2025.2.80), where a change in the first component is not a major upgrade.
*/
const CALENDAR_VERSION_MIN = 1000
/** Classifies the change from `current` to `available` as major, minor, patch or revision. */
export function classifyVersionChange(current?: string, available?: string): VersionChange {
current = current?.trim()
available = available?.trim()
if (!current || !available || current === available) {
return "other"
}
const from = splitVersion(current)
const to = splitVersion(available)
// a new epoch means the version scheme was reset, so the numbers aren't comparable
if (from.epoch !== to.epoch) {
return "other"
}
if (from.upstream === to.upstream) {
return from.revision !== to.revision ? "revision" : "other"
}
const fromMatch = /^\d+(?:\.\d+)*/.exec(from.upstream)
const toMatch = /^\d+(?:\.\d+)*/.exec(to.upstream)
if (!fromMatch || !toMatch) {
return "other"
}
const fromParts = fromMatch[0].split(".").map(Number)
const toParts = toMatch[0].split(".").map(Number)
if (fromParts[0] >= CALENDAR_VERSION_MIN || toParts[0] >= CALENDAR_VERSION_MIN) {
return "other"
}
const length = Math.max(fromParts.length, toParts.length)
for (let i = 0; i < length; i++) {
const a = fromParts[i] ?? 0
const b = toParts[i] ?? 0
if (a === b) {
continue
}
if (b < a) {
return "other"
}
return i === 0 ? "major" : i === 1 ? "minor" : "patch"
}
// same numbers, so only a suffix such as "~rc1", "+dfsg" or a letter changed
return "other"
}

View File

@@ -1,5 +1,5 @@
import { chartTimeData } from "@/lib/utils"
import { clearFailedResponse, getMonitorStats, withMonitorGaps } from "@/lib/network-monitor-utils"
import { getMonitorStats } from "@/lib/network-monitor-utils"
import type {
ChartTimes,
MonitorStats,
@@ -7,7 +7,7 @@ import type {
NetworkMonitorStatsRecord,
RawMonitorStatsRecord,
} from "@/types"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { appendData } from "@/components/routes/system/chart-data"
import { pb, getPbTimestamp } from "@/lib/api"
import { toast } from "@/components/ui/use-toast"
@@ -157,15 +157,12 @@ export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
interface UseNetworkMonitorStatsProps {
systemId: string
monitorId: string
/** Monitor probe interval in seconds, used to tell missing data apart from slow probes */
interval: number
chartTime: ChartTimes
enabled?: boolean
}
/** Returns the monitor's stats with empty records inserted where data is missing (see withMonitorGaps). */
export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
const { systemId, monitorId, interval, chartTime, enabled = true } = props
const { systemId, monitorId, chartTime, enabled = true } = props
const [monitorStats, setMonitorStats] = useState<NetworkMonitorStatsRecord[]>([])
// pending raw events to be merged (keyed by monitor+created)
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
@@ -278,7 +275,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
const monitorStats = data.Monitors?.[monitorId]
if (cancelled || !monitorStats) return
const stats = { created: Date.now(), stats: { [monitorId]: clearFailedResponse(monitorStats) } }
const stats = { created: Date.now(), stats: { [monitorId]: monitorStats } }
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
setMonitorStats(newStats)
},
@@ -294,10 +291,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
}
}, [chartTime, systemId, monitorId, enabled])
return useMemo(
() => withMonitorGaps(monitorStats, { id: monitorId, interval }, chartTimeData[chartTime].expectedInterval),
[monitorStats, monitorId, interval, chartTime]
)
return monitorStats
}
async function fetchMonitors(system?: string) {

View File

@@ -33,7 +33,6 @@ const Smart = lazy(() => import("@/components/routes/smart.tsx"))
const Monitors = lazy(() => import("@/components/routes/monitors.tsx"))
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
const ActiveAlerts = lazy(() => import("@/components/active-alerts.tsx").then((m) => ({ default: m.ActiveAlerts })))
const App = memo(() => {
const page = useStore($router)
@@ -110,9 +109,6 @@ const Layout = () => {
<Navbar />
</div>
<div className="container relative">
<Suspense>
<ActiveAlerts className="mb-4" />
</Suspense>
<App />
{copyContent && (
<Suspense>

View File

@@ -71,8 +71,6 @@ export interface SystemInfo {
bb?: number
/** agent version */
v: string
/** agent can read the system journal */
jl?: boolean
/** system is using podman */
p?: boolean
/** highest gpu utilization */
@@ -228,25 +226,6 @@ export interface ZfsVdev {
checksumErrs?: number
}
/** pending package update from GET /api/beszel/package-updates */
export interface PackageUpdate {
name: string
/** installed version, missing if unknown */
current?: string
available: string
security?: boolean
}
export interface PackageUpdates {
/** package manager name, e.g. "apt" */
manager?: string
/** unix time in seconds of the last check */
checkedAt?: number
/** true if the package manager flags security updates per package */
securityKnown?: boolean
packages: PackageUpdate[] | null
}
export interface ZfsDataset {
name: string
used?: number
@@ -698,10 +677,9 @@ export interface MonitorCertInfo {
/** Response times in microseconds and packet loss percentage (0-100). */
export interface MonitorStats {
/** null when no probe succeeded, so there is no response time */
res_avg: number | null
res_min: number | null
res_max: number | null
res_avg: number
res_min: number
res_max: number
loss: number
}

View File

@@ -5,7 +5,7 @@ mock.module("@lingui/core/macro", () => ({
plural: (_count: number, forms: { other?: string }) => forms.other ?? "",
}))
const { getMonitorStats, withMonitorGaps } = await import("../src/lib/network-monitor-utils")
const { getMonitorStats } = await import("../src/lib/network-monitor-utils")
describe("monitor stats derived from stored counts", () => {
test("retains probe weights and response precision", () => {
@@ -39,10 +39,10 @@ describe("monitor stats derived from stored counts", () => {
})
test.each([
{ total_count: 3, success_count: 0, loss: 100, res: null },
{ total_count: 0, success_count: 0, loss: 0, res: null },
{ total_count: 1, success_count: 1, loss: 0, res: 0 },
])("handles zero sums with $total_count attempts and $success_count successes", ({ loss, res, ...counts }) => {
{ total_count: 3, success_count: 0, loss: 100 },
{ total_count: 0, success_count: 0, loss: 0 },
{ total_count: 1, success_count: 1, loss: 0 },
])("handles zero sums with $total_count attempts and $success_count successes", ({ loss, ...counts }) => {
const stats = getMonitorStats({
monitor: "monitor1",
created: 1000,
@@ -51,39 +51,6 @@ describe("monitor stats derived from stored counts", () => {
res_sum: 0,
...counts,
})
expect(stats).toEqual({ res_avg: res, res_min: res, res_max: res, loss })
})
})
describe("monitor gaps", () => {
const monitor = { id: "m1", interval: 30 }
const stats = { res_avg: 1, res_min: 1, res_max: 1, loss: 0 }
const record = (created: number | null, id = monitor.id) => ({ created, stats: { [id]: stats } })
test("does not insert markers at the expected cadence", () => {
const records = [record(0), record(60_000), record(120_000)]
expect(withMonitorGaps(records, monitor, 60_000)).toEqual(records)
})
test("inserts a marker between records further apart than expected", () => {
const records = [record(60_000), record(300_000)]
expect(withMonitorGaps(records, monitor, 60_000)).toEqual([records[0], { created: null, stats: null }, records[1]])
})
test("uses the monitor interval when it is longer than the tier interval", () => {
const slowMonitor = { id: monitor.id, interval: 300 }
const records = [record(300_000), record(600_000), record(900_000)]
expect(withMonitorGaps(records, slowMonitor, 60_000)).toEqual(records)
expect(withMonitorGaps([records[0], record(1_200_000)], slowMonitor, 60_000)).toHaveLength(3)
})
test("skips records for other monitors and existing gap markers", () => {
const records = [
record(60_000),
record(90_000, "m2"),
{ created: null, stats: null },
record(120_000),
] as Parameters<typeof withMonitorGaps>[0]
expect(withMonitorGaps(records, monitor, 60_000)).toEqual([records[0], records[3]])
expect(stats).toEqual({ res_avg: 0, res_min: 0, res_max: 0, loss })
})
})

View File

@@ -1389,13 +1389,6 @@ EOF
[ "$HUB_URL_PROVIDED" = "true" ] && sed -i "s|^Environment=\"HUB_URL=.*\"|Environment=\"HUB_URL=$SED_HUB_URL\"|" /etc/systemd/system/beszel-agent.service
fi
# Let the agent service (not the beszel user) read the system journal for service logs.
# Admins can opt out with a drop-in that sets an empty SupplementaryGroups=.
if getent group systemd-journal >/dev/null 2>&1 && ! grep -q '^SupplementaryGroups=' /etc/systemd/system/beszel-agent.service; then
echo "Granting the agent service read access to the systemd journal"
sed -i '/^User=beszel$/a SupplementaryGroups=systemd-journal' /etc/systemd/system/beszel-agent.service
fi
# Load and start the service
printf "\nLoading and starting the agent service...\n"
systemctl daemon-reload