diff --git a/src/components/spatial/FleetGrid.tsx b/src/components/spatial/FleetGrid.tsx index 8e6c73c..36c4306 100644 --- a/src/components/spatial/FleetGrid.tsx +++ b/src/components/spatial/FleetGrid.tsx @@ -30,6 +30,23 @@ const STATUS_COLORS: Record = { maintenance: "bg-yellow-500", }; +function relativeTime(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function resourceColor(pct: number): string { + if (pct < 0.5) return "bg-green-500"; + if (pct < 0.8) return "bg-yellow-500"; + return "bg-red-500"; +} + export function FleetGrid() { const [filter, setFilter] = useState("all"); const [search, setSearch] = useState(""); @@ -70,7 +87,10 @@ export function FleetGrid() { -
+
{filtered.map((asset) => (
@@ -96,6 +116,9 @@ export function FleetGrid() { {(asset.resource * 100).toFixed(0)}%
+
+ {relativeTime(asset.lastPing)} +
))} diff --git a/src/hooks/useContractEvents.ts b/src/hooks/useContractEvents.ts index 0273e9b..34c0613 100644 --- a/src/hooks/useContractEvents.ts +++ b/src/hooks/useContractEvents.ts @@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useCallback } from "react"; +type Network = "testnet" | "futurenet" | "mainnet"; + interface ContractEvent { topic: string; value: string; @@ -17,7 +19,9 @@ interface DecodedEvent { raw: ContractEvent; } -const eventDecoders: Record DecodedEvent> = { +type EventDecoder = (event: ContractEvent) => DecodedEvent; + +const eventDecoders: Record = { "meter_reading": (e) => ({ type: "meter_reading", severity: "info", @@ -48,8 +52,90 @@ const eventDecoders: Record DecodedEvent> = { message: `Device fault reported: ${e.value}`, raw: e, }), + "pricing_update": (e) => ({ + type: "pricing_update", + severity: "info", + message: `Pricing updated to ${e.value}`, + raw: e, + }), }; +function decodeBase64XdrString(encoded: string): string { + try { + const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + const len = bytes.length; + if (len === 0) return encoded; + let offset = 0; + while (offset < len && bytes[offset] !== 0) { + offset++; + } + const text = new TextDecoder().decode(bytes.slice(0, offset || len)); + return text.replace(/[^\x20-\x7E]/g, "").trim() || encoded; + } catch { + return encoded; + } +} + +function hexToUtf8(hex: string): string { + try { + const bytes: number[] = []; + for (let i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substring(i, i + 2), 16)); + } + return new TextDecoder().decode(Uint8Array.from(bytes)).replace(/[^\x20-\x7E]/g, "").trim(); + } catch { + return hex; + } +} + +interface SorobanRpcEvent { + id?: string; + topic: string[]; + value: { xdr?: string; data?: string }; + contractId: string; + type?: string; + ledger?: number; + ledgerClosedAt?: string; +} + +function parseRawEvent(raw: unknown): ContractEvent | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as SorobanRpcEvent; + + let topicStr = "unknown"; + if (Array.isArray(r.topic) && r.topic.length > 0) { + topicStr = decodeBase64XdrString(r.topic[0]); + if (topicStr === r.topic[0]) { + topicStr = r.topic[0]; + } + } else if (typeof r.topic === "string") { + topicStr = r.topic; + } + + let valueStr = ""; + if (r.value) { + const rawXdr = r.value.xdr || r.value.data; + if (rawXdr) { + valueStr = decodeBase64XdrString(rawXdr); + if (valueStr === rawXdr) { + const hexMatch = rawXdr.match(/[0-9a-fA-F]{2,}/); + if (hexMatch) { + valueStr = hexToUtf8(hexMatch[0]); + } + } + } + } + if (!valueStr) valueStr = JSON.stringify(r.value); + + const contractId = r.contractId || ""; + const block = r.ledger || 0; + const timestamp = r.ledgerClosedAt + ? new Date(r.ledgerClosedAt).getTime() + : Date.now(); + + return { topic: topicStr, value: valueStr, contractId, timestamp, block }; +} + function decodeEvent(event: ContractEvent): DecodedEvent { const decoder = eventDecoders[event.topic]; if (decoder) return decoder(event); @@ -61,24 +147,43 @@ function decodeEvent(event: ContractEvent): DecodedEvent { }; } -export function useContractEvents(contractIds: string[]) { +const NETWORK_URLS: Record = { + testnet: "wss://testnet.sorobanrpc.com", + futurenet: "wss://futurenet.sorobanrpc.com", + mainnet: "wss://mainnet.sorobanrpc.com", +}; + +export function useContractEvents(contractIds: string[], network: Network = "testnet") { const [events, setEvents] = useState([]); const [latestBlock, setLatestBlock] = useState(0); const wsRef = useRef(null); const reconnectTimer = useRef | null>(null); const connect = useCallback(() => { + const baseUrl = NETWORK_URLS[network]; const ws = new WebSocket( - `wss://testnet.futurenet.sorobanrpc.com?stream=events&contracts=${contractIds.join(",")}` + `${baseUrl}?stream=events&contracts=${contractIds.join(",")}` ); wsRef.current = ws; ws.onmessage = (msg) => { try { - const raw: ContractEvent = JSON.parse(msg.data); - setLatestBlock(raw.block); - const decoded = decodeEvent(raw); - setEvents((prev) => [decoded, ...prev].slice(0, 200)); + const parsed = JSON.parse(msg.data); + const rawEvents = Array.isArray(parsed) ? parsed : [parsed]; + let latest = 0; + const decodedBatch: DecodedEvent[] = []; + + for (const raw of rawEvents) { + const contractEvent = parseRawEvent(raw); + if (!contractEvent) continue; + if (contractEvent.block > latest) latest = contractEvent.block; + decodedBatch.push(decodeEvent(contractEvent)); + } + + if (latest > 0) setLatestBlock(latest); + if (decodedBatch.length > 0) { + setEvents((prev) => [...decodedBatch, ...prev].slice(0, 200)); + } } catch { // skip malformed messages } @@ -91,7 +196,7 @@ export function useContractEvents(contractIds: string[]) { ws.onerror = () => { ws.close(); }; - }, [contractIds]); + }, [contractIds, network]); useEffect(() => { if (contractIds.length === 0) return;