diff --git a/beszel/internal/alerts/alerts.go b/beszel/internal/alerts/alerts.go index f7447da..55e464a 100644 --- a/beszel/internal/alerts/alerts.go +++ b/beszel/internal/alerts/alerts.go @@ -7,7 +7,6 @@ import ( "log" "net/mail" "net/url" - "os" "github.com/containrrr/shoutrrr" "github.com/labstack/echo/v5" @@ -18,16 +17,21 @@ import ( "github.com/pocketbase/pocketbase/tools/mailer" ) +type AlertManager struct { + app *pocketbase.PocketBase +} + type AlertData struct { - User *models.Record + UserID string Title string Message string Link string LinkText string } -type AlertManager struct { - app *pocketbase.PocketBase +type UserAlertSettings struct { + Emails []string `json:"emails"` + Webhooks []string `json:"webhooks"` } func NewAlertManager(app *pocketbase.PocketBase) *AlertManager { @@ -107,7 +111,7 @@ func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertR } if user := alertRecord.ExpandedOne("user"); user != nil { am.sendAlert(AlertData{ - User: user, + UserID: user.GetId(), Title: subject, Message: body, Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName), @@ -146,7 +150,7 @@ func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.R // send alert systemName := oldRecord.GetString("name") am.sendAlert(AlertData{ - User: user, + UserID: user.GetId(), Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus), Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName), @@ -156,18 +160,42 @@ func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.R } func (am *AlertManager) sendAlert(data AlertData) { - shoutrrrUrl := os.Getenv("SHOUTRRR_URL") - if shoutrrrUrl != "" { - err := am.SendShoutrrrAlert(shoutrrrUrl, data.Title, data.Message, data.Link, data.LinkText) - if err == nil { - log.Println("Sent shoutrrr alert") - return - } - log.Println("Failed to send alert via shoutrrr, falling back to email notification. ", "err", err.Error()) + // get user settings + record, err := am.app.Dao().FindFirstRecordByFilter( + "user_settings", "user={:user}", + dbx.Params{"user": data.UserID}, + ) + if err != nil { + log.Println("Failed to get user settings", "err", err.Error()) + return + } + // unmarshal user settings + userAlertSettings := UserAlertSettings{ + Emails: []string{}, + Webhooks: []string{}, + } + if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil { + log.Println("Failed to unmarshal user settings", "err", err.Error()) + } + // send alerts via webhooks + for _, webhook := range userAlertSettings.Webhooks { + err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText) + if err != nil { + am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error()) + } + } + // send alerts via email + if len(userAlertSettings.Emails) == 0 { + log.Println("No email addresses found") + return + } + addresses := []mail.Address{} + for _, email := range userAlertSettings.Emails { + addresses = append(addresses, mail.Address{Address: email}) + log.Println("Sending alert via email to", email) } - // todo: email enable / disable and testing message := mailer.Message{ - To: []mail.Address{{Address: data.User.GetString("email")}}, + To: addresses, Subject: data.Title, Text: data.Message + fmt.Sprintf("\n\n%s", data.Link), From: mail.Address{ diff --git a/beszel/internal/entities/user/user.go b/beszel/internal/entities/user/user.go new file mode 100644 index 0000000..07d5b2a --- /dev/null +++ b/beszel/internal/entities/user/user.go @@ -0,0 +1,8 @@ +package user + +type UserSettings struct { + // Language string `json:"lang"` + ChartTime string `json:"chartTime"` + NotificationEmails []string `json:"emails"` + NotificationWebhooks []string `json:"webhooks"` +} diff --git a/beszel/internal/hub/hub.go b/beszel/internal/hub/hub.go index 3014f10..8a482cb 100644 --- a/beszel/internal/hub/hub.go +++ b/beszel/internal/hub/hub.go @@ -4,6 +4,7 @@ import ( "beszel" "beszel/internal/alerts" "beszel/internal/entities/system" + "beszel/internal/entities/user" "beszel/internal/records" "beszel/site" "context" @@ -167,6 +168,36 @@ func (h *Hub) Run() { go h.updateSystem(e.Model.(*models.Record)) return nil }) + // default user settings + h.app.OnModelBeforeCreate("user_settings").Add(func(e *core.ModelEvent) error { + record := e.Model.(*models.Record) + // intialize settings with defaults + settings := user.UserSettings{ + // Language: "en", + ChartTime: "1h", + NotificationEmails: []string{}, + NotificationWebhooks: []string{}, + } + record.UnmarshalJSONField("settings", &settings) + if len(settings.NotificationEmails) == 0 { + // get user email from auth record + if errs := h.app.Dao().ExpandRecord(record, []string{"user"}, nil); len(errs) == 0 { + // app.Logger().Error("failed to expand user relation", "errs", errs) + if user := record.ExpandedOne("user"); user != nil { + settings.NotificationEmails = []string{user.GetString("email")} + } else { + log.Println("Failed to get user email from auth record") + } + } else { + log.Println("failed to expand user relation", "errs", errs) + } + } + // if len(settings.NotificationWebhooks) == 0 { + // settings.NotificationWebhooks = []string{""} + // } + record.Set("settings", settings) + return nil + }) // do things after a systems record is updated h.app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error { diff --git a/beszel/migrations/1722186612_collections_snapshot.go b/beszel/migrations/1726183779_collections_snapshot.go similarity index 86% rename from beszel/migrations/1722186612_collections_snapshot.go rename to beszel/migrations/1726183779_collections_snapshot.go index 6cf10b5..da90d9c 100644 --- a/beszel/migrations/1722186612_collections_snapshot.go +++ b/beszel/migrations/1726183779_collections_snapshot.go @@ -15,7 +15,7 @@ func init() { { "id": "2hz5ncl8tizk5nx", "created": "2024-07-07 16:08:20.979Z", - "updated": "2024-07-28 17:00:47.996Z", + "updated": "2024-07-28 17:14:24.492Z", "name": "systems", "type": "base", "system": false, @@ -120,7 +120,7 @@ func init() { { "id": "ej9oowivz8b2mht", "created": "2024-07-07 16:09:09.179Z", - "updated": "2024-07-22 20:13:31.324Z", + "updated": "2024-07-28 17:14:24.492Z", "name": "system_stats", "type": "base", "system": false, @@ -186,7 +186,7 @@ func init() { { "id": "juohu4jipgc13v7", "created": "2024-07-07 16:09:57.976Z", - "updated": "2024-07-22 20:13:31.324Z", + "updated": "2024-07-28 17:14:24.492Z", "name": "container_stats", "type": "base", "system": false, @@ -250,7 +250,7 @@ func init() { { "id": "_pb_users_auth_", "created": "2024-07-14 16:25:18.226Z", - "updated": "2024-07-28 17:02:08.311Z", + "updated": "2024-09-12 23:19:36.280Z", "name": "users", "type": "auth", "system": false, @@ -316,7 +316,7 @@ func init() { { "id": "elngm8x1l60zi2v", "created": "2024-07-15 01:16:04.044Z", - "updated": "2024-07-22 20:13:31.324Z", + "updated": "2024-07-28 17:14:24.492Z", "name": "alerts", "type": "base", "system": false, @@ -403,6 +403,53 @@ func init() { "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "options": {} + }, + { + "id": "4afacsdnlu8q8r2", + "created": "2024-09-12 17:42:55.324Z", + "updated": "2024-09-12 21:19:59.114Z", + "name": "user_settings", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "d5vztyxa", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "xcx4qgqq", + "name": "settings", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 2000000 + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)" + ], + "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "viewRule": null, + "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", + "deleteRule": null, + "options": {} } ]` diff --git a/beszel/site/src/components/routes/settings/general.tsx b/beszel/site/src/components/routes/settings/general.tsx index 00dea21..b28b46f 100644 --- a/beszel/site/src/components/routes/settings/general.tsx +++ b/beszel/site/src/components/routes/settings/general.tsx @@ -9,25 +9,63 @@ import { } from '@/components/ui/select' import { chartTimeData } from '@/lib/utils' import { Separator } from '@/components/ui/separator' -import { SaveIcon } from 'lucide-react' +import { LoaderCircleIcon, SaveIcon } from 'lucide-react' +import { UserSettings } from '@/types' +import { saveSettings } from './layout' +import { useState } from 'react' + +export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { + const [isLoading, setIsLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsLoading(true) + const formData = new FormData(e.target as HTMLFormElement) + const data = Object.fromEntries(formData) as Partial + await saveSettings(data) + setIsLoading(false) + } -export default function SettingsProfilePage() { return (
- {/*
-

General

-

Set your preferred language and timezone.

+
+

General

+

+ Set your preferred language and chart display options. +

- */} -
+ +
+
+
+

Language

+

+ Additional language support coming soon. +

+
+ + +
+

Chart options

Adjust display options for charts.

- - + @@ -43,11 +81,19 @@ export default function SettingsProfilePage() {

- -
+
) } diff --git a/beszel/site/src/components/routes/settings/layout.tsx b/beszel/site/src/components/routes/settings/layout.tsx index 7b67cec..374ca4c 100644 --- a/beszel/site/src/components/routes/settings/layout.tsx +++ b/beszel/site/src/components/routes/settings/layout.tsx @@ -6,6 +6,9 @@ import { useStore } from '@nanostores/react' import { $router } from '@/components/router.tsx' import { redirectPage } from '@nanostores/router' import { BellIcon, SettingsIcon } from 'lucide-react' +import { $userSettings, pb } from '@/lib/stores.ts' +import { toast } from '@/components/ui/use-toast.ts' +import { UserSettings } from '@/types.js' const General = lazy(() => import('./general.tsx')) const Notifications = lazy(() => import('./notifications.tsx')) @@ -23,6 +26,37 @@ const sidebarNavItems = [ }, ] +export async function saveSettings(newSettings: Partial) { + // console.log('Updating settings:', newSettings) + try { + // get fresh copy of settings + const req = await pb.collection('user_settings').getFirstListItem('', { + fields: 'id,settings', + }) + // make new user settings + const mergedSettings = { + ...req.settings, + ...newSettings, + } + // update user settings + const updatedSettings = await pb.collection('user_settings').update(req.id, { + settings: mergedSettings, + }) + $userSettings.set(updatedSettings.settings) + toast({ + title: 'Settings saved', + description: 'Your notification settings have been updated.', + }) + } catch (e) { + console.log('update settings', e) + toast({ + title: 'Failed to save settings', + description: 'Please check logs for more details.', + variant: 'destructive', + }) + } +} + export default function SettingsLayout() { const page = useStore($router) @@ -59,13 +93,13 @@ export default function SettingsLayout() { } function SettingsContent({ name }: { name: string }) { + const userSettings = useStore($userSettings) + switch (name) { case 'general': - return - // case 'display': - // return + return case 'notifications': - return + return } return '' } diff --git a/beszel/site/src/components/routes/settings/notifications.tsx b/beszel/site/src/components/routes/settings/notifications.tsx index 189d30a..c14017c 100644 --- a/beszel/site/src/components/routes/settings/notifications.tsx +++ b/beszel/site/src/components/routes/settings/notifications.tsx @@ -4,13 +4,13 @@ import { Label } from '@/components/ui/label' import { pb } from '@/lib/stores' import { Separator } from '@/components/ui/separator' import { Card } from '@/components/ui/card' -import { LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react' +import { BellIcon, LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react' import { useState } from 'react' import { toast } from '@/components/ui/use-toast' - -interface UserSettings { - webhooks: string[] -} +import { InputTags } from '@/components/ui/input-tags' +import { UserSettings } from '@/types' +import { saveSettings } from './layout' +import * as v from 'valibot' interface ShoutrrrUrlCardProps { url: string @@ -18,13 +18,15 @@ interface ShoutrrrUrlCardProps { onRemove: () => void } -const userSettings: UserSettings = { - webhooks: ['generic://webhook.site/xxx'], -} +const NotificationSchema = v.object({ + emails: v.array(v.pipe(v.string(), v.email())), + webhooks: v.array(v.pipe(v.string(), v.url())), +}) -const SettingsNotificationsPage = () => { - const [email, setEmail] = useState(pb.authStore.model?.email || '') +const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSettings }) => { const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? []) + const [emails, setEmails] = useState(userSettings.emails ?? []) + const [isLoading, setIsLoading] = useState(false) const addWebhook = () => setWebhooks([...webhooks, '']) const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index)) @@ -35,22 +37,35 @@ const SettingsNotificationsPage = () => { setWebhooks(newWebhooks) } - const saveSettings = async () => { - // TODO: Implement actual saving logic - console.log('Saving settings:', { email, webhooks }) - toast({ - title: 'Settings saved', - description: 'Your notification settings have been updated.', - }) + async function updateSettings() { + setIsLoading(true) + try { + const parsedData = v.parse(NotificationSchema, { emails, webhooks }) + console.log('parsedData', parsedData) + await saveSettings(parsedData) + } catch (e: any) { + toast({ + title: 'Failed to save settings', + description: e.message, + variant: 'destructive', + }) + } + setIsLoading(false) } return (
- {/*
-

Notifications

-

Configure how you receive notifications.

+
+

Notifications

+

+ Configure how you receive alert notifications. +

+

+ Looking for where to create system alerts? Click the bell icons{' '} + in the dashboard table. +

- */} +
@@ -60,13 +75,14 @@ const SettingsNotificationsPage = () => {

- setEmail(e.target.value)} +

- Separate multiple emails with commas. + Save address using enter key or comma.

@@ -109,8 +125,17 @@ const SettingsNotificationsPage = () => {
-
diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index 94ebfaa..46c31ad 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -1,4 +1,4 @@ -import { $systems, pb, $chartTime, $containerFilter } from '@/lib/stores' +import { $systems, pb, $chartTime, $containerFilter, $userSettings } from '@/lib/stores' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' @@ -62,7 +62,7 @@ export default function SystemDetail({ name }: { name: string }) { document.title = `${name} / Beszel` return () => { resetCharts() - $chartTime.set('1h') + $chartTime.set($userSettings.get().chartTime) $containerFilter.set('') setHasDocker(false) } diff --git a/beszel/site/src/components/table-alerts.tsx b/beszel/site/src/components/table-alerts.tsx index 95bde78..494eb28 100644 --- a/beszel/site/src/components/table-alerts.tsx +++ b/beszel/site/src/components/table-alerts.tsx @@ -54,10 +54,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) { {isAdmin() && ( Please{' '} - + configure an SMTP server {' '} to ensure alerts are delivered.{' '} diff --git a/beszel/site/src/components/ui/badge.tsx b/beszel/site/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/beszel/site/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/beszel/site/src/components/ui/input-tags.tsx b/beszel/site/src/components/ui/input-tags.tsx new file mode 100644 index 0000000..6843c3c --- /dev/null +++ b/beszel/site/src/components/ui/input-tags.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { XIcon } from 'lucide-react' +import { type InputProps } from './input' +import { cn } from '@/lib/utils' + +type InputTagsProps = Omit & { + value: string[] + onChange: React.Dispatch> +} + +const InputTags = React.forwardRef( + ({ className, value, onChange, ...props }, ref) => { + const [pendingDataPoint, setPendingDataPoint] = React.useState('') + + React.useEffect(() => { + if (pendingDataPoint.includes(',')) { + const newDataPoints = new Set([ + ...value, + ...pendingDataPoint.split(',').map((chunk) => chunk.trim()), + ]) + onChange(Array.from(newDataPoints)) + setPendingDataPoint('') + } + }, [pendingDataPoint, onChange, value]) + + const addPendingDataPoint = () => { + if (pendingDataPoint) { + const newDataPoints = new Set([...value, pendingDataPoint]) + onChange(Array.from(newDataPoints)) + setPendingDataPoint('') + } + } + + return ( +
+ {value.map((item) => ( + + {item} + + + ))} + setPendingDataPoint(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addPendingDataPoint() + } else if (e.key === 'Backspace' && pendingDataPoint.length === 0 && value.length > 0) { + e.preventDefault() + onChange(value.slice(0, -1)) + } + }} + {...props} + ref={ref} + /> +
+ ) + } +) + +InputTags.displayName = 'InputTags' + +export { InputTags } diff --git a/beszel/site/src/components/ui/table.tsx b/beszel/site/src/components/ui/table.tsx index 614d73a..874700e 100644 --- a/beszel/site/src/components/ui/table.tsx +++ b/beszel/site/src/components/ui/table.tsx @@ -44,7 +44,7 @@ const TableRow = React.forwardRef +/** User settings */ +export const $userSettings = map({ + chartTime: '1h', + emails: [pb.authStore.model?.email || ''], +}) +// update local storage on change +$userSettings.subscribe((value) => { + // console.log('user settings changed', value) + $chartTime.set(value.chartTime) +}) + /** Container chart filter */ export const $containerFilter = atom('') diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts index 0431379..e11661f 100644 --- a/beszel/site/src/lib/utils.ts +++ b/beszel/site/src/lib/utils.ts @@ -1,7 +1,7 @@ import { toast } from '@/components/ui/use-toast' import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' -import { $alerts, $copyContent, $systems, pb } from './stores' +import { $alerts, $copyContent, $systems, $userSettings, pb } from './stores' import { AlertRecord, ChartTimeData, ChartTimes, SystemRecord } from '@/types' import { RecordModel, RecordSubscription } from 'pocketbase' import { WritableAtom } from 'nanostores' @@ -270,3 +270,22 @@ export const useLocalStorage = (key: string, defaultValue: any) => { return [value, setValue] } + +export async function updateUserSettings() { + try { + const req = await pb.collection('user_settings').getFirstListItem('', { fields: 'settings' }) + $userSettings.set(req.settings) + return + } catch (e) { + console.log('get settings', e) + } + // create user settings if error fetching existing + try { + const createdSettings = await pb + .collection('user_settings') + .create({ user: pb.authStore.model!.id }) + $userSettings.set(createdSettings.settings) + } catch (e) { + console.log('create settings', e) + } +} diff --git a/beszel/site/src/main.tsx b/beszel/site/src/main.tsx index 2828171..60438f9 100644 --- a/beszel/site/src/main.tsx +++ b/beszel/site/src/main.tsx @@ -14,6 +14,7 @@ import { import { ModeToggle } from './components/mode-toggle.tsx' import { cn, + updateUserSettings, isAdmin, isReadOnlyUser, updateAlerts, @@ -68,9 +69,10 @@ const App = () => { $publicKey.set(data.key) $hubVersion.set(data.v) }) - // get servers / alerts + // get servers / alerts / settings updateSystemList() updateAlerts() + updateUserSettings() }, []) // update favicon @@ -101,7 +103,7 @@ const App = () => { return } else if (page.route === 'server') { return - } else if (page.path.startsWith('/settings')) { + } else if (page.route === 'settings') { return ( diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts index 6b7a963..b6c9a77 100644 --- a/beszel/site/src/types.d.ts +++ b/beszel/site/src/types.d.ts @@ -118,3 +118,10 @@ export interface ChartTimeData { getOffset: (endTime: Date) => Date } } + +export type UserSettings = { + // lang?: string + chartTime: ChartTimes + emails?: string[] + webhooks?: string[] +}