mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 17:59:28 +08:00
alert updates
This commit is contained in:
@@ -255,7 +255,7 @@ func init() {
|
||||
{
|
||||
"id": "elngm8x1l60zi2v",
|
||||
"created": "2024-07-15 01:16:04.044Z",
|
||||
"updated": "2024-07-15 01:19:11.639Z",
|
||||
"updated": "2024-07-15 18:48:55.881Z",
|
||||
"name": "alerts",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
@@ -270,7 +270,7 @@ func init() {
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
@@ -286,7 +286,7 @@ func init() {
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"cascadeDelete": false,
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
|
@@ -21,12 +21,12 @@ import {
|
||||
} from '@/components/ui/command'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $servers, navigate } from '@/lib/stores'
|
||||
import { $systems, navigate } from '@/lib/stores'
|
||||
import { isAdmin } from '@/lib/utils'
|
||||
|
||||
export default function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const servers = useStore($servers)
|
||||
const servers = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { $servers, pb } from '@/lib/stores'
|
||||
import { $systems, pb } from '@/lib/stores'
|
||||
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
|
||||
import { Suspense, lazy, useEffect, useState } from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
|
||||
@@ -39,7 +39,7 @@ function timestampToBrowserTime(timestamp: string) {
|
||||
// }
|
||||
|
||||
export default function ServerDetail({ name }: { name: string }) {
|
||||
const servers = useStore($servers)
|
||||
const servers = useStore($systems)
|
||||
const [server, setServer] = useState({} as SystemRecord)
|
||||
const [containers, setContainers] = useState([] as ContainerStatsRecord[])
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function ServerDetail({ name }: { name: string }) {
|
||||
}, [serverStats])
|
||||
|
||||
useEffect(() => {
|
||||
if ($servers.get().length === 0) {
|
||||
if ($systems.get().length === 0) {
|
||||
// console.log('skipping')
|
||||
return
|
||||
}
|
||||
|
@@ -55,23 +55,13 @@ import {
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
Trash2Icon,
|
||||
BellIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { $servers, pb, navigate } from '@/lib/stores'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { $systems, pb, navigate } from '@/lib/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { AddServerButton } from '../add-server'
|
||||
import { cn, copyToClipboard, isAdmin } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogHeader,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Separator } from '../ui/separator'
|
||||
import AlertsButton from '../table-alerts'
|
||||
|
||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
const val = info.getValue() as number
|
||||
@@ -106,14 +96,10 @@ function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Ico
|
||||
}
|
||||
|
||||
export default function SystemsTable() {
|
||||
const data = useStore($servers)
|
||||
const data = useStore($systems)
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log('servers', data)
|
||||
// }, [data])
|
||||
|
||||
const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -171,49 +157,7 @@ export default function SystemsTable() {
|
||||
const { id, name, status, host } = row.original
|
||||
return (
|
||||
<div className={'flex justify-end items-center gap-1'}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
|
||||
<BellIcon className="h-[1.2em] w-[1.2em] pointer-events-none" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-1">Alerts for {name}</DialogTitle>
|
||||
{isAdmin() && (
|
||||
<DialogDescription>
|
||||
Please{' '}
|
||||
<a
|
||||
href="/_/#/settings/mail"
|
||||
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
|
||||
>
|
||||
configure an SMTP server
|
||||
</a>{' '}
|
||||
to ensure alerts are delivered.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="space-y-2 flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
className="font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-base"
|
||||
htmlFor=":r3m:-form-item"
|
||||
>
|
||||
Status change
|
||||
</label>
|
||||
<p
|
||||
id=":r3m:-form-item-description"
|
||||
className="text-[0.8rem] text-muted-foreground"
|
||||
>
|
||||
Triggers when system status switches between up and down.
|
||||
</p>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertsButton system={row.original} />
|
||||
<AlertDialog>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
120
site/src/components/table-alerts.tsx
Normal file
120
site/src/components/table-alerts.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { $alerts, pb } from '@/lib/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { BellIcon } from 'lucide-react'
|
||||
import { cn, isAdmin } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from './ui/use-toast'
|
||||
|
||||
export default function AlertsButton({ system }: { system: SystemRecord }) {
|
||||
const alerts = useStore($alerts)
|
||||
|
||||
const active = useMemo(() => {
|
||||
return alerts.find((alert) => alert.system === system.id)
|
||||
}, [alerts, system])
|
||||
|
||||
const systemAlerts = useMemo(() => {
|
||||
return alerts.filter((alert) => alert.system === system.id) as AlertRecord[]
|
||||
}, [alerts, system])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'} aria-label="Alerts" data-nolink>
|
||||
<BellIcon
|
||||
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
|
||||
'fill-foreground': active,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isAdmin() && (
|
||||
<span>
|
||||
Please{' '}
|
||||
<a
|
||||
href="/_/#/settings/mail"
|
||||
className="font-medium text-primary opacity-80 hover:opacity-100 duration-100"
|
||||
>
|
||||
configure an SMTP server
|
||||
</a>{' '}
|
||||
to ensure alerts are delivered.{' '}
|
||||
</span>
|
||||
)}
|
||||
Webhook delivery and more alert options will be added in the future.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Alert system={system} alerts={systemAlerts} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
|
||||
const [pendingChange, setPendingChange] = useState(false)
|
||||
|
||||
const alert = useMemo(() => {
|
||||
return alerts.find((alert) => alert.name === 'status')
|
||||
}, [alerts])
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor="status"
|
||||
className="space-y-2 flex flex-row items-center justify-between rounded-lg border p-4 cursor-pointer"
|
||||
>
|
||||
<div className="grid gap-0.5 select-none">
|
||||
<p className="font-medium text-base">System status</p>
|
||||
<span
|
||||
id=":r3m:-form-item-description"
|
||||
className="block text-[0.8rem] text-foreground opacity-80"
|
||||
>
|
||||
Triggers when status switches between up and down.
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="status"
|
||||
className={cn('transition-opacity', pendingChange && 'opacity-40')}
|
||||
checked={!!alert}
|
||||
value={!!alert ? 'on' : 'off'}
|
||||
onCheckedChange={async (active) => {
|
||||
if (pendingChange) {
|
||||
return
|
||||
}
|
||||
setPendingChange(true)
|
||||
try {
|
||||
if (!active && alert) {
|
||||
await pb.collection('alerts').delete(alert.id)
|
||||
} else if (active) {
|
||||
pb.collection('alerts').create({
|
||||
system: system.id,
|
||||
user: pb.authStore.model!.id,
|
||||
name: 'status',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Failed to update alert',
|
||||
description: 'Please check logs for more details.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setPendingChange(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
import { atom } from 'nanostores'
|
||||
import { SystemRecord } from '@/types'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { createRouter } from '@nanostores/router'
|
||||
|
||||
/** PocketBase JS Client */
|
||||
export const pb = new PocketBase('/')
|
||||
|
||||
export const $router = createRouter(
|
||||
@@ -13,12 +14,19 @@ export const $router = createRouter(
|
||||
{ links: false }
|
||||
)
|
||||
|
||||
/** Navigate to url using router */
|
||||
export const navigate = (urlString: string) => {
|
||||
$router.open(urlString)
|
||||
}
|
||||
|
||||
/** Store if user is authenticated */
|
||||
export const $authenticated = atom(pb.authStore.isValid)
|
||||
|
||||
export const $servers = atom([] as SystemRecord[])
|
||||
/** List of system records */
|
||||
export const $systems = atom([] as SystemRecord[])
|
||||
|
||||
/** List of alert records */
|
||||
export const $alerts = atom([] as AlertRecord[])
|
||||
|
||||
/** SSH public key */
|
||||
export const $publicKey = atom('')
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { $servers, pb } from './stores'
|
||||
import { SystemRecord } from '@/types'
|
||||
import { $alerts, $systems, pb } from './stores'
|
||||
import { AlertRecord, SystemRecord } from '@/types'
|
||||
import { RecordModel, RecordSubscription } from 'pocketbase'
|
||||
import { WritableAtom } from 'nanostores'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -28,7 +30,15 @@ export const updateServerList = () => {
|
||||
pb.collection<SystemRecord>('systems')
|
||||
.getFullList({ sort: '+name' })
|
||||
.then((records) => {
|
||||
$servers.set(records)
|
||||
$systems.set(records)
|
||||
})
|
||||
}
|
||||
|
||||
export const updateAlerts = () => {
|
||||
pb.collection('alerts')
|
||||
.getFullList<AlertRecord>({ fields: 'id,name,system' })
|
||||
.then((records) => {
|
||||
$alerts.set(records)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,3 +66,33 @@ export const updateFavicon = (newIconUrl: string) =>
|
||||
((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl)
|
||||
|
||||
export const isAdmin = () => pb.authStore.model?.admin
|
||||
|
||||
/** Update systems / alerts list when records change */
|
||||
export function updateRecordList<T extends RecordModel>(
|
||||
e: RecordSubscription<T>,
|
||||
$store: WritableAtom<T[]>
|
||||
) {
|
||||
const curRecords = $store.get()
|
||||
const newRecords = []
|
||||
// console.log('e', e)
|
||||
if (e.action === 'delete') {
|
||||
for (const server of curRecords) {
|
||||
if (server.id !== e.record.id) {
|
||||
newRecords.push(server)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let found = 0
|
||||
for (const server of curRecords) {
|
||||
if (server.id === e.record.id) {
|
||||
found = newRecords.push(e.record)
|
||||
} else {
|
||||
newRecords.push(server)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newRecords.push(e.record)
|
||||
}
|
||||
}
|
||||
$store.set(newRecords)
|
||||
}
|
||||
|
@@ -3,11 +3,18 @@ import React, { Suspense, lazy, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import Home from './components/routes/home.tsx'
|
||||
import { ThemeProvider } from './components/theme-provider.tsx'
|
||||
import { $authenticated, $router, $servers, navigate, pb } from './lib/stores.ts'
|
||||
import { $alerts, $authenticated, $router, $systems, navigate, pb } from './lib/stores.ts'
|
||||
import { ModeToggle } from './components/mode-toggle.tsx'
|
||||
import { cn, isAdmin, updateFavicon, updateServerList } from './lib/utils.ts'
|
||||
import {
|
||||
cn,
|
||||
isAdmin,
|
||||
updateAlerts,
|
||||
updateFavicon,
|
||||
updateRecordList,
|
||||
updateServerList,
|
||||
} from './lib/utils.ts'
|
||||
import { buttonVariants } from './components/ui/button.tsx'
|
||||
import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, MailIcon, UserIcon } from 'lucide-react'
|
||||
import { DatabaseBackupIcon, Github, LogOutIcon, LogsIcon, UserIcon } from 'lucide-react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Toaster } from './components/ui/toaster.tsx'
|
||||
import { Logo } from './components/logo.tsx'
|
||||
@@ -24,7 +31,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './components/ui/dropdown-menu.tsx'
|
||||
import { SystemRecord } from './types'
|
||||
import { AlertRecord, SystemRecord } from './types'
|
||||
|
||||
const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
|
||||
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
|
||||
@@ -33,45 +40,26 @@ const LoginPage = lazy(() => import('./components/login.tsx'))
|
||||
const App = () => {
|
||||
const page = useStore($router)
|
||||
const authenticated = useStore($authenticated)
|
||||
const servers = useStore($servers)
|
||||
const servers = useStore($systems)
|
||||
|
||||
useEffect(() => {
|
||||
// get servers
|
||||
updateServerList()
|
||||
// change auth store on auth change
|
||||
pb.authStore.onChange(() => {
|
||||
$authenticated.set(pb.authStore.isValid)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// get servers / alerts
|
||||
updateServerList()
|
||||
updateAlerts()
|
||||
// subscribe to real time updates for systems / alerts
|
||||
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
||||
const curServers = $servers.get()
|
||||
const newServers = []
|
||||
// console.log('e', e)
|
||||
if (e.action === 'delete') {
|
||||
for (const server of curServers) {
|
||||
if (server.id !== e.record.id) {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let found = 0
|
||||
for (const server of curServers) {
|
||||
if (server.id === e.record.id) {
|
||||
found = newServers.push(e.record)
|
||||
} else {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newServers.push(e.record)
|
||||
}
|
||||
}
|
||||
$servers.set(newServers)
|
||||
updateRecordList(e, $systems)
|
||||
})
|
||||
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
|
||||
updateRecordList(e, $alerts)
|
||||
})
|
||||
return () => {
|
||||
pb.collection('systems').unsubscribe('*')
|
||||
pb.collection('alerts').unsubscribe('*')
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -114,7 +102,11 @@ const Layout = () => {
|
||||
const authenticated = useStore($authenticated)
|
||||
|
||||
if (!authenticated) {
|
||||
return <LoginPage />
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -191,7 +183,9 @@ const Layout = () => {
|
||||
</div>
|
||||
<div className="container mb-14 relative">
|
||||
<App />
|
||||
<CommandPalette />
|
||||
<Suspense>
|
||||
<CommandPalette />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -200,12 +194,8 @@ const Layout = () => {
|
||||
ReactDOM.createRoot(document.getElementById('app')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<Suspense>
|
||||
<Layout />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<Toaster />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
7
site/src/types.d.ts
vendored
7
site/src/types.d.ts
vendored
@@ -68,3 +68,10 @@ export interface SystemStatsRecord extends RecordModel {
|
||||
system: string
|
||||
info: SystemStats
|
||||
}
|
||||
|
||||
export interface AlertRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
name: string
|
||||
// user: string
|
||||
}
|
||||
|
Reference in New Issue
Block a user