alert updates

This commit is contained in:
Henry Dollman
2024-07-15 15:49:00 -04:00
parent f1819e59b9
commit 6696e1c749
9 changed files with 224 additions and 115 deletions

View File

@@ -255,7 +255,7 @@ func init() {
{ {
"id": "elngm8x1l60zi2v", "id": "elngm8x1l60zi2v",
"created": "2024-07-15 01:16:04.044Z", "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", "name": "alerts",
"type": "base", "type": "base",
"system": false, "system": false,
@@ -270,7 +270,7 @@ func init() {
"unique": false, "unique": false,
"options": { "options": {
"collectionId": "_pb_users_auth_", "collectionId": "_pb_users_auth_",
"cascadeDelete": false, "cascadeDelete": true,
"minSelect": null, "minSelect": null,
"maxSelect": 1, "maxSelect": 1,
"displayFields": null "displayFields": null
@@ -286,7 +286,7 @@ func init() {
"unique": false, "unique": false,
"options": { "options": {
"collectionId": "2hz5ncl8tizk5nx", "collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": false, "cascadeDelete": true,
"minSelect": null, "minSelect": null,
"maxSelect": 1, "maxSelect": 1,
"displayFields": null "displayFields": null

View File

@@ -21,12 +21,12 @@ import {
} from '@/components/ui/command' } from '@/components/ui/command'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { $servers, navigate } from '@/lib/stores' import { $systems, navigate } from '@/lib/stores'
import { isAdmin } from '@/lib/utils' import { isAdmin } from '@/lib/utils'
export default function CommandPalette() { export default function CommandPalette() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const servers = useStore($servers) const servers = useStore($systems)
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {

View File

@@ -1,4 +1,4 @@
import { $servers, pb } from '@/lib/stores' import { $systems, pb } from '@/lib/stores'
import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types' import { ContainerStatsRecord, SystemRecord, SystemStatsRecord } from '@/types'
import { Suspense, lazy, useEffect, useState } from 'react' import { Suspense, lazy, useEffect, useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../ui/card'
@@ -39,7 +39,7 @@ function timestampToBrowserTime(timestamp: string) {
// } // }
export default function ServerDetail({ name }: { name: string }) { export default function ServerDetail({ name }: { name: string }) {
const servers = useStore($servers) const servers = useStore($systems)
const [server, setServer] = useState({} as SystemRecord) const [server, setServer] = useState({} as SystemRecord)
const [containers, setContainers] = useState([] as ContainerStatsRecord[]) const [containers, setContainers] = useState([] as ContainerStatsRecord[])
@@ -107,7 +107,7 @@ export default function ServerDetail({ name }: { name: string }) {
}, [serverStats]) }, [serverStats])
useEffect(() => { useEffect(() => {
if ($servers.get().length === 0) { if ($systems.get().length === 0) {
// console.log('skipping') // console.log('skipping')
return return
} }

View File

@@ -55,23 +55,13 @@ import {
PauseCircleIcon, PauseCircleIcon,
PlayCircleIcon, PlayCircleIcon,
Trash2Icon, Trash2Icon,
BellIcon,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { $servers, pb, navigate } from '@/lib/stores' import { $systems, pb, navigate } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server' import { AddServerButton } from '../add-server'
import { cn, copyToClipboard, isAdmin } from '@/lib/utils' import { cn, copyToClipboard, isAdmin } from '@/lib/utils'
import { import AlertsButton from '../table-alerts'
Dialog,
DialogContent,
DialogTrigger,
DialogDescription,
DialogTitle,
DialogHeader,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Separator } from '../ui/separator'
function CellFormatter(info: CellContext<SystemRecord, unknown>) { function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -106,14 +96,10 @@ function sortableHeader(column: Column<SystemRecord, unknown>, name: string, Ico
} }
export default function SystemsTable() { export default function SystemsTable() {
const data = useStore($servers) const data = useStore($systems)
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
// useEffect(() => {
// console.log('servers', data)
// }, [data])
const columns: ColumnDef<SystemRecord>[] = useMemo(() => { const columns: ColumnDef<SystemRecord>[] = useMemo(() => {
return [ return [
{ {
@@ -171,49 +157,7 @@ export default function SystemsTable() {
const { id, name, status, host } = row.original const { id, name, status, host } = row.original
return ( return (
<div className={'flex justify-end items-center gap-1'}> <div className={'flex justify-end items-center gap-1'}>
<Dialog> <AlertsButton system={row.original} />
<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>
<AlertDialog> <AlertDialog>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View 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>
)
}

View File

@@ -1,8 +1,9 @@
import PocketBase from 'pocketbase' import PocketBase from 'pocketbase'
import { atom } from 'nanostores' import { atom } from 'nanostores'
import { SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
import { createRouter } from '@nanostores/router' import { createRouter } from '@nanostores/router'
/** PocketBase JS Client */
export const pb = new PocketBase('/') export const pb = new PocketBase('/')
export const $router = createRouter( export const $router = createRouter(
@@ -13,12 +14,19 @@ export const $router = createRouter(
{ links: false } { links: false }
) )
/** Navigate to url using router */
export const navigate = (urlString: string) => { export const navigate = (urlString: string) => {
$router.open(urlString) $router.open(urlString)
} }
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid) 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('') export const $publicKey = atom('')

View File

@@ -1,8 +1,10 @@
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { $servers, pb } from './stores' import { $alerts, $systems, pb } from './stores'
import { SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
import { RecordModel, RecordSubscription } from 'pocketbase'
import { WritableAtom } from 'nanostores'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -28,7 +30,15 @@ export const updateServerList = () => {
pb.collection<SystemRecord>('systems') pb.collection<SystemRecord>('systems')
.getFullList({ sort: '+name' }) .getFullList({ sort: '+name' })
.then((records) => { .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) ((document.querySelector("link[rel='icon']") as HTMLLinkElement).href = newIconUrl)
export const isAdmin = () => pb.authStore.model?.admin 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)
}

View File

@@ -3,11 +3,18 @@ import React, { Suspense, lazy, useEffect } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import Home from './components/routes/home.tsx' import Home from './components/routes/home.tsx'
import { ThemeProvider } from './components/theme-provider.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 { 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 { 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 { useStore } from '@nanostores/react'
import { Toaster } from './components/ui/toaster.tsx' import { Toaster } from './components/ui/toaster.tsx'
import { Logo } from './components/logo.tsx' import { Logo } from './components/logo.tsx'
@@ -24,7 +31,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from './components/ui/dropdown-menu.tsx' } from './components/ui/dropdown-menu.tsx'
import { SystemRecord } from './types' import { AlertRecord, SystemRecord } from './types'
const ServerDetail = lazy(() => import('./components/routes/server.tsx')) const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.tsx')) const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
@@ -33,45 +40,26 @@ const LoginPage = lazy(() => import('./components/login.tsx'))
const App = () => { const App = () => {
const page = useStore($router) const page = useStore($router)
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const servers = useStore($servers) const servers = useStore($systems)
useEffect(() => { useEffect(() => {
// get servers
updateServerList()
// change auth store on auth change // change auth store on auth change
pb.authStore.onChange(() => { pb.authStore.onChange(() => {
$authenticated.set(pb.authStore.isValid) $authenticated.set(pb.authStore.isValid)
}) })
}, []) // get servers / alerts
updateServerList()
useEffect(() => { updateAlerts()
// subscribe to real time updates for systems / alerts
pb.collection<SystemRecord>('systems').subscribe('*', (e) => { pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
const curServers = $servers.get() updateRecordList(e, $systems)
const newServers = [] })
// console.log('e', e) pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
if (e.action === 'delete') { updateRecordList(e, $alerts)
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)
}) })
return () => { return () => {
pb.collection('systems').unsubscribe('*') pb.collection('systems').unsubscribe('*')
pb.collection('alerts').unsubscribe('*')
} }
}, []) }, [])
@@ -114,7 +102,11 @@ const Layout = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
if (!authenticated) { if (!authenticated) {
return <LoginPage /> return (
<Suspense>
<LoginPage />
</Suspense>
)
} }
return ( return (
@@ -191,7 +183,9 @@ const Layout = () => {
</div> </div>
<div className="container mb-14 relative"> <div className="container mb-14 relative">
<App /> <App />
<Suspense>
<CommandPalette /> <CommandPalette />
</Suspense>
</div> </div>
</> </>
) )
@@ -200,12 +194,8 @@ const Layout = () => {
ReactDOM.createRoot(document.getElementById('app')!).render( ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<Suspense>
<Layout /> <Layout />
</Suspense>
<Suspense>
<Toaster /> <Toaster />
</Suspense>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
) )

7
site/src/types.d.ts vendored
View File

@@ -68,3 +68,10 @@ export interface SystemStatsRecord extends RecordModel {
system: string system: string
info: SystemStats info: SystemStats
} }
export interface AlertRecord extends RecordModel {
id: string
system: string
name: string
// user: string
}