mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
add settings page and api route for generating config.yml
This commit is contained in:
@@ -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})
|
||||||
|
}
|
||||||
|
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
93
beszel/site/src/components/routes/settings/config-yaml.tsx
Normal file
93
beszel/site/src/components/routes/settings/config-yaml.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@@ -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 />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
@@ -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%;
|
||||||
|
Reference in New Issue
Block a user