Skip to content
Closed
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
27 changes: 25 additions & 2 deletions src/components/spatial/FleetGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ const STATUS_COLORS: Record<FleetAsset["status"], string> = {
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<FleetAsset["status"] | "all">("all");
const [search, setSearch] = useState("");
Expand Down Expand Up @@ -70,7 +87,10 @@ export function FleetGrid() {
</div>
</div>

<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3"
style={{ contentVisibility: "auto" }}
>
{filtered.map((asset) => (
<div
key={asset.id}
Expand All @@ -88,14 +108,17 @@ export function FleetGrid() {
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
className={`h-full rounded-full transition-all ${resourceColor(asset.resource)}`}
style={{ width: `${asset.resource * 100}%` }}
/>
</div>
<span className="tabular-nums">
{(asset.resource * 100).toFixed(0)}%
</span>
</div>
<div className="text-muted-foreground">
{relativeTime(asset.lastPing)}
</div>
</div>
</div>
))}
Expand Down
121 changes: 113 additions & 8 deletions src/hooks/useContractEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useEffect, useRef, useState, useCallback } from "react";

type Network = "testnet" | "futurenet" | "mainnet";

interface ContractEvent {
topic: string;
value: string;
Expand All @@ -17,7 +19,9 @@ interface DecodedEvent {
raw: ContractEvent;
}

const eventDecoders: Record<string, (event: ContractEvent) => DecodedEvent> = {
type EventDecoder = (event: ContractEvent) => DecodedEvent;

const eventDecoders: Record<string, EventDecoder> = {
"meter_reading": (e) => ({
type: "meter_reading",
severity: "info",
Expand Down Expand Up @@ -48,8 +52,90 @@ const eventDecoders: Record<string, (event: ContractEvent) => 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);
Expand All @@ -61,24 +147,43 @@ function decodeEvent(event: ContractEvent): DecodedEvent {
};
}

export function useContractEvents(contractIds: string[]) {
const NETWORK_URLS: Record<Network, string> = {
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<DecodedEvent[]>([]);
const [latestBlock, setLatestBlock] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | 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
}
Expand All @@ -91,7 +196,7 @@ export function useContractEvents(contractIds: string[]) {
ws.onerror = () => {
ws.close();
};
}, [contractIds]);
}, [contractIds, network]);

useEffect(() => {
if (contractIds.length === 0) return;
Expand Down
Loading