Skip to content

Commit

Permalink
add settings page and api route for generating config.yml
Browse files Browse the repository at this point in the history
  • Loading branch information
henrygd committed Oct 23, 2024
1 parent c7463f2 commit f8f1e01
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 9 deletions.
99 changes: 93 additions & 6 deletions beszel/internal/hub/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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"`
}

Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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})
}
2 changes: 2 additions & 0 deletions beszel/internal/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down
93 changes: 93 additions & 0 deletions beszel/site/src/components/routes/settings/config-yaml.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
14 changes: 13 additions & 1 deletion beszel/site/src/components/routes/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useStore } from '@nanostores/react'
import { $router } from '@/components/router.tsx'
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 { toast } from '@/components/ui/use-toast.ts'
import { UserSettings } from '@/types.js'
import General from './general.tsx'
import Notifications from './notifications.tsx'
import ConfigYaml from './config-yaml.tsx'
import { isAdmin } from '@/lib/utils.ts'

const sidebarNavItems = [
{
Expand All @@ -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>) {
try {
// get fresh copy of settings
Expand Down Expand Up @@ -94,5 +104,7 @@ function SettingsContent({ name }: { name: string }) {
return <General userSettings={userSettings} />
case 'notifications':
return <Notifications userSettings={userSettings} />
case 'config':
return <ConfigYaml />
}
}
2 changes: 1 addition & 1 deletion beszel/site/src/components/systems-table/systems-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export default function SystemsTable({ filter }: { filter?: string }) {
<AlertDialogTitle>Are you sure you want to delete {name}?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
Expand Down
2 changes: 1 addition & 1 deletion beszel/site/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
--muted-foreground: 240 5.03% 64.9%;
--accent: 240 3.7% 15.88%;
--accent-foreground: 0 0% 98.04%;
--destructive: 0 56.48% 42.35%;
--destructive: 0 59% 46%;
--destructive-foreground: 0 0% 98.04%;
--border: 240 2.86% 12%;
--input: 240 3.7% 15.88%;
Expand Down

0 comments on commit f8f1e01

Please sign in to comment.