This commit is contained in:
henrygd
2026-04-28 17:46:56 -04:00
parent b182b699d7
commit 891b03426f
12 changed files with 359 additions and 228 deletions

View File

@@ -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<string, SystemRecord>, _changedSystem: SystemRecord | undefined) {
function onSystemsChanged(systems: Record<string, SystemRecord>, 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)

View File

@@ -116,64 +116,6 @@ export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) {
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
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<NetworkProbeStatsRecord>("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<NetworkProbeStatsRecord>("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") {

View File

@@ -472,3 +472,45 @@ export function secondsToUptimeString(seconds: number): string {
return secondsToString(seconds, "day")
}
}
const visualWidthCache = new Map<string, number>()
/** 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)
}