mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 17:07:47 +02:00
Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com> Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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")
|
||||
|
||||
17
internal/site/src/lib/network-monitor-utils.ts
Normal file
17
internal/site/src/lib/network-monitor-utils.ts
Normal 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}`
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)
|
||||
|
||||
351
internal/site/src/lib/use-network-monitors.ts
Normal file
351
internal/site/src/lib/use-network-monitors.ts
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user