add settings page and api route for generating config.yml

This commit is contained in:
Henry Dollman
2024-10-23 18:30:24 -04:00
parent c7463f2b9f
commit f8f1e01cb4
6 changed files with 203 additions and 9 deletions

View File

@@ -6,8 +6,13 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/spf13/cast"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -18,7 +23,7 @@ type Config struct {
type SystemConfig struct { type SystemConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port string `yaml:"port"` Port uint16 `yaml:"port"`
Users []string `yaml:"users"` Users []string `yaml:"users"`
} }
@@ -45,7 +50,7 @@ func (h *Hub) syncSystemsWithConfig() error {
// Create a map of email to user ID // Create a map of email to user ID
userEmailToID := make(map[string]string) 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 { if err != nil {
return err return err
} }
@@ -59,8 +64,8 @@ func (h *Hub) syncSystemsWithConfig() error {
// add default settings for systems if not defined in config // add default settings for systems if not defined in config
for i := range config.Systems { for i := range config.Systems {
system := &config.Systems[i] system := &config.Systems[i]
if system.Port == "" { if system.Port == 0 {
system.Port = "45876" system.Port = 45876
} }
if len(users) > 0 && len(system.Users) == 0 { if len(users) > 0 && len(system.Users) == 0 {
// default to first user if none are defined // default to first user if none are defined
@@ -80,7 +85,7 @@ func (h *Hub) syncSystemsWithConfig() error {
} }
// Get existing systems // 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 { if err != nil {
return err return err
} }
@@ -94,7 +99,7 @@ func (h *Hub) syncSystemsWithConfig() error {
// Process systems from config // Process systems from config
for _, sysConfig := range config.Systems { for _, sysConfig := range config.Systems {
key := sysConfig.Host + ":" + sysConfig.Port key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port))
if existingSystem, ok := existingSystemsMap[key]; ok { if existingSystem, ok := existingSystemsMap[key]; ok {
// Update existing system // Update existing system
existingSystem.Set("name", sysConfig.Name) existingSystem.Set("name", sysConfig.Name)
@@ -133,3 +138,85 @@ func (h *Hub) syncSystemsWithConfig() error {
log.Println("Systems synced with config.yml") log.Println("Systems synced with config.yml")
return nil 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})
}

View File

@@ -156,6 +156,8 @@ func (h *Hub) Run() {
}) })
// send test notification // send test notification
e.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification) 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 return nil
}) })

View File

@@ -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<string>('')
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 (
<div>
<div>
<h3 className="text-xl font-medium mb-2">YAML Configuration</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Export your current systems configuration.
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<div className="mb-4">
<p className="text-sm text-muted-foreground leading-relaxed my-1">
Systems may be managed in a{' '}
<code className="bg-muted rounded-sm px-1 text-primary">config.yml</code> file inside
your data directory.
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
On each restart, systems in the database will be updated to match the systems defined in
the file.
</p>
<Alert className="my-4 border-destructive text-destructive w-auto table md:pr-6">
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
<AlertTitle>Caution - potential data loss</AlertTitle>
<AlertDescription>
<p>
Existing systems not defined in <code>config.yml</code> will be deleted. Please make
regular backups.
</p>
</AlertDescription>
</Alert>
</div>
{configContent && (
<Textarea
autoFocus
defaultValue={configContent}
spellCheck="false"
rows={Math.min(25, configContent.split('\n').length)}
className="font-mono whitespace-pre"
/>
)}
</div>
<Separator className="my-5" />
<Button
type="button"
className="mt-2 flex items-center gap-1"
onClick={fetchConfig}
disabled={isLoading}
>
<ButtonIcon className={clsx('h-4 w-4 mr-0.5', isLoading && 'animate-spin')} />
Export configuration
</Button>
</div>
)
}

View File

@@ -5,12 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $router } from '@/components/router.tsx' import { $router } from '@/components/router.tsx'
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { BellIcon, SettingsIcon } from 'lucide-react' import { BellIcon, FileSlidersIcon, SettingsIcon } from 'lucide-react'
import { $userSettings, pb } from '@/lib/stores.ts' import { $userSettings, pb } from '@/lib/stores.ts'
import { toast } from '@/components/ui/use-toast.ts' import { toast } from '@/components/ui/use-toast.ts'
import { UserSettings } from '@/types.js' import { UserSettings } from '@/types.js'
import General from './general.tsx' import General from './general.tsx'
import Notifications from './notifications.tsx' import Notifications from './notifications.tsx'
import ConfigYaml from './config-yaml.tsx'
import { isAdmin } from '@/lib/utils.ts'
const sidebarNavItems = [ const sidebarNavItems = [
{ {
@@ -25,6 +27,14 @@ const sidebarNavItems = [
}, },
] ]
if (isAdmin()) {
sidebarNavItems.push({
title: 'YAML Config',
href: '/settings/config',
icon: FileSlidersIcon,
})
}
export async function saveSettings(newSettings: Partial<UserSettings>) { export async function saveSettings(newSettings: Partial<UserSettings>) {
try { try {
// get fresh copy of settings // get fresh copy of settings
@@ -94,5 +104,7 @@ function SettingsContent({ name }: { name: string }) {
return <General userSettings={userSettings} /> return <General userSettings={userSettings} />
case 'notifications': case 'notifications':
return <Notifications userSettings={userSettings} /> return <Notifications userSettings={userSettings} />
case 'config':
return <ConfigYaml />
} }
} }

View File

@@ -260,7 +260,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle> <AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete all current records This action cannot be undone. This will permanently delete all current records
for <code className={'bg-muted rounded-sm px-1'}>{name}</code> from the for <code className="bg-muted rounded-sm px-1">{name}</code> from the
database. database.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>

View File

@@ -48,7 +48,7 @@
--muted-foreground: 240 5.03% 64.9%; --muted-foreground: 240 5.03% 64.9%;
--accent: 240 3.7% 15.88%; --accent: 240 3.7% 15.88%;
--accent-foreground: 0 0% 98.04%; --accent-foreground: 0 0% 98.04%;
--destructive: 0 56.48% 42.35%; --destructive: 0 59% 46%;
--destructive-foreground: 0 0% 98.04%; --destructive-foreground: 0 0% 98.04%;
--border: 240 2.86% 12%; --border: 240 2.86% 12%;
--input: 240 3.7% 15.88%; --input: 240 3.7% 15.88%;