setSelectedConfig(config.id)}
- className={`p-4 rounded-lg border cursor-pointer transition-all hover:border-primary/50 ${
- selectedConfig === config.id
- ? "border-primary bg-primary/5"
- : "border-border bg-background"
- }`}
- >
-
-
-
-
-
- {config.machineType}
-
+ {vms.length === 0 ? (
+
+ No configurations available. Please try again later.
+
+ ) : (
+ vms.map((config) => (
+
setSelectedConfig(config.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setSelectedConfig(config.id);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ className={`p-4 rounded-lg border cursor-pointer transition-all hover:border-primary/50 ${
+ selectedConfig === config.id
+ ? "border-primary bg-primary/5"
+ : "border-border bg-background"
+ }`}
+ >
+
+
+
+
+
+ {config.machineType}
+
+
+
+ {config.description}
+
-
- {config.description}
-
-
-
-
- {prices[config.id]
- ? `${prices[config.id].toFixed(6)} SOL/hr`
- : "Calculating..."}
+
+
+ {prices[config.id]
+ ? `${prices[config.id].toFixed(6)} SOL/hr`
+ : "Calculating..."}
+
-
- ))}
+ ))
+ )}
@@ -194,6 +208,14 @@ export const Step1 = ({
setOs(osOption.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOs(osOption.value);
+ }
+ }}
+ role="button"
+ tabIndex={0}
className={`p-3 rounded-lg border cursor-pointer transition-all hover:border-primary/50 ${
os === osOption.value
? "border-primary bg-primary/5"
diff --git a/web-services/apps/frontend/src/components/RentVm/Step2.tsx b/web-services/apps/frontend/src/components/RentVm/Step2.tsx
index 7a7bb9f..fc10d9c 100644
--- a/web-services/apps/frontend/src/components/RentVm/Step2.tsx
+++ b/web-services/apps/frontend/src/components/RentVm/Step2.tsx
@@ -113,6 +113,11 @@ export const Step2 = ({
}
className="mt-2 w-33"
/>
+ {duration !== undefined && duration <= 0 && (
+
+ Duration must be greater than 0.
+
+ )}
)}
@@ -158,6 +163,11 @@ export const Step2 = ({
placeholder="Enter amount in SOL"
className="my-4 w-32"
/>
+ {escrowAmount !== undefined && escrowAmount <= 0 && (
+
+ Escrow amount must be greater than 0.
+
+ )}
≈{" "}
{selectedVMConfig
diff --git a/web-services/apps/frontend/src/components/RequireAuth.tsx b/web-services/apps/frontend/src/components/RequireAuth.tsx
new file mode 100644
index 0000000..9b477a3
--- /dev/null
+++ b/web-services/apps/frontend/src/components/RequireAuth.tsx
@@ -0,0 +1,35 @@
+import { motion } from "motion/react";
+import { Link } from "react-router-dom";
+import { useAuth } from "@/hooks/useAuth";
+import { Button } from "@/components/ui/button";
+
+interface RequireAuthProps {
+ children: React.ReactNode;
+ message?: string;
+}
+
+export function RequireAuth({
+ children,
+ message = "Please sign in to access this page.",
+}: RequireAuthProps) {
+ const { isAuthenticated } = useAuth();
+
+ if (isAuthenticated) return <>{children}>;
+
+ return (
+
+
+
+ {message}
+
+
+
+
+
+
+ );
+}
diff --git a/web-services/apps/frontend/src/components/Skeleton.tsx b/web-services/apps/frontend/src/components/Skeleton.tsx
new file mode 100644
index 0000000..76875ee
--- /dev/null
+++ b/web-services/apps/frontend/src/components/Skeleton.tsx
@@ -0,0 +1,18 @@
+import { cn } from "@/lib/utils";
+
+interface SkeletonProps {
+ className?: string;
+ variant?: "pulse" | "shimmer";
+}
+
+export function Skeleton({ className, variant = "shimmer" }: SkeletonProps) {
+ return (
+
+ );
+}
diff --git a/web-services/apps/frontend/src/components/WSConnectionDot.tsx b/web-services/apps/frontend/src/components/WSConnectionDot.tsx
new file mode 100644
index 0000000..aa435f7
--- /dev/null
+++ b/web-services/apps/frontend/src/components/WSConnectionDot.tsx
@@ -0,0 +1,41 @@
+import { motion } from "motion/react";
+import { useWSConnectionStatus } from "@/lib/useIndexerEvents";
+
+const dotColors: Record
= {
+ connected: "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.5)]",
+ connecting: "bg-amber-500 shadow-[0_0_6px_rgba(245,158,11,0.5)]",
+ disconnected: "bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.5)]",
+};
+
+const labels: Record = {
+ connected: "Connected",
+ connecting: "Connecting…",
+ disconnected: "Disconnected",
+};
+
+export function WSConnectionDot() {
+ const status = useWSConnectionStatus();
+
+ return (
+
+
+
+ {labels[status]}
+
+
+ );
+}
diff --git a/web-services/apps/frontend/src/components/ui/badge.tsx b/web-services/apps/frontend/src/components/ui/badge.tsx
index 72b073b..38692e7 100644
--- a/web-services/apps/frontend/src/components/ui/badge.tsx
+++ b/web-services/apps/frontend/src/components/ui/badge.tsx
@@ -32,10 +32,9 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
+ const Comp: React.ElementType = asChild ? Slot : "span";
return (
- // @ts-expect-error Slot component has incompatible props with span
& {
asChild?: boolean;
}) {
- const Comp = asChild ? Slot : "button";
+ const Comp: React.ElementType = asChild ? Slot : "button";
return (
- // @ts-expect-error Slot component has incompatible props with button
{
onConfirmed: async () => {
setTxStatus("confirmed");
try {
- const res = await axios.post(
- `${BACKEND_URL}/vm/topup`,
- {
- id: vm.id,
- instanceId: vm.instanceId,
- amount: topUpAmount,
- additionalEscrowDuration,
- },
- {
- headers: {
- "Content-Type": "application/json",
- Authorization: `${localStorage.getItem("token")}`,
- },
- },
- );
+ const res = await api.post("/vm/topup", {
+ id: vm.id,
+ instanceId: vm.instanceId,
+ amount: topUpAmount,
+ additionalEscrowDuration,
+ });
if (res.status === 200) {
toast.success("Escrow balance topped up successfully", {
position: "top-right",
@@ -194,13 +184,19 @@ export const BillingStatus = ({ vm }: { vm: VM }) => {
{topUpAmount.toFixed(3)} SOL
- {statusLabel && (
-
- {statusLabel}
-
- )}
+
+ {statusLabel && (
+
+ {statusLabel}
+
+ )}
+
diff --git a/web-services/apps/frontend/src/components/vmDetail/Overview.tsx b/web-services/apps/frontend/src/components/vmDetail/Overview.tsx
index ea00d82..a66d793 100644
--- a/web-services/apps/frontend/src/components/vmDetail/Overview.tsx
+++ b/web-services/apps/frontend/src/components/vmDetail/Overview.tsx
@@ -1,4 +1,4 @@
-import { motion } from "framer-motion";
+import { motion } from "motion/react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Copy, Monitor } from "lucide-react";
import type { VM } from "types/vm";
@@ -56,6 +56,8 @@ export const Overview = ({ vm }: { vm: VM }) => {
size="sm"
onClick={() => copyToClipboard(vm.ipAddress)}
className="h-6 w-6 p-0 cursor-pointer"
+ title="Copy IP address"
+ aria-label="Copy IP address"
>
@@ -80,6 +82,8 @@ export const Overview = ({ vm }: { vm: VM }) => {
size="sm"
onClick={() => copyToClipboard(vm.VMImage?.applicationUrl)}
className="h-6 w-6 p-0 cursor-pointer"
+ title="Copy application URL"
+ aria-label="Copy application URL"
>
diff --git a/web-services/apps/frontend/src/components/vmDetail/SSH.tsx b/web-services/apps/frontend/src/components/vmDetail/SSH.tsx
index f0abc92..251946e 100644
--- a/web-services/apps/frontend/src/components/vmDetail/SSH.tsx
+++ b/web-services/apps/frontend/src/components/vmDetail/SSH.tsx
@@ -45,6 +45,8 @@ export const SSH = ({ vm }: { vm: VM }) => {
onClick={() =>
copyToClipboard(`ssh-keygen -R ${vm.ipAddress}`)
}
+ title="Copy command"
+ aria-label="Copy command"
>
@@ -64,6 +66,8 @@ export const SSH = ({ vm }: { vm: VM }) => {
onClick={() =>
copyToClipboard(`chmod 600 ${vm.name}-key.pem`)
}
+ title="Copy command"
+ aria-label="Copy command"
>
@@ -84,6 +88,8 @@ export const SSH = ({ vm }: { vm: VM }) => {
`ssh -i ${vm.name}-key.pem axion@${vm.ipAddress}`,
)
}
+ title="Copy command"
+ aria-label="Copy command"
>
diff --git a/web-services/apps/frontend/src/components/vmDetail/Sidebar.tsx b/web-services/apps/frontend/src/components/vmDetail/Sidebar.tsx
index 8b146b4..4e3b7f7 100644
--- a/web-services/apps/frontend/src/components/vmDetail/Sidebar.tsx
+++ b/web-services/apps/frontend/src/components/vmDetail/Sidebar.tsx
@@ -1,11 +1,14 @@
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Copy, Monitor } from "lucide-react";
+import { Copy, Link2, Monitor } from "lucide-react";
import type { VM } from "types/vm";
import { copyToClipboard } from "@/lib/utils";
+import { toast } from "sonner";
export const Sidebar = ({ vm }: { vm: VM }) => {
+ const vmUrl = `${window.location.origin}/vm/${vm.id}`;
+
return (
{
Copy IP Address
+ {
+ copyToClipboard(vmUrl);
+ toast.success("VM link copied!", { position: "bottom-right" });
+ }}
+ >
+
+ Copy VM Link
+
{
+ localStorage.removeItem("token");
+ localStorage.removeItem("email");
+ window.location.href = "/";
+ }, []);
+
+ return useMemo(
+ () => ({ isAuthenticated, publicKey, token, wallet, signOut }),
+ [isAuthenticated, publicKey, token, wallet, signOut],
+ );
+}
diff --git a/web-services/apps/frontend/src/hooks/useDebounce.ts b/web-services/apps/frontend/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..0ab8b77
--- /dev/null
+++ b/web-services/apps/frontend/src/hooks/useDebounce.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay = 300) {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(id);
+ }, [value, delay]);
+
+ return debounced;
+}
diff --git a/web-services/apps/frontend/src/hooks/useLoadingTimeout.ts b/web-services/apps/frontend/src/hooks/useLoadingTimeout.ts
new file mode 100644
index 0000000..f0f4123
--- /dev/null
+++ b/web-services/apps/frontend/src/hooks/useLoadingTimeout.ts
@@ -0,0 +1,16 @@
+import { useEffect, useState } from "react";
+
+export function useLoadingTimeout(loading: boolean, timeoutMs = 30000) {
+ const [timedOut, setTimedOut] = useState(false);
+
+ useEffect(() => {
+ if (!loading) {
+ setTimedOut(false);
+ return;
+ }
+ const id = setTimeout(() => setTimedOut(true), timeoutMs);
+ return () => clearTimeout(id);
+ }, [loading, timeoutMs]);
+
+ return timedOut;
+}
diff --git a/web-services/apps/frontend/src/lib/Escrow.ts b/web-services/apps/frontend/src/lib/Escrow.ts
index ac7a2d1..fd16a15 100644
--- a/web-services/apps/frontend/src/lib/Escrow.ts
+++ b/web-services/apps/frontend/src/lib/Escrow.ts
@@ -24,8 +24,7 @@ export const StartRentalSessionWithEscrow = async (
signature: tx,
message: "Rental session started successfully with escrow",
};
- } catch (e) {
- console.error("Error starting rental session with escrow", e);
+ } catch {
return null;
}
};
@@ -48,8 +47,7 @@ export const TopUpEscrowSession = async (
signature: tx,
message: "Escrow session topped up successfully",
};
- } catch (e) {
- console.error("Error topping up escrow session", e);
+ } catch {
return null;
}
};
@@ -76,8 +74,7 @@ export const FinalizeRentalWithEscrow = async (
signature: tx,
message: "Rental finalized successfully with escrow",
};
- } catch (e) {
- console.error("Error finalizing rental with escrow", e);
+ } catch {
return null;
}
};
diff --git a/web-services/apps/frontend/src/lib/api.ts b/web-services/apps/frontend/src/lib/api.ts
index ab0197d..00967a0 100644
--- a/web-services/apps/frontend/src/lib/api.ts
+++ b/web-services/apps/frontend/src/lib/api.ts
@@ -1,16 +1,64 @@
import axios from "axios";
+import { toast } from "sonner";
+import { BACKEND_URL } from "@/config";
-axios.interceptors.response.use(
+export const api = axios.create({
+ baseURL: BACKEND_URL,
+ timeout: 30000,
+ headers: { "Content-Type": "application/json" },
+});
+
+api.interceptors.request.use((config) => {
+ const raw = localStorage.getItem("token");
+ if (raw) {
+ config.headers.Authorization = raw.startsWith("Bearer ")
+ ? raw
+ : `Bearer ${raw}`;
+ }
+ return config;
+});
+
+api.interceptors.response.use(
(response) => response,
(error) => {
- if (
- error.response?.status === 401 &&
- error.response?.data?.message === "Token expired"
- ) {
- localStorage.removeItem("token");
- localStorage.removeItem("email");
- window.location.href = "/signin";
+ const data = error.response?.data;
+ const status = error.response?.status;
+
+ const getErrorMessage = (): string => {
+ if (data?.error?.message) return data.error.message;
+ if (data?.error)
+ return typeof data.error === "string"
+ ? data.error
+ : JSON.stringify(data.error);
+ if (data?.message) return data.message;
+ if (error.message === "Network Error")
+ return "Network error. Please check your connection.";
+ if (error.code === "ECONNABORTED")
+ return "Request timed out. Please try again.";
+ return "Something went wrong. Please try again.";
+ };
+
+ if (status === 401) {
+ const msg = getErrorMessage();
+ if (
+ msg.toLowerCase().includes("token expired") ||
+ msg.toLowerCase().includes("no token")
+ ) {
+ localStorage.removeItem("token");
+ localStorage.removeItem("email");
+ toast.error("Session expired. Please sign in again.");
+ window.location.href = "/signin";
+ return Promise.reject(error);
+ }
}
+
+ toast.error(getErrorMessage(), { position: "bottom-right" });
return Promise.reject(error);
},
);
+
+export interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: { code: string; message: string; details?: unknown };
+}
diff --git a/web-services/apps/frontend/src/lib/contract.ts b/web-services/apps/frontend/src/lib/contract.ts
index f6de389..7df725b 100644
--- a/web-services/apps/frontend/src/lib/contract.ts
+++ b/web-services/apps/frontend/src/lib/contract.ts
@@ -1,17 +1,24 @@
-import { Connection, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
-import { AnchorProvider, Program, type Idl } from "@coral-xyz/anchor";
-import idl from "../../idl/contract.json";
+import { AnchorProvider, Program, type Idl, web3 } from "@coral-xyz/anchor";
import { type AnchorWallet } from "@solana/wallet-adapter-react";
import { BN } from "bn.js";
import { getAdminPublicKey, SOLANA_RPC_URL } from "@/config";
+import { idl as fallbackIdl } from "../contractidl";
+
+const generatedIdlModules = import.meta.glob("../../idl/contract.json", {
+ eager: true,
+ import: "default",
+});
+const idl = (generatedIdlModules["../../idl/contract.json"] ??
+ fallbackIdl) as Idl;
const VAULT_SEED = "axion_vault";
+const { Connection, LAMPORTS_PER_SOL, PublicKey } = web3;
export function getContract(wallet: AnchorWallet): Program {
if (!wallet) throw new Error("Wallet not connected");
const connection = new Connection(SOLANA_RPC_URL);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const provider = new AnchorProvider(connection, wallet as any, {});
+ const provider = new AnchorProvider(connection as any, wallet as any, {});
return new Program(idl as Idl, provider);
}
@@ -19,7 +26,7 @@ async function sendAndConfirm(program: Program, tx: Promise) {
const signature = await tx;
const conf = await program.provider.connection.confirmTransaction(signature);
if (conf.value.err) {
- console.error("Transaction failed", conf.value.err);
+ /* error logged silently */
return null;
}
return { success: true as const, signature, message: "" as string };
@@ -37,8 +44,7 @@ export const InitiatesVaultAccount = async (wallet: AnchorWallet) => {
);
if (!result) return null;
return { ...result, message: "Vault account initialized successfully" };
- } catch (error) {
- console.error("Error initializing vault account", error);
+ } catch {
return null;
}
};
@@ -71,8 +77,7 @@ export const FundVaultAccount = async (
message: "Vault account funded successfully",
balance: balance / LAMPORTS_PER_SOL,
};
- } catch (error) {
- console.error("Error funding vault account", error);
+ } catch {
return null;
}
};
@@ -96,8 +101,7 @@ export const transferFromVault = async (
...result,
message: "Funds transferred from vault account successfully",
};
- } catch (error) {
- console.error("Error transferring funds from vault account", error);
+ } catch {
return null;
}
};
@@ -122,8 +126,7 @@ export const EndRentalSession = async (id: string, wallet: AnchorWallet) => {
);
if (!result) return null;
return { ...result, message: "Rental session ended successfully" };
- } catch (error) {
- console.error("Error ending rental session", error);
+ } catch {
return null;
}
};
@@ -163,8 +166,7 @@ export const TransferToVaultAndStartRental = async (
"Funds transferred to vault and rental session started successfully",
rentalSessionPda,
};
- } catch (error) {
- console.error("Error transferring to vault and starting rental", error);
+ } catch {
return null;
}
};
@@ -187,8 +189,7 @@ export const WithdrawFromVault = async (
...result,
message: "Funds withdrawn from vault account successfully",
};
- } catch (error) {
- console.error("Error withdrawing from vault account", error);
+ } catch {
return null;
}
};
@@ -210,8 +211,7 @@ export const GetVaultBalance = async (wallet: AnchorWallet) => {
balance: balance / LAMPORTS_PER_SOL,
message: "Vault balance retrieved successfully",
};
- } catch (error) {
- console.error("Error fetching vault balance", error);
+ } catch {
return null;
}
};
diff --git a/web-services/apps/frontend/src/lib/depin.ts b/web-services/apps/frontend/src/lib/depin.ts
index f584d2d..df449d0 100644
--- a/web-services/apps/frontend/src/lib/depin.ts
+++ b/web-services/apps/frontend/src/lib/depin.ts
@@ -1,8 +1,7 @@
import { type AnchorWallet } from "@solana/wallet-adapter-react";
import { PublicKey } from "@solana/web3.js";
import { getContract } from "./contract";
-import axios from "axios";
-import { BACKEND_URL } from "@/config";
+import { api } from "./api";
export async function getEarnedSOL(
machineId: string,
@@ -28,12 +27,7 @@ export async function getEarnedSOL(
export async function claimSolana(
machineId: string,
pubKey: string,
- token: string,
): Promise<{ success: boolean; message: string }> {
- const res = await axios.post(
- `${BACKEND_URL}/user/depin/claimSOL`,
- { id: machineId, pubKey },
- { headers: { Authorization: `Bearer ${token}` } },
- );
+ const res = await api.post("/user/depin/claimSOL", { id: machineId, pubKey });
return res.data;
}
diff --git a/web-services/apps/frontend/src/lib/toast.ts b/web-services/apps/frontend/src/lib/toast.ts
new file mode 100644
index 0000000..0620b3b
--- /dev/null
+++ b/web-services/apps/frontend/src/lib/toast.ts
@@ -0,0 +1,26 @@
+import { toast } from "sonner";
+
+export const showError = (message: string) =>
+ toast.error(message, { position: "bottom-right", duration: 6000 });
+
+export const showSuccess = (message: string) =>
+ toast.success(message, { position: "bottom-right", duration: 3000 });
+
+export const showInfo = (message: string) =>
+ toast.info(message, { position: "bottom-right", duration: 4000 });
+
+export const showLoading = (message: string) =>
+ toast.loading(message, { position: "bottom-right" });
+
+export const dismissToast = () => toast.dismiss();
+
+export const showPromise = (
+ promise: Promise,
+ messages: { loading: string; success: string; error: string },
+) =>
+ toast.promise(promise, {
+ loading: messages.loading,
+ success: messages.success,
+ error: messages.error,
+ position: "bottom-right",
+ });
diff --git a/web-services/apps/frontend/src/lib/useIndexerEvents.ts b/web-services/apps/frontend/src/lib/useIndexerEvents.ts
index 4dedd2a..25b94ee 100644
--- a/web-services/apps/frontend/src/lib/useIndexerEvents.ts
+++ b/web-services/apps/frontend/src/lib/useIndexerEvents.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { WS_RELAYER_URL } from "@/config";
export interface IndexerEvent {
@@ -12,12 +12,50 @@ export interface IndexerEvent {
type EventHandler = (event: IndexerEvent) => void;
+export type WSConnectionState = "disconnected" | "connecting" | "connected";
+
// ── Singleton WS state ────────────────────────────────────────────────
let globalWs: WebSocket | null = null;
const listeners = new Set();
-// All pubkeys that have been requested — re-subscribed on every (re)connect
const subscribedPubkeys = new Set();
let reconnectTimer: ReturnType | null = null;
+let reconnectAttempt = 0;
+
+// ── Connection state tracking ─────────────────────────────────────────
+let _connectionState: WSConnectionState = "disconnected";
+const stateListeners = new Set<() => void>();
+
+function getConnectionState(): WSConnectionState {
+ return _connectionState;
+}
+
+function setConnectionState(state: WSConnectionState) {
+ _connectionState = state;
+ stateListeners.forEach((fn) => fn());
+}
+
+function subscribeToState(cb: () => void) {
+ stateListeners.add(cb);
+ return () => stateListeners.delete(cb);
+}
+
+export function useWSConnectionStatus(): WSConnectionState {
+ return useSyncExternalStore(subscribeToState, getConnectionState);
+}
+
+// ── Backoff ───────────────────────────────────────────────────────────
+const BACKOFF_BASE = 1000;
+const BACKOFF_MAX = 30000;
+
+function scheduleReconnect() {
+ const delay = Math.min(
+ BACKOFF_BASE * Math.pow(2, reconnectAttempt),
+ BACKOFF_MAX,
+ );
+ reconnectAttempt++;
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ reconnectTimer = setTimeout(connect, delay);
+}
function sendSubscribe(pubkey: string) {
if (globalWs?.readyState === WebSocket.OPEN) {
@@ -26,17 +64,16 @@ function sendSubscribe(pubkey: string) {
}
function connect() {
- if (globalWs?.readyState === WebSocket.OPEN) {
- return;
- }
- // If CONNECTING but we have pubkeys waiting, let it finish — onopen will subscribe them
- if (globalWs?.readyState === WebSocket.CONNECTING) {
- return;
- }
+ if (globalWs?.readyState === WebSocket.OPEN) return;
+ if (globalWs?.readyState === WebSocket.CONNECTING) return;
+
+ setConnectionState("connecting");
globalWs = new WebSocket(WS_RELAYER_URL);
globalWs.onopen = () => {
+ reconnectAttempt = 0;
+ setConnectionState("connected");
for (const pk of subscribedPubkeys) sendSubscribe(pk);
};
@@ -50,18 +87,16 @@ function connect() {
}
} catch {
console.error("[ws-indexer] failed to parse message:", msg.data);
- // ignore malformed
}
};
globalWs.onclose = () => {
globalWs = null;
- if (reconnectTimer) clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(connect, 3000);
+ setConnectionState("disconnected");
+ scheduleReconnect();
};
- globalWs.onerror = (e) => {
- console.error("[ws-indexer] error:", e);
+ globalWs.onerror = () => {
globalWs?.close();
};
}
diff --git a/web-services/apps/frontend/src/lib/vm.ts b/web-services/apps/frontend/src/lib/vm.ts
index ab05d80..09b1aeb 100644
--- a/web-services/apps/frontend/src/lib/vm.ts
+++ b/web-services/apps/frontend/src/lib/vm.ts
@@ -1,5 +1,4 @@
-import axios from "axios";
-import { runtimeEnv } from "../runtimeEnv";
+import { api } from "./api";
export const calculatePrice = async (
machineType: string,
@@ -7,24 +6,14 @@ export const calculatePrice = async (
duration: number,
): Promise => {
try {
- const response = await axios.get(
- `${runtimeEnv.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL || "http://localhost:3000/api/v2"}/vm/calculatePrice`,
- {
- params: {
- machineType,
- diskSize,
- },
- headers: {
- Authorization: `${localStorage.getItem("token")}`,
- },
- },
- );
- const price = response.data.price; // for 30 days
- const perDayPrice = price / 30; // Convert to per day price
- const Inhours = perDayPrice / (24 * 60); // Convert to mins
+ const response = await api.get("/vm/calculatePrice", {
+ params: { machineType, diskSize },
+ });
+ const price = response.data.price;
+ const perDayPrice = price / 30;
+ const Inhours = perDayPrice / (24 * 60);
return Inhours * duration;
- } catch (error) {
- console.error("Error calculating price:", error);
+ } catch {
return 0;
}
};
diff --git a/web-services/apps/frontend/src/main.tsx b/web-services/apps/frontend/src/main.tsx
index cc91779..100fbe4 100644
--- a/web-services/apps/frontend/src/main.tsx
+++ b/web-services/apps/frontend/src/main.tsx
@@ -20,7 +20,7 @@ createRoot(document.getElementById("root")!).render(
-
+
diff --git a/web-services/apps/frontend/src/pages/About.tsx b/web-services/apps/frontend/src/pages/About.tsx
index 17604d5..49b5bde 100644
--- a/web-services/apps/frontend/src/pages/About.tsx
+++ b/web-services/apps/frontend/src/pages/About.tsx
@@ -1,6 +1,7 @@
import { useRef } from "react";
import { motion, useInView } from "motion/react";
import { Link } from "react-router-dom";
+import { BackgroundGlow } from "@/components/BackgroundGlow";
function Reveal({
children,
@@ -46,103 +47,103 @@ export default function About() {
const beliefsInView = useInView(beliefsRef, { once: true, margin: "-80px" });
return (
-
- {/* hero — full-width editorial */}
-
-
+
+
+ {/* hero — full-width editorial */}
+
+
-
-
-
- About Axion
-
-
-
-
- We're building
- cloud that belongs
-
-
- to the network.
+
+
+
+ About Axion
-
-
+
-
-
- Axion is a decentralized cloud platform on Solana. Compute buyers
- pay with SOL through on-chain escrow. Compute sellers register
- physical machines and earn per second of workload served.
-
-
- No central authority. No billing department. No trust required.
-
-
-
+
+ We're building
+ cloud that belongs
+
+
+ to the network.
+
+
+
- {/* beliefs */}
-
- {BELIEFS.map((b, i) => (
-
- {b.n}
-
-
- {b.title}
-
-
- {b.body}
+
+ Axion is a decentralized cloud platform on Solana. Compute buyers
+ pay with SOL through on-chain escrow. Compute sellers register
+ physical machines and earn per second of workload served.
+
+
+ No central authority. No billing department. No trust required.
- ))}
-
+
- {/* footnote CTA */}
-
-
-
-
- Solana DePIN
-
-
-
+ {BELIEFS.map((b, i) => (
+
+
+ {b.n}
+
+
+ {b.title}
+
+
+ {b.body}
+
+
+ ))}
+
+
+ {/* footnote CTA */}
+
- Get in touch →
-
-
-
+
+
+
+ Solana DePIN
+
+
+
+ Get in touch →
+
+
+
+
);
}
diff --git a/web-services/apps/frontend/src/pages/Admin.tsx b/web-services/apps/frontend/src/pages/Admin.tsx
index 2ababa8..ae1f752 100644
--- a/web-services/apps/frontend/src/pages/Admin.tsx
+++ b/web-services/apps/frontend/src/pages/Admin.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useRef, useState } from "react";
-import { motion } from "motion/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { motion, AnimatePresence } from "motion/react";
import {
Card,
CardContent,
@@ -10,7 +10,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
@@ -31,12 +31,14 @@ import {
Server,
AlertCircle,
CheckCircle,
+ Check,
Clock,
ChevronLeft,
ChevronRight,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
+import { useLoadingTimeout } from "@/hooks/useLoadingTimeout";
import type { VM } from "types/vm";
import {
FundVaultAccount,
@@ -46,9 +48,10 @@ import {
isVaultInitialized,
} from "@/lib/contract";
import { useAnchorWallet } from "@solana/wallet-adapter-react";
-import axios from "axios";
-import { ADMIN_KEY, BACKEND_URL } from "@/config";
+import { api } from "@/lib/api";
+import { ADMIN_KEY } from "@/config";
import { formatter } from "@/lib/FormatTime";
+import { Skeleton } from "@/components/Skeleton";
import { useIndexerEvents, type IndexerEvent } from "@/lib/useIndexerEvents";
import { clusterApiUrl, Connection } from "@solana/web3.js";
@@ -121,6 +124,7 @@ export function AdminPage() {
// Per-operation states
const [initOp, setInitOp] = useState(IDLE);
+ const [initSuccess, setInitSuccess] = useState(false);
const [fundOp, setFundOp] = useState(IDLE);
const [withdrawOp, setWithdrawOp] = useState(IDLE);
const [balanceOp, setBalanceOp] = useState(IDLE);
@@ -137,7 +141,10 @@ export function AdminPage() {
// Form states
const [fundVault, setFundVault] = useState({ amount: "" });
const [withdrawVault, setWithdrawVault] = useState({ amount: "" });
+ const [errors, setErrors] = useState>({});
const [vms, setVMs] = useState([]);
+ const [loadingVms, setLoadingVms] = useState(false);
+ const timedOut = useLoadingTimeout(loadingVms, 30000);
// Track pending signatures → which op they belong to
const pendingSigs = useRef