mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +01:00
Compare commits
20 Commits
v0.12.1
...
custom-col
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9e2dec28 | ||
|
|
a0f271545a | ||
|
|
aa2bc9f118 | ||
|
|
b22ae87022 | ||
|
|
79e79079bc | ||
|
|
1811ebdee4 | ||
|
|
137f3f3e24 | ||
|
|
ed1d1e77c0 | ||
|
|
8c36dd1caa | ||
|
|
57bfe72486 | ||
|
|
75f66b0246 | ||
|
|
ce93d54aa7 | ||
|
|
39dbe0eac5 | ||
|
|
7282044f80 | ||
|
|
d77c37c0b0 | ||
|
|
e362cbbca5 | ||
|
|
118544926b | ||
|
|
d4bb0a0a30 | ||
|
|
fe5e35d1a9 | ||
|
|
60a6ae2caa |
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -29,7 +29,17 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '^1.22.1'
|
go-version: "^1.22.1"
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "9.0.x"
|
||||||
|
|
||||||
|
- name: Build .NET LHM executable for Windows sensors
|
||||||
|
run: |
|
||||||
|
dotnet build -c Release ./beszel/internal/agent/lhm/beszel_lhm.csproj
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: GoReleaser beszel
|
- name: GoReleaser beszel
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
@@ -40,3 +50,4 @@ jobs:
|
|||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ beszel/build
|
|||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
__debug_*
|
__debug_*
|
||||||
|
beszel/internal/agent/lhm/obj
|
||||||
|
beszel/internal/agent/lhm/bin
|
||||||
|
|||||||
@@ -202,13 +202,14 @@ winget:
|
|||||||
owner: henrygd
|
owner: henrygd
|
||||||
name: beszel-winget
|
name: beszel-winget
|
||||||
branch: henrygd.beszel-agent-{{ .Version }}
|
branch: henrygd.beszel-agent-{{ .Version }}
|
||||||
pull_request:
|
token: "{{ .Env.WINGET_TOKEN }}"
|
||||||
enabled: true
|
# pull_request:
|
||||||
draft: false
|
# enabled: true
|
||||||
base:
|
# draft: false
|
||||||
owner: microsoft
|
# base:
|
||||||
name: winget-pkgs
|
# owner: microsoft
|
||||||
branch: master
|
# name: winget-pkgs
|
||||||
|
# branch: master
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ ARCH ?= $(shell go env GOARCH)
|
|||||||
# Skip building the web UI if true
|
# Skip building the web UI if true
|
||||||
SKIP_WEB ?= false
|
SKIP_WEB ?= false
|
||||||
|
|
||||||
|
# Set executable extension based on target OS
|
||||||
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
.PHONY: tidy build-agent build-hub build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
@@ -30,11 +33,25 @@ build-web-ui:
|
|||||||
npm run --prefix ./site build; \
|
npm run --prefix ./site build; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-agent: tidy
|
# Conditional .NET build - only for Windows
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/agent
|
build-dotnet-conditional:
|
||||||
|
@if [ "$(OS)" = "windows" ]; then \
|
||||||
|
echo "Building .NET executable for Windows..."; \
|
||||||
|
if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update build-agent to include conditional .NET build
|
||||||
|
build-agent: tidy build-dotnet-conditional
|
||||||
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH) -ldflags "-w -s" beszel/cmd/hub
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" beszel/cmd/hub
|
||||||
|
|
||||||
build: build-agent build-hub
|
build: build-agent build-hub
|
||||||
|
|
||||||
@@ -67,6 +84,15 @@ dev-agent:
|
|||||||
else \
|
else \
|
||||||
go run beszel/cmd/agent; \
|
go run beszel/cmd/agent; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-dotnet:
|
||||||
|
@if command -v dotnet >/dev/null 2>&1; then \
|
||||||
|
rm -rf ./internal/agent/lhm/bin; \
|
||||||
|
dotnet build -c Release ./internal/agent/lhm/beszel_lhm.csproj; \
|
||||||
|
else \
|
||||||
|
echo "dotnet not found"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# KEY="..." make -j dev
|
# KEY="..." make -j dev
|
||||||
dev: dev-server dev-hub dev-agent
|
dev: dev-server dev-hub dev-agent
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -25,13 +26,16 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
|
builder := strings.Builder{}
|
||||||
fmt.Println("\nCommands:")
|
builder.WriteString("Usage: ")
|
||||||
fmt.Println(" health Check if the agent is running")
|
builder.WriteString(os.Args[0])
|
||||||
fmt.Println(" help Display this help message")
|
builder.WriteString(" [command] [flags]\n")
|
||||||
fmt.Println(" update Update to the latest version")
|
builder.WriteString("\nCommands:\n")
|
||||||
fmt.Println(" version Display the version")
|
builder.WriteString(" health Check if the agent is running\n")
|
||||||
fmt.Println("\nFlags:")
|
builder.WriteString(" help Display this help message\n")
|
||||||
|
builder.WriteString(" update Update to the latest version\n")
|
||||||
|
builder.WriteString("\nFlags:\n")
|
||||||
|
fmt.Print(builder.String())
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +115,12 @@ func main() {
|
|||||||
serverConfig.Addr = addr
|
serverConfig.Addr = addr
|
||||||
serverConfig.Network = agent.GetNetwork(addr)
|
serverConfig.Network = agent.GetNetwork(addr)
|
||||||
|
|
||||||
agent, err := agent.NewAgent("")
|
a, err := agent.NewAgent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to create agent: ", err)
|
log.Fatal("Failed to create agent: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := agent.Start(serverConfig); err != nil {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start server: ", err)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ type Agent struct {
|
|||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
// If the data directory is not set, it will attempt to find the optimal directory.
|
// If the data directory is not set, it will attempt to find the optimal directory.
|
||||||
func NewAgent(dataDir string) (agent *Agent, err error) {
|
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||||
agent = &Agent{
|
agent = &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir)
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
} else {
|
} else {
|
||||||
@@ -113,37 +113,37 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cachedData, ok := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(sessionID)
|
||||||
if ok {
|
if isCached {
|
||||||
slog.Debug("Cached stats", "session", sessionID)
|
slog.Debug("Cached data", "session", sessionID)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*cachedData = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", cachedData)
|
slog.Debug("System data", "data", data)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
cachedData.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", cachedData.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Docker stats", "err", err)
|
slog.Debug("Containers", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
cachedData.Stats.ExtraFs[name] = stats
|
data.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(sessionID, cachedData)
|
a.cache.Set(sessionID, data)
|
||||||
return cachedData
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
80
beszel/internal/agent/lhm/beszel_lhm.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using LibreHardwareMonitor.Hardware;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
var computer = new Computer
|
||||||
|
{
|
||||||
|
IsCpuEnabled = true,
|
||||||
|
IsGpuEnabled = true,
|
||||||
|
IsMemoryEnabled = true,
|
||||||
|
IsMotherboardEnabled = true,
|
||||||
|
IsStorageEnabled = true,
|
||||||
|
// IsPsuEnabled = true,
|
||||||
|
// IsNetworkEnabled = true,
|
||||||
|
};
|
||||||
|
computer.Open();
|
||||||
|
|
||||||
|
var reader = Console.In;
|
||||||
|
var writer = Console.Out;
|
||||||
|
|
||||||
|
string line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var hw in computer.Hardware)
|
||||||
|
{
|
||||||
|
// process main hardware sensors
|
||||||
|
ProcessSensors(hw, writer);
|
||||||
|
|
||||||
|
// process subhardware sensors
|
||||||
|
foreach (var subhardware in hw.SubHardware)
|
||||||
|
{
|
||||||
|
ProcessSensors(subhardware, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// send empty line to signal end of sensor data
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computer.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
|
||||||
|
{
|
||||||
|
var updated = false;
|
||||||
|
foreach (var sensor in hardware.Sensors)
|
||||||
|
{
|
||||||
|
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
|
||||||
|
if (!validTemp || sensor.Name.Contains("Distance"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated)
|
||||||
|
{
|
||||||
|
hardware.Update();
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = sensor.Name;
|
||||||
|
// if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
|
||||||
|
// usually this is a number like Temperature 3
|
||||||
|
if (sensor.Name.StartsWith("Temperature"))
|
||||||
|
{
|
||||||
|
name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// invariant culture assures the value is parsable as a float
|
||||||
|
var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
// write the name and value to the writer
|
||||||
|
writer.WriteLine($"{name}|{value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
11
beszel/internal/agent/lhm/beszel_lhm.csproj
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -84,10 +84,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry once on panic (gopsutil/issues/1832)
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error updating temperatures", "err", err)
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
if len(systemStats.Temperatures) > 0 {
|
if len(systemStats.Temperatures) > 0 {
|
||||||
|
|||||||
9
beszel/internal/agent/sensors_default.go
Normal file
9
beszel/internal/agent/sensors_default.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var getSensorTemps = sensors.TemperaturesWithContext
|
||||||
281
beszel/internal/agent/sensors_windows.go
Normal file
281
beszel/internal/agent/sensors_windows.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
//go:generate dotnet build -c Release lhm/beszel_lhm.csproj
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: This is always called from Agent.gatherStats() which holds Agent.Lock(),
|
||||||
|
// so no internal concurrency protection is needed.
|
||||||
|
|
||||||
|
// lhmProcess is a wrapper around the LHM .NET process.
|
||||||
|
type lhmProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
isRunning bool
|
||||||
|
stoppedNoSensors bool
|
||||||
|
consecutiveNoSensors uint8
|
||||||
|
execPath string
|
||||||
|
tempDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed all:lhm/bin/Release/net48
|
||||||
|
var lhmFs embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
beszelLhm *lhmProcess
|
||||||
|
beszelLhmOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoSensors = errors.New("no sensors found (try running as admin)")
|
||||||
|
|
||||||
|
// newlhmProcess copies the embedded LHM executable to a temporary directory and starts it.
|
||||||
|
func newlhmProcess() (*lhmProcess, error) {
|
||||||
|
destDir := filepath.Join(os.TempDir(), "beszel")
|
||||||
|
execPath := filepath.Join(destDir, "beszel_lhm.exe")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only copy if executable doesn't exist
|
||||||
|
if _, err := os.Stat(execPath); os.IsNotExist(err) {
|
||||||
|
if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to copy embedded directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm := &lhmProcess{
|
||||||
|
execPath: execPath,
|
||||||
|
tempDir: destDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.startProcess(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lhm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcess starts the external LHM process
|
||||||
|
func (lhm *lhmProcess) startProcess() error {
|
||||||
|
// Clean up any existing process
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
|
||||||
|
cmd := exec.Command(lhm.execPath)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
stdin.Close()
|
||||||
|
stdout.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update process state
|
||||||
|
lhm.cmd = cmd
|
||||||
|
lhm.stdin = stdin
|
||||||
|
lhm.stdout = stdout
|
||||||
|
lhm.scanner = bufio.NewScanner(stdout)
|
||||||
|
lhm.isRunning = true
|
||||||
|
|
||||||
|
// Give process a moment to initialize
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupProcess terminates the process and closes resources but preserves files
|
||||||
|
func (lhm *lhmProcess) cleanupProcess() {
|
||||||
|
lhm.isRunning = false
|
||||||
|
|
||||||
|
if lhm.cmd != nil && lhm.cmd.Process != nil {
|
||||||
|
lhm.cmd.Process.Kill()
|
||||||
|
lhm.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhm.stdin != nil {
|
||||||
|
lhm.stdin.Close()
|
||||||
|
lhm.stdin = nil
|
||||||
|
}
|
||||||
|
if lhm.stdout != nil {
|
||||||
|
lhm.stdout.Close()
|
||||||
|
lhm.stdout = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.cmd = nil
|
||||||
|
lhm.scanner = nil
|
||||||
|
lhm.stoppedNoSensors = false
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
if lhm.stoppedNoSensors {
|
||||||
|
// Fall back to gopsutil if we can't get sensors from LHM
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start process if it's not running
|
||||||
|
if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil {
|
||||||
|
err := lhm.startProcess()
|
||||||
|
if err != nil {
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to process
|
||||||
|
_, err = fmt.Fprintln(lhm.stdin, "getTemps")
|
||||||
|
if err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all sensor lines until we hit an empty line or EOF
|
||||||
|
for lhm.scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(lhm.scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
slog.Debug("Invalid sensor format", "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(parts[0])
|
||||||
|
valueStr := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(valueStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Failed to parse sensor", "err", err, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" || value <= 0 || value > 150 {
|
||||||
|
slog.Debug("Invalid sensor", "name", name, "val", value, "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
temps = append(temps, sensors.TemperatureStat{
|
||||||
|
SensorKey: name,
|
||||||
|
Temperature: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lhm.scanner.Err(); err != nil {
|
||||||
|
lhm.isRunning = false
|
||||||
|
return temps, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle no sensors case
|
||||||
|
if len(temps) == 0 {
|
||||||
|
lhm.consecutiveNoSensors++
|
||||||
|
if lhm.consecutiveNoSensors >= 3 {
|
||||||
|
lhm.stoppedNoSensors = true
|
||||||
|
slog.Warn(errNoSensors.Error())
|
||||||
|
lhm.cleanup()
|
||||||
|
}
|
||||||
|
return sensors.TemperaturesWithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
lhm.consecutiveNoSensors = 0
|
||||||
|
|
||||||
|
return temps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||||
|
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||||
|
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Error reading sensors", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize process once
|
||||||
|
beszelLhmOnce.Do(func() {
|
||||||
|
beszelLhm, err = newlhmProcess()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return temps, fmt.Errorf("failed to initialize lhm: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if beszelLhm == nil {
|
||||||
|
return temps, fmt.Errorf("lhm not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return beszelLhm.getTemps(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup terminates the process and closes resources
|
||||||
|
func (lhm *lhmProcess) cleanup() {
|
||||||
|
lhm.cleanupProcess()
|
||||||
|
if lhm.tempDir != "" {
|
||||||
|
os.RemoveAll(lhm.tempDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEmbeddedDir copies the embedded directory to the destination path
|
||||||
|
func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error {
|
||||||
|
entries, err := fs.ReadDir(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
srcEntryPath := path.Join(srcPath, entry.Name())
|
||||||
|
destEntryPath := filepath.Join(destPath, entry.Name())
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(srcEntryPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(destEntryPath, data, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -293,18 +293,11 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// app.Logger().Error("failed to save alert record", "err", err)
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
am.SendAlert(AlertMessageData{
|
||||||
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
Title: subject,
|
||||||
return
|
Message: body,
|
||||||
}
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
LinkText: "View " + systemName,
|
||||||
am.SendAlert(AlertMessageData{
|
})
|
||||||
UserID: user.Id,
|
|
||||||
Title: subject,
|
|
||||||
Message: body,
|
|
||||||
Link: am.hub.MakeLink("system", systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,29 +43,26 @@ func (um *UserManager) InitializeUserRole(e *core.RecordEvent) error {
|
|||||||
// Initialize user settings with defaults if not set
|
// Initialize user settings with defaults if not set
|
||||||
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||||
record := e.Record
|
record := e.Record
|
||||||
// intialize settings with defaults
|
// intialize settings with defaults (zero values can be ignored)
|
||||||
settings := UserSettings{
|
settings := UserSettings{
|
||||||
ChartTime: "1h",
|
ChartTime: "1h",
|
||||||
NotificationEmails: []string{},
|
|
||||||
NotificationWebhooks: []string{},
|
|
||||||
}
|
}
|
||||||
record.UnmarshalJSONField("settings", &settings)
|
record.UnmarshalJSONField("settings", &settings)
|
||||||
if len(settings.NotificationEmails) == 0 {
|
// get user email from auth record
|
||||||
// get user email from auth record
|
var user struct {
|
||||||
if errs := um.app.ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 {
|
Email string `db:"email"`
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
}
|
||||||
if user := record.ExpandedOne("user"); user != nil {
|
err := e.App.DB().NewQuery("SELECT email FROM users WHERE id = {:id}").Bind(dbx.Params{
|
||||||
settings.NotificationEmails = []string{user.GetString("email")}
|
"id": record.GetString("user"),
|
||||||
} else {
|
}).One(&user)
|
||||||
log.Println("Failed to get user email from auth record")
|
if err != nil {
|
||||||
}
|
log.Println("failed to get user email", "err", err)
|
||||||
} else {
|
return err
|
||||||
log.Println("failed to expand user relation", "errs", errs)
|
}
|
||||||
}
|
settings.NotificationEmails = []string{user.Email}
|
||||||
|
if len(settings.NotificationWebhooks) == 0 {
|
||||||
|
settings.NotificationWebhooks = []string{""}
|
||||||
}
|
}
|
||||||
// if len(settings.NotificationWebhooks) == 0 {
|
|
||||||
// settings.NotificationWebhooks = []string{""}
|
|
||||||
// }
|
|
||||||
record.Set("settings", settings)
|
record.Set("settings", settings)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
4
beszel/site/package-lock.json
generated
4
beszel/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -72,18 +72,18 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
|
||||||
// alertsSignature changes only when alerts for this system change
|
/* key to prevent re-rendering */
|
||||||
let alertsSignature = ""
|
const alertsSignature: string[] = []
|
||||||
|
|
||||||
const systemAlerts = alerts.filter((alert) => {
|
const systemAlerts = alerts.filter((alert) => {
|
||||||
if (alert.system === system.id) {
|
if (alert.system === system.id) {
|
||||||
alertsSignature += alert.name + alert.min + alert.value
|
alertsSignature.push(alert.name, alert.min, alert.value)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}) as AlertRecord[]
|
}) as AlertRecord[]
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// console.log("render modal", system.name, alertsSignature)
|
|
||||||
const data = Object.keys(alertInfo).map((name) => {
|
const data = Object.keys(alertInfo).map((name) => {
|
||||||
const alert = alertInfo[name as keyof typeof alertInfo]
|
const alert = alertInfo[name as keyof typeof alertInfo]
|
||||||
return {
|
return {
|
||||||
@@ -149,5 +149,5 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}, [alertsSignature, overwriteExisting])
|
}, [alertsSignature.join(""), overwriteExisting])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,5 +87,5 @@ export default function AreaChartDefault({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [chartData.systemStats.length, yAxisWidth, maxToggled])
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export const Home = memo(() => {
|
|||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
let alertsKey = ""
|
/* key to prevent re-rendering of active alerts */
|
||||||
|
const alertsKey: string[] = []
|
||||||
|
|
||||||
const activeAlerts = useMemo(() => {
|
const activeAlerts = useMemo(() => {
|
||||||
const activeAlerts = alerts.filter((alert) => {
|
const activeAlerts = alerts.filter((alert) => {
|
||||||
const active = alert.triggered && alert.name in alertInfo
|
const active = alert.triggered && alert.name in alertInfo
|
||||||
@@ -26,7 +28,7 @@ export const Home = memo(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
||||||
alertsKey += alert.id
|
alertsKey.push(alert.id)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return activeAlerts
|
return activeAlerts
|
||||||
@@ -81,7 +83,7 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey]
|
[alertsKey.join("")]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,30 @@ import { useState } from "react"
|
|||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
// import { setLang } from "@/lib/i18n"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
|
|
||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
// Remove all per-metric threshold state and UI
|
||||||
|
// Only keep general yellow/red threshold state and UI
|
||||||
|
const [yellow, setYellow] = useState(userSettings.meterThresholds?.yellow ?? 65)
|
||||||
|
const [red, setRed] = useState(userSettings.meterThresholds?.red ?? 90)
|
||||||
|
|
||||||
|
function handleResetThresholds() {
|
||||||
|
setYellow(65)
|
||||||
|
setRed(90)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
|
data.meterThresholds = { yellow, red }
|
||||||
await saveSettings(data)
|
await saveSettings(data)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -101,6 +114,45 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Dashboard meter thresholds</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Choose when the dashboard meters changes colors, based on percentage values.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="yellow-threshold"><Trans>Warning threshold (%)</Trans></Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="yellow-threshold"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={yellow}
|
||||||
|
onChange={e => setYellow(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="red-threshold"><Trans>Danger threshold (%)</Trans></Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="red-threshold"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={red}
|
||||||
|
onChange={e => setRed(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleResetThresholds} disabled={isLoading} className="mt-4">
|
||||||
|
<Trans>Reset to default</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{/* Unit preferences section fixed and wrapped in a div */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
@@ -133,7 +185,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="block" htmlFor="unitNet">
|
<Label className="block" htmlFor="unitNet">
|
||||||
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
<Trans comment="Context: Bytes or bits">Network unit</Trans>
|
||||||
@@ -156,7 +207,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="block" htmlFor="unitDisk">
|
<Label className="block" htmlFor="unitDisk">
|
||||||
<Trans>Disk unit</Trans>
|
<Trans>Disk unit</Trans>
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { SystemRecord } from "@/types"
|
||||||
|
import { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||||
|
import { ClassValue } from "clsx"
|
||||||
|
import {
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
CopyIcon,
|
||||||
|
CpuIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
MemoryStickIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
WifiIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
copyToClipboard,
|
||||||
|
decimalString,
|
||||||
|
formatBytes,
|
||||||
|
formatTemperature,
|
||||||
|
isReadOnlyUser,
|
||||||
|
parseSemVer,
|
||||||
|
} from "@/lib/utils"
|
||||||
|
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $userSettings, pb } from "@/lib/stores"
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useMemo, useRef, useState } from "react"
|
||||||
|
import { memo } from "react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
import { Dialog } from "../ui/dialog"
|
||||||
|
import { SystemDialog } from "../add-system"
|
||||||
|
import { AlertDialog } from "../ui/alert-dialog"
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../ui/alert-dialog"
|
||||||
|
import { buttonVariants } from "../ui/button"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param viewMode - "table" or "grid"
|
||||||
|
* @returns - Column definitions for the systems table
|
||||||
|
*/
|
||||||
|
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
|
const statusTranslations = {
|
||||||
|
up: () => t`Up`.toLowerCase(),
|
||||||
|
down: () => t`Down`.toLowerCase(),
|
||||||
|
paused: () => t`Paused`.toLowerCase(),
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
size: 200,
|
||||||
|
minSize: 0,
|
||||||
|
accessorKey: "name",
|
||||||
|
id: "system",
|
||||||
|
name: () => t`System`,
|
||||||
|
filterFn: (row, _, filterVal) => {
|
||||||
|
const filterLower = filterVal.toLowerCase()
|
||||||
|
const { name, status } = row.original
|
||||||
|
// Check if the filter matches the name or status for this row
|
||||||
|
if (
|
||||||
|
name.toLowerCase().includes(filterLower) ||
|
||||||
|
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
enableHiding: false,
|
||||||
|
invertSorting: false,
|
||||||
|
Icon: ServerIcon,
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
{info.getValue() as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.cpu,
|
||||||
|
id: "cpu",
|
||||||
|
name: () => t`CPU`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: CpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorKey: "info.mp",
|
||||||
|
accessorFn: ({ info }) => info.mp,
|
||||||
|
id: "memory",
|
||||||
|
name: () => t`Memory`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: MemoryStickIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dp,
|
||||||
|
id: "disk",
|
||||||
|
name: () => t`Disk`,
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: HardDriveIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.g,
|
||||||
|
id: "gpu",
|
||||||
|
name: () => "GPU",
|
||||||
|
cell: CellFormatter,
|
||||||
|
Icon: GpuIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loadAverage",
|
||||||
|
accessorFn: ({ info }) => {
|
||||||
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
|
// TODO: remove this in future release in favor of la array
|
||||||
|
if (!sum) {
|
||||||
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
|
size: 0,
|
||||||
|
Icon: HourglassIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const { info: sysInfo, status } = info.row.original
|
||||||
|
// agent version
|
||||||
|
const { minor, patch } = parseSemVer(sysInfo.v)
|
||||||
|
let loadAverages = sysInfo.la
|
||||||
|
|
||||||
|
// use legacy load averages if agent version is less than 12.1.0
|
||||||
|
if (!loadAverages || (minor === 12 && patch < 1)) {
|
||||||
|
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...loadAverages)
|
||||||
|
if (max === 0 && (status === "paused" || minor < 12)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotColor() {
|
||||||
|
const normalized = max / (sysInfo.t ?? 1)
|
||||||
|
if (status !== "up") return "bg-primary/30"
|
||||||
|
if (normalized < 0.7) return "bg-green-500"
|
||||||
|
if (normalized < 1) return "bg-yellow-500"
|
||||||
|
return "bg-red-600"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||||
|
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
|
||||||
|
{loadAverages?.map((la, i) => (
|
||||||
|
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
||||||
|
id: "net",
|
||||||
|
name: () => t`Net`,
|
||||||
|
size: 0,
|
||||||
|
Icon: EthernetIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const sys = info.row.original
|
||||||
|
if (sys.status === "paused") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.dt,
|
||||||
|
id: "temp",
|
||||||
|
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
||||||
|
size: 50,
|
||||||
|
hideSort: true,
|
||||||
|
Icon: ThermometerIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const val = info.getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
|
return (
|
||||||
|
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||||
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.v,
|
||||||
|
id: "agent",
|
||||||
|
name: () => t`Agent`,
|
||||||
|
// invertSorting: true,
|
||||||
|
size: 50,
|
||||||
|
Icon: WifiIcon,
|
||||||
|
hideSort: true,
|
||||||
|
header: sortableHeader,
|
||||||
|
cell(info) {
|
||||||
|
const version = info.getValue() as string
|
||||||
|
if (!version) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const system = info.row.original
|
||||||
|
return (
|
||||||
|
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
||||||
|
<IndicatorDot
|
||||||
|
system={system}
|
||||||
|
className={
|
||||||
|
(system.status !== "up" && "bg-primary/30") ||
|
||||||
|
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
||||||
|
"bg-yellow-500"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
// @ts-ignore
|
||||||
|
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||||
|
size: 50,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end items-center gap-1 -ms-3">
|
||||||
|
<AlertButton system={row.original} />
|
||||||
|
<ActionsButton system={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as ColumnDef<SystemRecord>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
|
||||||
|
const { column } = context
|
||||||
|
// @ts-ignore
|
||||||
|
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 px-3 flex"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="me-2 size-4" />}
|
||||||
|
{name()}
|
||||||
|
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const val = Number(info.getValue()) || 0
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
|
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
|
(info.row.original.status !== "up" && "bg-primary/30") ||
|
||||||
|
(val < 65 && "bg-green-500") ||
|
||||||
|
(val < 90 && "bg-yellow-500") ||
|
||||||
|
"bg-red-600"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `scalex(${val / 100})`,
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
|
className ||= {
|
||||||
|
"bg-green-500": system.status === "up",
|
||||||
|
"bg-red-500": system.status === "down",
|
||||||
|
"bg-primary/40": system.status === "paused",
|
||||||
|
"bg-yellow-500": system.status === "pending",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
|
// style={{ marginBottom: "-1px" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
let editOpened = useRef(false)
|
||||||
|
const { t } = useLingui()
|
||||||
|
const { id, status, host, name } = system
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size={"icon"} data-nolink>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{!isReadOnlyUser() && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
editOpened.current = true
|
||||||
|
setEditOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={cn(isReadOnlyUser() && "hidden")}
|
||||||
|
onClick={() => {
|
||||||
|
pb.collection("systems").update(id, {
|
||||||
|
status: status === "paused" ? "pending" : "paused",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "paused" ? (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(name)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy name</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Copy host</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
||||||
|
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* edit dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
||||||
|
</Dialog>
|
||||||
|
{/* deletion dialog */}
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Are you sure you want to delete {name}?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This action cannot be undone. This will permanently delete all current records for {name} from the
|
||||||
|
database.
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
||||||
|
onClick={() => pb.collection("systems").delete(id)}
|
||||||
|
>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [id, status, host, name, t, deleteOpen, editOpen])
|
||||||
|
})
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CellContext,
|
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
@@ -9,14 +8,13 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
HeaderContext,
|
|
||||||
Row,
|
Row,
|
||||||
Table as TableType,
|
Table as TableType,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -29,78 +27,46 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import {
|
import {
|
||||||
MoreHorizontalIcon,
|
|
||||||
ArrowUpDownIcon,
|
ArrowUpDownIcon,
|
||||||
MemoryStickIcon,
|
|
||||||
CopyIcon,
|
|
||||||
PauseCircleIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
WifiIcon,
|
|
||||||
HardDriveIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CpuIcon,
|
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
PenBoxIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useState } from "react"
|
||||||
import { $systems, $userSettings, pb } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import {
|
import { cn, useLocalStorage } from "@/lib/utils"
|
||||||
cn,
|
|
||||||
copyToClipboard,
|
|
||||||
isReadOnlyUser,
|
|
||||||
useLocalStorage,
|
|
||||||
formatTemperature,
|
|
||||||
decimalString,
|
|
||||||
formatBytes,
|
|
||||||
parseSemVer,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import AlertsButton from "../alerts/alert-button"
|
|
||||||
import { $router, Link, navigate } from "../router"
|
import { $router, Link, navigate } from "../router"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
|
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { ClassValue } from "clsx"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { SystemDialog } from "../add-system"
|
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
import { Dialog } from "../ui/dialog"
|
import AlertButton from "../alerts/alert-button"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
|
|
||||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||||
const val = Number(info.getValue()) || 0
|
const val = (info.getValue() as number) || 0
|
||||||
|
const userSettings = useStore($userSettings)
|
||||||
|
const yellow = userSettings?.meterThresholds?.yellow ?? 65
|
||||||
|
const red = userSettings?.meterThresholds?.red ?? 90
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
|
<span className="min-w-8">{decimalString(val, 1)}%</span>
|
||||||
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
(info.row.original.status !== "up" && "bg-primary/30") ||
|
(info.row.original.status !== "up" && "bg-primary/30") ||
|
||||||
(val < 65 && "bg-green-500") ||
|
(val < yellow! && "bg-green-500") ||
|
||||||
(val < 90 && "bg-yellow-500") ||
|
(val < red! && "bg-yellow-500") ||
|
||||||
"bg-red-600"
|
"bg-red-600"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: `scalex(${val / 100})`,
|
transform: `scalex(${val / 100})`,
|
||||||
@@ -145,218 +111,7 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columnDefs = useMemo(() => {
|
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [])
|
||||||
const statusTranslations = {
|
|
||||||
up: () => t`Up`.toLowerCase(),
|
|
||||||
down: () => t`Down`.toLowerCase(),
|
|
||||||
paused: () => t`Paused`.toLowerCase(),
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
size: 200,
|
|
||||||
minSize: 0,
|
|
||||||
accessorKey: "name",
|
|
||||||
id: "system",
|
|
||||||
name: () => t`System`,
|
|
||||||
filterFn: (row, _, filterVal) => {
|
|
||||||
const filterLower = filterVal.toLowerCase()
|
|
||||||
const { name, status } = row.original
|
|
||||||
// Check if the filter matches the name or status for this row
|
|
||||||
if (
|
|
||||||
name.toLowerCase().includes(filterLower) ||
|
|
||||||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
enableHiding: false,
|
|
||||||
invertSorting: false,
|
|
||||||
Icon: ServerIcon,
|
|
||||||
cell: (info) => (
|
|
||||||
<span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
|
|
||||||
<IndicatorDot system={info.row.original} />
|
|
||||||
<Button
|
|
||||||
data-nolink
|
|
||||||
variant={"ghost"}
|
|
||||||
className="text-primary/90 h-7 px-1.5 gap-1.5"
|
|
||||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
|
||||||
>
|
|
||||||
{info.getValue() as string}
|
|
||||||
<CopyIcon className="size-2.5" />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.cpu,
|
|
||||||
id: "cpu",
|
|
||||||
name: () => t`CPU`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: CpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// accessorKey: "info.mp",
|
|
||||||
accessorFn: ({ info }) => info.mp,
|
|
||||||
id: "memory",
|
|
||||||
name: () => t`Memory`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: MemoryStickIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.dp,
|
|
||||||
id: "disk",
|
|
||||||
name: () => t`Disk`,
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: HardDriveIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.g,
|
|
||||||
id: "gpu",
|
|
||||||
name: () => "GPU",
|
|
||||||
cell: CellFormatter,
|
|
||||||
Icon: GpuIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loadAverage",
|
|
||||||
accessorFn: ({ info }) => {
|
|
||||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
|
||||||
// TODO: remove this in future release in favor of la array
|
|
||||||
if (!sum) {
|
|
||||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
},
|
|
||||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
|
||||||
size: 0,
|
|
||||||
Icon: HourglassIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const { info: sysInfo, status } = info.row.original
|
|
||||||
// agent version
|
|
||||||
const { minor, patch } = parseSemVer(sysInfo.v)
|
|
||||||
let loadAverages = sysInfo.la
|
|
||||||
|
|
||||||
// use legacy load averages if agent version is less than 12.1.0
|
|
||||||
if (!loadAverages || (minor === 12 && patch < 1)) {
|
|
||||||
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max(...loadAverages)
|
|
||||||
if (max === 0 && (status === "paused" || minor < 12)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDotColor() {
|
|
||||||
const normalized = max / (sysInfo.t ?? 1)
|
|
||||||
if (status !== "up") return "bg-primary/30"
|
|
||||||
if (normalized < 0.7) return "bg-green-500"
|
|
||||||
if (normalized < 1) return "bg-yellow-500"
|
|
||||||
return "bg-red-600"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
|
||||||
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
|
|
||||||
{loadAverages?.map((la, i) => (
|
|
||||||
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
|
||||||
id: "net",
|
|
||||||
name: () => t`Net`,
|
|
||||||
size: 0,
|
|
||||||
Icon: EthernetIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const sys = info.row.original
|
|
||||||
if (sys.status === "paused") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
|
||||||
return (
|
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.dt,
|
|
||||||
id: "temp",
|
|
||||||
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
|
|
||||||
size: 50,
|
|
||||||
hideSort: true,
|
|
||||||
Icon: ThermometerIcon,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const val = info.getValue() as number
|
|
||||||
if (!val) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
|
||||||
return (
|
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorFn: ({ info }) => info.v,
|
|
||||||
id: "agent",
|
|
||||||
name: () => t`Agent`,
|
|
||||||
// invertSorting: true,
|
|
||||||
size: 50,
|
|
||||||
Icon: WifiIcon,
|
|
||||||
hideSort: true,
|
|
||||||
header: sortableHeader,
|
|
||||||
cell(info) {
|
|
||||||
const version = info.getValue() as string
|
|
||||||
if (!version) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const system = info.row.original
|
|
||||||
return (
|
|
||||||
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
|
|
||||||
<IndicatorDot
|
|
||||||
system={system}
|
|
||||||
className={
|
|
||||||
(system.status !== "up" && "bg-primary/30") ||
|
|
||||||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
|
||||||
"bg-yellow-500"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
// @ts-ignore
|
|
||||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
|
||||||
size: 50,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end items-center gap-1 -ms-3">
|
|
||||||
<AlertsButton system={row.original} />
|
|
||||||
<ActionsButton system={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] as ColumnDef<SystemRecord>[]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -634,7 +389,7 @@ const SystemCard = memo(
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
<div className="flex gap-1 flex-shrink-0 relative z-10">
|
||||||
<AlertsButton system={system} />
|
<AlertButton system={system} />
|
||||||
<ActionsButton system={system} />
|
<ActionsButton system={system} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -669,116 +424,3 @@ const SystemCard = memo(
|
|||||||
}, [system, colLength, t])
|
}, [system, colLength, t])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
let editOpened = useRef(false)
|
|
||||||
const { t } = useLingui()
|
|
||||||
const { id, status, host, name } = system
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{!isReadOnlyUser() && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
editOpened.current = true
|
|
||||||
setEditOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Edit</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(isReadOnlyUser() && "hidden")}
|
|
||||||
onClick={() => {
|
|
||||||
pb.collection("systems").update(id, {
|
|
||||||
status: status === "paused" ? "pending" : "paused",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "paused" ? (
|
|
||||||
<>
|
|
||||||
<PlayCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Resume</Trans>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PauseCircleIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Pause</Trans>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
|
|
||||||
<CopyIcon className="me-2.5 size-4" />
|
|
||||||
<Trans>Copy host</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
|
|
||||||
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
|
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* edit dialog */}
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
||||||
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
|
|
||||||
</Dialog>
|
|
||||||
{/* deletion dialog */}
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
<Trans>Are you sure you want to delete {name}?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<Trans>
|
|
||||||
This action cannot be undone. This will permanently delete all current records for {name} from the
|
|
||||||
database.
|
|
||||||
</Trans>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={() => pb.collection("systems").delete(id)}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [id, status, host, name, t, deleteOpen, editOpen])
|
|
||||||
})
|
|
||||||
|
|
||||||
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
|
||||||
className ||= {
|
|
||||||
"bg-green-500": system.status === "up",
|
|
||||||
"bg-red-500": system.status === "down",
|
|
||||||
"bg-primary/40": system.status === "paused",
|
|
||||||
"bg-yellow-500": system.status === "pending",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
|
||||||
// style={{ marginBottom: "-1px" }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const $maxValues = atom(false)
|
|||||||
export const $userSettings = map<UserSettings>({
|
export const $userSettings = map<UserSettings>({
|
||||||
chartTime: "1h",
|
chartTime: "1h",
|
||||||
emails: [pb.authStore.record?.email || ""],
|
emails: [pb.authStore.record?.email || ""],
|
||||||
|
meterThresholds: {
|
||||||
|
yellow: 65,
|
||||||
|
red: 90,
|
||||||
|
},
|
||||||
// unitTemp: "celsius",
|
// unitTemp: "celsius",
|
||||||
// unitNet: "mbps",
|
// unitNet: "mbps",
|
||||||
// unitDisk: "mbps",
|
// unitDisk: "mbps",
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "نسخ المضيف"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "نسخ أمر لينكس"
|
msgstr "نسخ أمر لينكس"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "نسخ الاسم"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "نسخ النص"
|
msgstr "نسخ النص"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Копирай хоста"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Копирай linux командата"
|
msgstr "Копирай linux командата"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Копирай име"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Копирай текста"
|
msgstr "Копирай текста"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopírovat hostitele"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopírovat příkaz Linux"
|
msgstr "Kopírovat příkaz Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopírovat název"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopírovat text"
|
msgstr "Kopírovat text"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopier host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopier Linux kommando"
|
msgstr "Kopier Linux kommando"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopier navn"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopier tekst"
|
msgstr "Kopier tekst"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Host kopieren"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Linux-Befehl kopieren"
|
msgstr "Linux-Befehl kopieren"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Name kopieren"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Text kopieren"
|
msgstr "Text kopieren"
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ msgstr "Copy host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copy Linux command"
|
msgstr "Copy Linux command"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copy name"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copy text"
|
msgstr "Copy text"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Copiar host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copiar comando de Linux"
|
msgstr "Copiar comando de Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copiar nombre"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copiar texto"
|
msgstr "Copiar texto"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "کپی میزبان"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "کپی دستور لینوکس"
|
msgstr "کپی دستور لینوکس"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "کپی نام"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "کپی متن"
|
msgstr "کپی متن"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Copier l'hôte"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copier la commande Linux"
|
msgstr "Copier la commande Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copier le nom"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copier le texte"
|
msgstr "Copier le texte"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopiraj hosta"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopiraj Linux komandu"
|
msgstr "Kopiraj Linux komandu"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopiraj naziv"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopiraj tekst"
|
msgstr "Kopiraj tekst"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Hoszt másolása"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Linux parancs másolása"
|
msgstr "Linux parancs másolása"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Név másolása"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Szöveg másolása"
|
msgstr "Szöveg másolása"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Afrita host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Afrita Linux aðgerð"
|
msgstr "Afrita Linux aðgerð"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Afrita nafn"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Afrita texta"
|
msgstr "Afrita texta"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Copia host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copia comando Linux"
|
msgstr "Copia comando Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copia nome"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copia testo"
|
msgstr "Copia testo"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "ホストをコピー"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Linuxコマンドをコピー"
|
msgstr "Linuxコマンドをコピー"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "名前をコピー"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "テキストをコピー"
|
msgstr "テキストをコピー"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "호스트 복사"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "리눅스 명령어 복사"
|
msgstr "리눅스 명령어 복사"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "이름 복사"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "텍스트 복사"
|
msgstr "텍스트 복사"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopieer host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopieer Linux-opdracht"
|
msgstr "Kopieer Linux-opdracht"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopieer naam"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopieer tekst"
|
msgstr "Kopieer tekst"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopier vert"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopier Linux-kommando"
|
msgstr "Kopier Linux-kommando"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopier navn"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopier tekst"
|
msgstr "Kopier tekst"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopiuj host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopiuj polecenie Linux"
|
msgstr "Kopiuj polecenie Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopiuj nazwę"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopiuj tekst"
|
msgstr "Kopiuj tekst"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Copiar host"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Copiar comando Linux"
|
msgstr "Copiar comando Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Copiar nome"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Copiar texto"
|
msgstr "Copiar texto"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Копировать хост"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Копировать команду Linux"
|
msgstr "Копировать команду Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Копировать имя"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Копировать текст"
|
msgstr "Копировать текст"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Kopiraj gostitelja"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopiraj Linux ukaz"
|
msgstr "Kopiraj Linux ukaz"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopiraj ime"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopiraj besedilo"
|
msgstr "Kopiraj besedilo"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: sv\n"
|
"Language: sv\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-07-25 22:44\n"
|
"PO-Revision-Date: 2025-08-01 23:21\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Swedish\n"
|
"Language-Team: Swedish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -27,7 +27,7 @@ msgstr "{0, plural, one {# dag} other {# dagar}}"
|
|||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "{0} of {1} row(s) selected."
|
msgid "{0} of {1} row(s) selected."
|
||||||
msgstr ""
|
msgstr "{0} av {1} rad(er) valda."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{hours, plural, one {# hour} other {# hours}}"
|
msgid "{hours, plural, one {# hour} other {# hours}}"
|
||||||
@@ -40,7 +40,7 @@ msgstr "1 timme"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
@@ -53,7 +53,7 @@ msgstr "12 timmar"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -66,7 +66,7 @@ msgstr "30 dagar"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
@@ -77,7 +77,7 @@ msgstr "Åtgärder"
|
|||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr ""
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
@@ -134,7 +134,7 @@ msgstr "Är du säker på att du vill ta bort {name}?"
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr "Är du säker?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
@@ -191,12 +191,12 @@ msgstr "Binär"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr ""
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr ""
|
msgstr "Bytes (KB/s, MB/s, GB/S)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
@@ -213,11 +213,11 @@ msgstr "Varning - potentiell dataförlust"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
msgstr ""
|
msgstr "Ändra enheter för mätvärden."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
@@ -259,7 +259,7 @@ msgstr "Bekräfta lösenord"
|
|||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr ""
|
msgstr "Ej ansluten"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -296,6 +296,10 @@ msgstr "Kopiera värd"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Kopiera Linux-kommando"
|
msgstr "Kopiera Linux-kommando"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Kopiera namn"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Kopiera text"
|
msgstr "Kopiera text"
|
||||||
@@ -310,7 +314,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr ""
|
msgstr "Kopiera YAML"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
@@ -329,7 +333,7 @@ msgstr "Skapa konto"
|
|||||||
#. Context: date created
|
#. Context: date created
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Created"
|
msgid "Created"
|
||||||
msgstr ""
|
msgstr "Skapad"
|
||||||
|
|
||||||
#. Dark theme
|
#. Dark theme
|
||||||
#: src/components/mode-toggle.tsx
|
#: src/components/mode-toggle.tsx
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Ana bilgisayarı kopyala"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Linux komutunu kopyala"
|
msgstr "Linux komutunu kopyala"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Adı kopyala"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Metni kopyala"
|
msgstr "Metni kopyala"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Копіювати хост"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Копіювати команду Linux"
|
msgstr "Копіювати команду Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Копіювати імʼя"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Копіювати текст"
|
msgstr "Копіювати текст"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "Sao chép máy chủ"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "Sao chép lệnh Linux"
|
msgstr "Sao chép lệnh Linux"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "Sao chép tên"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "Sao chép văn bản"
|
msgstr "Sao chép văn bản"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "复制主机名"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "复制 Linux 安装命令"
|
msgstr "复制 Linux 安装命令"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "复制名称"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "复制文本"
|
msgstr "复制文本"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "複製主機"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "複製 Linux 指令"
|
msgstr "複製 Linux 指令"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "複製名稱"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "複製文本"
|
msgstr "複製文本"
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ msgstr "複製主機"
|
|||||||
msgid "Copy Linux command"
|
msgid "Copy Linux command"
|
||||||
msgstr "複製 Linux 指令"
|
msgstr "複製 Linux 指令"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Copy name"
|
||||||
|
msgstr "複製名稱"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Copy text"
|
msgid "Copy text"
|
||||||
msgstr "複製文字"
|
msgstr "複製文字"
|
||||||
|
|||||||
4
beszel/site/src/types.d.ts
vendored
4
beszel/site/src/types.d.ts
vendored
@@ -228,6 +228,10 @@ export interface UserSettings {
|
|||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
emails?: string[]
|
emails?: string[]
|
||||||
webhooks?: string[]
|
webhooks?: string[]
|
||||||
|
meterThresholds?: {
|
||||||
|
yellow?: number
|
||||||
|
red?: number
|
||||||
|
}
|
||||||
unitTemp?: Unit
|
unitTemp?: Unit
|
||||||
unitNet?: Unit
|
unitNet?: Unit
|
||||||
unitDisk?: Unit
|
unitDisk?: Unit
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package beszel
|
|||||||
import "github.com/blang/semver"
|
import "github.com/blang/semver"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "0.12.1"
|
Version = "0.12.2"
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ It has a friendly web interface, simple configuration, and is ready to use out o
|
|||||||
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||||
- **Simple**: Easy setup with little manual configuration required.
|
- **Simple**: Easy setup with little manual configuration required.
|
||||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
|
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, load average, and status.
|
||||||
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
||||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
||||||
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
|
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
|||||||
--gecos "System user for $SERVICE"
|
--gecos "System user for $SERVICE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Enable docker
|
# Enable docker (only if docker group exists)
|
||||||
if ! getent group docker | grep -q "$SERVICE_USER"; then
|
if getent group docker >/dev/null 2>&1; then
|
||||||
echo "Adding $SERVICE_USER to docker group"
|
if ! getent group docker | grep -q "$SERVICE_USER"; then
|
||||||
usermod -aG docker "$SERVICE_USER"
|
echo "Adding $SERVICE_USER to docker group"
|
||||||
|
usermod -aG docker "$SERVICE_USER"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create config file if it doesn't already exist
|
# Create config file if it doesn't already exist
|
||||||
|
|||||||
373
supplemental/licenses/LibreHardwareMonitor/LICENSE
Normal file
373
supplemental/licenses/LibreHardwareMonitor/LICENSE
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
||||||
@@ -5,7 +5,7 @@ is_alpine() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is_openwrt() {
|
is_openwrt() {
|
||||||
cat /etc/os-release | grep -q "OpenWrt"
|
grep -qi "OpenWrt" /etc/os-release
|
||||||
}
|
}
|
||||||
|
|
||||||
# If SELinux is enabled, set the context of the binary
|
# If SELinux is enabled, set the context of the binary
|
||||||
@@ -227,8 +227,8 @@ if [ "$UNINSTALL" = true ]; then
|
|||||||
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
|
rm -f /var/log/beszel-agent.log /var/log/beszel-agent.err
|
||||||
elif is_openwrt; then
|
elif is_openwrt; then
|
||||||
echo "Stopping and disabling the agent service..."
|
echo "Stopping and disabling the agent service..."
|
||||||
service beszel-agent stop
|
/etc/init.d/beszel-agent stop
|
||||||
service beszel-agent disable
|
/etc/init.d/beszel-agent disable
|
||||||
|
|
||||||
echo "Removing the OpenWRT service files..."
|
echo "Removing the OpenWRT service files..."
|
||||||
rm -f /etc/init.d/beszel-agent
|
rm -f /etc/init.d/beszel-agent
|
||||||
@@ -288,13 +288,13 @@ package_installed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check for package manager and install necessary packages if not installed
|
# Check for package manager and install necessary packages if not installed
|
||||||
if is_alpine; then
|
if package_installed apk; then
|
||||||
if ! package_installed tar || ! package_installed curl || ! package_installed coreutils; then
|
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
|
||||||
apk update
|
apk update
|
||||||
apk add tar curl coreutils shadow
|
apk add tar curl coreutils shadow
|
||||||
fi
|
fi
|
||||||
elif is_openwrt; then
|
elif package_installed opkg; then
|
||||||
if ! package_installed tar || ! package_installed curl || ! package_installed coreutils; then
|
if ! package_installed tar || ! package_installed curl || ! package_installed sha256sum; then
|
||||||
opkg update
|
opkg update
|
||||||
opkg install tar curl coreutils
|
opkg install tar curl coreutils
|
||||||
fi
|
fi
|
||||||
@@ -335,11 +335,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create a dedicated user for the service if it doesn't exist
|
# Create a dedicated user for the service if it doesn't exist
|
||||||
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
echo "Creating a dedicated group for the Beszel Agent service..."
|
|
||||||
addgroup beszel
|
addgroup beszel
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
|
||||||
adduser -S -D -H -s /sbin/nologin -G beszel beszel
|
adduser -S -D -H -s /sbin/nologin -G beszel beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
||||||
@@ -347,10 +346,37 @@ if is_alpine; then
|
|||||||
echo "Adding beszel to docker group"
|
echo "Adding beszel to docker group"
|
||||||
usermod -aG docker beszel
|
usermod -aG docker beszel
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
elif is_openwrt; then
|
||||||
|
# Create beszel group first if it doesn't exist (check /etc/group directly)
|
||||||
|
if ! grep -q "^beszel:" /etc/group >/dev/null 2>&1; then
|
||||||
|
echo "beszel:x:999:" >> /etc/group
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create beszel user if it doesn't exist (double-check to prevent duplicates)
|
||||||
|
if ! id -u beszel >/dev/null 2>&1 && ! grep -q "^beszel:" /etc/passwd >/dev/null 2>&1; then
|
||||||
|
echo "beszel:x:999:999::/nonexistent:/bin/false" >> /etc/passwd
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add the user to the docker group if docker group exists and user is not already in it
|
||||||
|
if grep -q "^docker:" /etc/group >/dev/null 2>&1; then
|
||||||
|
echo "Adding beszel to docker group"
|
||||||
|
# Check if beszel is already in docker group
|
||||||
|
if ! grep "^docker:" /etc/group | grep -q "beszel"; then
|
||||||
|
# Add beszel to docker group by modifying /etc/group
|
||||||
|
# Handle both cases: group with existing members and group without members
|
||||||
|
if grep "^docker:" /etc/group | grep -q ":.*:.*$"; then
|
||||||
|
# Group has existing members, append with comma
|
||||||
|
sed -i 's/^docker:\([^:]*:[^:]*:\)\(.*\)$/docker:\1\2,beszel/' /etc/group
|
||||||
|
else
|
||||||
|
# Group has no members, just append
|
||||||
|
sed -i 's/^docker:\([^:]*:[^:]*:\)$/docker:\1beszel/' /etc/group
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
|
||||||
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
||||||
@@ -427,6 +453,11 @@ set_selinux_context
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
rm -rf "$TEMP_DIR"
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Make sure /etc/machine-id exists for persistent fingerprint
|
||||||
|
if [ ! -f /etc/machine-id ]; then
|
||||||
|
cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id
|
||||||
|
fi
|
||||||
|
|
||||||
# Check for NVIDIA GPUs and grant device permissions for systemd service
|
# Check for NVIDIA GPUs and grant device permissions for systemd service
|
||||||
detect_nvidia_devices() {
|
detect_nvidia_devices() {
|
||||||
local devices=""
|
local devices=""
|
||||||
@@ -546,10 +577,7 @@ start_service() {
|
|||||||
procd_set_param command /opt/beszel-agent/beszel-agent
|
procd_set_param command /opt/beszel-agent/beszel-agent
|
||||||
procd_set_param user beszel
|
procd_set_param user beszel
|
||||||
procd_set_param pidfile /var/run/beszel-agent.pid
|
procd_set_param pidfile /var/run/beszel-agent.pid
|
||||||
procd_set_param env PORT="$PORT"
|
procd_set_param env PORT="$PORT" KEY="$KEY" TOKEN="$TOKEN" HUB_URL="$HUB_URL"
|
||||||
procd_set_param env KEY="$KEY"
|
|
||||||
procd_set_param env TOKEN="$TOKEN"
|
|
||||||
procd_set_param env HUB_URL="$HUB_URL"
|
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
procd_close_instance
|
procd_close_instance
|
||||||
@@ -573,10 +601,10 @@ EOF
|
|||||||
|
|
||||||
# Enable the service
|
# Enable the service
|
||||||
chmod +x /etc/init.d/beszel-agent
|
chmod +x /etc/init.d/beszel-agent
|
||||||
service beszel-agent enable
|
/etc/init.d/beszel-agent enable
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
service beszel-agent restart
|
/etc/init.d/beszel-agent restart
|
||||||
|
|
||||||
# Auto-update service for OpenWRT using a crontab job
|
# Auto-update service for OpenWRT using a crontab job
|
||||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
@@ -604,9 +632,9 @@ EOF
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
# Check service status
|
# Check service status
|
||||||
if ! service beszel-agent running >/dev/null 2>&1; then
|
if ! /etc/init.d/beszel-agent running >/dev/null 2>&1; then
|
||||||
echo "Error: The Beszel Agent service is not running."
|
echo "Error: The Beszel Agent service is not running."
|
||||||
service beszel-agent status
|
/etc/init.d/beszel-agent status
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -217,13 +217,6 @@ function Update-ServicePath {
|
|||||||
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stop the service
|
|
||||||
Write-Host "Stopping beszel-agent service..."
|
|
||||||
& $nssmCommand stop beszel-agent
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host "Warning: Failed to stop service, continuing anyway..." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update the application path
|
# Update the application path
|
||||||
& $nssmCommand set beszel-agent Application $NewAgentPath
|
& $nssmCommand set beszel-agent Application $NewAgentPath
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
@@ -233,7 +226,25 @@ function Update-ServicePath {
|
|||||||
Write-Host "Service path updated to: $NewAgentPath"
|
Write-Host "Service path updated to: $NewAgentPath"
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
|
Start-BeszelAgentService -NSSMPath $nssmCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to start and monitor the service
|
||||||
|
function Start-BeszelAgentService {
|
||||||
|
param (
|
||||||
|
[string]$NSSMPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
Write-Host "Starting beszel-agent service..."
|
Write-Host "Starting beszel-agent service..."
|
||||||
|
|
||||||
|
# Determine the NSSM executable to use
|
||||||
|
$nssmCommand = "nssm"
|
||||||
|
if ($NSSMPath -and (Test-Path $NSSMPath)) {
|
||||||
|
$nssmCommand = $NSSMPath
|
||||||
|
} elseif (-not (Test-CommandExists "nssm")) {
|
||||||
|
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||||
|
}
|
||||||
|
|
||||||
& $nssmCommand start beszel-agent
|
& $nssmCommand start beszel-agent
|
||||||
$startResult = $LASTEXITCODE
|
$startResult = $LASTEXITCODE
|
||||||
|
|
||||||
@@ -255,7 +266,7 @@ function Update-ServicePath {
|
|||||||
|
|
||||||
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
||||||
$serviceStarted = $true
|
$serviceStarted = $true
|
||||||
Write-Host "Success! The beszel-agent service is now running with the updated path." -ForegroundColor Green
|
Write-Host "Success! The beszel-agent service is now running." -ForegroundColor Green
|
||||||
}
|
}
|
||||||
elseif ($serviceStatus -like "*PENDING*") {
|
elseif ($serviceStatus -like "*PENDING*") {
|
||||||
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
|
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
|
||||||
@@ -273,7 +284,7 @@ function Update-ServicePath {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
# NSSM start command was successful
|
# NSSM start command was successful
|
||||||
Write-Host "Success! The beszel-agent service is running with the updated path." -ForegroundColor Green
|
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +298,6 @@ $isAdmin = Test-Admin
|
|||||||
try {
|
try {
|
||||||
Write-Host "Beszel Agent Upgrade Script" -ForegroundColor Cyan
|
Write-Host "Beszel Agent Upgrade Script" -ForegroundColor Cyan
|
||||||
Write-Host "===========================" -ForegroundColor Cyan
|
Write-Host "===========================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# First: Check if service exists (doesn't require admin)
|
# First: Check if service exists (doesn't require admin)
|
||||||
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
||||||
@@ -312,6 +322,13 @@ try {
|
|||||||
Write-Host "Retrieving current service configuration..."
|
Write-Host "Retrieving current service configuration..."
|
||||||
$currentConfig = Get-ServiceConfiguration -NSSMPath $nssmPath
|
$currentConfig = Get-ServiceConfiguration -NSSMPath $nssmPath
|
||||||
|
|
||||||
|
# Stop the service before upgrade
|
||||||
|
Write-Host "Stopping beszel-agent service..."
|
||||||
|
& $nssmPath stop beszel-agent
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Warning: Failed to stop service, continuing anyway..." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
# Upgrade the agent (doesn't require admin)
|
# Upgrade the agent (doesn't require admin)
|
||||||
Write-Host "Upgrading beszel-agent..."
|
Write-Host "Upgrading beszel-agent..."
|
||||||
$newAgentPath = $null
|
$newAgentPath = $null
|
||||||
@@ -340,7 +357,8 @@ try {
|
|||||||
|
|
||||||
# Check if the path has changed
|
# Check if the path has changed
|
||||||
if ($currentConfig.CurrentPath -eq $newAgentPath) {
|
if ($currentConfig.CurrentPath -eq $newAgentPath) {
|
||||||
Write-Host "Agent path has not changed. Service update not needed." -ForegroundColor Green
|
Write-Host "Agent path has not changed. Restarting service..." -ForegroundColor Green
|
||||||
|
Start-BeszelAgentService -NSSMPath $nssmPath
|
||||||
Write-Host "Upgrade completed successfully!" -ForegroundColor Green
|
Write-Host "Upgrade completed successfully!" -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user