fix: error adding alerts to 50+ systems (#664)

- Fixed PB SDK autocancelling requests.
- Refactored SystemAlert and SystemAlertGlobal components.
- Introduced a batchWrapper function for handling batch operations on alerts.
This commit is contained in:
henrygd
2025-03-13 02:07:58 -04:00
parent 3b9910351d
commit 1d7c0ebc27
2 changed files with 273 additions and 204 deletions

View File

@@ -1,6 +1,6 @@
import { memo, useState } from "react" import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $alerts, $systems } from "@/lib/stores" import { $alerts } from "@/lib/stores"
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -23,39 +23,49 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
const alerts = useStore($alerts) const alerts = useStore($alerts)
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[] const hasAlert = alerts.some((alert) => alert.system === system.id)
const active = systemAlerts.length > 0
return ( return useMemo(
() => (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}> <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon <BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", { className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
"fill-primary": active, "fill-primary": hasAlert,
})} })}
/> />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-full overflow-auto max-w-[35rem]"> <DialogContent className="max-h-full overflow-auto max-w-[35rem]">
{opened && <TheContent data={{ system, alerts, systemAlerts }} />} {opened && <AlertDialogContent system={system} />}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
),
[opened, hasAlert]
) )
}) })
function TheContent({ function AlertDialogContent({ system }: { system: SystemRecord }) {
data: { system, alerts, systemAlerts }, const alerts = useStore($alerts)
}: {
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
}) {
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const systems = $systems.get()
const data = Object.keys(alertInfo).map((key) => { // alertsSignature changes only when alerts for this system change
const alert = alertInfo[key as keyof typeof alertInfo] let alertsSignature = ""
const systemAlerts = alerts.filter((alert) => {
if (alert.system === system.id) {
alertsSignature += alert.name + alert.min + alert.value
return true
}
return false
}) as AlertRecord[]
return useMemo(() => {
// console.log("render modal", system.name, alertsSignature)
const data = Object.keys(alertInfo).map((name) => {
const alert = alertInfo[name as keyof typeof alertInfo]
return { return {
key: key as keyof typeof alertInfo, name: name as keyof typeof alertInfo,
alert, alert,
system, system,
} }
@@ -91,7 +101,7 @@ function TheContent({
<TabsContent value="system"> <TabsContent value="system">
<div className="grid gap-3"> <div className="grid gap-3">
{data.map((d) => ( {data.map((d) => (
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} /> <SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
))} ))}
</div> </div>
</TabsContent> </TabsContent>
@@ -110,11 +120,12 @@ function TheContent({
</label> </label>
<div className="grid gap-3"> <div className="grid gap-3">
{data.map((d) => ( {data.map((d) => (
<SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} /> <SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
))} ))}
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</> </>
) )
}, [alertsSignature, overwriteExisting])
} }

View File

@@ -1,18 +1,19 @@
import { pb } from "@/lib/stores" import { $alerts, $systems, pb } from "@/lib/stores"
import { alertInfo, cn } from "@/lib/utils" import { alertInfo, cn } from "@/lib/utils"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types" import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useRef, useState } from "react" import { lazy, Suspense, useMemo, useState } from "react"
import { toast } from "../ui/use-toast" import { toast } from "../ui/use-toast"
import { RecordOptions } from "pocketbase" import { BatchService } from "pocketbase"
import { Trans, t, Plural } from "@lingui/macro" import { Trans, t, Plural } from "@lingui/macro"
import { getSemaphore } from "@henrygd/semaphore"
interface AlertData { interface AlertData {
checked?: boolean checked?: boolean
val?: number val?: number
min?: number min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void updateAlert?: (checked: boolean, value: number, min: number) => void
key: keyof typeof alertInfo name: keyof typeof alertInfo
alert: AlertInfo alert: AlertInfo
system: SystemRecord system: SystemRecord
} }
@@ -35,7 +36,7 @@ export function SystemAlert({
systemAlerts: AlertRecord[] systemAlerts: AlertRecord[]
data: AlertData data: AlertData
}) { }) {
const alert = systemAlerts.find((alert) => alert.name === data.key) const alert = systemAlerts.find((alert) => alert.name === data.name)
data.updateAlert = async (checked: boolean, value: number, min: number) => { data.updateAlert = async (checked: boolean, value: number, min: number) => {
try { try {
@@ -47,7 +48,7 @@ export function SystemAlert({
pb.collection("alerts").create({ pb.collection("alerts").create({
system: system.id, system: system.id,
user: pb.authStore.record!.id, user: pb.authStore.record!.id,
name: data.key, name: data.name,
value: value, value: value,
min: min, min: min,
}) })
@@ -66,31 +67,29 @@ export function SystemAlert({
return <AlertContent data={data} /> return <AlertContent data={data} />
} }
export function SystemAlertGlobal({ export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
data,
overwrite,
alerts,
systems,
}: {
data: AlertData
overwrite: boolean | "indeterminate"
alerts: AlertRecord[]
systems: SystemRecord[]
}) {
const systemsWithExistingAlerts = useRef<{ set: Set<string>; populatedSet: boolean }>({
set: new Set(),
populatedSet: false,
})
data.checked = false data.checked = false
data.val = data.min = 0 data.val = data.min = 0
data.updateAlert = async (checked: boolean, value: number, min: number) => { // set of system ids that have an alert for this name when the component is mounted
const { set, populatedSet } = systemsWithExistingAlerts.current const existingAlertsSystems = useMemo(() => {
const map = new Set<string>()
const alerts = $alerts.get()
for (const alert of alerts) {
if (alert.name === data.name) {
map.add(alert.system)
}
}
return map
}, [])
// if overwrite checked, make sure all alerts will be overwritten data.updateAlert = async (checked: boolean, value: number, min: number) => {
if (overwrite) { const sem = getSemaphore("alerts")
set.clear() await sem.acquire()
try {
// if another update is waiting behind, don't start this one
if (sem.size() > 1) {
return
} }
const recordData: Partial<AlertRecord> = { const recordData: Partial<AlertRecord> = {
@@ -99,66 +98,119 @@ export function SystemAlertGlobal({
triggered: false, triggered: false,
} }
// we can only send 50 in one batch const batch = batchWrapper("alerts", 25)
let done = 0 const systems = $systems.get()
const currentAlerts = $alerts.get()
while (done < systems.length) { // map of current alerts with this name right now by system id
const batch = pb.createBatch() const currentAlertsSystems = new Map<string, AlertRecord>()
let batchSize = 0 for (const alert of currentAlerts) {
if (alert.name === data.name) {
for (let i = done; i < Math.min(done + 50, systems.length); i++) { currentAlertsSystems.set(alert.system, alert)
const system = systems[i]
// if overwrite is false and system is in set (alert existed), skip
if (!overwrite && set.has(system.id)) {
continue
} }
// find matching existing alert
const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
// if first run, add system to set (alert already existed when global panel was opened)
if (existingAlert && !populatedSet && !overwrite) {
set.add(system.id)
continue
}
batchSize++
const requestOptions: RecordOptions = {
requestKey: system.id,
} }
// checked - make sure alert is created or updated if (overwrite) {
existingAlertsSystems.clear()
}
const processSystem = async (system: SystemRecord): Promise<void> => {
const existingAlert = existingAlertsSystems.has(system.id)
if (!overwrite && existingAlert) {
return
}
const currentAlert = currentAlertsSystems.get(system.id)
// delete existing alert if unchecked
if (!checked && currentAlert) {
return batch.remove(currentAlert.id)
}
if (checked && currentAlert) {
// update existing alert if checked
return batch.update(currentAlert.id, recordData)
}
if (checked) { if (checked) {
if (existingAlert) { // create new alert if checked and not existing
batch.collection("alerts").update(existingAlert.id, recordData, requestOptions) return batch.create({
} else {
batch.collection("alerts").create(
{
system: system.id, system: system.id,
user: pb.authStore.record!.id, user: pb.authStore.record!.id,
name: data.key, name: data.name,
...recordData, ...recordData,
}, })
requestOptions
)
}
} else if (existingAlert) {
batch.collection("alerts").delete(existingAlert.id)
} }
} }
try {
batchSize && batch.send() // make sure current system is updated in the first batch
} catch (e) { await processSystem(data.system)
failedUpdateToast() for (const system of systems) {
if (system.id === data.system.id) {
continue
}
if (sem.size() > 1) {
return
}
await processSystem(system)
}
await batch.send()
} finally { } finally {
done += 50 sem.release()
} }
} }
systemsWithExistingAlerts.current.populatedSet = true
}
return <AlertContent data={data} /> return <AlertContent data={data} />
} }
/**
* Creates a wrapper for performing batch operations on a specified collection.
*/
function batchWrapper(collection: string, batchSize: number) {
let batch: BatchService | undefined
let count = 0
const create = async <T extends Record<string, any>>(options: T) => {
batch ||= pb.createBatch()
batch.collection(collection).create(options)
if (++count >= batchSize) {
await send()
}
}
const update = async <T extends Record<string, any>>(id: string, data: T) => {
batch ||= pb.createBatch()
batch.collection(collection).update(id, data)
if (++count >= batchSize) {
await send()
}
}
const remove = async (id: string) => {
batch ||= pb.createBatch()
batch.collection(collection).delete(id)
if (++count >= batchSize) {
await send()
}
}
const send = async () => {
if (count) {
await batch?.send({ requestKey: null })
batch = undefined
count = 0
}
}
return {
update,
remove,
send,
create,
}
}
function AlertContent({ data }: { data: AlertData }) { function AlertContent({ data }: { data: AlertData }) {
const { key } = data const { name } = data
const singleDescription = data.alert.singleDesc?.() const singleDescription = data.alert.singleDesc?.()
@@ -166,17 +218,12 @@ function AlertContent({ data }: { data: AlertData }) {
const [min, setMin] = useState(data.min || 10) const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80)) const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
const newMin = useRef(min) const Icon = alertInfo[name].icon
const newValue = useRef(value)
const Icon = alertInfo[key].icon
const updateAlert = (c?: boolean) => data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
return ( return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group"> <div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label <label
htmlFor={`s${key}`} htmlFor={`s${name}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", { className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": checked, "pb-0": checked,
})} })}
@@ -188,11 +235,11 @@ function AlertContent({ data }: { data: AlertData }) {
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>} {!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
</div> </div>
<Switch <Switch
id={`s${key}`} id={`s${name}`}
checked={checked} checked={checked}
onCheckedChange={(checked) => { onCheckedChange={(newChecked) => {
setChecked(checked) setChecked(newChecked)
updateAlert(checked) data.updateAlert?.(newChecked, value, min)
}} }}
/> />
</label> </label>
@@ -201,7 +248,7 @@ function AlertContent({ data }: { data: AlertData }) {
<Suspense fallback={<div className="h-10" />}> <Suspense fallback={<div className="h-10" />}>
{!singleDescription && ( {!singleDescription && (
<div> <div>
<p id={`v${key}`} className="text-sm block h-8"> <p id={`v${name}`} className="text-sm block h-8">
<Trans> <Trans>
Average exceeds{" "} Average exceeds{" "}
<strong className="text-foreground"> <strong className="text-foreground">
@@ -212,20 +259,27 @@ function AlertContent({ data }: { data: AlertData }) {
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider
aria-labelledby={`v${key}`} aria-labelledby={`v${name}`}
defaultValue={[value]} defaultValue={[value]}
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()} onValueCommit={(val) => {
onValueChange={(val) => setValue(val[0])} data.updateAlert?.(true, val[0], min)
}}
onValueChange={(val) => {
setValue(val[0])
}}
min={1} min={1}
max={alertInfo[key].max ?? 99} max={alertInfo[name].max ?? 99}
/> />
</div> </div>
</div> </div>
)} )}
<div className={cn(singleDescription && "col-span-full lowercase")}> <div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${key}`} className="text-sm block h-8 first-letter:uppercase"> <p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && ( {singleDescription && (
<>{singleDescription}{` `}</> <>
{singleDescription}
{` `}
</>
)} )}
<Trans> <Trans>
For <strong className="text-foreground">{min}</strong>{" "} For <strong className="text-foreground">{min}</strong>{" "}
@@ -234,10 +288,14 @@ function AlertContent({ data }: { data: AlertData }) {
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider
aria-labelledby={`v${key}`} aria-labelledby={`v${name}`}
defaultValue={[min]} defaultValue={[min]}
onValueCommit={(val) => (newMin.current = val[0]) && updateAlert()} onValueCommit={(min) => {
onValueChange={(val) => setMin(val[0])} data.updateAlert?.(true, value, min[0])
}}
onValueChange={(val) => {
setMin(val[0])
}}
min={1} min={1}
max={60} max={60}
/> />