mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-17 13:34:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1df165d43 |
@@ -80,7 +80,7 @@ func newZfsBackend() *poolBackend {
|
||||
return &poolBackend{
|
||||
name: "zfs",
|
||||
poolStatsFn: optionalPoolSource(zfs.PoolStats),
|
||||
datasetsFn: optionalPoolSource(zfs.Datasets),
|
||||
datasetsFn: zfs.Datasets,
|
||||
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
|
||||
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
|
||||
}
|
||||
|
||||
@@ -323,21 +323,6 @@ func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
|
||||
assert.Len(t, usage, 1, "previous usage should be retained on error")
|
||||
}
|
||||
|
||||
func TestDatasetUsageClearsAbsentBackend(t *testing.T) {
|
||||
b := newZfsBackend()
|
||||
b.datasetUsage = map[string]zfsDatasetUsage{"/tank": {used: 1, avail: 1}}
|
||||
b.datasetsFn = optionalPoolSource(func() ([]zfs.Dataset, error) {
|
||||
return nil, zfs.ErrNoZfs
|
||||
})
|
||||
|
||||
datasets, err := b.datasets()
|
||||
require.NoError(t, err, "an absent backend must not produce an error to log")
|
||||
assert.Empty(t, datasets)
|
||||
b.refreshDatasetUsage()
|
||||
assert.Empty(t, b.datasetUsage)
|
||||
assert.False(t, b.lastUsageRefresh.IsZero())
|
||||
}
|
||||
|
||||
func TestGetDetailForceRefresh(t *testing.T) {
|
||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||
poolCalls := 0
|
||||
|
||||
@@ -70,9 +70,6 @@ type Dataset struct {
|
||||
// PoolStats returns capacity and health for all pools on the system using
|
||||
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
|
||||
func PoolStats() ([]PoolStat, error) {
|
||||
if err := checkZfsDevice(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
@@ -87,9 +84,6 @@ func PoolStats() ([]PoolStat, error) {
|
||||
// Datasets returns all datasets on the system with usage and mountpoint
|
||||
// information using `zfs list` (recursive by default).
|
||||
func Datasets() ([]Dataset, error) {
|
||||
if err := checkZfsDevice(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zfs list: %w", err)
|
||||
|
||||
+1
-17
@@ -13,10 +13,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
procZfsPath = "/proc/spl/kstat/zfs"
|
||||
devZfsPath = "/dev/zfs"
|
||||
)
|
||||
var procZfsPath = "/proc/spl/kstat/zfs"
|
||||
|
||||
func ARCSize() (uint64, error) {
|
||||
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
|
||||
@@ -43,19 +40,6 @@ func ARCSize() (uint64, error) {
|
||||
return 0, fmt.Errorf("size field not found in arcstats")
|
||||
}
|
||||
|
||||
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
|
||||
// waiting for ZFS utility commands to time out.
|
||||
func checkZfsDevice() error {
|
||||
_, err := os.Stat(devZfsPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNoZfs
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PoolKernelStats reads pool state and cumulative I/O counters directly from
|
||||
// procfs. These kstats are the same interfaces used by node_exporter's Linux
|
||||
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
|
||||
|
||||
@@ -88,66 +88,3 @@ func TestReadObjsetIORequiresAllCounters(t *testing.T) {
|
||||
_, _, err := readObjsetIO(path)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
oldDevZfsPath := devZfsPath
|
||||
devZfsPath = filepath.Join(root, "missing")
|
||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||
|
||||
oldCommandOutput := commandOutput
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
t.Fatalf("unexpected %s call with %v", name, args)
|
||||
return nil, nil
|
||||
}
|
||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||
|
||||
_, err := PoolStats()
|
||||
assert.ErrorIs(t, err, ErrNoZfs)
|
||||
_, err = Datasets()
|
||||
assert.ErrorIs(t, err, ErrNoZfs)
|
||||
}
|
||||
|
||||
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
|
||||
oldDevZfsPath := devZfsPath
|
||||
devZfsPath = filepath.Join(t.TempDir(), "zfs")
|
||||
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
|
||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||
|
||||
oldCommandOutput := commandOutput
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
assert.Equal(t, "zfs", name)
|
||||
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
|
||||
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||
}
|
||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||
|
||||
datasets, err := Datasets()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||
}
|
||||
|
||||
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
devFile := filepath.Join(root, "zfs")
|
||||
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
|
||||
|
||||
oldDevZfsPath := devZfsPath
|
||||
devZfsPath = devFile
|
||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||
|
||||
oldCommandOutput := commandOutput
|
||||
called := false
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
called = true
|
||||
assert.Equal(t, "zpool", name)
|
||||
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
|
||||
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||
}
|
||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||
|
||||
pools, err := PoolStats()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package zfs
|
||||
|
||||
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
|
||||
// through the ZFS utilities themselves.
|
||||
func checkZfsDevice() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//go:build testing && !linux
|
||||
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
|
||||
oldCommandOutput := commandOutput
|
||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||
switch name {
|
||||
case "zpool":
|
||||
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||
case "zfs":
|
||||
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||
default:
|
||||
t.Fatalf("unexpected command %s", name)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||
|
||||
pools, err := PoolStats()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||
datasets, err := Datasets()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ require (
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/sys v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
@@ -156,8 +156,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -6,16 +6,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/henrygd/beszel/internal/migrations"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -30,59 +26,6 @@ func jsonReader(v any) io.Reader {
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
type gatedReader struct {
|
||||
data []byte
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
offset int
|
||||
}
|
||||
|
||||
func (r *gatedReader) Read(p []byte) (int, error) {
|
||||
if r.offset == 0 {
|
||||
close(r.started)
|
||||
<-r.release
|
||||
}
|
||||
if r.offset >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.data[r.offset:])
|
||||
r.offset += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func firstUserTestMux(t *testing.T) (*beszelTests.TestHub, http.Handler) {
|
||||
t.Helper()
|
||||
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
_ = hub.StartHub()
|
||||
|
||||
router, err := apis.NewRouter(hub.TestApp)
|
||||
require.NoError(t, err)
|
||||
serveEvent := &core.ServeEvent{App: hub.TestApp, Router: router}
|
||||
|
||||
var handler http.Handler
|
||||
err = hub.TestApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||
var buildErr error
|
||||
handler, buildErr = e.Router.BuildMux()
|
||||
return buildErr
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, handler)
|
||||
return hub, handler
|
||||
}
|
||||
|
||||
func postFirstUser(handler http.Handler, email string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": email,
|
||||
"password": "password123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestApiRoutesAuthentication(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
@@ -846,87 +789,6 @@ func TestFirstUserCreation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFirstUserBootstrapAtomicity(t *testing.T) {
|
||||
t.Run("concurrent complete requests produce exactly one winner", func(t *testing.T) {
|
||||
hub, handler := firstUserTestMux(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
start := make(chan struct{})
|
||||
statuses := make(chan int, 2)
|
||||
for _, email := range []string{"first@example.com", "second@example.com"} {
|
||||
go func(email string) {
|
||||
<-start
|
||||
statuses <- postFirstUser(handler, email).Code
|
||||
}(email)
|
||||
}
|
||||
close(start)
|
||||
|
||||
got := []int{<-statuses, <-statuses}
|
||||
sort.Ints(got)
|
||||
require.Equal(t, []int{http.StatusOK, http.StatusForbidden}, got)
|
||||
|
||||
users, err := hub.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, superusers, 1)
|
||||
require.NotEqual(t, migrations.TempAdminEmail, superusers[0].Email())
|
||||
})
|
||||
|
||||
t.Run("partial body cannot retain stale bootstrap authorization", func(t *testing.T) {
|
||||
hub, handler := firstUserTestMux(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"email": "parked@example.com",
|
||||
"password": "password123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
gated := &gatedReader{
|
||||
data: body,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
parkedRequest := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", gated)
|
||||
parkedRequest.Header.Set("Content-Type", "application/json")
|
||||
parkedRecorder := httptest.NewRecorder()
|
||||
parkedDone := make(chan struct{})
|
||||
go func() {
|
||||
handler.ServeHTTP(parkedRecorder, parkedRequest)
|
||||
close(parkedDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-gated.started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("parked request did not begin reading its body")
|
||||
}
|
||||
|
||||
operatorRecorder := postFirstUser(handler, "operator@example.com")
|
||||
require.Equal(t, http.StatusOK, operatorRecorder.Code)
|
||||
lateRecorder := postFirstUser(handler, "late@example.com")
|
||||
require.Equal(t, http.StatusForbidden, lateRecorder.Code)
|
||||
|
||||
close(gated.release)
|
||||
select {
|
||||
case <-parkedDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("parked request did not finish")
|
||||
}
|
||||
require.Equal(t, http.StatusForbidden, parkedRecorder.Code)
|
||||
|
||||
users, err := hub.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
require.Equal(t, "operator@example.com", users[0].Email())
|
||||
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, superusers, 1)
|
||||
require.Equal(t, "operator@example.com", superusers[0].Email())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
|
||||
@@ -122,8 +122,6 @@ func (h *Hub) initialize(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// batch requests (for alerts)
|
||||
settings.Batch.Enabled = true
|
||||
settings.Batch.MaxRequests = 100
|
||||
settings.Batch.MaxBodySize = 1 << 20 // 1 MiB
|
||||
// set URL if APP_URL env is set
|
||||
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
||||
h.appURL = appURL
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function () {
|
||||
const page = useStore($router)
|
||||
const [isFirstRun, setFirstRun] = useState(false)
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t`Login` + " / Beszel"
|
||||
@@ -54,7 +54,7 @@ export default function () {
|
||||
<div
|
||||
className="grid gap-5 w-full px-4 mx-auto"
|
||||
// @ts-expect-error
|
||||
style={{ maxWidth: "21.5em", "--border": resolvedTheme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
||||
style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
|
||||
>
|
||||
<div className="absolute top-3 right-3">
|
||||
<ModeToggle />
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
||||
<Label className="block" htmlFor="lang">
|
||||
<Trans>Preferred Language</Trans>
|
||||
</Label>
|
||||
<Select name="lang" value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
||||
<Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
|
||||
<SelectTrigger id="lang">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { lazy, useEffect } from "react"
|
||||
import { $router } from "@/components/router.tsx"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||
import { toast } from "@/components/ui/use-toast.ts"
|
||||
import { saveUserSettings } from "@/lib/api"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $userSettings } from "@/lib/stores.ts"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { Separator } from "../../ui/separator"
|
||||
@@ -36,13 +36,24 @@ const HeartbeatSettings = lazy(heartbeatSettingsImport)
|
||||
|
||||
export async function saveSettings(newSettings: Partial<UserSettings>) {
|
||||
try {
|
||||
await saveUserSettings(newSettings)
|
||||
// get fresh copy of settings
|
||||
const req = await pb.collection("user_settings").getFirstListItem("", {
|
||||
fields: "id,settings",
|
||||
})
|
||||
// update user settings
|
||||
const updatedSettings = await pb.collection("user_settings").update(req.id, {
|
||||
settings: {
|
||||
...req.settings,
|
||||
...newSettings,
|
||||
},
|
||||
})
|
||||
$userSettings.set(updatedSettings.settings)
|
||||
toast({
|
||||
title: t`Settings saved`,
|
||||
description: t`Your user settings have been updated.`,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("save settings", e)
|
||||
// console.error('update settings', e)
|
||||
toast({
|
||||
title: t`Failed to save settings`,
|
||||
description: t`Check logs for more details.`,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { subscribeKeys } from "nanostores"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useContainerChartConfigs } from "@/components/charts/hooks"
|
||||
import { pb, queueUserSettings } from "@/lib/api"
|
||||
import { pb } from "@/lib/api"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import {
|
||||
$allSystemsById,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
$systems,
|
||||
$userSettings,
|
||||
} from "@/lib/stores"
|
||||
import { chartTimeData, listen, parseSemVer } from "@/lib/utils"
|
||||
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
|
||||
import type {
|
||||
ChartData,
|
||||
ContainerStatsRecord,
|
||||
@@ -35,42 +35,8 @@ export function useSystemData(id: string) {
|
||||
const systems = useStore($systems)
|
||||
const chartTime = useStore($chartTime)
|
||||
const maxValues = useStore($maxValues)
|
||||
const [grid, _setGrid] = useState<boolean>(
|
||||
() => $userSettings.get().grid ?? JSON.parse(localStorage.getItem("besz-grid") ?? "null") ?? true
|
||||
)
|
||||
const [displayMode, _setDisplayMode] = useState<"default" | "tabs">(
|
||||
() =>
|
||||
$userSettings.get().displayMode ??
|
||||
(JSON.parse(localStorage.getItem("besz-displayMode") || "null") as "default" | "tabs" | null) ??
|
||||
"default"
|
||||
)
|
||||
|
||||
const applied = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
return subscribeKeys($userSettings, ["grid", "displayMode"], (vals) => {
|
||||
if (!applied.current.has("grid") && vals.grid !== undefined) {
|
||||
applied.current.add("grid")
|
||||
_setGrid(vals.grid)
|
||||
}
|
||||
if (!applied.current.has("displayMode") && vals.displayMode !== undefined) {
|
||||
applied.current.add("displayMode")
|
||||
_setDisplayMode(vals.displayMode)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setGrid = useCallback((v: boolean) => {
|
||||
_setGrid(v)
|
||||
localStorage.setItem("besz-grid", JSON.stringify(v))
|
||||
$userSettings.setKey("grid", v)
|
||||
queueUserSettings({ grid: v })
|
||||
}, [])
|
||||
const setDisplayMode = useCallback((v: "default" | "tabs") => {
|
||||
_setDisplayMode(v)
|
||||
localStorage.setItem("besz-displayMode", JSON.stringify(v))
|
||||
$userSettings.setKey("displayMode", v)
|
||||
queueUserSettings({ displayMode: v })
|
||||
}, [])
|
||||
const [grid, setGrid] = useBrowserStorage("grid", true)
|
||||
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
|
||||
const [activeTab, setActiveTabRaw] = useState("core")
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
|
||||
const tabsRef = useRef<string[]>(["core", "disk"])
|
||||
@@ -205,7 +171,6 @@ export function useSystemData(id: string) {
|
||||
// get stats when system "changes." (Not just system to system,
|
||||
// also when new info comes in via systemManager realtime connection, indicating an update)
|
||||
useEffect(() => {
|
||||
const requestId = ++statsRequestId.current
|
||||
if (!system.id || !chartTime || chartTime === "1m") {
|
||||
return
|
||||
}
|
||||
@@ -214,6 +179,7 @@ export function useSystemData(id: string) {
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
|
||||
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
|
||||
const requestId = ++statsRequestId.current
|
||||
|
||||
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
|
||||
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
|
||||
@@ -237,7 +203,7 @@ export function useSystemData(id: string) {
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// Ignore responses for a previous system or chart time
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { subscribeKeys } from "nanostores"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
type ColumnDef,
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
Settings2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -43,9 +42,8 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import { queueUserSettings } from "@/lib/api"
|
||||
import { $downSystems, $pausedSystems, $systems, $upSystems, $userSettings } from "@/lib/stores"
|
||||
import { cn, runOnce } from "@/lib/utils"
|
||||
import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores"
|
||||
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import AlertButton from "../alerts/alert-button"
|
||||
import { $router, Link } from "../router"
|
||||
@@ -64,83 +62,14 @@ export default function SystemsTable() {
|
||||
const pausedSystems = $pausedSystems.get()
|
||||
const { i18n, t } = useLingui()
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
|
||||
() =>
|
||||
$userSettings.get().statusFilter ??
|
||||
(JSON.parse(localStorage.getItem("besz-statusFilter") || "null") as StatusFilter | null) ??
|
||||
"all"
|
||||
)
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
() =>
|
||||
$userSettings.get().sortMode ??
|
||||
JSON.parse(sessionStorage.getItem("besz-sortMode") || "null") ?? [{ id: "system", desc: false }]
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
"sortMode",
|
||||
[{ id: "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
() => $userSettings.get().cols ?? JSON.parse(localStorage.getItem("besz-cols") || "{}")
|
||||
)
|
||||
|
||||
// Apply settings from server once they load (handles incognito / new devices)
|
||||
const applied = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
return subscribeKeys($userSettings, ["cols", "statusFilter", "viewMode", "sortMode"], (vals) => {
|
||||
if (!applied.current.has("cols") && vals.cols !== undefined) {
|
||||
applied.current.add("cols")
|
||||
setColumnVisibility(vals.cols)
|
||||
}
|
||||
if (!applied.current.has("statusFilter") && vals.statusFilter !== undefined) {
|
||||
applied.current.add("statusFilter")
|
||||
setStatusFilter(vals.statusFilter)
|
||||
}
|
||||
if (!applied.current.has("viewMode") && vals.viewMode !== undefined) {
|
||||
applied.current.add("viewMode")
|
||||
setViewMode(vals.viewMode)
|
||||
}
|
||||
if (!applied.current.has("sortMode") && vals.sortMode !== undefined) {
|
||||
applied.current.add("sortMode")
|
||||
setSorting(vals.sortMode)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleColumnVisibilityChange = useCallback(
|
||||
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
|
||||
setColumnVisibility((prev) => {
|
||||
const next = typeof updater === "function" ? updater(prev) : updater
|
||||
localStorage.setItem("besz-cols", JSON.stringify(next))
|
||||
$userSettings.setKey("cols", next)
|
||||
queueUserSettings({ cols: next })
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleStatusFilterChange = useCallback((value: string) => {
|
||||
const next = value as StatusFilter
|
||||
setStatusFilter(next)
|
||||
localStorage.setItem("besz-statusFilter", JSON.stringify(next))
|
||||
$userSettings.setKey("statusFilter", next)
|
||||
queueUserSettings({ statusFilter: next })
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((view: string) => {
|
||||
const next = view as ViewMode
|
||||
setViewMode(next)
|
||||
localStorage.setItem("besz-viewMode", JSON.stringify(next))
|
||||
$userSettings.setKey("viewMode", next)
|
||||
queueUserSettings({ viewMode: next })
|
||||
}, [])
|
||||
|
||||
const handleSortingChange = useCallback((updater: SortingState | ((prev: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === "function" ? updater(prev) : updater
|
||||
sessionStorage.setItem("besz-sortMode", JSON.stringify(next))
|
||||
$userSettings.setKey("sortMode", next)
|
||||
queueUserSettings({ sortMode: next })
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
|
||||
|
||||
const locale = i18n.locale
|
||||
|
||||
@@ -158,12 +87,10 @@ export default function SystemsTable() {
|
||||
return Object.values(pausedSystems) ?? []
|
||||
}, [data, statusFilter])
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
() =>
|
||||
$userSettings.get().viewMode ??
|
||||
(JSON.parse(localStorage.getItem("besz-viewMode") || "null") as ViewMode | null) ??
|
||||
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||
(window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table")
|
||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||
"viewMode",
|
||||
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
|
||||
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -178,11 +105,11 @@ export default function SystemsTable() {
|
||||
data: filteredData,
|
||||
columns: columnDefs,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: handleSortingChange,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: handleColumnVisibilityChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
@@ -254,7 +181,11 @@ export default function SystemsTable() {
|
||||
<Trans>Layout</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup className="px-1 pb-1" value={viewMode} onValueChange={handleViewModeChange}>
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={viewMode}
|
||||
onValueChange={(view) => setViewMode(view as ViewMode)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||
<LayoutListIcon className="size-4" />
|
||||
<Trans>Table</Trans>
|
||||
@@ -275,7 +206,7 @@ export default function SystemsTable() {
|
||||
<DropdownMenuRadioGroup
|
||||
className="px-1 pb-1"
|
||||
value={statusFilter}
|
||||
onValueChange={handleStatusFilterChange}
|
||||
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
|
||||
<Trans>All Systems</Trans>
|
||||
@@ -314,9 +245,7 @@ export default function SystemsTable() {
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
handleSortingChange([
|
||||
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
|
||||
])
|
||||
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type ResolvedTheme = "dark" | "light"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
@@ -11,13 +10,11 @@ type ThemeProviderProps = {
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
@@ -30,28 +27,24 @@ export function ThemeProvider({
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
|
||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches)
|
||||
|
||||
media.addEventListener("change", onChange)
|
||||
return () => media.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
const resolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(resolvedTheme)
|
||||
}, [resolvedTheme])
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro"
|
||||
import PocketBase from "pocketbase"
|
||||
import { basePath } from "@/components/router"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { dynamicActivate, getLocale } from "@/lib/i18n"
|
||||
import type { ChartTimes, UserSettings } from "@/types"
|
||||
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
|
||||
import { chartTimeData, debounce } from "./utils"
|
||||
@@ -53,45 +52,11 @@ export function logOut() {
|
||||
pb.realtime.unsubscribe()
|
||||
}
|
||||
|
||||
/** Save a partial update to user settings in database immediately */
|
||||
export async function saveUserSettings(newSettings: Partial<UserSettings>) {
|
||||
// get fresh copy of settings so concurrent changes aren't overwritten
|
||||
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "id,settings" })
|
||||
const updatedSettings = await pb.collection("user_settings").update(req.id, {
|
||||
settings: {
|
||||
...req.settings,
|
||||
...newSettings,
|
||||
},
|
||||
})
|
||||
$userSettings.set(updatedSettings.settings)
|
||||
}
|
||||
|
||||
// keys queued by queueUserSettings, flushed together in a single request so that
|
||||
// two debounced saves for different keys can't race each other's read-modify-write
|
||||
// and silently drop one of the changes
|
||||
let queuedSettings: Partial<UserSettings> = {}
|
||||
|
||||
const flushQueuedSettings = debounce(() => {
|
||||
const toSave = queuedSettings
|
||||
queuedSettings = {}
|
||||
if (Object.keys(toSave).length === 0) {
|
||||
return
|
||||
}
|
||||
saveUserSettings(toSave).catch(console.error)
|
||||
}, 1000)
|
||||
|
||||
/** Queue a partial user settings update, merging with any other pending keys and saving them together after a debounce window */
|
||||
export function queueUserSettings(newSettings: Partial<UserSettings>) {
|
||||
queuedSettings = { ...queuedSettings, ...newSettings }
|
||||
flushQueuedSettings()
|
||||
}
|
||||
|
||||
/** Fetch or create user settings in database */
|
||||
export async function updateUserSettings() {
|
||||
try {
|
||||
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
|
||||
$userSettings.set(req.settings)
|
||||
dynamicActivate(req.settings.lang || getLocale())
|
||||
return
|
||||
} catch (e) {
|
||||
console.error("get settings", e)
|
||||
@@ -100,7 +65,6 @@ export async function updateUserSettings() {
|
||||
try {
|
||||
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
|
||||
$userSettings.set(createdSettings.settings)
|
||||
dynamicActivate(createdSettings.settings.lang || getLocale())
|
||||
} catch (e) {
|
||||
console.error("create settings", e)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ const Layout = () => {
|
||||
|
||||
const I18nApp = () => {
|
||||
useEffect(() => {
|
||||
// Activate a locale so I18nProvider can mount App and load the account settings.
|
||||
dynamicActivate(getLocale())
|
||||
}, [])
|
||||
|
||||
|
||||
Vendored
-7
@@ -370,13 +370,6 @@ export interface UserSettings {
|
||||
colorCrit?: number
|
||||
hourFormat?: HourFormat
|
||||
layoutWidth?: number
|
||||
lang?: string
|
||||
cols?: Record<string, boolean>
|
||||
statusFilter?: "all" | "up" | "down" | "paused" | "pending"
|
||||
viewMode?: "table" | "grid"
|
||||
sortMode?: Array<{ id: string; desc: boolean }>
|
||||
grid?: boolean
|
||||
displayMode?: "default" | "tabs"
|
||||
}
|
||||
|
||||
type ChartDataContainer = {
|
||||
|
||||
+29
-51
@@ -2,7 +2,6 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -16,8 +15,6 @@ type UserManager struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
var errBootstrapUnavailable = errors.New("bootstrap unavailable")
|
||||
|
||||
func NewUserManager(app core.App) *UserManager {
|
||||
return &UserManager{
|
||||
app: app,
|
||||
@@ -62,7 +59,17 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
|
||||
// Custom API endpoint to create the first user.
|
||||
// Mimics previous default behavior in PocketBase < 0.23.0 allowing user to be created through the Beszel UI.
|
||||
func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
|
||||
// Consume the complete body before evaluating the one-time bootstrap state.
|
||||
// check that there are no users
|
||||
totalUsers, err := um.app.CountRecords("users")
|
||||
if err != nil || totalUsers > 0 {
|
||||
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||
}
|
||||
// check that there is only one superuser and the email matches the email of the superuser we set up in initial-settings.go
|
||||
adminUsers, err := um.app.FindAllRecords(core.CollectionNameSuperusers)
|
||||
if err != nil || len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
|
||||
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||
}
|
||||
// create first user using supplied email and password in request body
|
||||
data := struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@@ -74,55 +81,26 @@ func (um *UserManager) CreateFirstUser(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"err": "Bad request"})
|
||||
}
|
||||
|
||||
err := um.app.RunInTransaction(func(txApp core.App) error {
|
||||
totalUsers, err := txApp.CountRecords("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if totalUsers > 0 {
|
||||
return errBootstrapUnavailable
|
||||
}
|
||||
|
||||
adminUsers, err := txApp.FindAllRecords(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(adminUsers) != 1 || adminUsers[0].GetString("email") != migrations.TempAdminEmail {
|
||||
return errBootstrapUnavailable
|
||||
}
|
||||
|
||||
collection, err := txApp.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := core.NewRecord(collection)
|
||||
user.SetEmail(data.Email)
|
||||
user.SetPassword(data.Password)
|
||||
user.Set("role", "admin")
|
||||
user.Set("verified", true)
|
||||
if err := txApp.Save(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, err = txApp.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUser := core.NewRecord(collection)
|
||||
adminUser.SetEmail(data.Email)
|
||||
adminUser.SetPassword(data.Password)
|
||||
if err := txApp.Save(adminUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txApp.Delete(adminUsers[0])
|
||||
})
|
||||
if errors.Is(err, errBootstrapUnavailable) {
|
||||
return e.JSON(http.StatusForbidden, map[string]string{"err": "Forbidden"})
|
||||
collection, _ := um.app.FindCollectionByNameOrId("users")
|
||||
user := core.NewRecord(collection)
|
||||
user.SetEmail(data.Email)
|
||||
user.SetPassword(data.Password)
|
||||
user.Set("role", "admin")
|
||||
user.Set("verified", true)
|
||||
if err := um.app.Save(user); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||
}
|
||||
if err != nil {
|
||||
// create superuser using the email of the first user
|
||||
collection, _ = um.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
adminUser := core.NewRecord(collection)
|
||||
adminUser.SetEmail(data.Email)
|
||||
adminUser.SetPassword(data.Password)
|
||||
if err := um.app.Save(adminUser); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||
}
|
||||
// delete the intial superuser
|
||||
if err := um.app.Delete(adminUsers[0]); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"err": err.Error()})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"msg": "User created"})
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package users_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/migrations"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
"github.com/henrygd/beszel/internal/users"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type blockedBody struct {
|
||||
io.Reader
|
||||
entered chan struct{}
|
||||
resume chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockedBody) Read(p []byte) (int, error) {
|
||||
if b.entered != nil {
|
||||
close(b.entered)
|
||||
b.entered = nil
|
||||
<-b.resume
|
||||
}
|
||||
return b.Reader.Read(p)
|
||||
}
|
||||
|
||||
func TestCreateFirstUserAtomic(t *testing.T) {
|
||||
for _, scenario := range []string{"parked body", "concurrent requests", "rollback"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
h, err := beszelTests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer h.Cleanup()
|
||||
h.StartHub()
|
||||
um := users.NewUserManager(h.App)
|
||||
invoke := func(body io.Reader) int {
|
||||
req := httptest.NewRequest("POST", "/api/beszel/create-user", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
if err := um.CreateFirstUser(&core.RequestEvent{App: h.App, Event: router.Event{Request: req, Response: res}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return res.Code
|
||||
}
|
||||
body := func(email string) io.Reader {
|
||||
return strings.NewReader(`{"email":"` + email + `","password":"password12345"}`)
|
||||
}
|
||||
await := func(results <-chan int) int {
|
||||
select {
|
||||
case status := <-results:
|
||||
return status
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("request did not finish")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
switch scenario {
|
||||
case "parked body":
|
||||
entered, resume := make(chan struct{}), make(chan struct{})
|
||||
defer func() {
|
||||
select {
|
||||
case <-resume:
|
||||
default:
|
||||
close(resume)
|
||||
}
|
||||
}()
|
||||
result := make(chan int, 1)
|
||||
go func() { result <- invoke(&blockedBody{body("attacker@example.com"), entered, resume}) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("request did not reach body parsing")
|
||||
}
|
||||
require.Equal(t, 200, invoke(body("operator@example.com")))
|
||||
close(resume)
|
||||
require.Equal(t, 403, await(result))
|
||||
case "concurrent requests":
|
||||
start := make(chan struct{})
|
||||
results := make(chan int, 2)
|
||||
for _, email := range []string{"one@example.com", "two@example.com"} {
|
||||
go func() { <-start; results <- invoke(body(email)) }()
|
||||
}
|
||||
close(start)
|
||||
require.ElementsMatch(t, []int{200, 403}, []int{await(results), await(results)})
|
||||
case "rollback":
|
||||
hook := h.OnRecordCreate(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordEvent) error {
|
||||
return errors.New("injected superuser creation failure")
|
||||
})
|
||||
require.Equal(t, 500, invoke(body("operator@example.com")))
|
||||
count, err := h.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
admins, err := h.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, admins, 1)
|
||||
require.Equal(t, migrations.TempAdminEmail, admins[0].Email())
|
||||
h.OnRecordCreate(core.CollectionNameSuperusers).Unbind(hook)
|
||||
require.Equal(t, 200, invoke(body("operator@example.com")))
|
||||
}
|
||||
count, err := h.CountRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, count)
|
||||
admins, err := h.FindAllRecords(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, admins, 1)
|
||||
require.NotEqual(t, migrations.TempAdminEmail, admins[0].Email())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user