feat: network monitoring from agents (#2266, #1911)

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com>
This commit is contained in:
hank
2026-09-18 13:22:50 -04:00
committed by GitHub
parent 4bf70700f2
commit bb1b39928e
89 changed files with 8699 additions and 457 deletions

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, NetworkIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
@@ -9,210 +9,223 @@ import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
NetworkMonitorLoss: {
name: () => t`Network Monitor Loss`,
unit: "%",
icon: NetworkIcon,
desc: () => t`Triggers when one hour loss exceeds a threshold`,
// note: () => t`Uses available history after three probes.`,
noDuration: true,
min: 0,
max: 99.9,
step: 0.1,
start: 5,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
},
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
noThreshold: true,
},
} as const
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
function unsubscribe() {
unsub?.()
}
function unsubscribe() {
unsub?.()
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
})()

View File

@@ -106,8 +106,11 @@ export async function updateUserSettings() {
}
}
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
export function getPbTimestamp(timeString: ChartTimes, d?: Date, createdIsNumber?: boolean) {
d ||= chartTimeData[timeString].getOffset(new Date())
if (createdIsNumber) {
return d.getTime()
}
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
const day = String(d.getUTCDate()).padStart(2, "0")

View File

@@ -0,0 +1,17 @@
import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
return {
res_avg: record.success_count > 0 ? record.res_sum / record.success_count : 0,
res_min: record.res_min,
res_max: record.res_max,
loss: record.total_count > 0 ? ((record.total_count - record.success_count) / record.total_count) * 100 : 0,
}
}
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}

View File

@@ -64,6 +64,9 @@ export const $containerFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")
/** Filter for network monitor charts (compare page and per-system monitor charts) */
export const $monitorFilter = atom("")
/** Fan-speed chart filter */
export const $fanFilter = atom("")
@@ -73,7 +76,5 @@ export const $copyContent = atom("")
/** Direction for localization */
export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name length. Used to set table column width. I know this
* is stupid but the table is virtualized and I know this will work.
*/
export const $longestSystemNameLen = atom(8)
/** Longest system name string. Used to reserve width in virtualized tables. */
export const $longestSystemName = atom("")

View File

@@ -5,20 +5,17 @@ import {
$allSystemsById,
$allSystemsByName,
$downSystems,
$longestSystemNameLen,
$longestSystemName,
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { getVisualStringWidth, updateFavicon } from "@/lib/utils"
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
const COLLECTION = pb.collection<SystemRecord>("systems")
const FIELDS_DEFAULT = "id,name,host,port,info,status"
/** Maximum system name length for display purposes */
const MAX_SYSTEM_NAME_LENGTH = 22
let initialized = false
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
let unsub: (() => void) | undefined | void
@@ -44,7 +41,7 @@ export function init() {
}
if (!newSystem) {
onSystemsChanged(newSystems, undefined)
onSystemsChanged(newSystems, newSystem, oldSystem)
return
}
@@ -68,20 +65,28 @@ export function init() {
}
// run things that need to be done when systems change
onSystemsChanged(newSystems, newSystem)
onSystemsChanged(newSystems, newSystem, oldSystem)
})
}
/** Update the longest system name length and favicon based on system status */
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
/** Update the longest system name string and favicon based on system status */
function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: SystemRecord, oldSystem?: SystemRecord) {
const downSystemsStore = $downSystems.get()
const downSystems = Object.values(downSystemsStore)
// Update longest system name length
const longestName = $longestSystemNameLen.get()
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
if (nameLen > longestName) {
$longestSystemNameLen.set(nameLen)
// if the old system's old name was the longest, we need to find the new longest name
// otherwise, if the changed system's new name is longer than the current longest, update it
const longestName = $longestSystemName.get()
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
let newLongest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, newLongest)) {
newLongest = systems[id].name
}
}
$longestSystemName.set(newLongest)
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
$longestSystemName.set(newSystem.name)
}
updateFavicon(downSystems.length)

View File

@@ -0,0 +1,351 @@
import { chartTimeData } from "@/lib/utils"
import { getMonitorStats } from "@/lib/network-monitor-utils"
import type {
ChartTimes,
MonitorStats,
NetworkMonitorRecord,
NetworkMonitorStatsRecord,
RawMonitorStatsRecord,
} from "@/types"
import { useEffect, useRef, useState } from "react"
import { appendData } from "@/components/routes/system/chart-data"
import { pb, getPbTimestamp } from "@/lib/api"
import { toast } from "@/components/ui/use-toast"
import type { RecordListOptions, RecordSubscription } from "pocketbase"
const cache = new Map<string, NetworkMonitorStatsRecord[]>()
function getCacheValue(monitorId: string, chartTime: ChartTimes | "rt") {
return cache.get(`${monitorId}:${chartTime}`) || []
}
function appendCacheValue(
monitorId: string,
chartTime: ChartTimes | "rt",
newStats: NetworkMonitorStatsRecord[],
maxPoints = 100
) {
const cache_key = `${monitorId}:${chartTime}`
const existingStats = getCacheValue(monitorId, chartTime)
if (existingStats) {
const { expectedInterval } = chartTimeData[chartTime]
const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints)
cache.set(cache_key, updatedStats)
return updatedStats
} else {
cache.set(cache_key, newStats)
return newStats
}
}
/** Merge an array of per-monitor raw records into the map-keyed format expected by chart components. */
export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[]): NetworkMonitorStatsRecord[] {
const byTimestamp = new Map<number, Record<string, MonitorStats>>()
for (const rec of rawRecords) {
let statsMap = byTimestamp.get(rec.created)
if (!statsMap) {
statsMap = {}
byTimestamp.set(rec.created, statsMap)
}
statsMap[rec.monitor] = getMonitorStats(rec)
}
return Array.from(byTimestamp.entries())
.sort(([a], [b]) => a - b)
.map(([created, stats]) => ({ created, stats }))
}
/** Fetch stats for one monitor and time range, returning merged chart records. */
async function fetchMonitorStats(
monitorId: string,
chartTime: ChartTimes,
cached?: NetworkMonitorStatsRecord[]
): Promise<NetworkMonitorStatsRecord[]> {
const lastCached = cached?.at(-1)?.created as number | undefined
const rawRecords = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").getFullList({
filter: pb.filter("monitor={:id} && created>{:created} && type={:type}", {
id: monitorId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, true),
type: chartTimeData[chartTime].type,
}),
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created",
sort: "created",
})
return mergeMonitorStats(rawRecords)
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
interface UseNetworkMonitorsProps {
systemId?: string
}
export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
const { systemId } = props
const [monitors, setMonitors] = useState<NetworkMonitorRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const pendingMonitorEvents = useRef(new Map<string, RecordSubscription<NetworkMonitorRecord>>())
const monitorBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
// initial load
useEffect(() => {
let cancelled = false
setIsLoading(true)
setMonitors([])
fetchMonitors(systemId).then((monitors) => {
if (cancelled) return
setMonitors(monitors)
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [systemId])
// subscribe to updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
function flushPendingMonitorEvents() {
monitorBatchTimeout.current = null
if (!pendingMonitorEvents.current.size) {
return
}
const events = pendingMonitorEvents.current
pendingMonitorEvents.current = new Map()
setMonitors((currentMonitors) => {
return applyMonitorEvents(currentMonitors ?? [], events.values(), systemId)
})
}
const pbOptions: RecordListOptions = { fields: NETWORK_MONITOR_FIELDS }
if (systemId) {
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
}
;(async () => {
try {
unsubscribe = await pb.collection<NetworkMonitorRecord>("network_monitors").subscribe(
"*",
(event) => {
pendingMonitorEvents.current.set(event.record.id, event)
if (!monitorBatchTimeout.current) {
monitorBatchTimeout.current = setTimeout(flushPendingMonitorEvents, 50)
}
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to monitors", error)
}
})()
return () => {
if (monitorBatchTimeout.current !== null) {
clearTimeout(monitorBatchTimeout.current)
monitorBatchTimeout.current = null
}
pendingMonitorEvents.current.clear()
unsubscribe?.()
}
}, [systemId])
return { monitors, isLoading }
}
interface UseNetworkMonitorStatsProps {
systemId: string
monitorId: string
chartTime: ChartTimes
enabled?: boolean
}
export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
const { systemId, monitorId, chartTime, enabled = true } = props
const [monitorStats, setMonitorStats] = useState<NetworkMonitorStatsRecord[]>([])
// pending raw events to be merged (keyed by monitor+created)
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
const mergeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setMonitorStats(getCacheValue(monitorId, chartTime === "1m" ? "rt" : chartTime))
}, [monitorId, chartTime])
// Fetch only the selected monitor's missing history.
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
const { expectedInterval } = chartTimeData[chartTime]
const cachedMonitorStats = getCacheValue(monitorId, chartTime)
if (cachedMonitorStats.length) {
setMonitorStats(cachedMonitorStats)
const lastCreated = cachedMonitorStats.at(-1)?.created
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
return
}
}
fetchMonitorStats(monitorId, chartTime, cachedMonitorStats)
.then((newMonitorStats) => {
if (cancelled) return
setMonitorStats(appendCacheValue(monitorId, chartTime, newMonitorStats))
})
.catch((error) => {
if (!cancelled) console.error("Failed to fetch monitor stats:", error)
})
return () => {
cancelled = true
}
}, [monitorId, chartTime, enabled])
// subscribe to new per-monitor stats records; batch them into merged chart records
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
const pbOptions = {
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created,type",
filter: pb.filter("monitor={:monitor} && type={:type}", {
monitor: monitorId,
type: chartTimeData[chartTime].type,
}),
}
function flushPending() {
mergeBatchTimeout.current = null
const pending = pendingRaw.current
pendingRaw.current = new Map()
const merged = mergeMonitorStats(Array.from(pending.values()))
if (merged.length > 0) {
const newStats = appendCacheValue(monitorId, chartTime, merged)
setMonitorStats(newStats)
}
}
;(async () => {
try {
unsubscribe = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").subscribe(
"*",
(event) => {
if (cancelled || event.action !== "create") {
return
}
const rec = event.record
pendingRaw.current.set(`${rec.monitor}:${rec.created}`, rec)
if (!mergeBatchTimeout.current) {
mergeBatchTimeout.current = setTimeout(flushPending, 200)
}
},
pbOptions
)
if (cancelled) unsubscribe()
} catch (error) {
console.error("Failed to subscribe to monitor stats:", error)
}
})()
return () => {
cancelled = true
if (mergeBatchTimeout.current) {
clearTimeout(mergeBatchTimeout.current)
mergeBatchTimeout.current = null
}
pendingRaw.current.clear()
unsubscribe?.()
}
}, [monitorId, chartTime, enabled])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
if (!enabled || chartTime !== "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
pb.realtime
.subscribe(
`rt_metrics`,
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
const monitorStats = data.Monitors?.[monitorId]
if (cancelled || !monitorStats) return
const stats = { created: Date.now(), stats: { [monitorId]: monitorStats } }
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
setMonitorStats(newStats)
},
{ query: { system: systemId } }
)
.then((us) => {
unsubscribe = us
if (cancelled) unsubscribe()
})
return () => {
cancelled = true
unsubscribe?.()
}
}, [chartTime, systemId, monitorId, enabled])
return monitorStats
}
async function fetchMonitors(system?: string) {
try {
return await pb.collection<NetworkMonitorRecord>("network_monitors").getFullList({
fields: NETWORK_MONITOR_FIELDS,
filter: system ? pb.filter("system={:system}", { system }) : undefined,
})
} catch (error) {
toast({
title: "Error",
description: (error as Error)?.message,
variant: "destructive",
})
return []
}
}
function applyMonitorEvents(
monitors: NetworkMonitorRecord[],
events: Iterable<RecordSubscription<NetworkMonitorRecord>>,
systemId?: string
) {
const monitorById = new Map(monitors.map((monitor) => [monitor.id, monitor]))
const createdMonitors: NetworkMonitorRecord[] = []
for (const { action, record } of events) {
const matchesSystemScope = !systemId || record.system === systemId
if (action === "delete" || !matchesSystemScope) {
monitorById.delete(record.id)
continue
}
if (!monitorById.has(record.id)) {
createdMonitors.push(record)
}
monitorById.set(record.id, record)
}
const nextMonitors: NetworkMonitorRecord[] = []
for (let index = createdMonitors.length - 1; index >= 0; index -= 1) {
nextMonitors.push(createdMonitors[index])
}
for (const monitor of monitors) {
const nextMonitor = monitorById.get(monitor.id)
if (!nextMonitor) {
continue
}
nextMonitors.push(nextMonitor)
monitorById.delete(monitor.id)
}
return nextMonitors
}

View File

@@ -72,7 +72,7 @@ export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
export const hourWithSeconds = (timestamp: string) => {
export const hourWithSeconds = (timestamp: string | number) => {
return hourWithSecondsFormatter.format(new Date(timestamp))
}
@@ -111,17 +111,18 @@ export const updateFavicon = (() => {
</linearGradient>
</defs>
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
${downCount > 0 &&
`
${
downCount > 0 &&
`
<circle cx="40" cy="50" r="22" fill="#f00"/>
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
`
}
}
</svg>
`
const blob = new Blob([svg], { type: "image/svg+xml" })
const url = URL.createObjectURL(blob)
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
}
})()
@@ -198,6 +199,26 @@ export function decimalString(num: number, digits = 2) {
return formatter.format(num)
}
export function formatMicroseconds(microseconds: number, showDigits = true): string {
if (!Number.isFinite(microseconds)) {
return "-"
}
if (microseconds < 1000) {
return `${microseconds}μs`
}
if (microseconds < 1_000_000) {
const milliseconds = microseconds / 1000
const digits = milliseconds >= 10 ? 1 : 2
return `${decimalString(milliseconds, showDigits ? digits : 0)}ms`
}
const seconds = microseconds / 1_000_000
const digits = seconds >= 10 ? 1 : 2
return `${decimalString(seconds, showDigits ? digits : 0)}s`
}
/** Get value from local or session storage */
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
const saved = storageInterface?.getItem(key)
@@ -365,12 +386,12 @@ export function formatDuration(
.join(" ")
}
/** Parse semver string into major, minor, and patch numbers
/** Parse semver string into major, minor, and patch numbers
* @example
* const semVer = "1.2.3"
* const { major, minor, patch } = parseSemVer(semVer)
* console.log(major, minor, patch) // 1, 2, 3
*/
*/
export const parseSemVer = (semVer = ""): SemVer => {
// if (semVer.startsWith("v")) {
// semVer = semVer.slice(1)
@@ -393,6 +414,12 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch
}
const MIN_NETWORK_MONITOR_AGENT_VERSION = parseSemVer("0.20.0")
export function supportsNetworkMonitors(system: Pick<SystemRecord, "info">) {
return compareSemVer(parseSemVer(system.info?.v), MIN_NETWORK_MONITOR_AGENT_VERSION) >= 0
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
@@ -422,10 +449,22 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
}) as T
}
/** Get the visual width of a string, accounting for full-width characters */
export function getVisualStringWidth(str: string): number {
const visualWidthCache = new Map<string, number>()
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
* Don't use for monospaced fonts, use .length instead
*/
function getVisualStringWidth(str: string): number {
const cached = visualWidthCache.get(str)
if (cached !== undefined) {
return cached
}
let width = 0
for (const char of str) {
if (char === ".") {
width += 0.7
continue
}
const code = char.codePointAt(0) || 0
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) {
@@ -443,16 +482,41 @@ export function getVisualStringWidth(str: string): number {
code > 0xffff // Emojis and other supplementary plane characters
width += isFullWidth ? 2 : 1
}
visualWidthCache.set(str, width)
return width
}
/** Compare the visual width of two strings imprecisely */
export function isVisuallyLonger(str1: string, str2: string): boolean {
return getVisualStringWidth(str1) > getVisualStringWidth(str2)
}
/** Parses a filter string into OR'd groups of AND'd terms: "a b, c" -> [["a","b"], ["c"]] */
export function parseFilterGroups(value: string): string[][] {
return value
.toLowerCase()
.split(",")
.map((group) => group.trim().split(" ").filter((term) => term.length > 0))
.filter((terms) => terms.length > 0)
}
/** True if every term in at least one OR'd group is found in searchString. */
export function matchesFilterGroups(searchString: string, groups: string[][]): boolean {
return groups.some((terms) => terms.every((term) => searchString.includes(term)))
}
/** Format seconds to hours, minutes, or seconds */
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
const countString = count.toLocaleString()
switch (unit) {
case "minute":
return plural(count, { one: `${countString} minute`, few: `${countString} minutes`, many: `${countString} minutes`, other: `${countString} minutes` })
return plural(count, {
one: `${countString} minute`,
few: `${countString} minutes`,
many: `${countString} minutes`,
other: `${countString} minutes`,
})
case "hour":
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
case "day":
@@ -469,4 +533,4 @@ export function secondsToUptimeString(seconds: number): string {
} else {
return secondsToString(seconds, "day")
}
}
}