Alert history updates

This commit is contained in:
henrygd
2025-07-21 20:07:52 -04:00
parent 9d7fb8ab80
commit 18d9258907
29 changed files with 1301 additions and 643 deletions

Binary file not shown.

View File

@@ -40,7 +40,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1"
@@ -49,6 +48,7 @@
"@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -2832,6 +2832,16 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tailwindcss/container-queries": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.2.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -5484,16 +5494,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonner": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",

View File

@@ -43,7 +43,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
"sonner": "^2.0.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.1"
@@ -52,6 +51,7 @@
"@lingui/cli": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.2",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.15",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -71,4 +71,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -1,146 +1,164 @@
import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button"
import { ArrowUpDown } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
{
accessorKey: "system",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
System <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{row.original.expand?.system?.name || row.original.system}</span>,
enableSorting: true,
filterFn: (row, _, filterValue) => {
const display = row.original.expand?.system?.name || row.original.system || ""
return display.toLowerCase().includes(filterValue.toLowerCase())
},
},
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{row.getValue("name")}</span>,
enableSorting: true,
filterFn: (row, _, filterValue) => {
const value = row.getValue("name") || ""
return String(value).toLowerCase().includes(filterValue.toLowerCase())
},
},
{
accessorKey: "value",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="text-right w-full justify-end"
>
Value <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <span className="text-center block">{Math.round(Number(row.getValue("value")))}</span>,
enableSorting: true,
},
{
accessorKey: "state",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="text-center w-full justify-start"
>
State <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const state = row.getValue("state") as string
let color = ""
if (state === "solved") color = "bg-green-100 text-green-800 border-green-200"
else if (state === "active") color = "bg-yellow-100 text-yellow-800 border-yellow-200"
return (
<span className="text-center block">
<Badge className={`capitalize ${color}`}>{state}</Badge>
</span>
)
},
enableSorting: true,
},
{
accessorKey: "create_date",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-center block">
{row.original.created_date ? new Date(row.original.created_date).toLocaleString() : ""}
</span>
),
enableSorting: true,
},
{
accessorKey: "solved_date",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Solved <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-center block">
{row.original.solved_date ? new Date(row.original.solved_date).toLocaleString() : ""}
</span>
),
enableSorting: true,
},
{
accessorKey: "duration",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Duration <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const created = row.original.created_date ? new Date(row.original.created_date) : null
const solved = row.original.solved_date ? new Date(row.original.solved_date) : null
if (!created || !solved) return <span className="text-center block"></span>
const diffMs = solved.getTime() - created.getTime()
if (diffMs < 0) return <span className="text-center block"></span>
const totalSeconds = Math.floor(diffMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return (
<span className="text-center block">
{[
hours ? `${hours}h` : null,
minutes ? `${minutes}m` : null,
`${seconds}s`
].filter(Boolean).join(" ")}
</span>
)
},
enableSorting: true,
},
]
{
accessorKey: "system",
enableSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>System</Trans>
</Button>
),
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
filterFn: (row, _, filterValue) => {
const display = row.original.expand?.system?.name || row.original.system || ""
return display.toLowerCase().includes(filterValue.toLowerCase())
},
},
{
// accessorKey: "name",
id: "name",
accessorFn: (record) => {
const name = record.name
const info = alertInfo[name]
return info?.name().replace("cpu", "CPU") || name
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Name</Trans>
</Button>
),
cell: ({ getValue, row }) => {
let name = getValue() as string
const info = alertInfo[row.original.name]
const Icon = info?.icon
return (
<span className="flex items-center gap-2 ps-1 min-w-40">
{Icon && <Icon className="size-3.5" />}
{name}
</span>
)
},
},
{
accessorKey: "value",
enableSorting: false,
header: () => (
<Button variant="ghost">
<Trans>Value</Trans>
</Button>
),
cell({ row, getValue }) {
const name = row.original.name
if (name === "Status") {
return <span className="ps-2">{t`Down`}</span>
}
const value = getValue() as number
const unit = alertInfo[name]?.unit
return (
<span className="tabular-nums ps-2.5">
{toFixedFloat(value, value < 10 ? 2 : 1)}
{unit}
</span>
)
},
},
{
accessorKey: "state",
enableSorting: true,
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>State</Trans>
</Button>
),
cell: ({ row }) => {
const resolved = row.original.resolved
return (
<Badge
className={cn(
"capitalize pointer-events-none",
resolved
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
: "bg-yellow-100 text-yellow-800 border-yellow-200"
)}
>
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
<Trans>{resolved ? "Resolved" : "Active"}</Trans>
</Badge>
)
},
},
{
accessorKey: "created",
accessorFn: (record) => formatShortDate(record.created),
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Created</Trans>
</Button>
),
cell: ({ getValue, row }) => (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
{getValue() as string}
</span>
),
},
{
accessorKey: "resolved",
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Resolved</Trans>
</Button>
),
cell: ({ row, getValue }) => {
const resolved = getValue() as string | null
if (!resolved) {
return null
}
return (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
{formatShortDate(resolved)}
</span>
)
},
},
{
accessorKey: "duration",
invertSorting: true,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aCreated = new Date(rowA.original.created)
const bCreated = new Date(rowB.original.created)
const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null
const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null
const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null
const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null
if (!aDuration && bDuration) return -1
if (aDuration && !bDuration) return 1
return (aDuration || 0) - (bDuration || 0)
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Duration</Trans>
</Button>
),
cell: ({ row }) => {
const duration = formatDuration(row.original.created, row.original.resolved)
if (!duration) {
return null
}
return <span className="ps-2">{duration}</span>
},
},
]

View File

@@ -69,7 +69,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<Server className="me-2 h-4 w-4" />
<Server className="me-2 size-4" />
<span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem>
@@ -86,7 +86,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<LayoutDashboard className="me-2 h-4 w-4" />
<LayoutDashboard className="me-2 size-4" />
<span>
<Trans>Dashboard</Trans>
</span>
@@ -100,7 +100,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<SettingsIcon className="me-2 h-4 w-4" />
<SettingsIcon className="me-2 size-4" />
<span>
<Trans>Settings</Trans>
</span>
@@ -113,7 +113,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<MailIcon className="me-2 h-4 w-4" />
<MailIcon className="me-2 size-4" />
<span>
<Trans>Notifications</Trans>
</span>
@@ -125,19 +125,31 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<FingerprintIcon className="me-2 h-4 w-4" />
<FingerprintIcon className="me-2 size-4" />
<span>
<Trans>Tokens & Fingerprints</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "alert-history" }))
setOpen(false)
}}
>
<LogsIcon className="me-2 size-4" />
<span>
<Trans>Alert History</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<BookIcon className="me-2 size-4" />
<span>
<Trans>Documentation</Trans>
</span>
@@ -155,7 +167,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/"), "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
<UsersIcon className="me-2 size-4" />
<span>
<Trans>Users</Trans>
</span>
@@ -167,7 +179,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/logs"), "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
<LogsIcon className="me-2 size-4" />
<span>
<Trans>Logs</Trans>
</span>
@@ -179,7 +191,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
<DatabaseBackupIcon className="me-2 size-4" />
<span>
<Trans>Backups</Trans>
</span>
@@ -192,7 +204,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />
<MailIcon className="me-2 size-4" />
<span>
<Trans>SMTP settings</Trans>
</span>

View File

@@ -110,10 +110,14 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}

View File

@@ -1,247 +1,385 @@
"use client"
import * as React from "react"
import { useStore } from "@nanostores/react"
import { $alertsHistory, pb } from "@/lib/stores"
import { pb } from "@/lib/stores"
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
import { AlertsHistoryRecord } from "@/types"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
flexRender,
ColumnFiltersState,
SortingState,
VisibilityState,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
flexRender,
ColumnFiltersState,
SortingState,
VisibilityState,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { alertsHistoryColumns } from "../../alerts-history-columns"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner"
import { memo, useEffect, useState } from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
DownloadIcon,
Trash2Icon,
} from "lucide-react"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { useToast } from "@/components/ui/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Alert History</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>View your 200 most recent alerts.</Trans>
</p>
</div>
)
})
export default function AlertsHistoryDataTable() {
const alertsHistory = useStore($alertsHistory)
const [data, setData] = useState<AlertsHistoryRecord[]>([])
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
const { toast } = useToast()
const [deleteOpen, setDeleteDialogOpen] = useState(false)
React.useEffect(() => {
pb.collection<AlertsHistoryRecord>("alerts_history")
.getFullList({
sort: "-created_date",
expand: "system,user,alert"
})
.then(records => {
$alertsHistory.set(records)
})
}, [])
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,name,value,state,created,resolved,expand.system.name",
}
// Initial load
pb.collection<AlertsHistoryRecord>("alerts_history")
.getFullList({
...pbOptions,
sort: "-created",
})
.then((records) => setData(records))
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const [combinedFilter, setCombinedFilter] = React.useState("")
const [globalFilter, setGlobalFilter] = React.useState("")
// Subscribe to changes
;(async () => {
unsubscribe = await pb.collection("alerts_history").subscribe(
"*",
(e) => {
if (e.action === "create") {
setData((current) => [e.record as AlertsHistoryRecord, ...current])
}
if (e.action === "update") {
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
}
if (e.action === "delete") {
setData((current) => current.filter((r) => r.id !== e.record.id))
}
},
pbOptions
)
})()
// Unsubscribe on unmount
return () => unsubscribe?.()
}, [])
const table = useReactTable({
data: alertsHistory,
columns: [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...alertsHistoryColumns,
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
globalFilterFn: (row, _columnId, filterValue) => {
const system = row.original.expand?.system?.name || row.original.system || ""
const name = row.getValue("name") || ""
const search = String(filterValue).toLowerCase()
return (
system.toLowerCase().includes(search) ||
String(name).toLowerCase().includes(search)
)
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
})
const table = useReactTable({
data,
columns: [
{
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...alertsHistoryColumns,
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const system = row.original.expand?.system?.name ?? ""
const name = row.getValue("name") ?? ""
const created = row.getValue("created") ?? ""
const search = String(filterValue).toLowerCase()
return (
system.toLowerCase().includes(search) ||
(name as string).toLowerCase().includes(search) ||
(created as string).toLowerCase().includes(search)
)
},
})
// Bulk delete handler
const handleBulkDelete = async () => {
if (!window.confirm("Are you sure you want to delete the selected records?")) return
const selectedIds = table.getSelectedRowModel().rows.map(row => row.original.id)
try {
await Promise.all(selectedIds.map(id => pb.collection("alerts_history").delete(id)))
$alertsHistory.set(alertsHistory.filter(r => !selectedIds.includes(r.id)))
toast.success("Deleted selected records.")
} catch (e) {
toast.error("Failed to delete some records.")
}
}
// Bulk delete handler
const handleBulkDelete = async () => {
setDeleteDialogOpen(false)
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
try {
let batch = pb.createBatch()
let inBatch = 0
for (const id of selectedIds) {
batch.collection("alerts_history").delete(id)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
inBatch && (await batch.send())
table.resetRowSelection()
} catch (e) {
toast({
variant: "destructive",
title: t`Error`,
description: `Failed to delete records.`,
})
}
}
// Export to CSV handler
const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return
const headers = ["system", "name", "value", "state", "created_date", "solved_date", "duration"]
const csvRows = [headers.join(",")]
for (const row of selectedRows) {
const r = row.original
csvRows.push([
r.expand?.system?.name || r.system,
r.name,
r.value,
r.state,
r.created_date,
r.solved_date,
(() => {
const created = r.created_date ? new Date(r.created_date) : null
const solved = r.solved_date ? new Date(r.solved_date) : null
if (!created || !solved) return ""
const diffMs = solved.getTime() - created.getTime()
if (diffMs < 0) return ""
const totalSeconds = Math.floor(diffMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return [
hours ? `${hours}h` : null,
minutes ? `${minutes}m` : null,
`${seconds}s`
].filter(Boolean).join(" ")
})()
].map(v => `"${v ?? ""}"`).join(","))
}
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "alerts_history.csv"
a.click()
URL.revokeObjectURL(url)
}
// Export to CSV handler
const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
system: (record) => record.expand?.system?.name || record.system,
name: (record) => alertInfo[record.name]?.name() || record.name,
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
created: (record) => formatShortDate(record.created),
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
}
const csvRows = [Object.keys(cells).join(",")]
for (const row of selectedRows) {
const r = row.original
csvRows.push(
Object.values(cells)
.map((val) => val(r))
.join(",")
)
}
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "alerts_history.csv"
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="w-full">
<div className="flex items-center py-4 gap-4">
<Input
placeholder="Filter system or name..."
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
className="max-w-sm"
/>
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<>
<Button
variant="destructive"
onClick={handleBulkDelete}
size="sm"
>
Delete Selected
</Button>
<Button
variant="outline"
onClick={handleExportCSV}
size="sm"
>
Export Selected
</Button>
</>
)}
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}
return (
<div className="@container w-full">
<div className="@3xl:flex items-end mb-4 gap-4">
<SectionIntro />
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="h-9 shrink-0">
<Trash2Icon className="size-4 shrink-0" />
<span className="ms-1">
<Trans>Delete</Trans>
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>This will permanently delete all selected records from the database.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleBulkDelete}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
<DownloadIcon className="size-4" />
<span className="ms-1">
<Trans>Export</Trans>
</span>
</Button>
</div>
)}
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="px-4 w-full max-w-full @3xl:w-64"
/>
</div>
</div>
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between ps-1 tabular-nums">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
<Trans>
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</Trans>
</div>
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
<Trans>Rows per page</Trans>
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
<Trans>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</Trans>
</div>
<div className="ms-auto flex items-center gap-2 lg:ms-0">
<Button
variant="outline"
className="hidden size-9 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="size-5" />
</Button>
<Button
variant="outline"
className="hidden size-9 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="size-5" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -17,16 +17,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
// Add state for alert history retention
const [alertHistoryRetention, setAlertHistoryRetention] = useState(userSettings.alertHistoryRetention || "3m")
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
// Add alertHistoryRetention to data
data.alertHistoryRetention = alertHistoryRetention
await saveSettings(data)
setIsLoading(false)
}
@@ -187,27 +182,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</div>
</div>
<Separator />
<div>
<Label htmlFor="alertHistoryRetention">
<Trans>Alert History Retention</Trans>
</Label>
<Select
name="alertHistoryRetention"
value={alertHistoryRetention}
onValueChange={setAlertHistoryRetention}
>
<SelectTrigger className="w-64 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">1 month</SelectItem>
<SelectItem value="3m">3 months</SelectItem>
<SelectItem value="6m">6 months</SelectItem>
<SelectItem value="1y">1 year</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>

View File

@@ -66,17 +66,17 @@ export default function SettingsLayout() {
icon: FingerprintIcon,
noReadOnly: true,
},
{
title: t`Alert History`,
href: getPagePath($router, "settings", { name: "alert-history" }),
icon: LogsIcon,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon,
admin: true,
},
{
title: t`Alerts History`,
href: getPagePath($router, "settings", { name: "alerts-history" }),
icon: LogsIcon,
},
]
const page = useStore($router)
@@ -127,7 +127,7 @@ function SettingsContent({ name }: { name: string }) {
return <ConfigYaml />
case "tokens":
return <Fingerprints />
case "alerts-history":
case "alert-history":
return <AlertsHistoryDataTable />
}
}

View File

@@ -33,7 +33,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
return (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2 truncate">
{item.icon && <item.icon className="h-4 w-4" />}
{item.icon && <item.icon className="size-4" />}
<span className="truncate">{item.title}</span>
</span>
</SelectItem>
@@ -45,7 +45,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
</div>
{/* Desktop View */}
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
{items.map((item) => {
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
return null
@@ -60,7 +60,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
)}
>
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
{item.icon && <item.icon className="size-4 shrink-0" />}
<span className="truncate">{item.title}</span>
</Link>
)

View File

@@ -159,7 +159,7 @@ const SectionUniversalToken = memo(() => {
or on hub restart.
</Trans>
</p>
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 pl-5 pr-4 rounded-md">
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
{!isLoading && (
<>
<Switch

View File

@@ -173,7 +173,7 @@ export default function SystemsTable() {
invertSorting: false,
Icon: ServerIcon,
cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5">
<span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} />
<Button
data-nolink
@@ -233,7 +233,7 @@ export default function SystemsTable() {
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
if (sysInfo.l1 == undefined) {
if (sysInfo.l1 === undefined) {
return null
}
@@ -245,13 +245,13 @@ export default function SystemsTable() {
const normalized = max / cpuThreads
if (status !== "up") return "bg-primary/30"
if (normalized < 0.7) return "bg-green-500"
if (normalized < 1.0) return "bg-yellow-500"
if (normalized < 1) return "bg-yellow-500"
return "bg-red-600"
}
return (
<div className="flex items-center gap-2 w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full", getDotColor())} />
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
{loadAverages.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))}
@@ -267,7 +267,7 @@ export default function SystemsTable() {
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
if (info.row.original.status !== "up") {
if (info.row.original.status === "paused") {
return null
}
const val = info.getValue() as number

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
@@ -11,13 +13,14 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
<Check className="size-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))

View File

@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
className
)}
{...props}

View File

@@ -15,9 +15,6 @@ export const $systems = atom([] as SystemRecord[])
/** List of alert records */
export const $alerts = atom([] as AlertRecord[])
/** List of alerts history records */
export const $alertsHistory = atom([] as AlertsHistoryRecord[])
/** SSH public key */
export const $publicKey = atom("")

View File

@@ -367,6 +367,7 @@ export async function updateUserSettings() {
export const chartMargin = { top: 12 }
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
@@ -455,3 +456,42 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/** Calculate duration between two dates and format as human-readable string */
export function formatDuration(
createdDate: string | null | undefined,
resolvedDate: string | null | undefined
): string {
const created = createdDate ? new Date(createdDate) : null
const resolved = resolvedDate ? new Date(resolvedDate) : null
if (!created || !resolved) return ""
const diffMs = resolved.getTime() - created.getTime()
if (diffMs < 0) return ""
const totalSeconds = Math.floor(diffMs / 1000)
let hours = Math.floor(totalSeconds / 3600)
let minutes = Math.floor((totalSeconds % 3600) / 60)
let seconds = totalSeconds % 60
// if seconds are close to 60, round up to next minute
// if minutes are close to 60, round up to next hour
if (seconds >= 58) {
minutes += 1
seconds = 0
}
if (minutes >= 60) {
hours += 1
minutes = 0
}
// For durations over 1 hour, omit seconds for cleaner display
if (hours > 0) {
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null].filter(Boolean).join(" ")
}
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, seconds ? `${seconds}s` : null]
.filter(Boolean)
.join(" ")
}

View File

@@ -190,14 +190,13 @@ export interface AlertRecord extends RecordModel {
}
export interface AlertsHistoryRecord extends RecordModel {
alert: string;
user: string;
system: string;
name: string;
value: number;
state: "active" | "solved";
created_date: string;
solved_date?: string | null;
alert: string
user: string
system: string
name: string
val: number
created: string
resolved?: string | null
}
export type ChartTimes = "1h" | "12h" | "24h" | "1w" | "30d"
@@ -221,9 +220,6 @@ export interface UserSettings {
unitTemp?: Unit
unitNet?: Unit
unitDisk?: Unit
// New field for alert history retention (e.g., '1m', '3m', '6m', '1y')
alertHistoryRetention?: string
}
type ChartDataContainer = {

View File

@@ -91,6 +91,7 @@ module.exports = {
},
},
plugins: [
require("@tailwindcss/container-queries"),
require("tailwindcss-animate"),
require("tailwindcss-rtl"),
function ({ addVariant }) {