From f8f1e01cb4e51d653bca31d4586a6a11f1dcd62b Mon Sep 17 00:00:00 2001 From: Henry Dollman Date: Wed, 23 Oct 2024 18:30:24 -0400 Subject: [PATCH] add settings page and api route for generating config.yml --- beszel/internal/hub/config.go | 99 +++++++++++++++++-- beszel/internal/hub/hub.go | 2 + .../routes/settings/config-yaml.tsx | 93 +++++++++++++++++ .../src/components/routes/settings/layout.tsx | 14 ++- .../systems-table/systems-table.tsx | 2 +- beszel/site/src/index.css | 2 +- 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 beszel/site/src/components/routes/settings/config-yaml.tsx diff --git a/beszel/internal/hub/config.go b/beszel/internal/hub/config.go index c62e777f..6f57bbe8 100644 --- a/beszel/internal/hub/config.go +++ b/beszel/internal/hub/config.go @@ -6,8 +6,13 @@ import ( "log" "os" "path/filepath" + "strconv" + "github.com/labstack/echo/v5" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/models" + "github.com/spf13/cast" "gopkg.in/yaml.v3" ) @@ -18,7 +23,7 @@ type Config struct { type SystemConfig struct { Name string `yaml:"name"` Host string `yaml:"host"` - Port string `yaml:"port"` + Port uint16 `yaml:"port"` Users []string `yaml:"users"` } @@ -45,7 +50,7 @@ func (h *Hub) syncSystemsWithConfig() error { // Create a map of email to user ID userEmailToID := make(map[string]string) - users, err := h.app.Dao().FindRecordsByFilter("users", "id != ''", "created", -1, 0) + users, err := h.app.Dao().FindRecordsByExpr("users", dbx.NewExp("id != ''")) if err != nil { return err } @@ -59,8 +64,8 @@ func (h *Hub) syncSystemsWithConfig() error { // add default settings for systems if not defined in config for i := range config.Systems { system := &config.Systems[i] - if system.Port == "" { - system.Port = "45876" + if system.Port == 0 { + system.Port = 45876 } if len(users) > 0 && len(system.Users) == 0 { // default to first user if none are defined @@ -80,7 +85,7 @@ func (h *Hub) syncSystemsWithConfig() error { } // Get existing systems - existingSystems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "", -1, 0) + existingSystems, err := h.app.Dao().FindRecordsByExpr("systems", dbx.NewExp("id != ''")) if err != nil { return err } @@ -94,7 +99,7 @@ func (h *Hub) syncSystemsWithConfig() error { // Process systems from config for _, sysConfig := range config.Systems { - key := sysConfig.Host + ":" + sysConfig.Port + key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port)) if existingSystem, ok := existingSystemsMap[key]; ok { // Update existing system existingSystem.Set("name", sysConfig.Name) @@ -133,3 +138,85 @@ func (h *Hub) syncSystemsWithConfig() error { log.Println("Systems synced with config.yml") return nil } + +// Generates content for the config.yml file as a YAML string +func (h *Hub) generateConfigYAML() (string, error) { + // Fetch all systems from the database + systems, err := h.app.Dao().FindRecordsByFilter("systems", "id != ''", "name", -1, 0) + if err != nil { + return "", err + } + + // Create a Config struct to hold the data + config := Config{ + Systems: make([]SystemConfig, 0, len(systems)), + } + + // Fetch all users at once + allUserIDs := make([]string, 0) + for _, system := range systems { + allUserIDs = append(allUserIDs, system.GetStringSlice("users")...) + } + userEmailMap, err := h.getUserEmailMap(allUserIDs) + if err != nil { + return "", err + } + + // Populate the Config struct with system data + for _, system := range systems { + userIDs := system.GetStringSlice("users") + userEmails := make([]string, 0, len(userIDs)) + for _, userID := range userIDs { + if email, ok := userEmailMap[userID]; ok { + userEmails = append(userEmails, email) + } + } + + sysConfig := SystemConfig{ + Name: system.GetString("name"), + Host: system.GetString("host"), + Port: cast.ToUint16(system.Get("port")), + Users: userEmails, + } + config.Systems = append(config.Systems, sysConfig) + } + + // Marshal the Config struct to YAML + yamlData, err := yaml.Marshal(&config) + if err != nil { + return "", err + } + + // Add a header to the YAML + yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...) + + return string(yamlData), nil +} + +// New helper function to get a map of user IDs to emails +func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) { + users, err := h.app.Dao().FindRecordsByIds("users", userIDs) + if err != nil { + return nil, err + } + + userEmailMap := make(map[string]string, len(users)) + for _, user := range users { + userEmailMap[user.Id] = user.GetString("email") + } + + return userEmailMap, nil +} + +// Returns the current config.yml file as a JSON object +func (h *Hub) getYamlConfig(c echo.Context) error { + requestData := apis.RequestInfo(c) + if requestData.AuthRecord == nil || requestData.AuthRecord.GetString("role") != "admin" { + return apis.NewForbiddenError("Forbidden", nil) + } + configContent, err := h.generateConfigYAML() + if err != nil { + return err + } + return c.JSON(200, map[string]string{"config": configContent}) +} diff --git a/beszel/internal/hub/hub.go b/beszel/internal/hub/hub.go index 6537e96a..acdce1e8 100644 --- a/beszel/internal/hub/hub.go +++ b/beszel/internal/hub/hub.go @@ -156,6 +156,8 @@ func (h *Hub) Run() { }) // send test notification e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification) + // API endpoint to get config.yml content + e.Router.GET("/api/beszel/config-yaml", h.getYamlConfig) return nil }) diff --git a/beszel/site/src/components/routes/settings/config-yaml.tsx b/beszel/site/src/components/routes/settings/config-yaml.tsx new file mode 100644 index 00000000..18fbacf1 --- /dev/null +++ b/beszel/site/src/components/routes/settings/config-yaml.tsx @@ -0,0 +1,93 @@ +import { isAdmin } from '@/lib/utils' +import { Separator } from '@/components/ui/separator' +import { Button } from '@/components/ui/button' +import { redirectPage } from '@nanostores/router' +import { $router } from '@/components/router' +import { AlertCircleIcon, FileSlidersIcon, LoaderCircleIcon } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { pb } from '@/lib/stores' +import { useState } from 'react' +import { Textarea } from '@/components/ui/textarea' +import { toast } from '@/components/ui/use-toast' +import clsx from 'clsx' + +export default function ConfigYaml() { + const [configContent, setConfigContent] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const ButtonIcon = isLoading ? LoaderCircleIcon : FileSlidersIcon + + async function fetchConfig() { + try { + setIsLoading(true) + const { config } = await pb.send<{ config: string }>('/api/beszel/config-yaml', {}) + setConfigContent(config) + } catch (error: any) { + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + if (!isAdmin()) { + redirectPage($router, 'settings', { name: 'general' }) + } + + return ( +
+
+

YAML Configuration

+

+ Export your current systems configuration. +

+
+ +
+
+

+ Systems may be managed in a{' '} + config.yml file inside + your data directory. +

+

+ On each restart, systems in the database will be updated to match the systems defined in + the file. +

+ + + Caution - potential data loss + +

+ Existing systems not defined in config.yml will be deleted. Please make + regular backups. +

+
+
+
+ {configContent && ( +