This commit is contained in:
henrygd
2026-04-26 22:40:18 -04:00
parent df249b24f6
commit e65a4a515e
13 changed files with 263 additions and 91 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
type RecordManager struct {
@@ -40,7 +41,7 @@ type StatsRecord struct {
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
now := time.Now().UTC()
longerRecordData := []LongerRecordData{
{
shorterType: "1m",
@@ -71,6 +72,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
var err error
collections := [3]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
@@ -96,49 +98,64 @@ func (rm *RecordManager) CreateLongerRecords() {
recordData := longerRecordData[i]
// log.Println("processing longer record type", recordData.longerType)
// add one minute padding for longer records because they are created slightly later than the job start time
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
// shorter records are created independently of longer records, so we shouldn't need to add padding
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
// loop through both collections
for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" {
count, err := txApp.CountRecords(
collection.Id,
dbx.NewExp(
"system = {:system} AND type = {:type} AND created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
),
)
var existingRecord struct {
Id string
}
params := dbx.Params{
"type": recordData.longerType,
"system": system.Id,
"created": getCreatedTimeField(collection.Name, longerRecordPeriod),
}
_ = db.Select("id").
From(collection.Name).
Where(dbx.NewExp("system = {:system} AND type = {:type} AND created > {:created}", params)).
Limit(1).
One(&existingRecord)
// continue if longer record exists
if err != nil || count > 0 {
if existingRecord.Id != "" {
continue
}
}
// get shorter records from the past x minutes
var recordIds RecordIds
err := txApp.DB().
Select("id").
From(collection.Name).
AndWhere(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
dbx.Params{
params := dbx.Params{
"type": recordData.shorterType,
"system": system.Id,
"created": shorterRecordPeriod,
},
"created": getCreatedTimeField(collection.Name, shorterRecordPeriod),
}
_ = txApp.DB().
Select("id").
From(collection.Name).
Where(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
params,
)).
All(&recordIds)
// continue if not enough shorter records
if err != nil || len(recordIds) < recordData.minShorterRecords {
if len(recordIds) < recordData.minShorterRecords {
continue
}
// average the shorter records and create longer record
longerRecord := core.NewRecord(collection)
longerRecord.Set("system", system.Id)
longerRecord.Set("type", recordData.longerType)
// network_probe_stats uses created as unix timestamp in milliseconds, so we need to set it manually here instead of relying on the default created field
if collection.Name == "network_probe_stats" {
longerRecord.Set("created", now.UnixMilli())
}
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
@@ -160,6 +177,13 @@ func (rm *RecordManager) CreateLongerRecords() {
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
}
func getCreatedTimeField(collectionName string, period time.Time) any {
if collectionName == "network_probe_stats" {
return period.UnixMilli()
}
return period.Format(types.DefaultDateLayout)
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
stats := make([]system.Stats, 0, len(records))

View File

@@ -3,7 +3,6 @@ package records
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/pocketbase/dbx"
@@ -75,26 +74,19 @@ func deleteOldSystemStats(app core.App) error {
}
now := time.Now().UTC()
db := app.DB()
for _, collection := range collections {
// Build the WHERE clause
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i := range recordData {
rd := recordData[i]
// Create parameterized condition for this record type
dateParam := fmt.Sprintf("date%d", i)
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
for _, rd := range recordData {
if _, err := query.Bind(dbx.Params{
"type": rd.recordType,
"created": getCreatedTimeField(collection, now.Add(-rd.retention)),
}).Execute(); err != nil {
return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
}
return nil
}

View File

@@ -66,7 +66,7 @@ export default function AreaChartDefault({
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const sourceData = customData ?? chartData.systemStats ?? []
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)

View File

@@ -68,7 +68,7 @@ export default function LineChartDefault({
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const sourceData = customData ?? chartData.systemStats ?? []
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)

View File

@@ -71,6 +71,7 @@ export function getProbeColumns(
<Checkbox
className="ms-2"
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label={t`Select all`}
/>
@@ -78,6 +79,7 @@ export function getProbeColumns(
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t`Select row`}
/>
@@ -264,14 +266,24 @@ export function getProbeColumns(
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
{!isBulkAction && (
<DropdownMenuItem onClick={() => onEdit?.(row.original)}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
onEdit?.(row.original)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onSetEnabled?.(actionRows, !shouldPause)}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
onSetEnabled?.(actionRows, !shouldPause)
}}
>
{shouldPause ? (
<>
<PauseCircleIcon className="me-2.5 size-4" />
@@ -286,7 +298,8 @@ export function getProbeColumns(
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
onClick={(event) => {
event.stopPropagation()
onDelete?.(actionRows)
}}
>

View File

@@ -33,11 +33,19 @@ import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/compon
import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api"
import { pb } from "@/lib/api"
import { $allSystemsById } from "@/lib/stores"
import { $allSystemsById, $chartTime, $direction } from "@/lib/stores"
import { cn, useBrowserStorage } from "@/lib/utils"
import type { NetworkProbeRecord } from "@/types"
import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
import { XIcon } from "lucide-react"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { ResponseChart, LossChart } from "@/components/routes/system/charts/probes-charts"
import { useNetworkProbeStats } from "@/lib/use-network-probes"
import { useStore } from "@nanostores/react"
import type { ChartData } from "@/types"
import { parseSemVer } from "@/lib/utils"
import { Separator } from "../ui/separator"
export default function NetworkProbesTableNew({
systemId,
@@ -325,6 +333,13 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [activeProbeId, setActiveProbeId] = useState<string | null>(null)
const activeProbe = activeProbeId ? table.options.data.find((probe) => probe.id === activeProbeId) : undefined
const openSheet = useCallback((probe: NetworkProbeRecord) => {
setActiveProbeId(probe.id)
setSheetOpen(true)
}, [])
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
@@ -360,6 +375,7 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
row={row}
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
openSheet={openSheet}
/>
)
})
@@ -373,6 +389,13 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
</TableBody>
</table>
</div>
<NetworkProbeSheet
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen)
}}
probe={activeProbe}
/>
</div>
)
})
@@ -399,13 +422,19 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
row,
virtualRow,
isSelected,
openSheet,
}: {
row: Row<NetworkProbeRecord>
virtualRow: VirtualItem
isSelected: boolean
openSheet: (probe: NetworkProbeRecord) => void
}) {
return (
<TableRow data-state={isSelected && "selected"} className="transition-opacity">
<TableRow
data-state={isSelected && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
@@ -421,3 +450,88 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
</TableRow>
)
})
function NetworkProbeSheet({
open,
onOpenChange,
probe,
}: {
open: boolean
onOpenChange: (open: boolean) => void
probe?: NetworkProbeRecord
}) {
if (!probe) {
return null
}
return <NetworkProbeSheetContent key={probe.system} open={open} onOpenChange={onOpenChange} probe={probe} />
}
function NetworkProbeSheetContent({
open,
onOpenChange,
probe,
}: {
open: boolean
onOpenChange: (open: boolean) => void
probe: NetworkProbeRecord
}) {
const chartTime = useStore($chartTime)
const direction = useStore($direction)
const system = useStore($allSystemsById)[probe.system]
const probeStats = useNetworkProbeStats({ systemId: probe.system, chartTime })
const chartData = useMemo<ChartData>(
() => ({
agentVersion: parseSemVer(system?.info?.v),
orientation: direction === "rtl" ? "right" : "left",
chartTime,
}),
[chartTime]
)
const hasProbeStats = probeStats.some((record) => record.stats?.[probe.id] != null)
const probeLabel = probe.name || probe.target
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-220 overflow-auto p-4 sm:p-6">
<SheetHeader className="mb-0 border-b p-0 pb-4">
<SheetTitle>{probeLabel}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
{system?.name ?? ""}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{probe.protocol.toUpperCase()}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{probe.target}
{probe.port > 0 && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<span>{probe.port}</span>
</>
)}
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">
<ChartTimeSelect className="bg-card" agentVersion={chartData.agentVersion} />
<ResponseChart
probeStats={probeStats}
grid={false}
probes={[probe]}
chartData={chartData}
empty={!hasProbeStats}
showFilter={false}
/>
<LossChart
probeStats={probeStats}
grid={false}
probes={[probe]}
chartData={chartData}
empty={!hasProbeStats}
showFilter={false}
/>
</div>
</SheetContent>
</Sheet>
)
}

View File

@@ -3,11 +3,11 @@ import { memo, useEffect } from "react"
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { useNetworkProbesData } from "@/lib/use-network-probes"
import { useNetworkProbes } from "@/lib/use-network-probes"
export default memo(() => {
const { t } = useLingui()
const { probes } = useNetworkProbesData({})
const probes = useNetworkProbes({})
useEffect(() => {
document.title = `${t`Network Probes`} / Beszel`

View File

@@ -1,6 +1,13 @@
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
import type {
ChartData,
ChartDataContainer,
ChartTimes,
ContainerStatsRecord,
NetworkProbeStatsRecord,
SystemStatsRecord,
} from "@/types"
type ChartTimeData = {
time: number
@@ -19,7 +26,7 @@ export const cache = new Map<
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
prev: T[],
prev: T[] = [],
newRecords: T[],
expectedInterval: number,
maxLen?: number
@@ -63,11 +70,11 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
})
}
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
const result = [] as ChartData["containerData"]
export function makeContainerData(containers: ContainerStatsRecord[]): ChartDataContainer[] {
const result = [] as ChartDataContainer[]
for (const { created, stats } of containers) {
if (!created) {
result.push({ created: null } as ChartData["containerData"][0])
result.push({ created: null } as ChartDataContainer)
continue
}
result.push(makeContainerPoint(new Date(created).getTime(), stats))
@@ -76,11 +83,8 @@ export function makeContainerData(containers: ContainerStatsRecord[]): ChartData
}
/** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint(
created: number,
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
export function makeContainerPoint(created: number, stats: ContainerStatsRecord["stats"]): ChartDataContainer {
const point: ChartDataContainer = { created } as ChartDataContainer
for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container
}

View File

@@ -16,6 +16,7 @@ type ProbeChartProps = {
probes: NetworkProbeRecord[]
chartData: ChartData
empty: boolean
showFilter?: boolean
}
type ProbeChartBaseProps = ProbeChartProps & {
@@ -39,8 +40,10 @@ function ProbeChart({
tickFormatter,
contentFormatter,
domain,
showFilter = probes.length > 1,
}: ProbeChartBaseProps) {
const filter = useStore($filter)
const storedFilter = useStore($filter)
const filter = showFilter ? storedFilter : ""
const { dataPoints, visibleKeys } = useMemo(() => {
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
@@ -78,12 +81,12 @@ function ProbeChart({
return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [probeStats, visibleKeys])
const legend = dataPoints.length < 10
const legend = dataPoints.length < 10 && dataPoints.length > 1
return (
<ChartCard
legend={legend}
cornerEl={<FilterBar store={$filter} />}
legend={legend || !showFilter}
cornerEl={showFilter ? <FilterBar store={$filter} /> : undefined}
empty={empty}
title={title}
description={description}
@@ -129,6 +132,30 @@ export function ResponseChart({ probeStats, grid, probes, chartData, empty }: Pr
)
}
export function MaxResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
const { t } = useLingui()
return (
<ProbeChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={empty}
valueIndex={0}
title={t`Response`}
description={t`Average response time`}
tickFormatter={(value) => formatMicroseconds(value, false)}
contentFormatter={({ value }) => {
if (typeof value !== "number") {
return value
}
return formatMicroseconds(value)
}}
/>
)
}
export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
const { t } = useLingui()

View File

@@ -5,7 +5,7 @@ import { ResponseChart, LossChart } from "./charts/probes-charts"
import type { SystemData } from "./use-system-data"
import { $chartTime } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { useNetworkProbesData } from "@/lib/use-network-probes"
import { useNetworkProbes, useNetworkProbeStats } from "@/lib/use-network-probes"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -56,7 +56,8 @@ function ProbesTable({ systemId, systemData }: { systemId: string; systemData: S
const { grid, chartData } = systemData ?? {}
const chartTime = useStore($chartTime)
const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime })
const probes = useNetworkProbes({ systemId })
const probeStats = useNetworkProbeStats({ systemId, chartTime })
return (
<>

View File

@@ -288,7 +288,7 @@ export function useSystemData(id: string) {
// derived values
const isLongerChart = !["1m", "1h"].includes(chartTime)
const showMax = maxValues && isLongerChart
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const dataEmpty = !chartLoading && chartData.systemStats?.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
const isPodman = details?.podman ?? system.info?.p ?? false

View File

@@ -36,22 +36,15 @@ const NETWORK_PROBE_FIELDS =
interface UseNetworkProbesProps {
systemId?: string
loadStats?: boolean
chartTime?: ChartTimes
existingProbes?: NetworkProbeRecord[]
}
export function useNetworkProbesData(props: UseNetworkProbesProps) {
const { systemId, loadStats, chartTime, existingProbes } = props
export function useNetworkProbes(props: UseNetworkProbesProps) {
const { systemId } = props
const [p, setProbes] = useState<NetworkProbeRecord[]>([])
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
const statsRequestId = useRef(0)
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const probes = existingProbes ?? p
// clear old data when systemId changes
// useEffect(() => {
// return setProbes([])
@@ -59,16 +52,11 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
// initial load - fetch probes if not provided by caller
useEffect(() => {
if (!existingProbes) {
fetchProbes(systemId).then((probes) => setProbes(probes))
}
}, [systemId])
// Subscribe to updates if probes not provided by caller
useEffect(() => {
if (existingProbes) {
return
}
let unsubscribe: (() => void) | undefined
function flushPendingProbeEvents() {
@@ -115,9 +103,22 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
}
}, [systemId])
return probes
}
interface UseNetworkProbeStatsProps {
systemId?: string
chartTime: ChartTimes
}
export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) {
const { systemId, chartTime } = props
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
const requestID = useRef(0)
// Subscribe to new probe stats
useEffect(() => {
if (!loadStats || !systemId) {
if (!systemId) {
return
}
let unsubscribe: (() => void) | undefined
@@ -175,12 +176,12 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
// fetch missing probe stats on load and when chart time changes
useEffect(() => {
if (!loadStats || !systemId || !chartTime || chartTime === "1m") {
if (!systemId || !chartTime || chartTime === "1m") {
return
}
const { expectedInterval } = chartTimeData[chartTime]
const requestId = ++statsRequestId.current
const requestId = ++requestID.current
const cachedProbeStats = getCacheValue(systemId, chartTime)
@@ -198,7 +199,7 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats, true).then(
(probeStats) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {
if (requestId !== requestID.current) {
return
}
const newStats = appendCacheValue(systemId, chartTime, probeStats)
@@ -209,7 +210,7 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
if (!loadStats || !systemId || chartTime !== "1m") {
if (!systemId || chartTime !== "1m") {
return
}
let unsubscribe: (() => void) | undefined
@@ -238,12 +239,8 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
return () => unsubscribe?.()
}, [chartTime, systemId])
return {
probes,
probeStats,
return probeStats
}
}
// function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
// const stats: NetworkProbeStatsRecord["stats"] = {}
// for (const probe of probes) {

View File

@@ -313,8 +313,8 @@ export interface SemVer {
export interface ChartData {
agentVersion: SemVer
systemStats: SystemStatsRecord[]
containerData: ChartDataContainer[]
systemStats?: SystemStatsRecord[]
containerData?: ChartDataContainer[]
orientation: "right" | "left"
chartTime: ChartTimes
}