This commit is contained in:
henrygd
2026-04-25 14:39:04 -04:00
parent 027159420c
commit ddd47e67ac
8 changed files with 81 additions and 41 deletions

View File

@@ -31,6 +31,12 @@ func bindNetworkProbesEvents(hub *Hub) {
if !e.Record.GetBool("enabled") { if !e.Record.GetBool("enabled") {
return nil return nil
} }
// if system connected, run the probe immediately
// if not, return and wait for the system to connect and sync probes then
system, err := hub.sm.GetSystem(e.Record.GetString("system"))
if err != nil || system.Status != "up" {
return nil
}
result, err := hub.upsertNetworkProbe(e.Record, true) result, err := hub.upsertNetworkProbe(e.Record, true)
if err != nil { if err != nil {
hub.Logger().Warn("failed to sync probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err) hub.Logger().Warn("failed to sync probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)

View File

@@ -111,6 +111,8 @@ export default function AreaChartDefault({
}) })
}, [areasKey, displayMaxToggled]) }, [areasKey, displayMaxToggled])
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
return useMemo(() => { return useMemo(() => {
if (displayData.length === 0) { if (displayData.length === 0) {
return null return null
@@ -146,7 +148,7 @@ export default function AreaChartDefault({
axisLine={false} axisLine={false}
/> />
)} )}
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)} {XAxis}
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
animationDuration={150} animationDuration={150}
@@ -167,5 +169,5 @@ export default function AreaChartDefault({
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) )
}, [displayData, yAxisWidth, filter, Areas]) }, [displayData, yAxisWidth, filter, Areas, XAxis])
} }

View File

@@ -42,6 +42,7 @@ export default function LineChartDefault({
truncate = false, truncate = false,
chartProps, chartProps,
connectNulls, connectNulls,
dot = false,
}: { }: {
chartData: ChartData chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
@@ -64,6 +65,7 @@ export default function LineChartDefault({
truncate?: boolean truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin"> chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
connectNulls?: boolean connectNulls?: boolean
dot?: boolean
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
@@ -87,6 +89,8 @@ export default function LineChartDefault({
// Use a stable key derived from data point identities and visual properties // Use a stable key derived from data point identities and visual properties
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0") const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
const Lines = useMemo(() => { const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => { return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint let { color } = dataPoint
@@ -99,7 +103,7 @@ export default function LineChartDefault({
dataKey={dataPoint.dataKey} dataKey={dataPoint.dataKey}
name={dataPoint.label} name={dataPoint.label}
type="monotoneX" type="monotoneX"
dot={false} dot={dot}
strokeWidth={1.5} strokeWidth={1.5}
stroke={color} stroke={color}
strokeOpacity={dataPoint.strokeOpacity} strokeOpacity={dataPoint.strokeOpacity}
@@ -148,7 +152,7 @@ export default function LineChartDefault({
axisLine={false} axisLine={false}
/> />
)} )}
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)} {XAxis}
<ChartTooltip <ChartTooltip
animationEasing="ease-out" animationEasing="ease-out"
animationDuration={150} animationDuration={150}
@@ -169,5 +173,5 @@ export default function LineChartDefault({
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
) )
}, [displayData, yAxisWidth, filter, Lines]) }, [displayData, yAxisWidth, filter, Lines, XAxis])
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "react" import { useRef, useState } from "react"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
@@ -111,13 +111,14 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [bulkInput, setBulkInput] = useState("") const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false) const [bulkLoading, setBulkLoading] = useState(false)
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("") const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
const bulkFormRef = useRef<HTMLFormElement>(null)
const { toast } = useToast() const { toast } = useToast()
const { t } = useLingui() const { t } = useLingui()
const systems = useStore($systems) const systems = useStore($systems)
const resetBulkForm = () => { const resetBulkForm = () => {
setBulkInput("") setBulkInput("")
setBulkSelectedSystemId("") // setBulkSelectedSystemId("")
} }
const openBulkAdd = (selectedSystemId?: string) => { const openBulkAdd = (selectedSystemId?: string) => {
@@ -140,13 +141,15 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
try { try {
const system = systemId ?? bulkSelectedSystemId const system = systemId ?? bulkSelectedSystemId
if (!system) {
throw new Error("Select a system.")
}
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim()) const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) { if (!rawLines.length) {
throw new Error("Enter at least one probe.") throw new Error("Enter at least one probe.")
} }
const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system)) const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system))
setBulkOpen(false)
closedForSubmit = true closedForSubmit = true
let batch = pb.createBatch() let batch = pb.createBatch()
let inBatch = 0 let inBatch = 0
@@ -221,16 +224,10 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans> <Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
<Trans> target[,protocol[,port[,interval[,name]]]] - TCP/HTTP default to port 443.
Paste one probe per line. See{" "}
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
the documentation
</a>
.
</Trans>
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<form onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden"> <form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 space-y-4 overflow-auto p-4"> <div className="flex-1 space-y-4 overflow-auto p-4">
{!systemId && ( {!systemId && (
<div className="grid gap-2"> <div className="grid gap-2">
@@ -252,8 +249,8 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
</div> </div>
)} )}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="bulk-probes"> <Label htmlFor="bulk-probes" className="sr-only">
<Trans>Entries</Trans> Entries
</Label> </Label>
<Textarea <Textarea
id="bulk-probes" id="bulk-probes"
@@ -262,10 +259,10 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault() e.preventDefault()
handleBulkSubmit(e) bulkFormRef.current?.requestSubmit()
} }
}} }}
className="h-120 font-mono text-sm bg-muted/40" className="h-200 font-mono text-sm bg-muted/40"
style={{ maxHeight: `calc(100vh - 20rem)` }} style={{ maxHeight: `calc(100vh - 20rem)` }}
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")} placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
required required
@@ -338,8 +335,12 @@ function ProbeDialogContent({
setLoading(true) setLoading(true)
try { try {
const selectedSystem = systemId ?? selectedSystemId
if (!selectedSystem) {
throw new Error("Select a system.")
}
const payload = buildProbePayload({ const payload = buildProbePayload({
system: systemId ?? selectedSystemId, system: selectedSystem,
target, target,
protocol, protocol,
port: protocol === "tcp" ? Number(port) : 0, port: protocol === "tcp" ? Number(port) : 0,

View File

@@ -97,6 +97,7 @@ function ProbeChart({
contentFormatter={contentFormatter} contentFormatter={contentFormatter}
legend={legend} legend={legend}
filter={filter} filter={filter}
dot={chartData.chartTime === "1m"}
/> />
</ChartCard> </ChartCard>
) )

View File

@@ -1,11 +1,10 @@
import type { JSX } from "react"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import * as React from "react" import * as React from "react"
import * as RechartsPrimitive from "recharts" import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils" import { chartTimeData, cn } from "@/lib/utils"
import type { ChartTimes } from "@/types" import type { ChartTimes } from "@/types"
import { Separator } from "./separator" import { Separator } from "./separator"
import { AxisDomain } from "recharts/types/util/types" import type { AxisDomain } from "recharts/types/util/types"
import { timeTicks } from "d3-time" import { timeTicks } from "d3-time"
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
@@ -102,7 +101,7 @@ const ChartTooltipContent = React.forwardRef<
labelKey?: string labelKey?: string
unit?: string unit?: string
filter?: string filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string contentFormatter?: (item: unknown, key: string) => React.ReactNode | string
truncate?: boolean truncate?: boolean
showTotal?: boolean showTotal?: boolean
totalLabel?: React.ReactNode totalLabel?: React.ReactNode
@@ -176,7 +175,13 @@ const ChartTooltipContent = React.forwardRef<
} }
const totalKey = "__total__" const totalKey = "__total__"
const totalItem: any = { const totalItem: {
value: number
name: string
dataKey: string
color: string | undefined
payload?: unknown
} = {
value: totalValue, value: totalValue,
name: totalName, name: totalName,
dataKey: totalKey, dataKey: totalKey,
@@ -401,21 +406,23 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config] return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
} }
let cachedAxis: { interface XAxisData {
time: number el: React.ReactElement
el: JSX.Element domain: [number, number]
} }
const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => { const xAxisCache = new Map<ChartTimes, XAxisData>()
if (Math.abs(lastCreationTime - cachedAxis?.time) < 1000) {
return cachedAxis.el function createXAxisData(chartTime: ChartTimes): XAxisData {
} // console.log("Creating XAxis for", chartTime, new Date())
const now = new Date(lastCreationTime + 1000) const axisEndTime = Date.now() + 500
const startTime = chartTimeData[chartTime].getOffset(now) const axisEndDate = new Date(axisEndTime)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime()) const startTime = chartTimeData[chartTime].getOffset(axisEndDate)
const domain = [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()] const ticks = timeTicks(startTime, axisEndDate, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
cachedAxis = { const domain: [number, number] = [startTime.getTime(), axisEndTime]
time: lastCreationTime,
return {
domain,
el: ( el: (
<RechartsPrimitive.XAxis <RechartsPrimitive.XAxis
dataKey="created" dataKey="created"
@@ -431,9 +438,27 @@ const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
/> />
), ),
} }
}
function xAxis(chartTime: ChartTimes, lastCreated: number) {
if (!lastCreated) {
return null
}
const cachedAxis = xAxisCache.get(chartTime)
const expectedInterval = chartTimeData[chartTime].expectedInterval
const conservativeEndTime = Date.now() - expectedInterval / 2
const axisEndTime = Math.max(lastCreated, conservativeEndTime)
if (cachedAxis && axisEndTime < cachedAxis.domain[1]) {
return cachedAxis.el return cachedAxis.el
} }
const axisData = createXAxisData(chartTime)
xAxisCache.set(chartTime, axisData)
return axisData.el
}
export { export {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,

View File

@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40", "border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40!",
className className
)} )}
{...props} {...props}

View File

@@ -224,7 +224,7 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
// if no previous data or the last data point is older than 1min, // if no previous data or the last data point is older than 1min,
// create a new data set starting with a point 1 second ago to seed the chart data // create a new data set starting with a point 1 second ago to seed the chart data
if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) { if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
prev = [{ created: now - 1000, stats: probesToStats(probes) }] prev = [{ created: now - 2000, stats: probesToStats(probes) }]
} }
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
const newStats = appendData(prev, [stats], 1000, 120) const newStats = appendData(prev, [stats], 1000, 120)
@@ -248,6 +248,7 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
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) {
// TODO: include only if probe.updated < charttime
stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h] stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
} }
return stats return stats