mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-27 20:07:48 +02:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab27ab642 |
@@ -2,9 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -51,10 +49,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
return true
|
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) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
// Build command arguments, optionally selecting a device via -d
|
// 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 != "" {
|
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||||
args = append(args, "-d", dev)
|
args = append(args, "-d", dev)
|
||||||
}
|
}
|
||||||
@@ -82,64 +80,48 @@ func (gm *GPUManager) collectIntelStats() (err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := gm.parseIntelJSONStream(stdout); err != nil {
|
scanner := bufio.NewScanner(stdout)
|
||||||
return err
|
var header1 string
|
||||||
}
|
var engineNames []string
|
||||||
// The closing "]" is printed as the process exits, so read to EOF to let
|
var friendlyNames []string
|
||||||
// it finish instead of killing it.
|
var preEngineCols int
|
||||||
_, _ = io.Copy(io.Discard, stdout)
|
var powerIndex int
|
||||||
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
|
|
||||||
}
|
|
||||||
var hadDataRow bool
|
var hadDataRow bool
|
||||||
// skip first data row because it sometimes has erroneous data
|
// skip first data row because it sometimes has erroneous data
|
||||||
var skippedFirstDataRow bool
|
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
|
for scanner.Scan() {
|
||||||
// process was killed) is the normal end of the stream rather than an error.
|
line := strings.TrimSpace(scanner.Text())
|
||||||
for dec.More() {
|
if line == "" {
|
||||||
var sample intelGpuJSONSample
|
continue
|
||||||
if err := dec.Decode(&sample); err != nil {
|
|
||||||
if er.eof {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if !skippedFirstDataRow {
|
||||||
skippedFirstDataRow = true
|
skippedFirstDataRow = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stats := parseIntelJSONSample(sample)
|
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
||||||
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) {
|
if err != nil {
|
||||||
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg)
|
return err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
hadDataRow = true
|
hadDataRow = true
|
||||||
gm.updateIntelFromStats(&stats)
|
gm.updateIntelFromStats(&sample)
|
||||||
|
}
|
||||||
|
if scanErr := scanner.Err(); scanErr != nil {
|
||||||
|
return scanErr
|
||||||
}
|
}
|
||||||
if !hadDataRow {
|
if !hadDataRow {
|
||||||
return errNoValidData
|
return errNoValidData
|
||||||
@@ -147,82 +129,80 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// eofReader records whether the underlying reader has returned io.EOF. The
|
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
|
||||||
// json decoder reports a stream ending mid-value as a syntax error, so this
|
// Build indexes
|
||||||
// is how a truncated final sample is told apart from invalid output.
|
h1 := strings.Fields(header1)
|
||||||
type eofReader struct {
|
h2 := strings.Fields(header2)
|
||||||
r io.Reader
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
eof bool
|
// Collect engine names from header1
|
||||||
}
|
for _, col := range h1 {
|
||||||
|
key := strings.TrimRightFunc(col, func(r rune) bool {
|
||||||
func (e *eofReader) Read(p []byte) (int, error) {
|
return (r >= '0' && r <= '9') || r == '/'
|
||||||
n, err := e.r.Read(p)
|
})
|
||||||
if err == io.EOF {
|
var friendly string
|
||||||
e.eof = true
|
switch key {
|
||||||
}
|
case "RCS":
|
||||||
return n, err
|
friendly = "Render/3D"
|
||||||
}
|
case "BCS":
|
||||||
|
friendly = "Blitter"
|
||||||
// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it.
|
case "VCS":
|
||||||
func peekNonSpace(br *bufio.Reader) (byte, error) {
|
friendly = "Video"
|
||||||
for {
|
case "VECS":
|
||||||
b, err := br.Peek(1)
|
friendly = "VideoEnhance"
|
||||||
if err != nil {
|
case "CCS":
|
||||||
return 0, err
|
friendly = "Compute"
|
||||||
}
|
|
||||||
switch b[0] {
|
|
||||||
case ' ', '\t', '\n', '\r':
|
|
||||||
_, _ = br.ReadByte()
|
|
||||||
default:
|
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
|
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
||||||
// needed fields are mapped.
|
fields := strings.Fields(line)
|
||||||
type intelGpuJSONSample struct {
|
if len(fields) == 0 {
|
||||||
Power *struct {
|
return sample, errNoValidData
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if len(sample.Engines) > 0 {
|
// Make sure row has enough columns for engines
|
||||||
stats.Engines = make(map[string]float64, len(sample.Engines))
|
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
||||||
for key, engine := range sample.Engines {
|
return sample, errNoValidData
|
||||||
stats.Engines[intelEngineClass(key)] += engine.Busy
|
}
|
||||||
|
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
|
if len(engineNames) > 0 {
|
||||||
}
|
sample.Engines = make(map[string]float64, len(engineNames))
|
||||||
|
for k := range engineNames {
|
||||||
// intelEngineClass returns the engine class name for an engine key. Keys are
|
base := preEngineCols + 3*k
|
||||||
// class names ("Render/3D", "Video") in class view, which JSON output uses by
|
if base < len(fields) {
|
||||||
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in
|
busy := 0.0
|
||||||
// physical view, which older versions use.
|
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
|
||||||
func intelEngineClass(key string) string {
|
busy = v
|
||||||
if i := strings.LastIndexByte(key, '/'); i >= 0 {
|
}
|
||||||
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil {
|
cur := sample.Engines[friendlyNames[k]]
|
||||||
return key[:i]
|
sample.Engines[friendlyNames[k]] = cur + busy
|
||||||
|
} else {
|
||||||
|
sample.Engines[friendlyNames[k]] = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return key
|
return sample, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1433,10 +1431,12 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
|||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
||||||
|
|
||||||
intelPath := filepath.Join(dir, "intel_gpu_top")
|
intelPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
intelScript := "#!/bin/sh\necho '" + intelJSONStream(true,
|
intelScript := `#!/bin/sh
|
||||||
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||||
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
|
echo " req act /s % gpu pkg rd wr % se wa % se wa"
|
||||||
) + "'\n"
|
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))
|
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
|
||||||
|
|
||||||
rocmPath := filepath.Join(dir, "rocm-smi")
|
rocmPath := filepath.Join(dir, "rocm-smi")
|
||||||
@@ -1752,61 +1752,19 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
|||||||
assert.Equal(t, float64(2), gpu.Count)
|
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) {
|
func TestIntelCollectorStreaming(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
|
|
||||||
engines := func(render, blitter, video float64) map[string]float64 {
|
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||||
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
|
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
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 {
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1823,168 +1781,229 @@ func TestIntelCollectorStreaming(t *testing.T) {
|
|||||||
gpu := gm.GpuDataMap["i0"]
|
gpu := gm.GpuDataMap["i0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
// 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
|
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
||||||
// Engines aggregated from samples 2-4
|
// Engines aggregated from samples 2-4
|
||||||
assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75
|
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
|
||||||
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00
|
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 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, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
|
||||||
// Count should be 3 samples (first is skipped)
|
// Count should be 3 samples (first is skipped)
|
||||||
assert.Equal(t, float64(3), gpu.Count)
|
assert.Equal(t, float64(3), gpu.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntelJSONStream(t *testing.T) {
|
func TestParseIntelHeaders(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}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
header1 string
|
||||||
wantErr error
|
header2 string
|
||||||
wantAnyErr bool
|
wantEngineNames []string
|
||||||
wantCount float64
|
wantFriendlyNames []string
|
||||||
wantPower float64
|
wantPowerIndex int
|
||||||
wantPkg float64
|
wantPreEngineCols int
|
||||||
wantEngines map[string]float64
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "array still open while process runs",
|
name: "basic headers with RCS BCS VCS",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1]),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "closed array",
|
name: "basic headers with RCS BCS VCS using index in name",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "truncated final sample",
|
name: "headers with only RCS",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4,
|
||||||
|
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names
|
name: "headers with VECS and CCS",
|
||||||
name: "legacy output without array and with engine instances",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
|
||||||
input: intelJSONStream(false,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
|
||||||
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}),
|
wantEngineNames: []string{"VECS", "CCS"},
|
||||||
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}),
|
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
|
||||||
),
|
wantPowerIndex: 4,
|
||||||
wantCount: 1,
|
wantPreEngineCols: 8, // 14 total - 3*2 = 8
|
||||||
wantPower: 1.5,
|
|
||||||
wantPkg: 2.5,
|
|
||||||
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// energy counter read lower than the previous sample in intel_gpu_top
|
name: "no engines",
|
||||||
name: "sample with invalid power is skipped",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
|
||||||
input: intelJSONStream(true, first, classView[0],
|
header2: " req act /s % gpu pkg rd wr",
|
||||||
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}),
|
wantEngineNames: nil, // no engines found, slices remain nil
|
||||||
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}),
|
wantFriendlyNames: nil,
|
||||||
classView[1],
|
wantPowerIndex: -1, // no engines, so no search
|
||||||
),
|
wantPreEngineCols: 0,
|
||||||
wantCount: 2,
|
|
||||||
wantPower: 3,
|
|
||||||
wantPkg: 5,
|
|
||||||
wantEngines: classViewWant,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only samples with invalid power",
|
name: "power index not found",
|
||||||
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
wantErr: errNoValidData,
|
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",
|
name: "empty headers",
|
||||||
input: "",
|
header1: "",
|
||||||
wantErr: errNoValidData,
|
header2: "",
|
||||||
},
|
wantEngineNames: nil, // empty input, slices remain nil
|
||||||
{
|
wantFriendlyNames: nil,
|
||||||
name: "only first sample, which is skipped",
|
wantPowerIndex: -1,
|
||||||
input: intelJSONStream(true, first),
|
wantPreEngineCols: 0,
|
||||||
wantErr: errNoValidData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid output",
|
|
||||||
input: "intel_gpu_top: command failed",
|
|
||||||
wantAnyErr: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
gm := &GPUManager{}
|
||||||
err := gm.parseIntelJSONStream(strings.NewReader(tt.input))
|
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
|
||||||
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)
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["i0"]
|
assert.Equal(t, tt.wantEngineNames, engineNames)
|
||||||
require.NotNil(t, gpu)
|
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
|
||||||
assert.Equal(t, tt.wantCount, gpu.Count)
|
assert.Equal(t, tt.wantPowerIndex, powerIndex)
|
||||||
assert.InDelta(t, tt.wantPower, gpu.Power, 0.001)
|
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntelJSONSample(t *testing.T) {
|
func TestParseIntelData(t *testing.T) {
|
||||||
t.Run("without power", func(t *testing.T) {
|
tests := []struct {
|
||||||
var sample intelGpuJSONSample
|
name string
|
||||||
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample))
|
line string
|
||||||
stats := parseIntelJSONSample(sample)
|
engineNames []string
|
||||||
assert.Zero(t, stats.PowerGPU)
|
friendlyNames []string
|
||||||
assert.Zero(t, stats.PowerPkg)
|
powerIndex int
|
||||||
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines)
|
preEngineCols int
|
||||||
})
|
wantPowerGPU float64
|
||||||
|
wantEngines map[string]float64
|
||||||
t.Run("without engines", func(t *testing.T) {
|
wantErr error
|
||||||
var sample intelGpuJSONSample
|
}{
|
||||||
require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample))
|
{
|
||||||
stats := parseIntelJSONSample(sample)
|
name: "basic data with power and engines",
|
||||||
assert.Equal(t, 1.25, stats.PowerGPU)
|
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
assert.Equal(t, 4.5, stats.PowerPkg)
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
assert.Nil(t, stats.Engines)
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
})
|
powerIndex: 4,
|
||||||
}
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
func TestIntelEngineClass(t *testing.T) {
|
wantEngines: map[string]float64{
|
||||||
tests := map[string]string{
|
"Render/3D": 12.34,
|
||||||
"Render/3D": "Render/3D",
|
"Blitter": 0.00,
|
||||||
"Render/3D/0": "Render/3D",
|
"Video": 5.00,
|
||||||
"Blitter": "Blitter",
|
},
|
||||||
"Blitter/0": "Blitter",
|
},
|
||||||
"Video/1": "Video",
|
{
|
||||||
"VideoEnhance/0": "VideoEnhance",
|
name: "data with zero power",
|
||||||
"Compute/3": "Compute",
|
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
|
||||||
"[unknown]": "[unknown]",
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
"[unknown]/0": "[unknown]",
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
"Video/": "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
|
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
output := intelJSONStream(true,
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
echo "$@" > %s
|
||||||
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||||
)
|
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
||||||
script := fmt.Sprintf("#!/bin/sh\necho \"$@\" > %s\necho '%s'\n", argsFile, output)
|
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 {
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -2022,5 +2043,5 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|||||||
argsStr := strings.TrimSpace(string(data))
|
argsStr := strings.TrimSpace(string(data))
|
||||||
require.Contains(t, argsStr, "-d sriov")
|
require.Contains(t, argsStr, "-d sriov")
|
||||||
require.Contains(t, argsStr, "-s ")
|
require.Contains(t, argsStr, "-s ")
|
||||||
require.Contains(t, argsStr, "-J")
|
require.Contains(t, argsStr, "-l")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
@@ -55,7 +54,6 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
||||||
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
||||||
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -200,20 +198,6 @@ func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,25 +25,16 @@ const (
|
|||||||
pacmanSyncInterval = 12 * time.Hour
|
pacmanSyncInterval = 12 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// packageUpdatesResult is the outcome of one package manager check.
|
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
|
||||||
type packageUpdatesResult struct {
|
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
|
||||||
// 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)
|
|
||||||
|
|
||||||
// packageUpdatesManager periodically checks the host package manager for pending
|
// packageUpdatesManager periodically checks the host package manager for pending
|
||||||
// updates in the background and caches the result, so checks never delay metrics.
|
// updates in the background and caches the result, so checks never delay metrics.
|
||||||
type packageUpdatesManager struct {
|
type packageUpdatesManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
name string
|
|
||||||
check packageUpdatesCheck
|
check packageUpdatesCheck
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
result packageUpdatesResult
|
counts []uint16
|
||||||
checkedAt time.Time
|
checkedAt time.Time
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -56,37 +46,24 @@ func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
|
|||||||
if runtime.GOOS != "linux" || runningInContainer() {
|
if runtime.GOOS != "linux" || runningInContainer() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
interval, enabled := packageUpdatesInterval()
|
interval := defaultPackageUpdatesInterval
|
||||||
if !enabled {
|
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
|
||||||
return nil
|
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)
|
name, check := detectPackageManager(dataDir)
|
||||||
if check == nil {
|
if check == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
slog.Debug("Package updates", "manager", name, "interval", interval)
|
slog.Debug("Package updates", "manager", name, "interval", interval)
|
||||||
return &packageUpdatesManager{name: name, check: check, interval: interval}
|
return &packageUpdatesManager{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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// get returns the last cached counts and starts a background check if they are stale.
|
// 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
|
pm.running = true
|
||||||
go pm.refresh()
|
go pm.refresh()
|
||||||
}
|
}
|
||||||
return pm.result.counts
|
return pm.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *packageUpdatesManager) refresh() {
|
func (pm *packageUpdatesManager) refresh() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := pm.check(ctx)
|
counts, err := pm.check(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Package updates check failed", "err", err)
|
slog.Debug("Package updates check failed", "err", err)
|
||||||
result = packageUpdatesResult{}
|
counts = nil
|
||||||
}
|
}
|
||||||
pm.Lock()
|
pm.Lock()
|
||||||
pm.result = result
|
pm.counts = counts
|
||||||
pm.checkedAt = time.Now()
|
pm.checkedAt = time.Now()
|
||||||
pm.running = false
|
pm.running = false
|
||||||
pm.Unlock()
|
pm.Unlock()
|
||||||
@@ -181,92 +143,42 @@ func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name
|
|||||||
return string(out), err
|
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.
|
// checkApt simulates a full upgrade against the current package lists.
|
||||||
// It never refreshes the lists; apt-daily or the user does that.
|
// 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")
|
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return packageUpdatesResult{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
packages := parseAptSimulate(out)
|
total, security := parseAptSimulate(out)
|
||||||
return packageUpdatesResult{
|
return []uint16{total, security}, nil
|
||||||
counts: []uint16{uint16(len(packages)), countSecurity(packages)},
|
|
||||||
packages: packages,
|
|
||||||
securityKnown: true,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
|
// 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) ([]uint16, error) {
|
||||||
func checkDnf(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return packageUpdatesResult{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
packages := parseDnfCheckUpdate(out)
|
total := 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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
||||||
if err == nil {
|
if err != nil {
|
||||||
// --security lists the lowest version that fixes an advisory, which may be
|
return []uint16{total}, nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
for i := range packages {
|
return []uint16{total, parseDnfCheckUpdate(out)}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkZypper lists package updates. Security updates come from patches, which
|
func checkZypper(ctx context.Context) ([]uint16, error) {
|
||||||
// zypper does not map to packages here, so only the security count is known.
|
|
||||||
func checkZypper(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return packageUpdatesResult{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
packages := parseZypperListUpdates(out)
|
total := parseZypperTable(out)
|
||||||
result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}}
|
|
||||||
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
||||||
if err == nil {
|
if err != nil {
|
||||||
result.counts = append(result.counts, parseZypperTable(out))
|
return []uint16{total}, nil
|
||||||
}
|
}
|
||||||
return result, nil
|
return []uint16{total, parseZypperTable(out)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
|
// 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
|
// checks never overlap (packageUpdatesManager.running), so no lock is needed
|
||||||
var lastSync time.Time
|
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,
|
// -n with a missing database reports no updates rather than failing,
|
||||||
// so always sync first and whenever the private copy is missing
|
// so always sync first and whenever the private copy is missing
|
||||||
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
|
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...)
|
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return packageUpdatesResult{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if sync {
|
if sync {
|
||||||
lastSync = time.Now()
|
lastSync = time.Now()
|
||||||
}
|
}
|
||||||
packages := parsePacmanCheckUpdates(out)
|
return []uint16{parsePacmanCheckUpdates(out)}, nil
|
||||||
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, 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")
|
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return packageUpdatesResult{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
packages := parseApkUpgradable(out)
|
return []uint16{parseApkUpgradable(out)}, nil
|
||||||
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, 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])".
|
// "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.
|
// New dependencies have no "[old version]" and are not counted.
|
||||||
func parseAptSimulate(out string) (packages []system.PackageUpdate) {
|
func parseAptSimulate(out string) (total, security uint16) {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
fields := strings.Fields(line)
|
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
|
continue
|
||||||
}
|
}
|
||||||
pkg := system.PackageUpdate{
|
total++
|
||||||
Name: fields[1],
|
|
||||||
Current: strings.Trim(fields[2], "[]"),
|
|
||||||
Available: strings.TrimPrefix(fields[3], "("),
|
|
||||||
}
|
|
||||||
start := strings.IndexByte(line, '(')
|
start := strings.IndexByte(line, '(')
|
||||||
end := strings.IndexByte(line, ')')
|
end := strings.IndexByte(line, ')')
|
||||||
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
|
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
|
||||||
packages = append(packages, pkg)
|
security++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return packages
|
return total, security
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDnfCheckUpdate parses "name.arch version repo" lines, stopping at the
|
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
|
||||||
// obsoletes section so obsoleted packages are not listed twice. Names keep the
|
// obsoletes section so obsoleted packages are not counted twice.
|
||||||
// arch so they can be matched with rpm output. dnf4 wraps a long name.arch onto
|
func parseDnfCheckUpdate(out string) (count uint16) {
|
||||||
// its own line, with the version and repo on the next line.
|
|
||||||
func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
|
|
||||||
var wrappedName string
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
@@ -356,44 +260,11 @@ func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if wrappedName != "" && len(fields) == 2 {
|
if len(fields) == 3 && strings.Contains(fields[0], ".") {
|
||||||
fields = []string{wrappedName, fields[0], fields[1]}
|
count++
|
||||||
}
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return packages
|
return count
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseZypperTable counts the data rows of a zypper table (the lines after the
|
// 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
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseZypperListUpdates parses the `zypper list-updates` table, locating the
|
// parsePacmanCheckUpdates counts "name old -> new" lines.
|
||||||
// columns by their header names.
|
func parsePacmanCheckUpdates(out string) (count uint16) {
|
||||||
func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
|
|
||||||
nameCol, currentCol, availableCol := -1, -1, -1
|
|
||||||
var header []string
|
|
||||||
inTable := false
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
if strings.Contains(scanner.Text(), " -> ") {
|
||||||
switch {
|
count++
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return packages
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePacmanCheckUpdates parses "name old -> new" lines.
|
// parseApkUpgradable counts lines of `apk -u list`, which look like
|
||||||
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
|
|
||||||
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
|
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
|
||||||
func parseApkUpgradable(out string) (packages []system.PackageUpdate) {
|
func parseApkUpgradable(out string) (count uint16) {
|
||||||
const marker = "[upgradable from:"
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
if strings.Contains(scanner.Text(), "[upgradable from:") {
|
||||||
i := strings.Index(line, marker)
|
count++
|
||||||
fields := strings.Fields(line)
|
|
||||||
if i < 0 || len(fields) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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
|
return count
|
||||||
}
|
|
||||||
|
|
||||||
// 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:]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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.
|
// 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) {
|
func TestParseAptSimulate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
file string
|
file string
|
||||||
total, security int
|
total, security uint16
|
||||||
}{
|
}{
|
||||||
{"apt_debian12.txt", 44, 5},
|
{"apt_debian12.txt", 44, 5},
|
||||||
{"apt_ubuntu2204.txt", 58, 45},
|
{"apt_ubuntu2204.txt", 58, 45},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
packages := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
|
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
|
||||||
assert.Len(t, packages, tt.total)
|
assert.Equal(t, tt.total, total)
|
||||||
assert.EqualValues(t, tt.security, countSecurity(packages))
|
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) {
|
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])
|
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 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 ]
|
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])
|
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
||||||
Remv oldpkg [1.0]`
|
Remv oldpkg [1.0]`
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
total, security := parseAptSimulate(out)
|
||||||
{Name: "linux-image-generic", Current: "6.8.0-49.49", Available: "6.8.0-50.50", Security: true},
|
assert.Equal(t, uint16(2), total)
|
||||||
{Name: "gcc-12-base", Current: "12.3.0-1ubuntu1~22.04", Available: "12.3.0-1ubuntu1~22.04.3"},
|
assert.Equal(t, uint16(1), security)
|
||||||
}, parseAptSimulate(out))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("no updates", func(t *testing.T) {
|
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) {
|
func TestParseDnfCheckUpdate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
file string
|
file string
|
||||||
count int
|
count uint16
|
||||||
}{
|
}{
|
||||||
{"dnf4_rocky9_check_update.txt", 110},
|
{"dnf4_rocky9_check_update.txt", 110},
|
||||||
{"dnf4_rocky9_check_update_security.txt", 53},
|
{"dnf4_rocky9_check_update_security.txt", 53},
|
||||||
@@ -153,95 +71,19 @@ func TestParseDnfCheckUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
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) {
|
t.Run("obsoletes section and notices", 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) {
|
|
||||||
out := `
|
out := `
|
||||||
kernel.x86_64 5.14.0-503.el9 baseos
|
kernel.x86_64 5.14.0-503.el9 baseos
|
||||||
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
|
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
|
Obsoleting Packages
|
||||||
grub2-tools.x86_64 1:2.06-80.el9 baseos
|
grub2-tools.x86_64 1:2.06-80.el9 baseos
|
||||||
grub2-tools.x86_64 1:2.06-77.el9 @baseos
|
grub2-tools.x86_64 1:2.06-77.el9 @baseos
|
||||||
`
|
`
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
assert.Equal(t, uint16(1), parseDnfCheckUpdate(out))
|
||||||
{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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
func TestParsePacmanCheckUpdates(t *testing.T) {
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
|
||||||
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
|
assert.Zero(t, parsePacmanCheckUpdates(""))
|
||||||
{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(""))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseApkUpgradable(t *testing.T) {
|
func TestParseApkUpgradable(t *testing.T) {
|
||||||
packages := parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt"))
|
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
|
||||||
assert.Len(t, packages, 10)
|
assert.Zero(t, parseApkUpgradable(""))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPackageUpdatesManagerCaching(t *testing.T) {
|
func TestPackageUpdatesManagerCaching(t *testing.T) {
|
||||||
calls := make(chan struct{}, 10)
|
calls := make(chan struct{}, 10)
|
||||||
packages := []system.PackageUpdate{{Name: "libc6", Current: "1", Available: "2", Security: true}}
|
result := []uint16{3, 1}
|
||||||
result := packageUpdatesResult{counts: []uint16{3, 1}, packages: packages, securityKnown: true}
|
|
||||||
var resultErr error
|
var resultErr error
|
||||||
pm := &packageUpdatesManager{
|
pm := &packageUpdatesManager{
|
||||||
name: "apt",
|
|
||||||
interval: time.Hour,
|
interval: time.Hour,
|
||||||
check: func(context.Context) (packageUpdatesResult, error) {
|
check: func(context.Context) ([]uint16, error) {
|
||||||
calls <- struct{}{}
|
calls <- struct{}{}
|
||||||
return result, resultErr
|
return result, resultErr
|
||||||
},
|
},
|
||||||
@@ -343,9 +132,6 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
|
|||||||
}, time.Second, time.Millisecond)
|
}, time.Second, time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// no check has finished yet
|
|
||||||
assert.Equal(t, system.PackageUpdates{Manager: "apt"}, pm.list())
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// first call starts a background check and returns nothing yet
|
// first call starts a background check and returns nothing yet
|
||||||
assert.Nil(t, pm.get(now))
|
assert.Nil(t, pm.get(now))
|
||||||
@@ -355,49 +141,15 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
|
|||||||
// cached result within interval, no new check
|
// cached result within interval, no new check
|
||||||
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
|
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
|
||||||
assert.Len(t, calls, 1)
|
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
|
// 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)))
|
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
|
||||||
waitIdle()
|
waitIdle()
|
||||||
assert.Len(t, calls, 2)
|
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.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) {
|
func TestPacmanCheckSync(t *testing.T) {
|
||||||
@@ -425,10 +177,9 @@ echo "linux 6.1-1 -> 6.2-1"
|
|||||||
}
|
}
|
||||||
|
|
||||||
// first check syncs
|
// first check syncs
|
||||||
result, err := check(context.Background())
|
counts, err := check(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []uint16{1}, result.counts)
|
assert.Equal(t, []uint16{1}, counts)
|
||||||
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
|
|
||||||
// later checks reuse the synced copy
|
// later checks reuse the synced copy
|
||||||
_, err = check(context.Background())
|
_, err = check(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
2
go.mod
@@ -12,7 +12,7 @@ require (
|
|||||||
github.com/lxzan/gws v1.10.2
|
github.com/lxzan/gws v1.10.2
|
||||||
github.com/mdlayher/genetlink v1.4.0
|
github.com/mdlayher/genetlink v1.4.0
|
||||||
github.com/mdlayher/netlink v1.11.2
|
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/nicholas-fedor/shoutrrr v0.21.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -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/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 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
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.9.0 h1:d5mmqw9S2U4f95dcW5wnnUagpI6GJh328/AchVSP4ko=
|
||||||
github.com/mdlayher/wifi v0.8.0/go.mod h1:QHQ211ZKtZKSKssCznixGUOqBcoyBQAuQWSAOnanY4A=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ const (
|
|||||||
GetZfsData
|
GetZfsData
|
||||||
// Sync network monitor configuration to agent
|
// Sync network monitor configuration to agent
|
||||||
SyncNetworkMonitors
|
SyncNetworkMonitors
|
||||||
// Request the list of pending package updates from agent
|
|
||||||
GetPackageUpdates
|
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -202,8 +202,6 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// get pending package updates
|
|
||||||
apiAuth.GET("/package-updates", h.getPackageUpdates)
|
|
||||||
// /containers routes
|
// /containers routes
|
||||||
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
// get container logs
|
// get container logs
|
||||||
@@ -447,23 +445,6 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||||
// Fetches fresh SMART data from the agent and updates the collection
|
// Fetches fresh SMART data from the agent and updates the collection
|
||||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||||
|
|||||||
@@ -548,59 +548,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||||
TestAppFactory: testAppFactory,
|
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
|
// /systemd routes
|
||||||
{
|
{
|
||||||
Name: "GET /systemd/info - no auth should fail",
|
Name: "GET /systemd/info - no auth should fail",
|
||||||
|
|||||||
@@ -791,15 +791,6 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
|
|||||||
return result, err
|
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.
|
// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
|
||||||
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
|
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
|||||||
@@ -22,34 +22,12 @@ export type DataPoint<T = SystemStatsRecord> = {
|
|||||||
order?: number
|
order?: number
|
||||||
strokeOpacity?: number
|
strokeOpacity?: number
|
||||||
activeDot?: boolean
|
activeDot?: boolean
|
||||||
dot?: boolean | typeof isolatedDot
|
dot?: boolean
|
||||||
/** Which Y axis this series plots against. Defaults to "left". */
|
/** Which Y axis this series plots against. Defaults to "left". */
|
||||||
yAxisId?: "left" | "right"
|
yAxisId?: "left" | "right"
|
||||||
strokeDasharray?: string
|
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({
|
export default function LineChartDefault({
|
||||||
chartData,
|
chartData,
|
||||||
customData,
|
customData,
|
||||||
|
|||||||
@@ -706,7 +706,6 @@ function NetworkMonitorSheetContent({
|
|||||||
const monitorStats = useNetworkMonitorStats({
|
const monitorStats = useNetworkMonitorStats({
|
||||||
systemId: monitor.system,
|
systemId: monitor.system,
|
||||||
monitorId: monitor.id,
|
monitorId: monitor.id,
|
||||||
interval: monitor.interval,
|
|
||||||
chartTime,
|
chartTime,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { memo, useState } from "react"
|
import { memo, useState } from "react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
|
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
|
||||||
import { SystemStatus } from "@/lib/enums"
|
|
||||||
import type { GPUData } from "@/types"
|
import type { GPUData } from "@/types"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import InfoBar from "./system/info-bar"
|
import InfoBar from "./system/info-bar"
|
||||||
@@ -17,13 +16,12 @@ import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
|||||||
import {
|
import {
|
||||||
LazyContainersTable,
|
LazyContainersTable,
|
||||||
LazyNetworkMonitorsTable,
|
LazyNetworkMonitorsTable,
|
||||||
LazyPackageUpdatesTable,
|
|
||||||
LazySmartTable,
|
LazySmartTable,
|
||||||
LazySystemdTable,
|
LazySystemdTable,
|
||||||
LazyZfsTable,
|
LazyZfsTable,
|
||||||
} from "./system/lazy-tables"
|
} from "./system/lazy-tables"
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
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 { GpuIcon } from "../ui/icons"
|
||||||
import SystemdTable from "../systemd-table/systemd-table"
|
import SystemdTable from "../systemd-table/systemd-table"
|
||||||
import ContainersTable from "../containers-table/containers-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 hasGpu = hasGpuData || hasGpuPowerData
|
||||||
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
|
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
|
||||||
const hasNetworkMonitors = supportsNetworkMonitors(system)
|
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
|
// keep tabsRef in sync for keyboard navigation
|
||||||
const tabs = ["core", "network", "disk"]
|
const tabs = ["core", "network", "disk"]
|
||||||
if (hasGpu) tabs.push("gpu")
|
if (hasGpu) tabs.push("gpu")
|
||||||
if (hasContainers) tabs.push("containers")
|
if (hasContainers) tabs.push("containers")
|
||||||
if (hasSystemd) tabs.push("services")
|
if (hasSystemd) tabs.push("services")
|
||||||
if (packageUpdates) tabs.push("updates")
|
|
||||||
tabsRef.current = tabs
|
tabsRef.current = tabs
|
||||||
|
|
||||||
// shared chart props
|
// shared chart props
|
||||||
@@ -168,8 +163,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||||
|
|
||||||
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
|
|
||||||
|
|
||||||
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
|
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -209,12 +202,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<Trans>Services</Trans>
|
<Trans>Services</Trans>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{packageUpdates && (
|
|
||||||
<TabsTrigger value="updates" className="w-full flex items-center gap-2">
|
|
||||||
<PackageIcon className="size-3.5" />
|
|
||||||
<Trans>Updates</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
|
<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} />}
|
{mountedTabs.has("services") && <SystemdTable systemId={system.id} />}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{packageUpdates && (
|
|
||||||
<TabsContent value="updates" forceMount className={activeTab === "updates" ? "contents" : "hidden"}>
|
|
||||||
{mountedTabs.has("updates") && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getMonitorTarget, monitorGapRecord } from "@/lib/network-monitor-utils"
|
import { getMonitorTarget } from "@/lib/network-monitor-utils"
|
||||||
import LineChartDefault, { isolatedDot } from "@/components/charts/line-chart"
|
import LineChartDefault from "@/components/charts/line-chart"
|
||||||
import type { DataPoint } from "@/components/charts/line-chart"
|
import type { DataPoint } from "@/components/charts/line-chart"
|
||||||
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
|
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
|
||||||
import { $monitorFilter } from "@/lib/stores"
|
import { $monitorFilter } from "@/lib/stores"
|
||||||
@@ -77,14 +77,10 @@ function MonitorChart({
|
|||||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
return { dataPoints: points, visibleKeys: visibleIDs }
|
||||||
}, [monitors, filter, metric, chartData.chartTime, color])
|
}, [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(() => {
|
const filteredMonitorStats = useMemo(() => {
|
||||||
if (!multipleMonitors) return monitorStats
|
if (!visibleKeys.length) return monitorStats
|
||||||
return monitorStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
|
return monitorStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
|
||||||
}, [monitorStats, visibleKeys, multipleMonitors])
|
}, [monitorStats, visibleKeys])
|
||||||
|
|
||||||
const legend = dataPoints.length < 10 && showFilter
|
const legend = dataPoints.length < 10 && showFilter
|
||||||
|
|
||||||
@@ -103,7 +99,7 @@ function MonitorChart({
|
|||||||
customData={filteredMonitorStats}
|
customData={filteredMonitorStats}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
domain={domain ?? ["auto", "auto"]}
|
domain={domain ?? ["auto", "auto"]}
|
||||||
connectNulls={multipleMonitors}
|
connectNulls
|
||||||
tickFormatter={tickFormatter}
|
tickFormatter={tickFormatter}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
@@ -129,10 +125,9 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
// only one monitor is relevant for this chart
|
// only one monitor is relevant for this chart
|
||||||
const dataPoints: DataPoint<NetworkMonitorStatsRecord>[] = useMemo(() => {
|
const dataPoints: DataPoint<NetworkMonitorStatsRecord>[] = useMemo(() => {
|
||||||
const dataFn = (metric: keyof MonitorStats) => (record: NetworkMonitorStatsRecord) =>
|
const dataFn = (metric: keyof MonitorStats) => (record: NetworkMonitorStatsRecord) =>
|
||||||
record.stats?.[monitor?.id ?? ""]?.[metric] ?? null
|
record.stats?.[monitor?.id ?? ""]?.[metric] ?? "-"
|
||||||
const avgPoint = {
|
const avgPoint = {
|
||||||
label: "Avg",
|
label: "Avg",
|
||||||
dot: isolatedDot,
|
|
||||||
dataKey: dataFn("res_avg"),
|
dataKey: dataFn("res_avg"),
|
||||||
color: 1,
|
color: 1,
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -144,7 +139,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Max",
|
label: "Max",
|
||||||
dot: isolatedDot,
|
|
||||||
dataKey: dataFn("res_max"),
|
dataKey: dataFn("res_max"),
|
||||||
color: 3,
|
color: 3,
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -152,7 +146,6 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
avgPoint,
|
avgPoint,
|
||||||
{
|
{
|
||||||
label: "Min",
|
label: "Min",
|
||||||
dot: isolatedDot,
|
|
||||||
dataKey: dataFn("res_min"),
|
dataKey: dataFn("res_min"),
|
||||||
color: 2,
|
color: 2,
|
||||||
order: 2,
|
order: 2,
|
||||||
@@ -160,14 +153,10 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
]
|
]
|
||||||
}, [chartTime, hasLongInterval, monitor?.id])
|
}, [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 data = useMemo(() => {
|
||||||
const id = monitor?.id ?? ""
|
if (!monitor) return []
|
||||||
return monitorStats.map((record) =>
|
return monitorStats.filter((record) => record.stats && monitor.id in record.stats)
|
||||||
record.stats?.[id] && record.stats[id].res_avg == null ? monitorGapRecord : record
|
}, [monitor, monitorStats])
|
||||||
)
|
|
||||||
}, [monitorStats, monitor?.id])
|
|
||||||
|
|
||||||
const legend = dataPoints.length > 1
|
const legend = dataPoints.length > 1
|
||||||
|
|
||||||
@@ -185,6 +174,7 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
customData={data}
|
customData={data}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
domain={["auto", "auto"]}
|
domain={["auto", "auto"]}
|
||||||
|
connectNulls
|
||||||
legend={legend}
|
legend={legend}
|
||||||
tickFormatter={(value) => formatMicroseconds(value, false)}
|
tickFormatter={(value) => formatMicroseconds(value, false)}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
|
|||||||
@@ -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"))
|
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
|
||||||
|
|
||||||
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
|
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -524,11 +524,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const system = info.row.original
|
const system = info.row.original
|
||||||
let color = "text-red-500"
|
const color = {
|
||||||
if (system.status === SystemStatus.Up) {
|
"text-green-500": version === globalThis.BESZEL.HUB_VERSION,
|
||||||
color = version === globalThis.BESZEL.HUB_VERSION ? "text-green-500" : "text-yellow-500"
|
"text-yellow-500": version !== globalThis.BESZEL.HUB_VERSION,
|
||||||
} else if (system.status === SystemStatus.Paused) {
|
"text-red-500": system.status !== SystemStatus.Up,
|
||||||
color = "text-primary/40"
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import type {
|
import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
|
||||||
MonitorCertInfo,
|
|
||||||
MonitorStats,
|
|
||||||
NetworkMonitorRecord,
|
|
||||||
NetworkMonitorStatsRecord,
|
|
||||||
RawMonitorStatsRecord,
|
|
||||||
} from "@/types"
|
|
||||||
import { toFixedFloat } from "./utils"
|
import { toFixedFloat } from "./utils"
|
||||||
|
|
||||||
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
|
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
|
||||||
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
|
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
|
||||||
const success = record.success_count > 0
|
|
||||||
return {
|
return {
|
||||||
res_avg: success ? toFixedFloat(record.res_sum / record.success_count, 2) : null,
|
res_avg: record.success_count > 0 ? toFixedFloat(record.res_sum / record.success_count, 2) : 0,
|
||||||
res_min: success ? record.res_min : null,
|
res_min: record.res_min,
|
||||||
res_max: success ? record.res_max : null,
|
res_max: record.res_max,
|
||||||
loss:
|
loss:
|
||||||
record.total_count > 0
|
record.total_count > 0
|
||||||
? toFixedFloat(((record.total_count - record.success_count) / record.total_count) * 100, 2)
|
? 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">) {
|
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
|
||||||
if (monitor.protocol !== "tcp") return monitor.target
|
if (monitor.protocol !== "tcp") return monitor.target
|
||||||
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
|
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { chartTimeData } from "@/lib/utils"
|
import { chartTimeData } from "@/lib/utils"
|
||||||
import { clearFailedResponse, getMonitorStats, withMonitorGaps } from "@/lib/network-monitor-utils"
|
import { getMonitorStats } from "@/lib/network-monitor-utils"
|
||||||
import type {
|
import type {
|
||||||
ChartTimes,
|
ChartTimes,
|
||||||
MonitorStats,
|
MonitorStats,
|
||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
NetworkMonitorStatsRecord,
|
NetworkMonitorStatsRecord,
|
||||||
RawMonitorStatsRecord,
|
RawMonitorStatsRecord,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { appendData } from "@/components/routes/system/chart-data"
|
import { appendData } from "@/components/routes/system/chart-data"
|
||||||
import { pb, getPbTimestamp } from "@/lib/api"
|
import { pb, getPbTimestamp } from "@/lib/api"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
@@ -157,15 +157,12 @@ export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
|
|||||||
interface UseNetworkMonitorStatsProps {
|
interface UseNetworkMonitorStatsProps {
|
||||||
systemId: string
|
systemId: string
|
||||||
monitorId: string
|
monitorId: string
|
||||||
/** Monitor probe interval in seconds, used to tell missing data apart from slow probes */
|
|
||||||
interval: number
|
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the monitor's stats with empty records inserted where data is missing (see withMonitorGaps). */
|
|
||||||
export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
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[]>([])
|
const [monitorStats, setMonitorStats] = useState<NetworkMonitorStatsRecord[]>([])
|
||||||
// pending raw events to be merged (keyed by monitor+created)
|
// pending raw events to be merged (keyed by monitor+created)
|
||||||
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
|
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
|
||||||
@@ -278,7 +275,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
|
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
|
||||||
const monitorStats = data.Monitors?.[monitorId]
|
const monitorStats = data.Monitors?.[monitorId]
|
||||||
if (cancelled || !monitorStats) return
|
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)
|
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
|
||||||
setMonitorStats(newStats)
|
setMonitorStats(newStats)
|
||||||
},
|
},
|
||||||
@@ -294,10 +291,7 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
|
|||||||
}
|
}
|
||||||
}, [chartTime, systemId, monitorId, enabled])
|
}, [chartTime, systemId, monitorId, enabled])
|
||||||
|
|
||||||
return useMemo(
|
return monitorStats
|
||||||
() => withMonitorGaps(monitorStats, { id: monitorId, interval }, chartTimeData[chartTime].expectedInterval),
|
|
||||||
[monitorStats, monitorId, interval, chartTime]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMonitors(system?: string) {
|
async function fetchMonitors(system?: string) {
|
||||||
|
|||||||
26
internal/site/src/types.d.ts
vendored
26
internal/site/src/types.d.ts
vendored
@@ -226,25 +226,6 @@ export interface ZfsVdev {
|
|||||||
checksumErrs?: number
|
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 {
|
export interface ZfsDataset {
|
||||||
name: string
|
name: string
|
||||||
used?: number
|
used?: number
|
||||||
@@ -696,10 +677,9 @@ export interface MonitorCertInfo {
|
|||||||
|
|
||||||
/** Response times in microseconds and packet loss percentage (0-100). */
|
/** Response times in microseconds and packet loss percentage (0-100). */
|
||||||
export interface MonitorStats {
|
export interface MonitorStats {
|
||||||
/** null when no probe succeeded, so there is no response time */
|
res_avg: number
|
||||||
res_avg: number | null
|
res_min: number
|
||||||
res_min: number | null
|
res_max: number
|
||||||
res_max: number | null
|
|
||||||
loss: number
|
loss: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mock.module("@lingui/core/macro", () => ({
|
|||||||
plural: (_count: number, forms: { other?: string }) => forms.other ?? "",
|
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", () => {
|
describe("monitor stats derived from stored counts", () => {
|
||||||
test("retains probe weights and response precision", () => {
|
test("retains probe weights and response precision", () => {
|
||||||
@@ -39,10 +39,10 @@ describe("monitor stats derived from stored counts", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{ total_count: 3, success_count: 0, loss: 100, res: null },
|
{ total_count: 3, success_count: 0, loss: 100 },
|
||||||
{ total_count: 0, success_count: 0, loss: 0, res: null },
|
{ total_count: 0, success_count: 0, loss: 0 },
|
||||||
{ total_count: 1, success_count: 1, loss: 0, res: 0 },
|
{ total_count: 1, success_count: 1, loss: 0 },
|
||||||
])("handles zero sums with $total_count attempts and $success_count successes", ({ loss, res, ...counts }) => {
|
])("handles zero sums with $total_count attempts and $success_count successes", ({ loss, ...counts }) => {
|
||||||
const stats = getMonitorStats({
|
const stats = getMonitorStats({
|
||||||
monitor: "monitor1",
|
monitor: "monitor1",
|
||||||
created: 1000,
|
created: 1000,
|
||||||
@@ -51,39 +51,6 @@ describe("monitor stats derived from stored counts", () => {
|
|||||||
res_sum: 0,
|
res_sum: 0,
|
||||||
...counts,
|
...counts,
|
||||||
})
|
})
|
||||||
expect(stats).toEqual({ res_avg: res, res_min: res, res_max: res, loss })
|
expect(stats).toEqual({ res_avg: 0, res_min: 0, res_max: 0, 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]])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user