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/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
) )
type RecordManager struct { type RecordManager struct {
@@ -40,7 +41,7 @@ type StatsRecord struct {
// Create longer records by averaging shorter records // Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() { func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now() now := time.Now().UTC()
longerRecordData := []LongerRecordData{ longerRecordData := []LongerRecordData{
{ {
shorterType: "1m", shorterType: "1m",
@@ -71,6 +72,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction // wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
var err error var err error
collections := [3]*core.Collection{} collections := [3]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats") collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
@@ -96,49 +98,64 @@ func (rm *RecordManager) CreateLongerRecords() {
recordData := longerRecordData[i] recordData := longerRecordData[i]
// log.Println("processing longer record type", recordData.longerType) // 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 // 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 // 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 // loop through both collections
for _, collection := range collections { for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run // check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" { if recordData.longerType != "10m" {
count, err := txApp.CountRecords( var existingRecord struct {
collection.Id, Id string
dbx.NewExp( }
"system = {:system} AND type = {:type} AND created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod}, 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 // continue if longer record exists
if err != nil || count > 0 { if existingRecord.Id != "" {
continue continue
} }
} }
// get shorter records from the past x minutes // get shorter records from the past x minutes
var recordIds RecordIds var recordIds RecordIds
err := txApp.DB(). params := dbx.Params{
Select("id").
From(collection.Name).
AndWhere(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
dbx.Params{
"type": recordData.shorterType, "type": recordData.shorterType,
"system": system.Id, "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) All(&recordIds)
// continue if not enough shorter records // continue if not enough shorter records
if err != nil || len(recordIds) < recordData.minShorterRecords { if len(recordIds) < recordData.minShorterRecords {
continue continue
} }
// average the shorter records and create longer record // average the shorter records and create longer record
longerRecord := core.NewRecord(collection) longerRecord := core.NewRecord(collection)
longerRecord.Set("system", system.Id) longerRecord.Set("system", system.Id)
longerRecord.Set("type", recordData.longerType) 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 { switch collection.Name {
case "system_stats": case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds)) 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()) // 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 // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats { func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
stats := make([]system.Stats, 0, len(records)) stats := make([]system.Stats, 0, len(records))

View File

@@ -3,7 +3,6 @@ package records
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -75,26 +74,19 @@ func deleteOldSystemStats(app core.App) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
db := app.DB()
for _, collection := range collections { for _, collection := range collections {
// Build the WHERE clause query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
var conditionParts []string for _, rd := range recordData {
var params dbx.Params = make(map[string]any) if _, err := query.Bind(dbx.Params{
for i := range recordData { "type": rd.recordType,
rd := recordData[i] "created": getCreatedTimeField(collection, now.Add(-rd.retention)),
// Create parameterized condition for this record type }).Execute(); err != nil {
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) return fmt.Errorf("failed to delete from %s: %v", collection, err)
} }
} }
}
return nil return nil
} }

View File

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

View File

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

View File

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

View File

@@ -33,11 +33,19 @@ import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/compon
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api" import { isReadOnlyUser } from "@/lib/api"
import { pb } 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 { cn, useBrowserStorage } from "@/lib/utils"
import type { NetworkProbeRecord } from "@/types" import type { NetworkProbeRecord } from "@/types"
import { AddProbeDialog, EditProbeDialog } from "./probe-dialog" import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
import { XIcon } from "lucide-react" 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({ export default function NetworkProbesTableNew({
systemId, systemId,
@@ -325,6 +333,13 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
}) { }) {
// The virtualizer will need a reference to the scrollable container element // The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null) 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>({ const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length, count: rows.length,
@@ -360,6 +375,7 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
row={row} row={row}
virtualRow={virtualRow} virtualRow={virtualRow}
isSelected={row.getIsSelected()} isSelected={row.getIsSelected()}
openSheet={openSheet}
/> />
) )
}) })
@@ -373,6 +389,13 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
</TableBody> </TableBody>
</table> </table>
</div> </div>
<NetworkProbeSheet
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen)
}}
probe={activeProbe}
/>
</div> </div>
) )
}) })
@@ -399,13 +422,19 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
row, row,
virtualRow, virtualRow,
isSelected, isSelected,
openSheet,
}: { }: {
row: Row<NetworkProbeRecord> row: Row<NetworkProbeRecord>
virtualRow: VirtualItem virtualRow: VirtualItem
isSelected: boolean isSelected: boolean
openSheet: (probe: NetworkProbeRecord) => void
}) { }) {
return ( 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) => ( {row.getVisibleCells().map((cell) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
@@ -421,3 +450,88 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
</TableRow> </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 NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
import { ActiveAlerts } from "@/components/active-alerts" import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link" import { FooterRepoLink } from "@/components/footer-repo-link"
import { useNetworkProbesData } from "@/lib/use-network-probes" import { useNetworkProbes } from "@/lib/use-network-probes"
export default memo(() => { export default memo(() => {
const { t } = useLingui() const { t } = useLingui()
const { probes } = useNetworkProbesData({}) const probes = useNetworkProbes({})
useEffect(() => { useEffect(() => {
document.title = `${t`Network Probes`} / Beszel` document.title = `${t`Network Probes`} / Beszel`

View File

@@ -1,6 +1,13 @@
import { getPbTimestamp, pb } from "@/lib/api" import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils" 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 = { type ChartTimeData = {
time: number 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. /** 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. */ * 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 }>( export function appendData<T extends { created: string | number | null }>(
prev: T[], prev: T[] = [],
newRecords: T[], newRecords: T[],
expectedInterval: number, expectedInterval: number,
maxLen?: number maxLen?: number
@@ -63,11 +70,11 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
}) })
} }
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] { export function makeContainerData(containers: ContainerStatsRecord[]): ChartDataContainer[] {
const result = [] as ChartData["containerData"] const result = [] as ChartDataContainer[]
for (const { created, stats } of containers) { for (const { created, stats } of containers) {
if (!created) { if (!created) {
result.push({ created: null } as ChartData["containerData"][0]) result.push({ created: null } as ChartDataContainer)
continue continue
} }
result.push(makeContainerPoint(new Date(created).getTime(), stats)) 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. */ /** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint( export function makeContainerPoint(created: number, stats: ContainerStatsRecord["stats"]): ChartDataContainer {
created: number, const point: ChartDataContainer = { created } as ChartDataContainer
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
for (const container of stats) { for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container ;(point as Record<string, unknown>)[container.n] = container
} }

View File

@@ -16,6 +16,7 @@ type ProbeChartProps = {
probes: NetworkProbeRecord[] probes: NetworkProbeRecord[]
chartData: ChartData chartData: ChartData
empty: boolean empty: boolean
showFilter?: boolean
} }
type ProbeChartBaseProps = ProbeChartProps & { type ProbeChartBaseProps = ProbeChartProps & {
@@ -39,8 +40,10 @@ function ProbeChart({
tickFormatter, tickFormatter,
contentFormatter, contentFormatter,
domain, domain,
showFilter = probes.length > 1,
}: ProbeChartBaseProps) { }: ProbeChartBaseProps) {
const filter = useStore($filter) const storedFilter = useStore($filter)
const filter = showFilter ? storedFilter : ""
const { dataPoints, visibleKeys } = useMemo(() => { const { dataPoints, visibleKeys } = useMemo(() => {
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h) 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)) return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [probeStats, visibleKeys]) }, [probeStats, visibleKeys])
const legend = dataPoints.length < 10 const legend = dataPoints.length < 10 && dataPoints.length > 1
return ( return (
<ChartCard <ChartCard
legend={legend} legend={legend || !showFilter}
cornerEl={<FilterBar store={$filter} />} cornerEl={showFilter ? <FilterBar store={$filter} /> : undefined}
empty={empty} empty={empty}
title={title} title={title}
description={description} 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) { export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
const { t } = useLingui() const { t } = useLingui()

View File

@@ -5,7 +5,7 @@ import { ResponseChart, LossChart } from "./charts/probes-charts"
import type { SystemData } from "./use-system-data" import type { SystemData } from "./use-system-data"
import { $chartTime } from "@/lib/stores" import { $chartTime } from "@/lib/stores"
import { useStore } from "@nanostores/react" 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")) 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 { grid, chartData } = systemData ?? {}
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime }) const probes = useNetworkProbes({ systemId })
const probeStats = useNetworkProbeStats({ systemId, chartTime })
return ( return (
<> <>

View File

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

View File

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

View File

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