mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-17 18:56:17 +01:00
rename /src to /internal (sorry i'll fix the prs)
This commit is contained in:
171
internal/site/src/lib/alerts.ts
Normal file
171
internal/site/src/lib/alerts.ts
Normal 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,
|
||||
}
|
||||
})()
|
||||
66
internal/site/src/lib/api.ts
Normal file
66
internal/site/src/lib/api.ts
Normal 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}`
|
||||
}
|
||||
55
internal/site/src/lib/enums.ts
Normal file
55
internal/site/src/lib/enums.ts
Normal 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",
|
||||
}
|
||||
69
internal/site/src/lib/i18n.ts
Normal file
69
internal/site/src/lib/i18n.ts
Normal 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
|
||||
137
internal/site/src/lib/languages.ts
Normal file
137
internal/site/src/lib/languages.ts
Normal 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
|
||||
70
internal/site/src/lib/stores.ts
Normal file
70
internal/site/src/lib/stores.ts
Normal 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)
|
||||
174
internal/site/src/lib/systemsManager.ts
Normal file
174
internal/site/src/lib/systemsManager.ts
Normal 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?.())
|
||||
0
internal/site/src/lib/time.ts
Normal file
0
internal/site/src/lib/time.ts
Normal file
160
internal/site/src/lib/use-intersection-observer.ts
Normal file
160
internal/site/src/lib/use-intersection-observer.ts
Normal 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
|
||||
}
|
||||
369
internal/site/src/lib/utils.ts
Normal file
369
internal/site/src/lib/utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user