Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function App() {
})
}, [nodes, query, activeTag, activeRegion, sort, regions])

const selectedNode = selected ? nodes.get(selected) || null : null
const selectedNode = selected ? nodes.get(selected) || [...nodes.values()].find(n => n.uuid === selected) || null : null

if (configError) {
return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/NodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function NodeCard({ node }: { node: Node }) {
const virt = virtLabel(node)
const cpu = cpuLabel(node)
const Wrapper = node.online ? 'a' : 'div'
const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.uuid)}` } : {}
const wrapperProps = node.online ? { href: `#${encodeURIComponent(node.id)}` } : {}

return (
<Wrapper {...wrapperProps} className={cn('block', !node.online && 'cursor-default')}>
Expand Down
6 changes: 3 additions & 3 deletions src/components/NodeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Node } from '../types'

interface Props {
nodes: Node[]
onOpen?: (uuid: string) => void
onOpen?: (id: string) => void
}

export function NodeTable({ nodes, onOpen }: Props) {
Expand Down Expand Up @@ -39,8 +39,8 @@ export function NodeTable({ nodes, onOpen }: Props) {
const virt = virtLabel(n)
return (
<TableRow
key={n.uuid}
onClick={() => onOpen?.(n.uuid)}
key={n.id}
onClick={() => onOpen?.(n.id)}
className={cn('cursor-pointer', !n.online && 'opacity-60')}
>
<TableCell>
Expand Down
6 changes: 3 additions & 3 deletions src/components/WorldMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Node } from '../types'

interface Props {
nodes: Node[]
onOpen?: (uuid: string) => void
onOpen?: (id: string) => void
}

const MAP_W = 900
Expand Down Expand Up @@ -135,7 +135,7 @@ export function WorldMap({ nodes, onOpen }: Props) {
onMouseLeave={scheduleClose}
onClick={(e: any) => {
e.stopPropagation?.()
if (!isCluster) onOpen?.(node.uuid)
if (!isCluster) onOpen?.(node.id)
}}
style={CURSOR}
>
Expand Down Expand Up @@ -227,7 +227,7 @@ function ClusterList({
lng: number
state: 'open' | 'closed'
onAnimationEnd?: () => void
onPick: (uuid: string) => void
onPick: (id: string) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
}) {
Expand Down
266 changes: 84 additions & 182 deletions src/hooks/useNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dynamicSummaryMulti, kvGetMulti, listAgentUuids, staticDataMulti } from
import { isOnline } from '../utils/status'
import type { DynamicSummary, HistorySample, Node, NodeMeta, SiteConfig } from '../types'

type Agent = Pick<Node, 'uuid' | 'source' | 'meta' | 'static'>
type Agent = Pick<Node, 'id' | 'uuid' | 'source' | 'meta' | 'static'>

interface BackendError {
source: string
Expand All @@ -13,103 +13,32 @@ interface BackendError {

const STATIC_FIELDS = ['cpu', 'system']
const DYNAMIC_FIELDS = [
'cpu_usage',
'used_memory',
'total_memory',
'available_memory',
'used_swap',
'total_swap',
'total_space',
'available_space',
'read_speed',
'write_speed',
'receive_speed',
'transmit_speed',
'total_received',
'total_transmitted',
'load_one',
'load_five',
'load_fifteen',
'uptime',
'boot_time',
'process_count',
'tcp_connections',
'udp_connections',
'cpu_usage', 'used_memory', 'total_memory', 'available_memory', 'used_swap', 'total_swap',
'total_space', 'available_space', 'read_speed', 'write_speed', 'receive_speed', 'transmit_speed',
'total_received', 'total_transmitted', 'load_one', 'load_five', 'load_fifteen', 'uptime', 'boot_time',
'process_count', 'tcp_connections', 'udp_connections',
]
const META_KEYS = [
'metadata_name',
'metadata_region',
'metadata_tags',
'metadata_hidden',
'metadata_virtualization',
'metadata_latitude',
'metadata_longitude',
'metadata_order',
'metadata_price',
'metadata_price_unit',
'metadata_price_cycle',
'metadata_expire_time',
'metadata_name', 'metadata_region', 'metadata_tags', 'metadata_hidden', 'metadata_virtualization',
'metadata_latitude', 'metadata_longitude', 'metadata_order', 'metadata_price', 'metadata_price_unit',
'metadata_price_cycle', 'metadata_expire_time',
]
const DYN_INTERVAL_MS = 2000
const HISTORY_LIMIT = 60

function emptyMeta(): NodeMeta {
return {
name: '',
region: '',
tags: [],
hidden: false,
virtualization: '',
lat: null,
lng: null,
order: 0,
price: 0,
priceUnit: '$',
priceCycle: 30,
expireTime: '',
}
}
const nodeId = (source: string, uuid: string) => `${source}::${uuid}`

function blankAgent(uuid: string, source: string): Agent {
return { uuid, source, meta: emptyMeta(), static: {} }
}
function emptyMeta(): NodeMeta { return { name: '', region: '', tags: [], hidden: false, virtualization: '', lat: null, lng: null, order: 0, price: 0, priceUnit: '$', priceCycle: 30, expireTime: '' } }
function blankAgent(uuid: string, source: string): Agent { return { id: nodeId(source, uuid), uuid, source, meta: emptyMeta(), static: {} } }

function parseMeta(raw: Record<string, unknown>): NodeMeta {
const lat = Number(raw.metadata_latitude)
const lng = Number(raw.metadata_longitude)
const order = Number(raw.metadata_order)
const price = Number(raw.metadata_price)
const cycle = Number(raw.metadata_price_cycle)
return {
name: raw.metadata_name ? String(raw.metadata_name) : '',
region: raw.metadata_region ? String(raw.metadata_region) : '',
tags: Array.isArray(raw.metadata_tags) ? raw.metadata_tags.filter(Boolean) : [],
hidden: Boolean(raw.metadata_hidden),
virtualization: raw.metadata_virtualization ? String(raw.metadata_virtualization) : '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
order: Number.isFinite(order) ? order : 0,
price: Number.isFinite(price) ? price : 0,
priceUnit: raw.metadata_price_unit ? String(raw.metadata_price_unit) : '$',
priceCycle: Number.isFinite(cycle) && cycle > 0 ? cycle : 30,
expireTime: raw.metadata_expire_time ? String(raw.metadata_expire_time) : '',
}
const lat = Number(raw.metadata_latitude), lng = Number(raw.metadata_longitude), order = Number(raw.metadata_order), price = Number(raw.metadata_price), cycle = Number(raw.metadata_price_cycle)
return { name: raw.metadata_name ? String(raw.metadata_name) : '', region: raw.metadata_region ? String(raw.metadata_region) : '', tags: Array.isArray(raw.metadata_tags) ? raw.metadata_tags.filter(Boolean) : [], hidden: Boolean(raw.metadata_hidden), virtualization: raw.metadata_virtualization ? String(raw.metadata_virtualization) : '', lat: Number.isFinite(lat) ? lat : null, lng: Number.isFinite(lng) ? lng : null, order: Number.isFinite(order) ? order : 0, price: Number.isFinite(price) ? price : 0, priceUnit: raw.metadata_price_unit ? String(raw.metadata_price_unit) : '$', priceCycle: Number.isFinite(cycle) && cycle > 0 ? cycle : 30, expireTime: raw.metadata_expire_time ? String(raw.metadata_expire_time) : '' }
}

function sampleFrom(row: DynamicSummary): HistorySample {
const memTotal = row.total_memory || 0
const diskTotal = row.total_space || 0
return {
t: row.timestamp,
cpu: row.cpu_usage ?? null,
mem: memTotal && row.used_memory != null ? (row.used_memory / memTotal) * 100 : null,
disk:
diskTotal && row.available_space != null
? ((diskTotal - row.available_space) / diskTotal) * 100
: null,
netIn: row.receive_speed ?? 0,
netOut: row.transmit_speed ?? 0,
}
const memTotal = row.total_memory || 0, diskTotal = row.total_space || 0
return { t: row.timestamp, cpu: row.cpu_usage ?? null, mem: memTotal && row.used_memory != null ? (row.used_memory / memTotal) * 100 : null, disk: diskTotal && row.available_space != null ? ((diskTotal - row.available_space) / diskTotal) * 100 : null, netIn: row.receive_speed ?? 0, netOut: row.transmit_speed ?? 0 }
}

export function useNodes(config: SiteConfig | null) {
Expand All @@ -122,14 +51,41 @@ export function useNodes(config: SiteConfig | null) {
const [pool, setPool] = useState<BackendPool | null>(null)

useEffect(() => {
if (!config?.site_tokens?.length) {
setLoading(false)
return
}
if (!config?.site_tokens?.length) { setLoading(false); return }
const pool = new BackendPool(config.site_tokens)
setPool(pool)
const sourceUuids = new Map<string, string[]>()

const tickDynamic = async () => {
const updates: Array<{ source: string; row: DynamicSummary }> = []
await Promise.allSettled(pool.entries.map(async entry => {
const uuids = sourceUuids.get(entry.name) || []
if (!uuids.length) return
try {
const rows = await dynamicSummaryMulti(entry.client, uuids, DYNAMIC_FIELDS)
for (const row of rows || []) updates.push({ source: entry.name, row })
} catch {}
}))
if (!updates.length) return

setLive(prev => {
const next = new Map(prev)
for (const { source, row } of updates) next.set(nodeId(source, row.uuid), row)
return next
})
setHistory(prev => {
const next = new Map(prev)
for (const { source, row } of updates) {
const key = nodeId(source, row.uuid)
const arr = next.get(key) || []
const sample = sampleFrom(row)
const dedup = arr.length && arr[arr.length - 1].t === sample.t ? arr : arr.concat(sample)
next.set(key, dedup.slice(-HISTORY_LIMIT))
}
return next
})
}

const bootstrap = async () => {
const agentsRes = await pool.fanout(listAgentUuids)
setErrors(prev => [...prev, ...agentsRes.errors])
Expand All @@ -138,118 +94,64 @@ export function useNodes(config: SiteConfig | null) {
for (const { source, rows } of agentsRes.ok) {
const uuids = rows ?? []
sourceUuids.set(source, uuids)
for (const uuid of uuids) seed.set(uuid, blankAgent(uuid, source))
for (const uuid of uuids) seed.set(nodeId(source, uuid), blankAgent(uuid, source))
}
setAgents(seed)

await Promise.all(
pool.entries.map(async entry => {
const uuids = sourceUuids.get(entry.name) || []
if (!uuids.length) return

const kvItems = uuids.flatMap(u => META_KEYS.map(k => ({ namespace: u, key: k })))
const [meta, stat] = await Promise.allSettled([
kvGetMulti(entry.client, kvItems),
staticDataMulti(entry.client, uuids, STATIC_FIELDS),
])

setAgents(prev => {
const next = new Map(prev)

if (meta.status === 'fulfilled' && meta.value) {
const grouped = new Map<string, Record<string, unknown>>()
for (const row of meta.value) {
if (!row || row.value == null) continue
let bucket = grouped.get(row.namespace)
if (!bucket) grouped.set(row.namespace, (bucket = {}))
bucket[row.key] = row.value
}
for (const uuid of uuids) {
const cur = next.get(uuid) ?? blankAgent(uuid, entry.name)
next.set(uuid, { ...cur, meta: parseMeta(grouped.get(uuid) ?? {}) })
}
await Promise.all(pool.entries.map(async entry => {
const uuids = sourceUuids.get(entry.name) || []
if (!uuids.length) return
const kvItems = uuids.flatMap(u => META_KEYS.map(k => ({ namespace: u, key: k })))
const [meta, stat] = await Promise.allSettled([kvGetMulti(entry.client, kvItems), staticDataMulti(entry.client, uuids, STATIC_FIELDS)])

setAgents(prev => {
const next = new Map(prev)
if (meta.status === 'fulfilled' && meta.value) {
const grouped = new Map<string, Record<string, unknown>>()
for (const row of meta.value) {
if (!row || row.value == null) continue
let bucket = grouped.get(row.namespace)
if (!bucket) grouped.set(row.namespace, (bucket = {}))
bucket[row.key] = row.value
}

if (stat.status === 'fulfilled' && stat.value) {
for (const row of stat.value) {
if (!row.uuid) continue
const cur = next.get(row.uuid) ?? blankAgent(row.uuid, entry.name)
next.set(row.uuid, { ...cur, static: row })
}
for (const uuid of uuids) {
const id = nodeId(entry.name, uuid)
const cur = next.get(id) ?? blankAgent(uuid, entry.name)
next.set(id, { ...cur, meta: parseMeta(grouped.get(uuid) ?? {}) })
}
}
if (stat.status === 'fulfilled' && stat.value) {
for (const row of stat.value) {
if (!row.uuid) continue
const id = nodeId(entry.name, row.uuid)
const cur = next.get(id) ?? blankAgent(row.uuid, entry.name)
next.set(id, { ...cur, static: row })
}
return next
})
}),
)
}
return next
})
}))

await tickDynamic()
setLoading(false)
}

const tickDynamic = async () => {
const updates: DynamicSummary[] = []
await Promise.allSettled(
pool.entries.map(async entry => {
const uuids = sourceUuids.get(entry.name) || []
if (!uuids.length) return
try {
const rows = await dynamicSummaryMulti(entry.client, uuids, DYNAMIC_FIELDS)
for (const row of rows || []) updates.push(row)
} catch {}
}),
)
if (!updates.length) return
bootstrap().catch((e: unknown) => { setErrors(prev => [...prev, { source: '*', error: e }]); setLoading(false) })

setLive(prev => {
const next = new Map(prev)
for (const row of updates) next.set(row.uuid, row)
return next
})
setHistory(prev => {
const next = new Map(prev)
for (const row of updates) {
const arr = next.get(row.uuid) || []
const sample = sampleFrom(row)
const dedup = arr.length && arr[arr.length - 1].t === sample.t ? arr : arr.concat(sample)
next.set(row.uuid, dedup.slice(-HISTORY_LIMIT))
}
return next
})
}

bootstrap().catch((e: unknown) => {
setErrors(prev => [...prev, { source: '*', error: e }])
setLoading(false)
})

const onVisible = () => {
if (document.visibilityState === 'visible') tickDynamic()
}
const onVisible = () => { if (document.visibilityState === 'visible') tickDynamic() }
document.addEventListener('visibilitychange', onVisible)

const dynTimer = setInterval(tickDynamic, DYN_INTERVAL_MS)
const clockTimer = setInterval(() => setTick(t => t + 1), 5000)

return () => {
clearInterval(dynTimer)
clearInterval(clockTimer)
document.removeEventListener('visibilitychange', onVisible)
setPool(null)
pool.close()
}
return () => { clearInterval(dynTimer); clearInterval(clockTimer); document.removeEventListener('visibilitychange', onVisible); setPool(null); pool.close() }
}, [config])

const nodes = useMemo(() => {
const now = Date.now()
const out = new Map<string, Node>()
for (const [uuid, a] of agents) {
const dyn = live.get(uuid) || null
out.set(uuid, {
...a,
dynamic: dyn,
history: history.get(uuid) || [],
online: isOnline(dyn?.timestamp, now),
})
for (const [id, a] of agents) {
const dyn = live.get(id) || null
out.set(id, { ...a, dynamic: dyn, history: history.get(id) || [], online: isOnline(dyn?.timestamp, now) })
}
return out
}, [agents, live, history, tick])
Expand Down
Loading