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

View File

@@ -42,6 +42,7 @@ export default function LineChartDefault({
truncate = false,
chartProps,
connectNulls,
dot = false,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
@@ -64,6 +65,7 @@ export default function LineChartDefault({
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
connectNulls?: boolean
dot?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
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
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint
@@ -99,7 +103,7 @@ export default function LineChartDefault({
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
dot={dot}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
@@ -148,7 +152,7 @@ export default function LineChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
{XAxis}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
@@ -169,5 +173,5 @@ export default function LineChartDefault({
</LineChart>
</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 { useStore } from "@nanostores/react"
import { pb } from "@/lib/api"
@@ -111,13 +111,14 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
const bulkFormRef = useRef<HTMLFormElement>(null)
const { toast } = useToast()
const { t } = useLingui()
const systems = useStore($systems)
const resetBulkForm = () => {
setBulkInput("")
setBulkSelectedSystemId("")
// setBulkSelectedSystemId("")
}
const openBulkAdd = (selectedSystemId?: string) => {
@@ -140,13 +141,15 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
try {
const system = systemId ?? bulkSelectedSystemId
if (!system) {
throw new Error("Select a system.")
}
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) {
throw new Error("Enter at least one probe.")
}
const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system))
setBulkOpen(false)
closedForSubmit = true
let batch = pb.createBatch()
let inBatch = 0
@@ -221,16 +224,10 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
</SheetTitle>
<SheetDescription>
<Trans>
Paste one probe per line. See{" "}
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
the documentation
</a>
.
</Trans>
target[,protocol[,port[,interval[,name]]]] - TCP/HTTP default to port 443.
</SheetDescription>
</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">
{!systemId && (
<div className="grid gap-2">
@@ -252,8 +249,8 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
</div>
)}
<div className="grid gap-2">
<Label htmlFor="bulk-probes">
<Trans>Entries</Trans>
<Label htmlFor="bulk-probes" className="sr-only">
Entries
</Label>
<Textarea
id="bulk-probes"
@@ -262,10 +259,10 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
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)` }}
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
required
@@ -338,8 +335,12 @@ function ProbeDialogContent({
setLoading(true)
try {
const selectedSystem = systemId ?? selectedSystemId
if (!selectedSystem) {
throw new Error("Select a system.")
}
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
system: selectedSystem,
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
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
)}
{...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,
// 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) {
prev = [{ created: now - 1000, stats: probesToStats(probes) }]
prev = [{ created: now - 2000, stats: probesToStats(probes) }]
}
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
const newStats = appendData(prev, [stats], 1000, 120)
@@ -248,6 +248,7 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
const stats: NetworkProbeStatsRecord["stats"] = {}
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]
}
return stats