From 891b03426ff5c3cbe8b3bf7c56d1ef0ae1889ec2 Mon Sep 17 00:00:00 2001 From: henrygd Date: Tue, 28 Apr 2026 17:46:56 -0400 Subject: [PATCH] updates --- agent/probe.go | 33 ++++--- internal/entities/probe/probe.go | 34 ++++++- internal/hub/probes.go | 57 +++++------ internal/hub/systems/system.go | 96 ++++++++++--------- internal/records/records.go | 8 +- .../network-probes-table.tsx | 27 +++--- .../network-probes-table/probe-dialog.tsx | 67 +++++++++---- .../routes/system/charts/probes-charts.tsx | 89 +++++++++++++---- internal/site/src/lib/systemsManager.ts | 27 +++--- internal/site/src/lib/use-network-probes.ts | 91 +++++++----------- internal/site/src/lib/utils.ts | 42 ++++++++ internal/site/src/types.d.ts | 16 ++-- 12 files changed, 359 insertions(+), 228 deletions(-) diff --git a/agent/probe.go b/agent/probe.go index 3518cd12..f4e40dfd 100644 --- a/agent/probe.go +++ b/agent/probe.go @@ -402,20 +402,29 @@ func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (prob } result := agg.result() - loss1m := result[3] - response1h := hourAgg.avgResponse() + + res := result[0] + res1h := hourAgg.avgResponse() + resMin := result[1] + resMin1h := float64(hourAgg.minUs) + resMax := result[2] + resMax1h := float64(hourAgg.maxUs) + loss := result[3] loss1h := hourAgg.lossPercentage() - if hourAgg.successCount > 0 { - return probe.Result{ - result[0], - response1h, - float64(hourAgg.minUs), - float64(hourAgg.maxUs), - loss1m, - loss1h, - }, true + + if hourAgg.successCount == 0 { + resMin1h, resMax1h = 0, 0 } - return probe.Result{result[0], response1h, 0, 0, loss1m, loss1h}, true + return probe.Result{ + res, + res1h, + resMin, + resMin1h, + resMax, + resMax1h, + loss, + loss1h, + }, true } // aggregateSamplesSince aggregates raw samples newer than the cutoff. diff --git a/internal/entities/probe/probe.go b/internal/entities/probe/probe.go index acdd5ea4..1b2f5501 100644 --- a/internal/entities/probe/probe.go +++ b/internal/entities/probe/probe.go @@ -38,15 +38,19 @@ type SyncResponse struct { // // 0: avg response in microseconds // -// 1: average response over the last hour in microseconds +// 1: 1h average response in microseconds // -// 2: min response over the last hour in microseconds +// 2: min response in microseconds // -// 3: max response over the last hour in microseconds +// 3: 1h min response in microseconds // -// 4: packet loss percentage (0-100) +// 4: max response in microseconds // -// 5: packet loss percentage over the last hour (0-100) +// 5: 1h max response in microseconds +// +// 6: packet loss percentage (0-100) +// +// 7: 1h packet loss percentage (0-100) type Result []float64 // Get returns the value at the specified index or 0 if the index is out of range. @@ -56,3 +60,23 @@ func (r Result) Get(index int) float64 { } return 0 } + +// Stats holds only 1m values for a single target, which are used for charts. +// +// 0: avg response in microseconds +// +// 1: min response in microseconds +// +// 2: max response in microseconds +// +// 3: packet loss percentage (0-100) +type Stats []float64 + +func (s Stats) FromResult(result Result) Stats { + return Stats{ + result.Get(0), // avg response + result.Get(2), // min response + result.Get(4), // max response + result.Get(6), // packet loss + } +} diff --git a/internal/hub/probes.go b/internal/hub/probes.go index b42db2da..85a96107 100644 --- a/internal/hub/probes.go +++ b/internal/hub/probes.go @@ -36,28 +36,16 @@ func bindNetworkProbesEvents(hub *Hub) { return nil } // if system connected, run the probe immediately - // if not, return and wait for the system to connect and sync probes then + // if not, return and wait for the system to connect and sync probes on reg schedule system, err := hub.sm.GetSystem(e.Record.GetString("system")) - if err != nil || system.Status != "up" { - return nil + if err == nil && system.Status == "up" { + go hub.upsertNetworkProbe(e.Record, true) } - 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) - return nil - } - if result == nil { - return nil - } - setProbeResultFields(e.Record, *result) - if err := e.App.SaveNoValidate(e.Record); err != nil { - hub.Logger().Warn("failed to save initial probe result", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err) - } - return e.Next() + return err }) - // On API update requests, if the probe config changed in a way that requires a new ID, we will create a new - // record with the new ID and delete the old one. Otherwise, we will just update the existing probe on the agent. + // On API update requests, if the probe config changed in a way that requires a new ID, create a new + // record with the new ID and delete the old one. Otherwise, just update the existing probe on the agent. hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error { systemID := e.Record.GetString("system") ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record)) @@ -73,18 +61,15 @@ func bindNetworkProbesEvents(hub *Hub) { } err := e.Next() if e.Record.GetBool("enabled") { - var result *probe.Result + // if the probe is enabled, sync the updated config to the agent now runNow := !e.Record.Original().GetBool("enabled") - result, err = hub.upsertNetworkProbe(e.Record, runNow) - if result != nil { - setProbeResultFields(e.Record, *result) - _ = e.App.SaveNoValidate(e.Record) - } + err = hub.upsertNetworkProbe(e.Record, runNow) } else { + // if the probe is paused, remove it from the agent err = hub.deleteNetworkProbe(e.Record) } if err != nil { - hub.Logger().Warn("failed to sync updated probe", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err) + hub.Logger().Warn("failed to sync updated probe", "system", systemID, "probe", e.Record.Id, "err", err) } return nil }) @@ -115,10 +100,10 @@ func setProbeResultFields(record *core.Record, result probe.Result) { nowString := now.Format(types.DefaultDateLayout) record.Set("res", result.Get(0)) record.Set("resAvg1h", result.Get(1)) - record.Set("resMin1h", result.Get(2)) - record.Set("resMax1h", result.Get(3)) - record.Set("loss", result.Get(4)) - record.Set("loss1h", result.Get(5)) + record.Set("resMin1h", result.Get(3)) + record.Set("resMax1h", result.Get(5)) + // record.Set("loss", result.Get(4)) + record.Set("loss1h", result.Get(7)) record.Set("updated", nowString) } @@ -133,14 +118,20 @@ func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record { return newRecord } -// upsertNetworkProbe applies the record's probe config to the target system. -func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) { +// upsertNetworkProbe creates or updates the record's probe on the target system. If runNow +// is true, it will also trigger an immediate probe run and update the record with the result. +func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) error { systemID := record.GetString("system") system, err := h.sm.GetSystem(systemID) if err != nil { - return nil, err + return err } - return system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow) + result, err := system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow) + if err != nil || result == nil { + return err + } + setProbeResultFields(record, *result) + return h.App.SaveNoValidate(record) } // deleteNetworkProbe removes the record's probe from the target system. diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index de877f92..5c0494b3 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -30,6 +30,7 @@ import ( "github.com/lxzan/gws" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/crypto/ssh" ) @@ -314,16 +315,16 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s return err } -func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, systemId string) error { - if len(data) == 0 { +func updateNetworkProbesRecords(app core.App, probeResults map[string]probe.Result, systemId string) error { + if len(probeResults) == 0 { return nil } var err error - collectionName := "network_probes" + probeCollectionName := "network_probes" // If realtime updates are active, we save via PocketBase records to trigger realtime events. // Otherwise we can do a more efficient direct update via SQL - realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool { + realtimeActive := utils.RealtimeActiveForCollection(app, probeCollectionName, func(filterQuery string) bool { return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId) }) @@ -334,63 +335,68 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst var updateQuery *dbx.Query if !realtimeActive { db = app.DB() - sql := fmt.Sprintf("UPDATE %s SET res={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss={:loss}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName) - updateQuery = db.NewQuery(sql) + probeFields := []string{"res", "resMin1h", "resMax1h", "resAvg1h", "loss1h", "updated"} + setClauses := make([]string, len(probeFields)) + for i, f := range probeFields { + setClauses[i] = fmt.Sprintf("%s={:%s}", f, f) + } + queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", probeCollectionName, strings.Join(setClauses, ", ")) + updateQuery = db.NewQuery(queryString) } // update network_probes records - for id, values := range data { + for id, values := range probeResults { + probeData := map[string]any{ + "id": id, + "res": values.Get(0), + "resAvg1h": values.Get(1), + "resMin1h": values.Get(3), + "resMax1h": values.Get(5), + "loss1h": values.Get(7), + "updated": nowString, + } switch realtimeActive { case true: var record *core.Record - record, err = app.FindRecordById(collectionName, id) + record, err = app.FindRecordById(probeCollectionName, id) if err == nil { - record.Set("res", values.Get(0)) - record.Set("resAvg1h", values.Get(1)) - record.Set("resMin1h", values.Get(2)) - record.Set("resMax1h", values.Get(3)) - record.Set("loss", values.Get(4)) - record.Set("loss1h", values.Get(5)) - record.Set("updated", nowString) + record.Load(probeData) err = app.SaveNoValidate(record) } default: - _, err = updateQuery.Bind(dbx.Params{ - "id": id, - "res": values.Get(0), - "resAvg1h": values.Get(1), - "resMin1h": values.Get(2), - "resMax1h": values.Get(3), - "loss": values.Get(4), - "loss1h": values.Get(5), - "updated": nowString, - }).Execute() + _, err = updateQuery.Bind(dbx.Params(probeData)).Execute() } if err != nil { app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err) } } - // insert network probe stats records - switch realtimeActive { - case true: - collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats") - record := core.NewRecord(collection) - record.Set("system", systemId) - record.Set("stats", data) - record.Set("type", "1m") - record.Set("created", nowMilli) - err = app.SaveNoValidate(record) - default: - var statsJson types.JSONRaw - if err := statsJson.Scan(data); err == nil { - insertQuery := db.NewQuery("INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})") - _, err = insertQuery.Bind(dbx.Params{ - "system": systemId, - "stats": statsJson, - "type": "1m", - "created": nowMilli, - }).Execute() + // handle stats collection as well + statsCollectionName := "network_probe_stats" + + // we don't need the hour values for the stats collection + stats := make(map[string]probe.Stats, len(probeResults)) + for key, values := range probeResults { + stats[key] = probe.Stats{}.FromResult(values) + } + + statsRecordData := map[string]any{ + "system": systemId, + "type": "1m", + "created": nowMilli, + } + var statsJson types.JSONRaw + if err = statsJson.Scan(stats); err == nil { + statsRecordData["stats"] = statsJson + switch realtimeActive { + case true: + collection, _ := app.FindCachedCollectionByNameOrId(statsCollectionName) + record := core.NewRecord(collection) + record.Load(statsRecordData) + err = app.SaveNoValidate(record) + default: + statsRecordData["id"] = security.PseudorandomStringWithAlphabet(10, core.DefaultIdAlphabet) + _, err = db.Insert(statsCollectionName, dbx.Params(statsRecordData)).Execute() } } if err != nil { diff --git a/internal/records/records.go b/internal/records/records.go index 3846f9f8..9f6d3c9f 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -174,7 +174,7 @@ func (rm *RecordManager) CreateLongerRecords() { return nil }) - // log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds()) + // slog.Info("finished creating longer records", "time (ms)", time.Since(now).Milliseconds()) } func getCreatedTimeField(collectionName string, period time.Time) any { @@ -567,11 +567,11 @@ func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) ma } for i := range vals { switch i { - case 2: // min fields + case 1: // min fields if s.counts[i] == 0 || vals[i] < s.sums[i] { s.sums[i] = vals[i] } - case 3: // max fields + case 2: // max fields if s.counts[i] == 0 || vals[i] > s.sums[i] { s.sums[i] = vals[i] } @@ -591,7 +591,7 @@ func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) ma } for i := range s.sums { switch i { - case 2, 3: // min and max fields should not be averaged + case 1, 2: // min and max fields should not be averaged continue default: if s.counts[i] > 0 { 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 a9511b12..f09b9529 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 @@ -34,18 +34,20 @@ import { useToast } from "@/components/ui/use-toast" import { isReadOnlyUser } from "@/lib/api" import { pb } from "@/lib/api" import { $allSystemsById, $chartTime, $direction } from "@/lib/stores" -import { cn, useBrowserStorage } from "@/lib/utils" +import { cn, isVisuallyLonger, 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 { LossChart, AvgMinMaxResponseChart } 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" +import { $router, Link } from "../router" +import { getPagePath } from "@nanostores/router" export default function NetworkProbesTableNew({ systemId, @@ -74,10 +76,10 @@ export default function NetworkProbesTableNew({ let longestTarget = "" for (const p of probes) { const name = p.name || p.target - if (name.length > longestName.length) { + if (isVisuallyLonger(name, longestName)) { longestName = name } - if (p.target.length > longestTarget.length) { + if (isVisuallyLonger(p.target, longestTarget)) { longestTarget = p.target } } @@ -266,7 +268,7 @@ export default function NetworkProbesTableNew({ )} )} - {canManageProbes ? : null} + {canManageProbes ? : null} {canManageProbes ? ( record.stats?.[probe.id] != null) const probeLabel = probe.name || probe.target @@ -499,7 +501,9 @@ function NetworkProbeSheetContent({ {probeLabel} - {system?.name ?? ""} + + {system?.name ?? ""} + {probe.protocol.toUpperCase()} @@ -514,14 +518,7 @@ function NetworkProbeSheetContent({
- + & { interval: number } +const defaultInterval = 20 + const ProbeProtocolSchema = v.picklist(["icmp", "tcp", "http"]) const ProbeIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)) @@ -99,7 +101,7 @@ function normalizeHttpTarget(target: string, port: number) { return `${port === 443 ? "https" : "http"}://${target}` } -function buildProbePayload(values: ProbeValues) { +function buildProbePayload(values: ProbeValues, enabled = true) { const normalizedValues = v.safeParse(NormalizedProbeValuesSchema, values) if (!normalizedValues.success) { throw new Error(normalizedValues.issues[0]?.message || "Invalid probe") @@ -107,7 +109,7 @@ function buildProbePayload(values: ProbeValues) { const payload = { system: values.system, - enabled: true, + enabled, ...normalizedValues.output, } @@ -123,6 +125,11 @@ function buildProbePayload(values: ProbeValues) { return payload } +type ProbeIdentity = Pick +function getProbeIdentityKey({ system, target, protocol, port }: ProbeIdentity) { + return `${system}${target}${protocol}${port}` +} + function parseBulkProbeLine(line: string, lineNumber: number, system: string) { const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",") const parsed = v.safeParse(BulkProbeSchema, { @@ -142,12 +149,12 @@ function parseBulkProbeLine(line: string, lineNumber: number, system: string) { protocol: (parsed.output.protocol?.toLowerCase() || (/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol, port: parsed.output.port ? Number(parsed.output.port) : 0, - interval: parsed.output.interval || "30", + interval: parsed.output.interval || `${defaultInterval}`, name: parsed.output.name || undefined, }) } -export function AddProbeDialog({ systemId }: { systemId?: string }) { +export function AddProbeDialog({ systemId, probes }: { systemId?: string; probes: NetworkProbeRecord[] }) { const [open, setOpen] = useState(false) const [bulkOpen, setBulkOpen] = useState(false) const [bulkInput, setBulkInput] = useState("") @@ -192,10 +199,29 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) { } const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system)) + const existingProbeKeys = new Set( + probes.filter((probe) => probe.system === system).map((probe) => getProbeIdentityKey(probe)) + ) + const newPayloads = [] as typeof payloads + + for (const payload of payloads) { + const probeKey = getProbeIdentityKey(payload) + if (existingProbeKeys.has(probeKey)) { + continue + } + + existingProbeKeys.add(probeKey) + newPayloads.push(payload) + } + + if (!newPayloads.length) { + throw new Error("No new probes to add. All entries already exist.") + } + closedForSubmit = true let batch = pb.createBatch() let inBatch = 0 - for (const payload of payloads) { + for (const payload of newPayloads) { batch.collection("network_probes").create(payload) inBatch++ if (inBatch > 20) { @@ -209,7 +235,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) { } resetBulkForm() - toast({ title: t`Probes created`, description: `${payloads.length} probe(s) added.` }) + toast({ title: t`Probes created`, description: `${newPayloads.length} probe(s) added.` }) } catch (err: unknown) { if (closedForSubmit) { setBulkOpen(true) @@ -337,10 +363,11 @@ export function EditProbeDialog({ systemId?: string probe?: NetworkProbeRecord }) { - if (!probe) { + const hasOpened = useRef(false) + if (!probe && !hasOpened.current) { return null } - + hasOpened.current = true return ( @@ -366,7 +393,7 @@ function ProbeDialogContent({ const [port, setPort] = useState( (probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : "" ) - const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? 30)) + const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? defaultInterval)) const [name, setName] = useState(probe?.name ?? "") const [loading, setLoading] = useState(false) const [selectedSystemId, setSelectedSystemId] = useState(probe?.system ?? "") @@ -385,7 +412,7 @@ function ProbeDialogContent({ setProtocol(probe?.protocol ?? "icmp") setTarget(probe?.target ?? "") setPort((probe?.protocol === "tcp" || probe?.protocol === "http") && probe.port ? String(probe.port) : "") - setProbeInterval(String(probe?.interval ?? 30)) + setProbeInterval(String(probe?.interval ?? defaultInterval)) setName(probe?.name ?? "") setSelectedSystemId(probe?.system ?? "") setLoading(false) @@ -400,14 +427,17 @@ function ProbeDialogContent({ if (!selectedSystem) { throw new Error("Select a system.") } - const payload = buildProbePayload({ - system: selectedSystem, - target, - protocol, - port: protocol === "tcp" || protocol === "http" ? Number(port) : 0, - interval: probeInterval, - name, - }) + const payload = buildProbePayload( + { + system: selectedSystem, + target, + protocol, + port: protocol === "tcp" || protocol === "http" ? Number(port) : 0, + interval: probeInterval, + name, + }, + probe ? probe.enabled : true + ) if (probe) { await pb.collection("network_probes").update(probe.id, payload) } else { @@ -490,7 +520,6 @@ function ProbeDialogContent({ placeholder="443" min={1} max={65535} - required={protocol === "tcp"} />
)} 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 324cf8cf..32a490f5 100644 --- a/internal/site/src/components/routes/system/charts/probes-charts.tsx +++ b/internal/site/src/components/routes/system/charts/probes-charts.tsx @@ -132,27 +132,82 @@ export function ResponseChart({ probeStats, grid, probes, chartData, empty }: Pr ) } -export function MaxResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) { +interface AvgMinMaxResponseChartProps { + probeStats: NetworkProbeStatsRecord[] + probe: NetworkProbeRecord | null + chartData: ChartData + empty: boolean +} + +export function AvgMinMaxResponseChart({ probeStats, probe, chartData, empty }: AvgMinMaxResponseChartProps) { const { t } = useLingui() + const { chartTime } = chartData + const hasLongInterval = (probe?.interval ?? 61) > 60 + + // only one probe is relevant for this chart + const dataPoints: DataPoint[] = useMemo(() => { + const dataFn = (index: number) => (record: NetworkProbeStatsRecord) => + record.stats?.[probe?.id ?? ""]?.[index] ?? "-" + const avgPoint = { + label: "Avg", + dataKey: dataFn(0), + color: 1, + order: 0, + } + if (chartTime === "1m" || (hasLongInterval && chartTime === "1h")) { + // avg, min, max are all the same for 1m interval, so just show avg + return [avgPoint] + } + return [ + { + label: "Max", + dataKey: dataFn(2), + color: 3, + order: 0, + }, + avgPoint, + { + label: "Min", + dataKey: dataFn(1), + color: 2, + order: 2, + }, + ] + }, [chartTime, hasLongInterval]) + + const data = useMemo(() => { + if (!probe) return [] + return probeStats.filter((record) => record.stats && probe.id in record.stats) + }, [probe, probeStats]) + + const legend = dataPoints.length > 1 + return ( - formatMicroseconds(value, false)} - contentFormatter={({ value }) => { - if (typeof value !== "number") { - return value - } - return formatMicroseconds(value) - }} - /> + description={t`Average, minimum, and maximum response time`} + grid={false} + > + formatMicroseconds(value, false)} + contentFormatter={({ value }) => { + if (typeof value !== "number") { + return value + } + return formatMicroseconds(value) + }} + /> + ) } @@ -166,7 +221,7 @@ export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeC probes={probes} chartData={chartData} empty={empty} - valueIndex={4} + valueIndex={3} title={t`Loss`} description={t`Packet loss (%)`} domain={[0, 100]} diff --git a/internal/site/src/lib/systemsManager.ts b/internal/site/src/lib/systemsManager.ts index c1b35ba4..e383a0f4 100644 --- a/internal/site/src/lib/systemsManager.ts +++ b/internal/site/src/lib/systemsManager.ts @@ -9,7 +9,7 @@ import { $pausedSystems, $upSystems, } from "@/lib/stores" -import { updateFavicon } from "@/lib/utils" +import { isVisuallyLonger, updateFavicon } from "@/lib/utils" import type { SystemRecord } from "@/types" import { SystemStatus } from "./enums" @@ -41,7 +41,7 @@ export function init() { } if (!newSystem) { - onSystemsChanged(newSystems, undefined) + onSystemsChanged(newSystems, newSystem, oldSystem) return } @@ -65,23 +65,28 @@ export function init() { } // run things that need to be done when systems change - onSystemsChanged(newSystems, newSystem) + onSystemsChanged(newSystems, newSystem, oldSystem) }) } /** Update the longest system name string and favicon based on system status */ -function onSystemsChanged(systems: Record, _changedSystem: SystemRecord | undefined) { +function onSystemsChanged(systems: Record, newSystem?: SystemRecord, oldSystem?: SystemRecord) { const downSystemsStore = $downSystems.get() const downSystems = Object.values(downSystemsStore) - let longestName = "" - for (const system of Object.values(systems)) { - if (system.name.length > longestName.length) { - longestName = system.name + // if the old system's old name was the longest, we need to find the new longest name + // otherwise, if the changed system's new name is longer than the current longest, update it + const longestName = $longestSystemName.get() + if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) { + let newLongest = "" + for (const id in systems) { + if (isVisuallyLonger(systems[id].name, newLongest)) { + newLongest = systems[id].name + } } - } - if ($longestSystemName.get() !== longestName) { - $longestSystemName.set(longestName) + $longestSystemName.set(newLongest) + } else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) { + $longestSystemName.set(newSystem.name) } updateFavicon(downSystems.length) diff --git a/internal/site/src/lib/use-network-probes.ts b/internal/site/src/lib/use-network-probes.ts index 2ea812ee..7d29f2d9 100644 --- a/internal/site/src/lib/use-network-probes.ts +++ b/internal/site/src/lib/use-network-probes.ts @@ -116,64 +116,6 @@ export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) { const [probeStats, setProbeStats] = useState([]) const requestID = useRef(0) - // Subscribe to new probe stats - useEffect(() => { - if (!systemId) { - return - } - let unsubscribe: (() => void) | undefined - const pbOptions = { - fields: "stats,created,type", - filter: pb.filter("system = {:system}", { system: systemId }), - } - - ;(async () => { - try { - unsubscribe = await pb.collection("network_probe_stats").subscribe( - "*", - (event) => { - if (!chartTime || event.action !== "create") { - return - } - // if (typeof event.record.created === "string") { - // event.record.created = new Date(event.record.created).getTime() - // } - // return if not current chart time - // we could append to other chart times, but we would need to check the timestamps - // to make sure they fit in correctly, so for simplicity just ignore non-chart-time updates - // and fetch them via API when the user switches to that chart time - const chartTimeRecordType = chartTimeData[chartTime].type as ChartTimes - if (event.record.type !== chartTimeRecordType) { - // const lastCreated = getCacheValue(systemId, chartTime)?.at(-1)?.created ?? 0 - // if (lastCreated) { - // // if the new record is close enough to the last cached record, append it to the cache so it's available immediately if the user switches to that chart time - // const { expectedInterval } = chartTimeData[chartTime] - // if (event.record.created - lastCreated < expectedInterval * 1.5) { - // console.log( - // `Caching out-of-chart-time probe stats record for chart time ${chartTime} (record type: ${event.record.type})` - // ) - // const newStats = appendCacheValue(systemId, chartTime, [event.record]) - // cache.set(`${systemId}${chartTime}`, newStats) - // } - // } - // console.log(`Received probe stats for non-current chart time (${event.record.type}), ignoring for now`) - return - } - - // console.log("Appending new probe stats to chart:", event.record) - const newStats = appendCacheValue(systemId, chartTime, [event.record]) - setProbeStats(newStats) - }, - pbOptions - ) - } catch (error) { - console.error("Failed to subscribe to probe stats:", error) - } - })() - - return () => unsubscribe?.() - }, [systemId]) - // fetch missing probe stats on load and when chart time changes useEffect(() => { if (!systemId || !chartTime || chartTime === "1m") { @@ -208,6 +150,39 @@ export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) { ) }, [chartTime]) + // Subscribe to new probe stats on non-1m chart times (1h, 12h, etc) + useEffect(() => { + if (!systemId || !chartTime || chartTime === "1m") { + return + } + let unsubscribe: (() => void) | undefined + const pbOptions = { + fields: "stats,created,type", + filter: pb.filter("system={:system} && type={:type}", { system: systemId, type: chartTimeData[chartTime].type }), + } + + ;(async () => { + try { + unsubscribe = await pb.collection("network_probe_stats").subscribe( + "*", + (event) => { + if (event.action !== "create") { + return + } + // console.log("Appending new probe stats to chart:", event.record) + const newStats = appendCacheValue(systemId, chartTime, [event.record]) + setProbeStats(newStats) + }, + pbOptions + ) + } catch (error) { + console.error("Failed to subscribe to probe stats:", error) + } + })() + + return () => unsubscribe?.() + }, [systemId, chartTime]) + // subscribe to realtime metrics if chart time is 1m useEffect(() => { if (!systemId || chartTime !== "1m") { diff --git a/internal/site/src/lib/utils.ts b/internal/site/src/lib/utils.ts index 1c4e79d4..7d515147 100644 --- a/internal/site/src/lib/utils.ts +++ b/internal/site/src/lib/utils.ts @@ -472,3 +472,45 @@ export function secondsToUptimeString(seconds: number): string { return secondsToString(seconds, "day") } } + +const visualWidthCache = new Map() + +/** Get the visual width of a string, accounting for full-width and narrow punctuation characters. + * Don't use for monospaced fonts, use .length instead + */ +export function getVisualStringWidth(str: string): number { + const cached = visualWidthCache.get(str) + if (cached !== undefined) { + return cached + } + let width = 0 + for (const char of str) { + if (char === ".") { + width += 0.7 + continue + } + const code = char.codePointAt(0) || 0 + // Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji + if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) { + width += 1.8 + continue + } + // Count CJK and other full-width characters as 2 units, others as 1 + // Arabic and Cyrillic are counted as 1 + const isFullWidth = + (code >= 0x2e80 && code <= 0x9fff) || // CJK Radicals, Symbols, and Ideographs + (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs + (code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms + (code >= 0xff00 && code <= 0xff60) || // Fullwidth Forms + (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Symbols + code > 0xffff // Emojis and other supplementary plane characters + width += isFullWidth ? 2 : 1 + } + visualWidthCache.set(str, width) + + return width +} + +export function isVisuallyLonger(str1: string, str2: string): boolean { + return getVisualStringWidth(str1) > getVisualStringWidth(str2) +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 8e4f476d..a9ba1c06 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -564,23 +564,21 @@ export interface NetworkProbeRecord { } /** - * 0: avg 1 minute response in microseconds + * Stats holds only 1m values for a single target, which are used for charts. * - * 1: avg response over 1 hour in microseconds + * 0: avg response in microseconds * - * 2: min response over the last hour in microseconds + * 1: min response in microseconds * - * 3: max response over the last hour in microseconds + * 2: max response in microseconds * - * 4: packet loss % - * - * 5: packet loss over the last hour in % + * 3: packet loss percentage (0-100) */ -type ProbeResult = number[] +type ProbeStats = number[] export interface NetworkProbeStatsRecord { id?: string type?: string - stats: Record + stats: Record created: number // unix timestamp (ms) for Recharts xAxis }