mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
alert / settings page updates
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"beszel/internal/entities/system"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -254,13 +253,13 @@ func (am *AlertManager) SendTestNotification(c echo.Context) error {
|
||||
return apis.NewForbiddenError("Forbidden", nil)
|
||||
}
|
||||
url := c.QueryParam("url")
|
||||
log.Println("url", url)
|
||||
// log.Println("url", url)
|
||||
if url == "" {
|
||||
return c.JSON(http.StatusOK, map[string]string{"err": "URL is required"})
|
||||
return c.JSON(200, map[string]string{"err": "URL is required"})
|
||||
}
|
||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, map[string]string{"err": err.Error()})
|
||||
return c.JSON(200, map[string]string{"err": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"err": false})
|
||||
return c.JSON(200, map[string]bool{"err": false})
|
||||
}
|
||||
|
@@ -41,9 +41,9 @@ export default function SettingsLayout() {
|
||||
<CardDescription>Manage notification and display preferences.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Separator className="my-5" />
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||
<aside className="lg:w-48 w-full overflow-auto">
|
||||
<Separator className="my-3 md:my-5" />
|
||||
<div className="flex flex-col gap-3 md:flex-row md:gap-5 lg:gap-10">
|
||||
<aside className="md:w-48 w-full">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className="flex-1">
|
||||
|
@@ -4,12 +4,46 @@ import { Label } from '@/components/ui/label'
|
||||
import { pb } from '@/lib/stores'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Card } from '@/components/ui/card'
|
||||
// import { Switch } from '@/components/ui/switch'
|
||||
import { LoaderCircleIcon, PlusIcon, SaveIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
|
||||
export default function SettingsNotificationsPage() {
|
||||
interface UserSettings {
|
||||
webhooks: string[]
|
||||
}
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
onUrlChange: (value: string) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const userSettings: UserSettings = {
|
||||
webhooks: ['generic://webhook.site/xxx'],
|
||||
}
|
||||
|
||||
const SettingsNotificationsPage = () => {
|
||||
const [email, setEmail] = useState(pb.authStore.model?.email || '')
|
||||
const [webhooks, setWebhooks] = useState(userSettings.webhooks ?? [])
|
||||
|
||||
const addWebhook = () => setWebhooks([...webhooks, ''])
|
||||
const removeWebhook = (index: number) => setWebhooks(webhooks.filter((_, i) => i !== index))
|
||||
|
||||
const updateWebhook = (index: number, value: string) => {
|
||||
const newWebhooks = [...webhooks]
|
||||
newWebhooks[index] = value
|
||||
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.',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <div>
|
||||
@@ -22,11 +56,15 @@ export default function SettingsNotificationsPage() {
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-lg font-medium">Email notifications</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave the emails field to disable email notifications.
|
||||
Leave blank to disable email notifications.
|
||||
</p>
|
||||
</div>
|
||||
<Label className="block">To email(s)</Label>
|
||||
<Input placeholder="name@example.com" defaultValue={pb.authStore.model?.email} />
|
||||
<Input
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Separate multiple emails with commas.
|
||||
</p>
|
||||
@@ -47,20 +85,31 @@ export default function SettingsNotificationsPage() {
|
||||
to integrate with popular notification services.
|
||||
</p>
|
||||
</div>
|
||||
<ShoutrrrUrlCard />
|
||||
{webhooks.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<ShoutrrrUrlCard
|
||||
key={index}
|
||||
url={webhook}
|
||||
onUrlChange={(value: string) => updateWebhook(index, value)}
|
||||
onRemove={() => removeWebhook(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 flex items-center gap-1"
|
||||
// onClick={() => append({ value: '' })}
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 -ml-0.5" />
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<Button type="submit" className="flex items-center gap-1.5">
|
||||
<Button type="button" className="flex items-center gap-1.5" onClick={saveSettings}>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
Save settings
|
||||
</Button>
|
||||
@@ -69,68 +118,65 @@ export default function SettingsNotificationsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
async function sendTestNotification(url: string) {
|
||||
const res = await pb.send('/api/beszel/send-test-notification', { url })
|
||||
if ('err' in res && !res.err) {
|
||||
toast({
|
||||
title: 'Test notification sent',
|
||||
description: 'Check your notification service',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: res.err ?? 'Failed to send test notification',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// todo unique ids
|
||||
function ShoutrrrUrlCard() {
|
||||
const [url, setUrl] = useState('')
|
||||
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const sendTestNotification = async () => {
|
||||
setIsLoading(true)
|
||||
const res = await pb.send('/api/beszel/send-test-notification', { url })
|
||||
if ('err' in res && !res.err) {
|
||||
toast({
|
||||
title: 'Test notification sent',
|
||||
description: 'Check your notification service',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: res.err ?? 'Failed to send test notification',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-muted/30 p-3.5">
|
||||
<Card className="bg-muted/30 p-2 md:p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label htmlFor="name" className="sr-only">
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
className="light:bg-card"
|
||||
required
|
||||
placeholder="generic://webhook.site/xxxxxx"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-28"
|
||||
className="w-20 md:w-28"
|
||||
disabled={isLoading || url === ''}
|
||||
onClick={async () => {
|
||||
setIsLoading(true)
|
||||
await sendTestNotification(url)
|
||||
setIsLoading(false)
|
||||
}}
|
||||
onClick={sendTestNotification}
|
||||
>
|
||||
{isLoading ? <LoaderCircleIcon className="sh-4 w-4 animate-spin" /> : 'Test URL'}
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span>
|
||||
Test <span className="hidden md:inline">URL</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
// onClick={() => append({ value: '' })}
|
||||
aria-label="Delete"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2Icon className="sh-4 w-4" />
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* <Label htmlFor="enabled-01" className="sr-only">
|
||||
Enabled
|
||||
</Label>
|
||||
<Switch defaultChecked id="enabled-01" className="ml-2" /> */}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsNotificationsPage
|
||||
|
@@ -1,8 +1,16 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '../../ui/button'
|
||||
import { $router, Link } from '../../router'
|
||||
import { $router, Link, navigate } from '../../router'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import React from 'react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
items: {
|
||||
@@ -16,25 +24,45 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||
const page = useStore($router)
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn('flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1', className)}
|
||||
{...props}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex items-center gap-3',
|
||||
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
|
||||
'justify-start'
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<>
|
||||
{/* Mobile View */}
|
||||
<div className="md:hidden">
|
||||
<Select onValueChange={(value: string) => navigate(value)} defaultValue={page?.path}>
|
||||
<SelectTrigger className="w-full mb-3">
|
||||
<SelectValue placeholder="Select a page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Desktop View */}
|
||||
<nav className={cn('hidden md:grid gap-1', className)} {...props}>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex items-center gap-3',
|
||||
page?.path === item.href ? 'bg-muted hover:bg-muted' : 'hover:bg-muted/50',
|
||||
'justify-start'
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user