From e65a4a515e577c497c358141f4451e509811b59e Mon Sep 17 00:00:00 2001 From: henrygd Date: Sun, 26 Apr 2026 22:40:18 -0400 Subject: [PATCH] updates --- internal/records/records.go | 62 ++++++--- internal/records/records_deletion.go | 26 ++-- .../site/src/components/charts/area-chart.tsx | 2 +- .../site/src/components/charts/line-chart.tsx | 2 +- .../network-probes-columns.tsx | 21 +++- .../network-probes-table.tsx | 118 +++++++++++++++++- .../site/src/components/routes/probes.tsx | 4 +- .../components/routes/system/chart-data.ts | 24 ++-- .../routes/system/charts/probes-charts.tsx | 35 +++++- .../components/routes/system/lazy-tables.tsx | 5 +- .../routes/system/use-system-data.ts | 2 +- internal/site/src/lib/use-network-probes.ts | 49 ++++---- internal/site/src/types.d.ts | 4 +- 13 files changed, 263 insertions(+), 91 deletions(-) diff --git a/internal/records/records.go b/internal/records/records.go index 961e50fc..3846f9f8 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -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(). + params := dbx.Params{ + "type": recordData.shorterType, + "system": system.Id, + "created": getCreatedTimeField(collection.Name, shorterRecordPeriod), + } + + _ = txApp.DB(). Select("id"). From(collection.Name). - AndWhere(dbx.NewExp( + Where(dbx.NewExp( "system={:system} AND type={:type} AND created > {:created}", - dbx.Params{ - "type": recordData.shorterType, - "system": system.Id, - "created": shorterRecordPeriod, - }, + 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)) diff --git a/internal/records/records_deletion.go b/internal/records/records_deletion.go index 72f92c9a..e6d67688 100644 --- a/internal/records/records_deletion.go +++ b/internal/records/records_deletion.go @@ -3,7 +3,6 @@ package records import ( "fmt" "log/slog" - "strings" "time" "github.com/pocketbase/dbx" @@ -75,24 +74,17 @@ 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 { - return fmt.Errorf("failed to delete from %s: %v", collection, err) + 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 diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index 69532299..18360029 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -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) diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index b8947674..88e4bdb2 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -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) diff --git a/internal/site/src/components/network-probes-table/network-probes-columns.tsx b/internal/site/src/components/network-probes-table/network-probes-columns.tsx index c2a13230..c1aad175 100644 --- a/internal/site/src/components/network-probes-table/network-probes-columns.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-columns.tsx @@ -71,6 +71,7 @@ export function getProbeColumns( event.stopPropagation()} onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)} aria-label={t`Select all`} /> @@ -78,6 +79,7 @@ export function getProbeColumns( cell: ({ row }) => ( event.stopPropagation()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label={t`Select row`} /> @@ -264,14 +266,24 @@ export function getProbeColumns( - + event.stopPropagation()}> {!isBulkAction && ( - onEdit?.(row.original)}> + { + event.stopPropagation() + onEdit?.(row.original) + }} + > Edit )} - onSetEnabled?.(actionRows, !shouldPause)}> + { + event.stopPropagation() + onSetEnabled?.(actionRows, !shouldPause) + }} + > {shouldPause ? ( <> @@ -286,7 +298,8 @@ export function getProbeColumns( { + onClick={(event) => { + event.stopPropagation() onDelete?.(actionRows) }} > diff --git a/internal/site/src/components/network-probes-table/network-probes-table.tsx b/internal/site/src/components/network-probes-table/network-probes-table.tsx index 6dbc9a59..a9511b12 100644 --- a/internal/site/src/components/network-probes-table/network-probes-table.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-table.tsx @@ -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(null) + const [sheetOpen, setSheetOpen] = useState(false) + const [activeProbeId, setActiveProbeId] = useState(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({ 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({ + { + setSheetOpen(nextOpen) + }} + probe={activeProbe} + /> ) }) @@ -399,13 +422,19 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({ row, virtualRow, isSelected, + openSheet, }: { row: Row virtualRow: VirtualItem isSelected: boolean + openSheet: (probe: NetworkProbeRecord) => void }) { return ( - + openSheet(row.original)} + > {row.getVisibleCells().map((cell) => ( ) }) + +function NetworkProbeSheet({ + open, + onOpenChange, + probe, +}: { + open: boolean + onOpenChange: (open: boolean) => void + probe?: NetworkProbeRecord +}) { + if (!probe) { + return null + } + + return +} + +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( + () => ({ + 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 ( + + + + {probeLabel} + + {system?.name ?? ""} + + {probe.protocol.toUpperCase()} + + {probe.target} + {probe.port > 0 && ( + <> + + {probe.port} + + )} + + +
+ + + +
+
+
+ ) +} diff --git a/internal/site/src/components/routes/probes.tsx b/internal/site/src/components/routes/probes.tsx index a5591e43..ab79b811 100644 --- a/internal/site/src/components/routes/probes.tsx +++ b/internal/site/src/components/routes/probes.tsx @@ -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` diff --git a/internal/site/src/components/routes/system/chart-data.ts b/internal/site/src/components/routes/system/chart-data.ts index d8691b1c..f65301bd 100644 --- a/internal/site/src/components/routes/system/chart-data.ts +++ b/internal/site/src/components/routes/system/chart-data.ts @@ -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( - prev: T[], + prev: T[] = [], newRecords: T[], expectedInterval: number, maxLen?: number @@ -63,11 +70,11 @@ export async function getStats)[container.n] = container } diff --git a/internal/site/src/components/routes/system/charts/probes-charts.tsx b/internal/site/src/components/routes/system/charts/probes-charts.tsx index 6badca3b..324cf8cf 100644 --- a/internal/site/src/components/routes/system/charts/probes-charts.tsx +++ b/internal/site/src/components/routes/system/charts/probes-charts.tsx @@ -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 ( } + legend={legend || !showFilter} + cornerEl={showFilter ? : 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 ( + 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() diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index a2414fd5..9e2dba94 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -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 ( <> diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index e1258abe..f987af0d 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -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 diff --git a/internal/site/src/lib/use-network-probes.ts b/internal/site/src/lib/use-network-probes.ts index a9b66aa5..2ea812ee 100644 --- a/internal/site/src/lib/use-network-probes.ts +++ b/internal/site/src/lib/use-network-probes.ts @@ -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([]) - const [probeStats, setProbeStats] = useState([]) - const statsRequestId = useRef(0) + const [probes, setProbes] = useState([]) const pendingProbeEvents = useRef(new Map>()) const probeBatchTimeout = useRef | 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)) - } + 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([]) + 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("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) { diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 945b0d52..8e4f476d 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -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 }