rename /src to /internal (sorry i'll fix the prs)

This commit is contained in:
henrygd
2025-09-09 13:29:07 -04:00
parent 86ea23fe39
commit 8a13b05c20
177 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { t } from "@lingui/core/macro"
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
import type { AlertInfo, AlertRecord } from "@/types"
import { pb } from "./api"
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
} as const
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
function unsubscribe() {
unsub?.()
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
})()

View File

@@ -0,0 +1,66 @@
import { t } from "@lingui/core/macro"
import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData } from "./utils"
/** PocketBase JS Client */
export const pb = new PocketBase(basePath)
export const isAdmin = () => pb.authStore.record?.role === "admin"
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
export const verifyAuth = () => {
pb.collection("users")
.authRefresh()
.catch(() => {
logOut()
toast({
title: t`Failed to authenticate`,
description: t`Please log in again`,
variant: "destructive",
})
})
}
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
$allSystemsByName.set({})
$alerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()
pb.realtime.unsubscribe()
}
/** Fetch or create user settings in database */
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
return
} catch (e) {
console.error("get settings", e)
}
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
$userSettings.set(createdSettings.settings)
} catch (e) {
console.error("create settings", e)
}
}
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
d ||= chartTimeData[timeString].getOffset(new Date())
const year = d.getUTCFullYear()
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
const day = String(d.getUTCDate()).padStart(2, "0")
const hours = String(d.getUTCHours()).padStart(2, "0")
const minutes = String(d.getUTCMinutes()).padStart(2, "0")
const seconds = String(d.getUTCSeconds()).padStart(2, "0")
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

View File

@@ -0,0 +1,55 @@
/** Operating system */
export enum Os {
Linux = 0,
Darwin,
Windows,
FreeBSD,
}
/** Type of chart */
export enum ChartType {
Memory,
Disk,
Network,
CPU,
}
/** Unit of measurement */
export enum Unit {
Bytes,
Bits,
Celsius,
Fahrenheit,
}
/** Meter state for color */
export enum MeterState {
Good,
Warn,
Crit,
}
/** System status states */
export enum SystemStatus {
Up = "up",
Down = "down",
Pending = "pending",
Paused = "paused",
}
/** Battery state */
export enum BatteryState {
Unknown,
Empty,
Full,
Charging,
Discharging,
Idle,
}
/** Time format */
export enum HourFormat {
// Default = "Default",
"12h" = "12h",
"24h" = "24h",
}

View File

@@ -0,0 +1,69 @@
import type { Messages } from "@lingui/core"
import { i18n } from "@lingui/core"
import { t } from "@lingui/core/macro"
import { detect, fromNavigator, fromStorage } from "@lingui/detect-locale"
import languages from "@/lib/languages"
import { messages as enMessages } from "@/locales/en/en"
import { BatteryState } from "./enums"
import { $direction } from "./stores"
// activates locale
function activateLocale(locale: string, messages: Messages = enMessages) {
i18n.load(locale, messages)
i18n.activate(locale)
document.documentElement.lang = locale
localStorage.setItem("lang", locale)
$direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
}
// dynamically loads translations for the given locale
export async function dynamicActivate(locale: string) {
if (locale === "en") {
activateLocale(locale)
} else {
try {
const { messages }: { messages: Messages } = await import(`../locales/${locale}/${locale}.ts`)
activateLocale(locale, messages)
} catch (error) {
console.error(`Error loading ${locale}`, error)
activateLocale("en")
}
}
}
export function getLocale() {
// let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
let locale = detect(fromStorage("lang"), fromNavigator(), "en")
// log if dev
if (import.meta.env.DEV) {
console.log("detected locale", locale)
}
// handle zh variants
if (locale?.startsWith("zh-")) {
// map zh variants to zh-CN
const zhVariantMap: Record<string, string> = {
"zh-HK": "zh-HK",
"zh-TW": "zh",
"zh-MO": "zh",
"zh-Hant": "zh",
}
return zhVariantMap[locale] || "zh-CN"
}
locale = (locale || "en").split("-")[0]
// use en if locale is not in languages
if (!languages.some((l) => l.lang === locale)) {
locale = "en"
}
return locale
}
////////////////////////////////////////////////////////
export const batteryStateTranslations = {
[BatteryState.Unknown]: () => t({ message: "Unknown", comment: "Context: Battery state" }),
[BatteryState.Empty]: () => t({ message: "Empty", comment: "Context: Battery state" }),
[BatteryState.Full]: () => t({ message: "Full", comment: "Context: Battery state" }),
[BatteryState.Charging]: () => t({ message: "Charging", comment: "Context: Battery state" }),
[BatteryState.Discharging]: () => t({ message: "Discharging", comment: "Context: Battery state" }),
[BatteryState.Idle]: () => t({ message: "Idle", comment: "Context: Battery state" }),
} as const

View File

@@ -0,0 +1,137 @@
export default [
{
lang: "ar",
label: "العربية",
e: "🇵🇸",
},
{
lang: "bg",
label: "Български",
e: "🇧🇬",
},
{
lang: "cs",
label: "Čeština",
e: "🇨🇿",
},
{
lang: "da",
label: "Dansk",
e: "🇩🇰",
},
{
lang: "de",
label: "Deutsch",
e: "🇩🇪",
},
{
lang: "en",
label: "English",
e: "🇺🇸",
},
{
lang: "es",
label: "Español",
e: "🇲🇽",
},
{
lang: "fa",
label: "فارسی",
e: "🇮🇷",
},
{
lang: "fr",
label: "Français",
e: "🇫🇷",
},
{
lang: "hr",
label: "Hrvatski",
e: "🇭🇷",
},
{
lang: "hu",
label: "Magyar",
e: "🇭🇺",
},
{
lang: "it",
label: "Italiano",
e: "🇮🇹",
},
{
lang: "ja",
label: "日本語",
e: "🇯🇵",
},
{
lang: "ko",
label: "한국어",
e: "🇰🇷",
},
{
lang: "nl",
label: "Nederlands",
e: "🇳🇱",
},
{
lang: "no",
label: "Norsk",
e: "🇳🇴",
},
{
lang: "pl",
label: "Polski",
e: "🇵🇱",
},
{
lang: "pt",
label: "Português",
e: "🇧🇷",
},
{
lang: "tr",
label: "Türkçe",
e: "🇹🇷",
},
{
lang: "ru",
label: "Русский",
e: "🇷🇺",
},
{
lang: "sl",
label: "Slovenščina",
e: "🇸🇮",
},
{
lang: "sv",
label: "Svenska",
e: "🇸🇪",
},
{
lang: "uk",
label: "Українська",
e: "🇺🇦",
},
{
lang: "vi",
label: "Tiếng Việt",
e: "🇻🇳",
},
{
lang: "zh-CN",
label: "简体中文",
e: "🇨🇳",
},
{
lang: "zh-HK",
label: "繁體中文",
e: "🇭🇰",
},
{
lang: "zh",
label: "繁體中文",
e: "🇹🇼",
},
] as const

View File

@@ -0,0 +1,70 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)
/** Map of system records by name */
export const $allSystemsByName = map<Record<string, SystemRecord>>({})
/** Map of system records by id */
export const $allSystemsById = map<Record<string, SystemRecord>>({})
/** Map of up systems by id */
export const $upSystems = map<Record<string, SystemRecord>>({})
/** Map of down systems by id */
export const $downSystems = map<Record<string, SystemRecord>>({})
/** Map of paused systems by id */
export const $pausedSystems = map<Record<string, SystemRecord>>({})
/** List of all system records */
export const $systems: ReadableAtom<SystemRecord[]> = computed($allSystemsById, Object.values)
/** Map of alert records by system id and alert name */
export const $alerts = map<AlertMap>({})
/** SSH public key */
export const $publicKey = atom("")
/** Chart time period */
export const $chartTime = atom<ChartTimes>("1h")
/** Whether to display average or max chart values */
export const $maxValues = atom(false)
// export const UserSettingsSchema = v.object({
// chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
// emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
// webhooks: v.optional(v.array(v.string())),
// colorWarn: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// colorDanger: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))),
// unitTemp: v.optional(v.enum(Unit)),
// unitNet: v.optional(v.enum(Unit)),
// unitDisk: v.optional(v.enum(Unit)),
// })
/** User settings */
export const $userSettings = map<UserSettings>({
chartTime: "1h",
emails: [pb.authStore.record?.email || ""],
unitNet: Unit.Bytes,
unitTemp: Unit.Celsius,
})
// update chart time on change
listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chartTime))
/** Container chart filter */
export const $containerFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")
/** Fallback copy to clipboard dialog content */
export const $copyContent = atom("")
/** Direction for localization */
export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name length. Used to set table column width. I know this
* is stupid but the table is virtualized and I know this will work.
*/
export const $longestSystemNameLen = atom(8)

View File

@@ -0,0 +1,174 @@
import type { PreinitializedMapStore } from "nanostores"
import { pb, verifyAuth } from "@/lib/api"
import {
$allSystemsById,
$allSystemsByName,
$downSystems,
$longestSystemNameLen,
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED, updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
const COLLECTION = pb.collection<SystemRecord>("systems")
const FIELDS_DEFAULT = "id,name,host,port,info,status"
/** Maximum system name length for display purposes */
const MAX_SYSTEM_NAME_LENGTH = 20
let initialized = false
let unsub: (() => void) | undefined | void
/** Initialize the systems manager and set up listeners */
export function init() {
if (initialized) {
return
}
initialized = true
// sync system stores on change
$allSystemsById.listen((newSystems, oldSystems, changedKey) => {
const oldSystem = oldSystems[changedKey]
const newSystem = newSystems[changedKey]
// if system is undefined (deleted), remove it from the stores
if (oldSystem && !newSystem?.id) {
removeFromStore(oldSystem, $upSystems)
removeFromStore(oldSystem, $downSystems)
removeFromStore(oldSystem, $pausedSystems)
removeFromStore(oldSystem, $allSystemsById)
}
if (!newSystem) {
onSystemsChanged(newSystems, undefined)
return
}
const newStatus = newSystem.status
if (newStatus === SystemStatus.Up) {
$upSystems.setKey(newSystem.id, newSystem)
removeFromStore(newSystem, $downSystems)
removeFromStore(newSystem, $pausedSystems)
} else if (newStatus === SystemStatus.Down) {
$downSystems.setKey(newSystem.id, newSystem)
removeFromStore(newSystem, $upSystems)
removeFromStore(newSystem, $pausedSystems)
} else if (newStatus === SystemStatus.Paused) {
$pausedSystems.setKey(newSystem.id, newSystem)
removeFromStore(newSystem, $upSystems)
removeFromStore(newSystem, $downSystems)
} else if (newStatus === SystemStatus.Pending) {
removeFromStore(newSystem, $upSystems)
removeFromStore(newSystem, $downSystems)
removeFromStore(newSystem, $pausedSystems)
}
// run things that need to be done when systems change
onSystemsChanged(newSystems, newSystem)
})
}
/** Update the longest system name length and favicon based on system status */
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
const upSystemsStore = $upSystems.get()
const downSystemsStore = $downSystems.get()
const upSystems = Object.values(upSystemsStore)
const downSystems = Object.values(downSystemsStore)
// Update longest system name length
const longestName = $longestSystemNameLen.get()
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, changedSystem?.name.length || 0)
if (nameLen > longestName) {
$longestSystemNameLen.set(nameLen)
}
// Update favicon based on system status
if (downSystems.length > 0) {
updateFavicon(FAVICON_RED)
} else if (upSystems.length > 0) {
updateFavicon(FAVICON_GREEN)
} else {
updateFavicon(FAVICON_DEFAULT)
}
}
/** Fetch systems from collection */
async function fetchSystems(): Promise<SystemRecord[]> {
try {
return await COLLECTION.getFullList({ sort: "+name", fields: FIELDS_DEFAULT })
} catch (error) {
console.error("Failed to fetch systems:", error)
return []
}
}
/** Add system to both name and ID stores */
export function add(system: SystemRecord) {
$allSystemsByName.setKey(system.name, system)
$allSystemsById.setKey(system.id, system)
}
/** Update system in stores */
export function update(system: SystemRecord) {
// if name changed, make sure old name is removed from the name store
const oldName = $allSystemsById.get()[system.id]?.name
if (oldName !== system.name) {
$allSystemsByName.setKey(oldName, undefined as any)
}
add(system)
}
/** Remove system from stores */
export function remove(system: SystemRecord) {
removeFromStore(system, $allSystemsByName)
removeFromStore(system, $allSystemsById)
removeFromStore(system, $upSystems)
removeFromStore(system, $downSystems)
removeFromStore(system, $pausedSystems)
}
/** Remove system from specific store */
function removeFromStore(system: SystemRecord, store: PreinitializedMapStore<Record<string, SystemRecord>>) {
const key = store === $allSystemsByName ? system.name : system.id
store.setKey(key, undefined as any)
}
/** Action functions for subscription */
const actionFns: Record<string, (system: SystemRecord) => void> = {
create: add,
update: update,
delete: remove,
}
/** Subscribe to real-time system updates from the collection */
export async function subscribe() {
try {
unsub = await COLLECTION.subscribe("*", ({ action, record }) => actionFns[action]?.(record), {
fields: FIELDS_DEFAULT,
})
} catch (error) {
console.error("Failed to subscribe to systems collection:", error)
}
}
/** Refresh all systems with latest data from the hub */
export async function refresh() {
try {
const records = await fetchSystems()
if (!records.length) {
// No systems found, verify authentication
verifyAuth()
return
}
for (const record of records) {
add(record)
}
} catch (error) {
console.error("Failed to refresh systems:", error)
}
}
/** Unsubscribe from real-time system updates */
export const unsubscribe = () => (unsub = unsub?.())

View File

View File

@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react"
// adapted from usehooks-ts/use-intersection-observer
/** The hook internal state. */
type State = {
/** A boolean indicating if the element is intersecting. */
isIntersecting: boolean
/** The intersection observer entry. */
entry?: IntersectionObserverEntry
}
/** Represents the options for configuring the Intersection Observer. */
type UseIntersectionObserverOptions = {
/**
* The element that is used as the viewport for checking visibility of the target.
* @default null
*/
root?: Element | Document | null
/**
* A margin around the root.
* @default '0%'
*/
rootMargin?: string
/**
* A threshold indicating the percentage of the target's visibility needed to trigger the callback.
* @default 0
*/
threshold?: number | number[]
/**
* If true, freezes the intersection state once the element becomes visible.
* @default true
*/
freeze?: boolean
/**
* A callback function to be invoked when the intersection state changes.
* @param {boolean} isIntersecting - A boolean indicating if the element is intersecting.
* @param {IntersectionObserverEntry} entry - The intersection observer Entry.
* @default undefined
*/
onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void
/**
* The initial state of the intersection.
* @default false
*/
initialIsIntersecting?: boolean
}
/**
* The return type of the useIntersectionObserver hook.
*
* Supports both tuple and object destructing.
* @param {(node: Element | null) => void} ref - The ref callback function.
* @param {boolean} isIntersecting - A boolean indicating if the element is intersecting.
* @param {IntersectionObserverEntry | undefined} entry - The intersection observer Entry.
*/
type IntersectionReturn = {
ref: (node?: Element | null) => void
isIntersecting: boolean
entry?: IntersectionObserverEntry
}
/**
* Custom hook that tracks the intersection of a DOM element with its containing element or the viewport using the [`Intersection Observer API`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
* @param {UseIntersectionObserverOptions} options - The options for the Intersection Observer.
* @returns {IntersectionReturn} The ref callback, a boolean indicating if the element is intersecting, and the intersection observer entry.
* @example
* ```tsx
* const { ref, isIntersecting, entry } = useIntersectionObserver({ threshold: 0.5 });
* ```
*/
export function useIntersectionObserver({
threshold = 0,
root = null,
rootMargin = "0%",
freeze = true,
initialIsIntersecting = false,
onChange,
}: UseIntersectionObserverOptions = {}): IntersectionReturn {
const [ref, setRef] = useState<Element | null>(null)
const [state, setState] = useState<State>(() => ({
isIntersecting: initialIsIntersecting,
entry: undefined,
}))
const callbackRef = useRef<UseIntersectionObserverOptions["onChange"]>(undefined)
callbackRef.current = onChange
const frozen = state.entry?.isIntersecting && freeze
useEffect(() => {
// Ensure we have a ref to observe
if (!ref) return
// Ensure the browser supports the Intersection Observer API
if (!("IntersectionObserver" in window)) return
// Skip if frozen
if (frozen) return
let unobserve: (() => void) | undefined
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
const thresholds = Array.isArray(observer.thresholds) ? observer.thresholds : [observer.thresholds]
entries.forEach((entry) => {
const isIntersecting =
entry.isIntersecting && thresholds.some((threshold) => entry.intersectionRatio >= threshold)
setState({ isIntersecting, entry })
if (callbackRef.current) {
callbackRef.current(isIntersecting, entry)
}
if (isIntersecting && freeze && unobserve) {
unobserve()
unobserve = undefined
}
})
},
{ threshold, root, rootMargin }
)
observer.observe(ref)
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ref,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(threshold),
root,
rootMargin,
frozen,
freeze,
])
// ensures that if the observed element changes, the intersection observer is reinitialized
const prevRef = useRef<Element | null>(null)
useEffect(() => {
if (!ref && state.entry?.target && !freeze && !frozen && prevRef.current !== state.entry.target) {
prevRef.current = state.entry.target
setState({ isIntersecting: initialIsIntersecting, entry: undefined })
}
}, [ref, state.entry, freeze, frozen, initialIsIntersecting])
return {
ref: setRef,
isIntersecting: !!state.isIntersecting,
entry: state.entry,
} as IntersectionReturn
}

View File

@@ -0,0 +1,369 @@
import { t } from "@lingui/core/macro"
import { type ClassValue, clsx } from "clsx"
import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
import { prependBasePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, MeterState, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
import { listenKeys } from "nanostores"
export const FAVICON_DEFAULT = "favicon.svg"
export const FAVICON_GREEN = "favicon-green.svg"
export const FAVICON_RED = "favicon-red.svg"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Adds event listener to node and returns function that removes the listener */
export function listen<T extends Event = Event>(node: Node, event: string, handler: (event: T) => void) {
node.addEventListener(event, handler as EventListener)
return () => node.removeEventListener(event, handler as EventListener)
}
export async function copyToClipboard(content: string) {
const duration = 1500
try {
await navigator.clipboard.writeText(content)
toast({
duration,
description: t`Copied to clipboard`,
})
} catch (e) {
$copyContent.set(content)
}
}
// Create formatters directly without intermediate containers
const createHourWithMinutesFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
hour12,
})
const createShortDateFormatter = (hour12?: boolean) =>
new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "numeric",
hour12,
})
// Initialize formatters with default values
let hourWithMinutesFormatter = createHourWithMinutesFormatter()
let shortDateFormatter = createShortDateFormatter()
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
export const hourWithMinutes = (timestamp: string) => {
return hourWithMinutesFormatter.format(new Date(timestamp))
}
export const formatShortDate = (timestamp: string) => {
return shortDateFormatter.format(new Date(timestamp))
}
// Update the time formatters if user changes hourFormat
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
if (!hourFormat) return
const newHour12 = hourFormat === HourFormat["12h"]
if (currentHour12() !== newHour12) {
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
shortDateFormatter = createShortDateFormatter(newHour12)
}
})
const dayFormatter = new Intl.DateTimeFormat(undefined, {
day: "numeric",
month: "short",
})
export const formatDay = (timestamp: string) => {
return dayFormatter.format(new Date(timestamp))
}
export const updateFavicon = (newIcon: string) => {
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`)
}
export const chartTimeData: ChartTimeData = {
"1h": {
type: "1m",
expectedInterval: 60_000,
label: () => t`1 hour`,
// ticks: 12,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -1),
},
"12h": {
type: "10m",
expectedInterval: 60_000 * 10,
label: () => t`12 hours`,
ticks: 12,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -12),
},
"24h": {
type: "20m",
expectedInterval: 60_000 * 20,
label: () => t`24 hours`,
format: (timestamp: string) => hourWithMinutes(timestamp),
getOffset: (endTime: Date) => timeHour.offset(endTime, -24),
},
"1w": {
type: "120m",
expectedInterval: 60_000 * 120,
label: () => t`1 week`,
ticks: 7,
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -7),
},
"30d": {
type: "480m",
expectedInterval: 60_000 * 480,
label: () => t`30 days`,
ticks: 30,
format: (timestamp: string) => formatDay(timestamp),
getOffset: (endTime: Date) => timeDay.offset(endTime, -30),
},
}
/** Format number to x decimal places, without trailing zeros */
export function toFixedFloat(num: number, digits: number) {
return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))
}
const decimalFormatters: Map<number, Intl.NumberFormat> = new Map()
/** Format number to x decimal places, maintaining trailing zeros */
export function decimalString(num: number, digits = 2) {
if (digits === 0) {
return Math.ceil(num).toString()
}
let formatter = decimalFormatters.get(digits)
if (!formatter) {
formatter = new Intl.NumberFormat(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
decimalFormatters.set(digits, formatter)
}
return formatter.format(num)
}
/** Get value from local or session storage */
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
const saved = storageInterface?.getItem(key)
return saved ? JSON.parse(saved) : defaultValue
}
/** Hook to sync value in local or session storage */
export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterface: Storage = localStorage) {
key = `besz-${key}`
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue, storageInterface)
})
// biome-ignore lint/correctness/useExhaustiveDependencies: storageInterface won't change
useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
/** Format temperature to user's preferred unit */
export function formatTemperature(celsius: number, unit?: Unit): { value: number; unit: string } {
if (!unit) {
unit = $userSettings.get().unitTemp || Unit.Celsius
}
// need loose equality check due to form data being strings
if (unit === Unit.Fahrenheit) {
return {
value: celsius * 1.8 + 32,
unit: "°F",
}
}
return {
value: celsius,
unit: "°C",
}
}
/** Format bytes to user's preferred unit */
export function formatBytes(
size: number,
perSecond = false,
unit = Unit.Bytes,
isMegabytes = false
): { value: number; unit: string } {
// Convert MB to bytes if isMegabytes is true
if (isMegabytes) size *= 1024 * 1024
// need loose equality check due to form data being strings
if (unit === Unit.Bits) {
const bits = size * 8
const suffix = perSecond ? "ps" : ""
if (bits < 1000) return { value: bits, unit: `b${suffix}` }
if (bits < 1_000_000) return { value: bits / 1_000, unit: `Kb${suffix}` }
if (bits < 1_000_000_000)
return {
value: bits / 1_000_000,
unit: `Mb${suffix}`,
}
if (bits < 1_000_000_000_000)
return {
value: bits / 1_000_000_000,
unit: `Gb${suffix}`,
}
return {
value: bits / 1_000_000_000_000,
unit: `Tb${suffix}`,
}
}
// bytes
const suffix = perSecond ? "/s" : ""
if (size < 100) return { value: size, unit: `B${suffix}` }
if (size < 1000 * 1024) return { value: size / 1024, unit: `KB${suffix}` }
if (size < 1000 * 1024 ** 2)
return {
value: size / 1024 ** 2,
unit: `MB${suffix}`,
}
if (size < 1000 * 1024 ** 3)
return {
value: size / 1024 ** 3,
unit: `GB${suffix}`,
}
return {
value: size / 1024 ** 4,
unit: `TB${suffix}`,
}
}
export const chartMargin = { top: 12 }
/**
* Retuns value of system host, truncating full path if socket.
* @example
* // Assuming system.host is "/var/run/beszel.sock"
* const hostname = getHostDisplayValue(system) // hostname will be "beszel.sock"
*/
export const getHostDisplayValue = (system: SystemRecord): string => system.host.slice(system.host.lastIndexOf("/") + 1)
// export function formatUptimeString(uptimeSeconds: number): string {
// if (!uptimeSeconds || isNaN(uptimeSeconds)) return ""
// if (uptimeSeconds < 3600) {
// const minutes = Math.trunc(uptimeSeconds / 60)
// return plural({ minutes }, { one: "# minute", other: "# minutes" })
// } else if (uptimeSeconds < 172800) {
// const hours = Math.trunc(uptimeSeconds / 3600)
// console.log(hours)
// return plural({ hours }, { one: "# hour", other: "# hours" })
// } else {
// const days = Math.trunc(uptimeSeconds / 86400)
// return plural({ days }, { one: "# day", other: "# days" })
// }
// }
/** Generate a random token for the agent */
export const generateToken = () => {
try {
return crypto?.randomUUID()
} catch (e) {
return Array.from({ length: 2 }, () => (performance.now() * Math.random()).toString(16).replace(".", "-")).join("-")
}
}
/** Get the hub URL from the global BESZEL object */
export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
/** Calculate duration between two dates and format as human-readable string */
export function formatDuration(
createdDate: string | null | undefined,
resolvedDate: string | null | undefined
): string {
const created = createdDate ? new Date(createdDate) : null
const resolved = resolvedDate ? new Date(resolvedDate) : null
if (!created || !resolved) return ""
const diffMs = resolved.getTime() - created.getTime()
if (diffMs < 0) return ""
const totalSeconds = Math.floor(diffMs / 1000)
let hours = Math.floor(totalSeconds / 3600)
let minutes = Math.floor((totalSeconds % 3600) / 60)
let seconds = totalSeconds % 60
// if seconds are close to 60, round up to next minute
// if minutes are close to 60, round up to next hour
if (seconds >= 58) {
minutes += 1
seconds = 0
}
if (minutes >= 60) {
hours += 1
minutes = 0
}
// For durations over 1 hour, omit seconds for cleaner display
if (hours > 0) {
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null].filter(Boolean).join(" ")
}
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, seconds ? `${seconds}s` : null]
.filter(Boolean)
.join(" ")
}
export const parseSemVer = (semVer = ""): SemVer => {
// if (semVer.startsWith("v")) {
// semVer = semVer.slice(1)
// }
if (semVer.includes("-")) {
semVer = semVer.slice(0, semVer.indexOf("-"))
}
const parts = semVer.split(".").map(Number)
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
}
/** Get meter state from 0-100 value. Used for color coding meters. */
export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
// Cache for runOnce
// biome-ignore lint/complexity/noBannedTypes: Function is used to allow any function to be passed in
const runOnceCache = new WeakMap<Function, { done: boolean; result: unknown }>()
/** Run a function only once */
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
return ((...args: Parameters<T>) => {
let state = runOnceCache.get(fn)
if (!state) {
state = { done: false, result: undefined }
runOnceCache.set(fn, state)
}
if (!state.done) {
state.result = fn(...args)
state.done = true
}
return state.result
}) as T
}