diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..11e1194 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,111 @@ +name: CD + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + contents: write + +jobs: + update-manifests: + name: Update Kubernetes Manifests + + runs-on: ubuntu-latest + + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: main + + - name: Download Changed Services + uses: dawidd6/action-download-artifact@v6 + with: + workflow: ci.yml + run_id: ${{ github.event.workflow_run.id }} + name: changed-services + path: changed + + - name: Check Changed Services + id: changed + shell: bash + run: | + if [ ! -d changed ]; then + echo "deploy=false" >> $GITHUB_OUTPUT + exit 0 + fi + + if grep -Rq "true" changed; then + echo "deploy=true" >> $GITHUB_OUTPUT + else + echo "deploy=false" >> $GITHUB_OUTPUT + fi + + - name: Configure Git + if: steps.changed.outputs.deploy == 'true' + run: | + git config user.name github-actions + git config user.email github-actions@github.com + + - name: Update Image Tags + if: steps.changed.outputs.deploy == 'true' + shell: bash + run: | + VERSION=v1.2.${{ github.event.workflow_run.run_number }} + + if [ "$(cat changed/backend)" = "true" ]; then + sed -i "s|image: krishanand01/axion-backend:.*|image: krishanand01/axion-backend:$VERSION|g" ops/deployment.yml + fi + + if [ "$(cat changed/frontend)" = "true" ]; then + sed -i "s|image: krishanand01/axion-frontend:.*|image: krishanand01/axion-frontend:$VERSION|g" ops/deployment.yml + fi + + if [ "$(cat changed/worker)" = "true" ]; then + sed -i "s|image: krishanand01/axion-worker:.*|image: krishanand01/axion-worker:$VERSION|g" ops/deployment.yml + fi + + if [ "$(cat changed/ws-relayer)" = "true" ]; then + sed -i "s|image: krishanand01/axion-ws-relayer:.*|image: krishanand01/axion-ws-relayer:$VERSION|g" ops/deployment.yml + fi + + if [ "$(cat changed/depin-ws-relayer)" = "true" ]; then + sed -i "s|image: krishanand01/axion-depin-ws-relayer:.*|image: krishanand01/axion-depin-ws-relayer:$VERSION|g" ops/deployment.yml + fi + + if [ "$(cat changed/indexer)" = "true" ]; then + sed -i "s|image: krishanand01/axion-indexer:.*|image: krishanand01/axion-indexer:$VERSION|g" ops/deployment.yml + fi + + - name: Check For Manifest Changes + if: steps.changed.outputs.deploy == 'true' + id: gitcheck + shell: bash + run: | + if git diff --quiet ops/deployment.yml; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Commit Changes + if: | + steps.changed.outputs.deploy == 'true' && + steps.gitcheck.outputs.changed == 'true' + run: | + git add ops/deployment.yml + git commit -m "chore: update image tags to v1.2.${{ github.event.workflow_run.run_number }}" + + - name: Push Changes + if: | + steps.changed.outputs.deploy == 'true' && + steps.gitcheck.outputs.changed == 'true' + run: | + git push origin main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94d430e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,268 @@ +name: CI + +on: + pull_request: + paths: + - "apps/**" + - "packages/**" + - "indexer/**" + - "docker/**" + - ".github/workflows/**" + - "turbo.json" + - "package.json" + - "bun.lock" + + push: + branches: + - main + paths: + - "apps/**" + - "packages/**" + - "indexer/**" + - "docker/**" + - ".github/workflows/**" + - "turbo.json" + - "package.json" + - "bun.lock" + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Lint / Build + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + working-directory: web-services + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun Dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + .turbo + key: ${{ runner.os }}-bun-${{ hashFiles('web-services/bun.lock') }} + + - name: Install Dependencies + run: bun install + + - name: Run Lint + run: bun run lint + + - name: Run Build + run: bun run build + + detect-changes: + name: Detect Changed Services + runs-on: ubuntu-latest + needs: quality + + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + worker: ${{ steps.filter.outputs.worker }} + ws-relayer: ${{ steps.filter.outputs.ws-relayer }} + depin-ws-relayer: ${{ steps.filter.outputs.depin-ws-relayer }} + indexer: ${{ steps.filter.outputs.indexer }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect Changed Files + id: filter + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + else + CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }}) + fi + + echo "$CHANGED_FILES" + + echo "backend=false" >> $GITHUB_OUTPUT + echo "frontend=false" >> $GITHUB_OUTPUT + echo "worker=false" >> $GITHUB_OUTPUT + echo "ws-relayer=false" >> $GITHUB_OUTPUT + echo "depin-ws-relayer=false" >> $GITHUB_OUTPUT + echo "indexer=false" >> $GITHUB_OUTPUT + + if echo "$CHANGED_FILES" | grep -q '^web-services/apps/backend/'; then + echo "backend=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/apps/frontend/'; then + echo "frontend=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/apps/worker/'; then + echo "worker=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/apps/ws-relayer/'; then + echo "ws-relayer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/apps/depin-ws-relayer/'; then + echo "depin-ws-relayer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^indexer/'; then + echo "indexer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/docker/backend.dockerfile'; then + echo "backend=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/docker/frontend.dockerfile'; then + echo "frontend=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/docker/worker.dockerfile'; then + echo "worker=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/docker/ws-relayer.dockerfile'; then + echo "ws-relayer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/docker/depin-ws-relayer.dockerfile'; then + echo "depin-ws-relayer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^indexer/Dockerfile'; then + echo "indexer=true" >> $GITHUB_OUTPUT + fi + + if echo "$CHANGED_FILES" | grep -q '^web-services/packages/'; then + echo "backend=true" >> $GITHUB_OUTPUT + echo "frontend=true" >> $GITHUB_OUTPUT + echo "worker=true" >> $GITHUB_OUTPUT + echo "ws-relayer=true" >> $GITHUB_OUTPUT + echo "depin-ws-relayer=true" >> $GITHUB_OUTPUT + fi + + - name: Save Changed Services + run: | + mkdir -p changed + + echo '${{ steps.filter.outputs.backend }}' > changed/backend + echo '${{ steps.filter.outputs.frontend }}' > changed/frontend + echo '${{ steps.filter.outputs.worker }}' > changed/worker + echo '${{ steps.filter.outputs.ws-relayer }}' > changed/ws-relayer + echo '${{ steps.filter.outputs.depin-ws-relayer }}' > changed/depin-ws-relayer + echo '${{ steps.filter.outputs.indexer }}' > changed/indexer + + - name: Upload Changed Services + uses: actions/upload-artifact@v4 + with: + name: changed-services + path: changed/ + + docker-build: + name: Docker Build & Push + runs-on: ubuntu-latest + + needs: + - quality + - detect-changes + + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' + + strategy: + fail-fast: false + + matrix: + include: + - service: backend + dockerfile: web-services/docker/backend.dockerfile + context: web-services + image: axion-backend + changed: ${{ needs.detect-changes.outputs.backend }} + + - service: frontend + dockerfile: web-services/docker/frontend.dockerfile + context: web-services + image: axion-frontend + changed: ${{ needs.detect-changes.outputs.frontend }} + + - service: worker + dockerfile: web-services/docker/worker.dockerfile + context: web-services + image: axion-worker + changed: ${{ needs.detect-changes.outputs.worker }} + + - service: ws-relayer + dockerfile: web-services/docker/ws-relayer.dockerfile + context: web-services + image: axion-ws-relayer + changed: ${{ needs.detect-changes.outputs.ws-relayer }} + + - service: depin-ws-relayer + dockerfile: web-services/docker/depin-ws-relayer.dockerfile + context: web-services + image: axion-depin-ws-relayer + changed: ${{ needs.detect-changes.outputs.depin-ws-relayer }} + + - service: indexer + dockerfile: indexer/Dockerfile + context: indexer + image: axion-indexer + changed: ${{ needs.detect-changes.outputs.indexer }} + + steps: + - name: Checkout + if: matrix.changed == 'true' + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + if: matrix.changed == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: matrix.changed == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push + if: matrix.changed == 'true' + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: true + + build-args: | + ${{ matrix.service == 'frontend' && format('VITE_BACKEND_URL={0}', secrets.BACKEND_URL) || '' }} + + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}:latest + ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}:v1.2.${{ github.run_number }} + + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/web-services/apps/backend/index.ts b/web-services/apps/backend/index.ts index 0651f8d..b031aaa 100644 --- a/web-services/apps/backend/index.ts +++ b/web-services/apps/backend/index.ts @@ -1,6 +1,7 @@ import express from "express"; import cors from "cors"; import type { NextFunction, Request, Response } from "express"; +import { sendError, logger } from "@axion/utilities"; import UserRouter from "./routes/user"; import vmInstance from "./routes/vmInstance"; import vm from "./routes/vm"; @@ -8,11 +9,14 @@ import depinVM from "./routes/depinVm"; import indexerRouter from "./routes/indexer"; process.on("unhandledRejection", (reason) => { - console.error("Unhandled Rejection:", reason); + logger.error( + "Unhandled Rejection", + reason instanceof Error ? reason : new Error(String(reason)), + ); }); process.on("uncaughtException", (error) => { - console.error("Uncaught Exception:", error); + logger.error("Uncaught Exception", error); }); const app = express(); @@ -26,10 +30,10 @@ app.use("/api/v2/user/depin", depinVM); app.use("/api/v2/indexer", indexerRouter); app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { - console.error("Unhandled error:", err); - res.status(500).json({ error: "Internal server error" }); + logger.error("Unhandled route error", err); + sendError(res, err); }); app.listen(3000, () => { - console.log("Backend server is running on http://localhost:3000"); + logger.info("Backend server started", { port: 3000 }); }); diff --git a/web-services/apps/backend/redis.ts b/web-services/apps/backend/redis.ts index f25b9ca..575bdbb 100644 --- a/web-services/apps/backend/redis.ts +++ b/web-services/apps/backend/redis.ts @@ -1,7 +1,22 @@ import { createQueue } from "@axion/utilities/redis"; -export const vmQueue = createQueue("vm-termination"); -export const terminateDepinVMQueue = createQueue("terminate-depin-vm"); -export const activateHostQueue = createQueue("changeVMStatus"); -export const initialiseAccount = createQueue("initialise-host-pda"); -export const claimRewardsQueue = createQueue("claim-rewards"); +const DEFAULT_RETRY = { + attempts: 3, + backoff: { type: "exponential" as const, delay: 2000 }, +}; + +export const vmQueue = createQueue("vm-termination", { + defaultJobOptions: DEFAULT_RETRY, +}); +export const terminateDepinVMQueue = createQueue("terminate-depin-vm", { + defaultJobOptions: DEFAULT_RETRY, +}); +export const activateHostQueue = createQueue("changeVMStatus", { + defaultJobOptions: DEFAULT_RETRY, +}); +export const initialiseAccount = createQueue("initialise-host-pda", { + defaultJobOptions: DEFAULT_RETRY, +}); +export const claimRewardsQueue = createQueue("claim-rewards", { + defaultJobOptions: DEFAULT_RETRY, +}); diff --git a/web-services/apps/backend/routes/depinVm.ts b/web-services/apps/backend/routes/depinVm.ts index 0b234eb..0828491 100644 --- a/web-services/apps/backend/routes/depinVm.ts +++ b/web-services/apps/backend/routes/depinVm.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { authMiddleware } from "@axion/utilities/auth"; +import { authMiddleware, logger } from "@axion/utilities"; import prisma from "@axion/db"; import { ChangeVMStatusSchema, @@ -250,8 +250,8 @@ depinVM.post("/deploy", authMiddleware, async (req, res) => { name: txn.name, }); } catch (error) { - console.error("Error deploying image:", error); - fail(res, 500, "Internal server error"); + logger.error("Error deploying image", error); + fail(res, 500, "Internal server error", "DEPLOY_FAILED"); } }); @@ -306,8 +306,8 @@ depinVM.delete("/terminate/:id", authMiddleware, async (req, res) => { ok(res, { message: "Termination request sent successfully" }); } catch (error) { - console.error("Error terminating VM:", error); - fail(res, 500, "Internal server error"); + logger.error("Error terminating VM", error); + fail(res, 500, "Internal server error", "TERMINATE_FAILED"); } }); @@ -404,8 +404,8 @@ depinVM.post("/depinVerification", async (req, res) => { tunnelId: vm.tunnelId, }); } catch (error) { - console.error("Error in depin verification:", error); - fail(res, 500, "Internal server error"); + logger.error("Error in depin verification", error); + fail(res, 500, "Internal server error", "VERIFICATION_FAILED"); } }); @@ -461,8 +461,8 @@ depinVM.post("/register", authMiddleware, async (req, res) => { ok(res, { message: "VM registered successfully", vm }); } catch (error) { - console.error("Error registering VM:", error); - fail(res, 500, "Internal server error"); + logger.error("Error registering VM", error); + fail(res, 500, "Internal server error", "REGISTER_FAILED"); } }); @@ -506,8 +506,8 @@ depinVM.post("/changeVisibility", authMiddleware, async (req, res) => { ok(res, { message: "VM visibility updated successfully" }); } catch (error) { - console.error("Error fetching VM:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VM", error); + fail(res, 500, "Internal server error", "VM_FETCH_FAILED"); } }); @@ -524,8 +524,8 @@ depinVM.get("/getAll", authMiddleware, async (req, res) => { }); ok(res, vms); } catch (error) { - console.error("Error fetching VMs:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VMs", error); + fail(res, 500, "Internal server error", "VMS_FETCH_FAILED"); } }); @@ -544,8 +544,8 @@ depinVM.get("/getById", authMiddleware, async (req, res) => { } ok(res, vm); } catch (error) { - console.error("Error fetching VM:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VM by ID", error); + fail(res, 500, "Internal server error", "VM_BY_ID_FAILED"); } }); @@ -576,8 +576,8 @@ depinVM.post("/claimSOL", authMiddleware, async (req, res) => { }); ok(res, { message: "Claim request submitted" }); } catch (error) { - console.error("Error claiming SOL:", error); - fail(res, 500, "Internal server error"); + logger.error("Error claiming SOL", error); + fail(res, 500, "Internal server error", "CLAIM_SOL_FAILED"); } }); @@ -594,8 +594,8 @@ depinVM.get("/settlement/:id", authMiddleware, async (req, res) => { }); ok(res, { settlement }); } catch (error) { - console.error("Error fetching settlement:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching settlement", error); + fail(res, 500, "Internal server error", "SETTLEMENT_FETCH_FAILED"); } }); diff --git a/web-services/apps/backend/routes/indexer.ts b/web-services/apps/backend/routes/indexer.ts index bc47a4c..0d120da 100644 --- a/web-services/apps/backend/routes/indexer.ts +++ b/web-services/apps/backend/routes/indexer.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import type { Request, Response } from "express"; import prisma from "@axion/db"; +import { logger } from "@axion/utilities"; const router = Router(); @@ -32,14 +33,24 @@ interface IndexerEvent { router.post("/webhook", async (req: Request, res: Response) => { const token = req.headers["x-indexer-token"]; if (token !== INDEXER_TOKEN) { - res.status(401).json({ error: "Unauthorized" }); + res + .status(401) + .json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Unauthorized" }, + }); return; } const event: IndexerEvent = req.body; if (!event.instruction || !event.signature) { - res.status(400).json({ error: "Invalid event payload" }); + res + .status(400) + .json({ + success: false, + error: { code: "INVALID_PAYLOAD", message: "Invalid event payload" }, + }); return; } @@ -61,8 +72,13 @@ router.post("/webhook", async (req: Request, res: Response) => { await handleInstruction(event); res.status(200).json({ received: true }); } catch (error) { - console.error(`[Indexer] Error handling ${event.instruction}:`, error); - res.status(500).json({ error: "Failed to process event" }); + logger.error(`[Indexer] Error handling ${event.instruction}`, error); + res + .status(500) + .json({ + success: false, + error: { code: "INDEXER_ERROR", message: "Failed to process event" }, + }); } }); diff --git a/web-services/apps/backend/routes/user.ts b/web-services/apps/backend/routes/user.ts index 1ddf9e9..9f97515 100644 --- a/web-services/apps/backend/routes/user.ts +++ b/web-services/apps/backend/routes/user.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { SignInSchema, SignUpSchema } from "@axion/types"; import prisma from "@axion/db"; -import { authMiddleware } from "@axion/utilities/auth"; +import { authMiddleware, logger } from "@axion/utilities"; import { fail, getUserOr404, @@ -41,8 +41,8 @@ UserRouter.post("/signup", async (req, res) => { return; } } - console.error("Error during signup:", error); - fail(res, 500, "Internal server error"); + logger.error("Error during signup", error); + fail(res, 500, "Internal server error", "SIGNUP_FAILED"); } }); @@ -64,8 +64,8 @@ UserRouter.post("/login", async (req, res) => { token: signToken({ userId: user.id }, "1Day"), }); } catch (error) { - console.error("Error during login:", error); - fail(res, 500, "Internal server error"); + logger.error("Error during login", error); + fail(res, 500, "Internal server error", "LOGIN_FAILED"); } }); diff --git a/web-services/apps/backend/routes/vm.ts b/web-services/apps/backend/routes/vm.ts index 39444aa..92ecac2 100644 --- a/web-services/apps/backend/routes/vm.ts +++ b/web-services/apps/backend/routes/vm.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import prisma from "@axion/db"; import { Router } from "express"; import axios from "axios"; -import { authMiddleware } from "@axion/utilities/auth"; +import { authMiddleware, logger } from "@axion/utilities"; import { EscrowTopUpSchema } from "@axion/types"; import { vmQueue } from "../redis"; import { fail, getUserOr404, ok, MINUTE_MS } from "../utils/helpers"; @@ -33,8 +33,8 @@ vm.get("/calculatePrice", authMiddleware, async (req, res) => { const solPrice = await getSolPrice(); ok(res, { price: totalPrice / solPrice }); } catch (error) { - console.error("Error calculating price:", error); - fail(res, 500, "Internal server error"); + logger.error("Error calculating price", error); + fail(res, 500, "Internal server error", "PRICE_CALC_FAILED"); } }); @@ -43,8 +43,8 @@ vm.get("/getVMTypes", authMiddleware, async (req, res) => { const vmTypes = await prisma.vMTypes.findMany(); ok(res, vmTypes); } catch (error) { - console.error("Error fetching VM types:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VM types", error); + fail(res, 500, "Internal server error", "VM_TYPES_FAILED"); } }); @@ -60,8 +60,8 @@ vm.get("/getAll", authMiddleware, async (req, res) => { }); ok(res, vms); } catch (error) { - console.error("Error fetching VMs:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VMs", error); + fail(res, 500, "Internal server error", "VM_FETCH_FAILED"); } }); @@ -77,8 +77,8 @@ vm.get("/checkNameAvailability", authMiddleware, async (req, res) => { }); ok(res, { available: !existingVM }); } catch (error) { - console.error("Error checking name availability:", error); - fail(res, 500, "Internal server error"); + logger.error("Error checking name availability", error); + fail(res, 500, "Internal server error", "NAME_CHECK_FAILED"); } }); @@ -140,8 +140,8 @@ vm.post("/topup", authMiddleware, async (req, res) => { ok(res, { msg: "Top up successful", vm: updatedVM }); } catch (error) { - console.error("Error during top up:", error); - fail(res, 500, "Internal server error"); + logger.error("Error during top up", error); + fail(res, 500, "Internal server error", "TOPUP_FAILED"); } }); diff --git a/web-services/apps/backend/routes/vmInstance.ts b/web-services/apps/backend/routes/vmInstance.ts index 0f11461..cb2e651 100644 --- a/web-services/apps/backend/routes/vmInstance.ts +++ b/web-services/apps/backend/routes/vmInstance.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { Router } from "express"; -import { authMiddleware } from "@axion/utilities/auth"; +import { authMiddleware, logger } from "@axion/utilities"; import { VmInstanceSchema } from "@axion/types"; import prisma from "@axion/db"; import { createInstance } from "../utils/createVm"; @@ -125,8 +125,8 @@ vmInstance.post("/create", authMiddleware, async (req, res) => { PrivateKey: transaction.privateKey, }); } catch (error) { - console.error("Error during VM instance creation:", error); - fail(res, 500, "Internal server error"); + logger.error("Error during VM instance creation", error); + fail(res, 500, "Internal server error", "VM_CREATE_FAILED"); } }); @@ -170,8 +170,8 @@ vmInstance.get("/pollStatus", authMiddleware, async (req, res) => { }); ok(res, { vmId, status }); } catch (e) { - console.error("Error during VM status polling:", e); - fail(res, 500, "Internal server error"); + logger.error("Error during VM status polling", e); + fail(res, 500, "Internal server error", "VM_POLL_FAILED"); } }); @@ -204,8 +204,8 @@ vmInstance.delete("/destroy", authMiddleware, async (req, res) => { remainingTime: remainingTime > 0 ? remainingTime : 0, }); } catch (error) { - console.error("Error during VM instance deletion:", error); - fail(res, 500, "Internal server error"); + logger.error("Error during VM instance deletion", error); + fail(res, 500, "Internal server error", "VM_DELETE_FAILED"); } }); @@ -218,8 +218,8 @@ vmInstance.get("/getAll", authMiddleware, async (req, res) => { }); ok(res, { vms }); } catch (error) { - console.error("Error fetching VM instances:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VM instances", error); + fail(res, 500, "Internal server error", "VM_FETCH_FAILED"); } }); @@ -241,8 +241,8 @@ vmInstance.get("/getDetails", authMiddleware, async (req, res) => { } ok(res, { vmInstance }); } catch (error) { - console.error("Error fetching VM instance details:", error); - fail(res, 500, "Internal server error"); + logger.error("Error fetching VM instance details", error); + fail(res, 500, "Internal server error", "VM_DETAILS_FAILED"); } }); diff --git a/web-services/apps/backend/utils/helpers.ts b/web-services/apps/backend/utils/helpers.ts index fdabfed..65e17d4 100644 --- a/web-services/apps/backend/utils/helpers.ts +++ b/web-services/apps/backend/utils/helpers.ts @@ -11,18 +11,26 @@ export function ok(res: Response, data: unknown, status = 200) { res.status(status).json(data); } -export function fail(res: Response, status: number, msg: string) { - res.status(status).json({ error: msg }); +export function fail( + res: Response, + status: number, + msg: string, + code = "ERROR", +) { + res.status(status).json({ + success: false, + error: { code, message: msg }, + }); } export async function getUserOr404(res: Response, userId?: string) { if (!userId) { - fail(res, 400, "User ID is required"); + fail(res, 400, "User ID is required", "MISSING_USER_ID"); return null; } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { - fail(res, 404, "User not found"); + fail(res, 404, "User not found", "USER_NOT_FOUND"); return null; } return user; diff --git a/web-services/apps/frontend/index.html b/web-services/apps/frontend/index.html index 1340296..ad677ae 100644 --- a/web-services/apps/frontend/index.html +++ b/web-services/apps/frontend/index.html @@ -6,9 +6,23 @@ Axion — Decentralized Cloud Computing + -
+
+
+
diff --git a/web-services/apps/frontend/src/App.css b/web-services/apps/frontend/src/App.css index e2f960e..2892ada 100644 --- a/web-services/apps/frontend/src/App.css +++ b/web-services/apps/frontend/src/App.css @@ -1,42 +1,55 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem 0; - text-align: center; +.skeleton-shimmer { + position: relative; + overflow: hidden; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.skeleton-shimmer::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.06) 50%, + transparent 100% + ); + animation: shimmer 2s infinite; } -@keyframes logo-spin { - from { - transform: rotate(0deg); +@keyframes shimmer { + 0% { + transform: translateX(-100%); } - to { - transform: rotate(360deg); + 100% { + transform: translateX(100%); } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); } + 20%, 40%, 60%, 80% { transform: translateX(2px); } } -.card { - padding: 2em; +.animate-shake { + animation: shake 0.5s ease-in-out; } -.read-the-docs { - color: #888; +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .skeleton-shimmer::after { + background: none; + } + + .skeleton-shimmer { + background: rgba(128, 128, 128, 0.15); + } } diff --git a/web-services/apps/frontend/src/App.tsx b/web-services/apps/frontend/src/App.tsx index 2199caf..4a202e5 100644 --- a/web-services/apps/frontend/src/App.tsx +++ b/web-services/apps/frontend/src/App.tsx @@ -1,22 +1,18 @@ -import { Routes, Route } from "react-router-dom"; +import { lazy, Suspense } from "react"; +import { Routes, Route, useLocation } from "react-router-dom"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { ErrorBoundary } from "./components/ErrorBoundary"; -import Landing from "./pages/Landing"; +import { RequireAuth } from "./components/RequireAuth"; import { Dashboard } from "./pages/Dashboard"; -import { RentVM } from "./pages/RentVm"; import { VMDetails } from "./pages/vmDetail"; import { Hosting } from "./pages/Hosting"; import { SignUp } from "./pages/Signup"; import "@solana/wallet-adapter-react-ui/styles.css"; import { SignIn } from "./pages/Signin"; -import SSHTerminal from "./pages/Terminal"; -import { AdminPage } from "./pages/Admin"; -import { ComingSoon } from "./components/ComingSoon"; -import { HostRegister } from "./pages/HostMachine"; +import { NotFound } from "./pages/NotFound"; import { DepinDeployment } from "./pages/DepinDeployment"; import { HostDashboard } from "./pages/HostDashboard"; -import { DeployApp } from "./pages/deployImage"; import { HostMachineDetails } from "./pages/HostMachineDetails"; -import Docs from "./pages/Docs"; import ApiReference from "./pages/ApiReference"; import Tutorials from "./pages/Tutorials"; import Status from "./pages/Status"; @@ -33,46 +29,191 @@ import Roadmap from "./pages/Roadmap"; import ClaimRewards from "./pages/ClaimRewards"; import Host from "./pages/Host"; +const Landing = lazy(() => import("./pages/Landing")); +const RentVM = lazy(() => + import("./pages/RentVm").then((m) => ({ default: m.RentVM })), +); +const AdminPage = lazy(() => + import("./pages/Admin").then((m) => ({ default: m.AdminPage })), +); +const SSHTerminal = lazy(() => import("./pages/Terminal")); +const HostRegister = lazy(() => + import("./pages/HostMachine").then((m) => ({ default: m.HostRegister })), +); +const Docs = lazy(() => import("./pages/Docs")); +const DeployApp = lazy(() => + import("./pages/deployImage").then((m) => ({ default: m.DeployApp })), +); + function App() { + const location = useLocation(); return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + +

Loading...

+ + } + > + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> +
+ + +
); } diff --git a/web-services/apps/frontend/src/components/Appbar.tsx b/web-services/apps/frontend/src/components/Appbar.tsx index 916efe3..dc021e2 100644 --- a/web-services/apps/frontend/src/components/Appbar.tsx +++ b/web-services/apps/frontend/src/components/Appbar.tsx @@ -1,11 +1,18 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { Button } from "./ui/button"; -import { motion, useMotionValueEvent, useScroll } from "motion/react"; +import { + motion, + useMotionValueEvent, + useScroll, + AnimatePresence, +} from "motion/react"; import { useWallet } from "@solana/wallet-adapter-react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import ProfileDropdown from "./user-dropdown"; import { AnimatedThemeToggler } from "./ui/animated-theme-toggler"; import { AxionLogo } from "./AxionLogo"; +import { Menu, X } from "lucide-react"; +import { WSConnectionDot } from "./WSConnectionDot"; export const Appbar = () => { const { wallet } = useWallet(); @@ -13,7 +20,27 @@ export const Appbar = () => { const [scrolled, setScrolled] = useState(false); const { scrollY } = useScroll(); const [userDropdownOpen, setUserDropdownOpen] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const location = useLocation(); + const navigate = useNavigate(); + + const navigateTo = useCallback( + (link: string) => { + navigate(link); + setMobileMenuOpen(false); + }, + [navigate], + ); + + const handleNavKeyDown = useCallback( + (e: React.KeyboardEvent, link: string) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigateTo(link); + } + }, + [navigateTo], + ); const navItems = [ { name: "Dashboard", link: "/dashboard" }, @@ -54,15 +81,18 @@ export const Appbar = () => { - {/* Nav items */} -
+ {/* Desktop nav items */} +
{navItems.map((item, idx) => ( setHovered(idx)} onMouseLeave={() => setHovered(null)} - onClick={() => item.link && (window.location.href = item.link)} + onClick={() => navigateTo(item.link)} + onKeyDown={(e) => handleNavKeyDown(e, item.link)} > {hovered === idx && ( { ))}
+ {/* Mobile hamburger */} + + {/* CTA / Wallet */}
+ {localStorage.getItem("token") && wallet?.adapter.connected ? (
+ + {/* Mobile menu */} + + {mobileMenuOpen && ( + <> + setMobileMenuOpen(false)} + aria-hidden="true" + /> + +
+ + Menu + + +
+ +
+ + )} +
); diff --git a/web-services/apps/frontend/src/components/BackgroundGlow.tsx b/web-services/apps/frontend/src/components/BackgroundGlow.tsx new file mode 100644 index 0000000..810d5a8 --- /dev/null +++ b/web-services/apps/frontend/src/components/BackgroundGlow.tsx @@ -0,0 +1,20 @@ +interface BackgroundGlowProps { + color?: string; + size?: string; + position?: string; +} + +export function BackgroundGlow({ + color = "rgba(153,69,255,0.05)", + size = "40% 30%", + position = "60% 0%", +}: BackgroundGlowProps) { + return ( +
+ ); +} diff --git a/web-services/apps/frontend/src/components/ComingSoon.tsx b/web-services/apps/frontend/src/components/ComingSoon.tsx index 65fd4a6..c370d0e 100644 --- a/web-services/apps/frontend/src/components/ComingSoon.tsx +++ b/web-services/apps/frontend/src/components/ComingSoon.tsx @@ -1,129 +1,130 @@ -import { motion } from 'framer-motion'; -import { Clock, Rocket } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; +import { motion } from "motion/react"; +import { Clock, Rocket } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; export function ComingSoon({ isDepin }: { isDepin: boolean }) { - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.2, - delayChildren: 0.3, - }, - }, - }; + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.3, + }, + }, + }; - return ( -
- {/* Background elements */} -
- - {/* Animated background shapes */} - - - - {/* Grid pattern */} -
-
-
+ return ( +
+ {/* Background elements */} +
-
- - {/* Status badge */} - - - - Coming Soon - - + {/* Animated background shapes */} + + - {/* Main heading */} - -

- {isDepin ? - - DePin{' '} - - : - - This feature is {' '} - - } - - {isDepin ? 'Hosting' : ''} - -
- Coming Soon -

-

- We are working hard to bring you the next generation of decentralized cloud computing. - Stay tuned for something extraordinary. -

-
+ {/* Grid pattern */} +
+
+
- {/* Animated rocket */} - -
- - - - - {/* Animated rings */} - - -
-
- +
+ + {/* Status badge */} + + + + Coming Soon + + + + {/* Main heading */} + +

+ {isDepin ? ( + + DePin{" "} + + ) : ( + + This feature is{" "} + + )} + + {isDepin ? "Hosting" : ""} + +
+ Coming Soon +

+

+ We are working hard to bring you the next generation of + decentralized cloud computing. Stay tuned for something + extraordinary. +

+
+ + {/* Animated rocket */} + +
+ + + + + {/* Animated rings */} + +
-
- ); -} \ No newline at end of file + + +
+
+ ); +} diff --git a/web-services/apps/frontend/src/components/DepinDeployment/EscrowCard.tsx b/web-services/apps/frontend/src/components/DepinDeployment/EscrowCard.tsx index 2820fb6..d75fe47 100644 --- a/web-services/apps/frontend/src/components/DepinDeployment/EscrowCard.tsx +++ b/web-services/apps/frontend/src/components/DepinDeployment/EscrowCard.tsx @@ -16,8 +16,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { TopUpEscrowSession } from "@/lib/Escrow"; import { useAnchorWallet } from "@solana/wallet-adapter-react"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { useTxConfirm } from "@/lib/useTxConfirm"; import type { VM } from "types/vm"; @@ -48,13 +47,11 @@ export const EscrowCard = ({ vm }: { vm: VM }) => { watch(tx.signature, { onConfirmed: async () => { try { - await axios.post( - `${BACKEND_URL}/vm/topup`, - { id: vm.id, instanceId: vm.instanceId, amount }, - { - headers: { Authorization: `${localStorage.getItem("token")}` }, - }, - ); + await api.post("/vm/topup", { + id: vm.id, + instanceId: vm.instanceId, + amount, + }); toast.success("Escrow topped up!", { position: "top-right" }); } catch { toast.error("On-chain confirmed but backend update failed", { diff --git a/web-services/apps/frontend/src/components/DepinDeployment/SettlementSummary.tsx b/web-services/apps/frontend/src/components/DepinDeployment/SettlementSummary.tsx index 63cae78..20b0945 100644 --- a/web-services/apps/frontend/src/components/DepinDeployment/SettlementSummary.tsx +++ b/web-services/apps/frontend/src/components/DepinDeployment/SettlementSummary.tsx @@ -1,8 +1,7 @@ import { motion } from "motion/react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Receipt, Clock, ExternalLink } from "lucide-react"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { useEffect, useState } from "react"; interface SettlementData { @@ -22,10 +21,7 @@ export const SettlementSummary = ({ vmId }: { vmId: string }) => { useEffect(() => { const fetch = async () => { try { - const res = await axios.get( - `${BACKEND_URL}/user/depin/settlement/${vmId}`, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const res = await api.get(`/user/depin/settlement/${vmId}`); setSettlement(res.data.settlement); } catch { /* not settled yet */ diff --git a/web-services/apps/frontend/src/components/DepinDeployment/StopDialog.tsx b/web-services/apps/frontend/src/components/DepinDeployment/StopDialog.tsx index 743ad50..ae5cc74 100644 --- a/web-services/apps/frontend/src/components/DepinDeployment/StopDialog.tsx +++ b/web-services/apps/frontend/src/components/DepinDeployment/StopDialog.tsx @@ -15,8 +15,7 @@ import { Clock, } from "lucide-react"; import { toast } from "sonner"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; type Stage = | "idle" @@ -56,10 +55,7 @@ export const StopDialog = ({ setStage("submitting"); setErrorMsg(""); try { - const res = await axios.delete( - `${BACKEND_URL}/user/depin/terminate/${vmId}`, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const res = await api.delete(`/user/depin/terminate/${vmId}`); if (res.status !== 200) { setStage("failed"); @@ -77,10 +73,7 @@ export const StopDialog = ({ pollRef.current = setInterval(async () => { attempts++; try { - const sr = await axios.get( - `${BACKEND_URL}/user/depin/settlement/${vmId}`, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const sr = await api.get(`/user/depin/settlement/${vmId}`); if (sr.data.settlement) { stopPolling(); setStage("done"); diff --git a/web-services/apps/frontend/src/components/DepinHostDashboard/Table.tsx b/web-services/apps/frontend/src/components/DepinHostDashboard/Table.tsx index 3a75f09..56ff850 100644 --- a/web-services/apps/frontend/src/components/DepinHostDashboard/Table.tsx +++ b/web-services/apps/frontend/src/components/DepinHostDashboard/Table.tsx @@ -19,8 +19,7 @@ import { Button } from "../ui/button"; import { motion } from "motion/react"; import { Badge } from "../ui/badge"; import { toast } from "sonner"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -91,20 +90,12 @@ export const DashboardTable = ({ return; } try { - const res = await axios.post( - `${BACKEND_URL}/user/depin/changeVisibility`, - { - id: machineId, - pubKey: wallet?.publicKey?.toBase58(), - status: !isActive, - Key: key, - }, - { - headers: { - Authorization: `${localStorage.getItem("token")}`, - }, - }, - ); + const res = await api.post("/user/depin/changeVisibility", { + id: machineId, + pubKey: wallet?.publicKey?.toBase58(), + status: !isActive, + Key: key, + }); if (res.status === 200) { toast.success( `Machine ${!isActive ? "activated" : "deactivated"} successfully`, @@ -118,8 +109,7 @@ export const DashboardTable = ({ ); setMachines(updatedMachines); } - } catch (error) { - console.error("Error changing machine status:", error); + } catch { toast.error("Failed to change machine status. Please try again."); } }; @@ -128,11 +118,10 @@ export const DashboardTable = ({ if (!wallet?.publicKey) return; setClaimingId(machineId); try { - const res = await axios.post( - `${BACKEND_URL}/user/depin/claimSOL`, - { id: machineId, pubKey: wallet.publicKey.toBase58() }, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const res = await api.post("/user/depin/claimSOL", { + id: machineId, + pubKey: wallet.publicKey.toBase58(), + }); if (res.status === 200) { toast.success("Claim request submitted. Rewards will arrive shortly."); } diff --git a/web-services/apps/frontend/src/components/DepinHosting/Step1.tsx b/web-services/apps/frontend/src/components/DepinHosting/Step1.tsx index 07268bf..9e41e96 100644 --- a/web-services/apps/frontend/src/components/DepinHosting/Step1.tsx +++ b/web-services/apps/frontend/src/components/DepinHosting/Step1.tsx @@ -260,7 +260,7 @@ function NativeSelect({ - setFormData({ ...formData, cpu: value }) - } + onValueChange={(value) => { + setFormData({ ...formData, cpu: value }); + setErrors((prev) => ({ ...prev, cpu: "" })); + }} > @@ -182,6 +234,9 @@ export const Form = ({ formData, setFormData, setVm, setStep }: FormProps) => { 8 cores + {errors.cpu && ( +

{errors.cpu}

+ )}
@@ -194,9 +249,10 @@ export const Form = ({ formData, setFormData, setVm, setStep }: FormProps) => { + {errors.ram && ( +

{errors.ram}

+ )}
@@ -224,12 +283,19 @@ export const Form = ({ formData, setFormData, setVm, setStep }: FormProps) => { id="storage" type="number" value={formData.diskSize} - onChange={(e) => - setFormData({ ...formData, diskSize: e.target.value }) - } + onChange={(e) => { + setFormData({ ...formData, diskSize: e.target.value }); + setErrors((prev) => ({ ...prev, diskSize: "" })); + }} min="1" max="1000" + className={errors.diskSize ? "animate-shake" : ""} /> + {errors.diskSize && ( +

+ {errors.diskSize} +

+ )}
+
+ + + 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 void>>( @@ -176,17 +183,21 @@ export function AdminPage() { }, }); + const fetchVMs = useCallback(async () => { + setLoadingVms(true); + try { + const res = await api.get(`/vm/getAll?adminKey=${wallet?.publicKey}`); + if (res.status === 200) setVMs(res.data || []); + } catch { + toast.error("Failed to load virtual machines"); + } + setLoadingVms(false); + }, [wallet?.publicKey]); + useEffect(() => { if (activeTab !== "vms") return; - axios - .get(`${BACKEND_URL}/vm/getAll?adminKey=${wallet?.publicKey}`, { - headers: { Authorization: `${localStorage.getItem("token")}` }, - }) - .then((res) => { - if (res.status === 200) setVMs(res.data || []); - }) - .catch(() => toast.error("Failed to load virtual machines")); - }, [activeTab, wallet?.publicKey]); + fetchVMs(); + }, [activeTab, fetchVMs]); // ── Helper: submit tx, show "submitted", wait for indexer event ── function watchTx( @@ -274,14 +285,18 @@ export function AdminPage() { watchTx(res.signature, setInitOp, "Vault initialized successfully", () => { setVaultBalance(0); setVaultExists(true); + setInitSuccess(true); + setTimeout(() => setInitSuccess(false), 2000); }); }; const handleFund = async () => { + const newErrors: Record = {}; if (!fundVault.amount || parseFloat(fundVault.amount) <= 0) { - toast.error("Valid amount required"); - return; + newErrors.fundAmount = "Valid amount is required"; } + setErrors((prev) => ({ ...prev, ...newErrors })); + if (Object.keys(newErrors).length > 0) return; setFundOp({ status: "submitted", message: "Signing transaction…" }); const res = await FundVaultAccount(wallet!, parseFloat(fundVault.amount)); if (!res?.success || !res.signature) { @@ -300,10 +315,12 @@ export function AdminPage() { }; const handleWithdraw = async () => { - if (!withdrawVault.amount) { - toast.error("Amount is required"); - return; + const newErrors: Record = {}; + if (!withdrawVault.amount || parseFloat(withdrawVault.amount) <= 0) { + newErrors.withdrawAmount = "Valid amount is required"; } + setErrors((prev) => ({ ...prev, ...newErrors })); + if (Object.keys(newErrors).length > 0) return; const amt = parseFloat(withdrawVault.amount); if (vaultBalance !== null && amt > vaultBalance) { toast.error("Insufficient vault balance"); @@ -382,11 +399,7 @@ export function AdminPage() { ); }; - if ( - !wallet || - wallet.publicKey.toBase58() !== ADMIN_KEY || - !localStorage.getItem("token") - ) { + if (wallet?.publicKey?.toBase58() !== ADMIN_KEY) { return (
@@ -401,7 +414,10 @@ export function AdminPage() { const busy = (op: OpState) => op.status === "submitted"; return ( -
+
@@ -457,302 +473,391 @@ export function AdminPage() { - {/* ── Vault tab ─────────────────────────────────────────── */} - -
- {/* Initialize Vault */} - - - - Initialize Vault - - - {vaultExists - ? "Vault is already initialized" - : "Create the platform vault account"} - - - - - - - - - {/* Fund Vault */} - - - - Fund - Vault - - - Add funds to your vault account - - - -
- - - setFundVault({ ...fundVault, amount: e.target.value }) - } - /> -
- - -
-
- - {/* Withdraw Funds */} - - - - {" "} - Withdraw Funds - - - Withdraw funds from your vault to an address - - - -
- - - setWithdrawVault({ - ...withdrawVault, - amount: e.target.value, - }) - } - /> -
- - -
-
- - {/* Check Balance */} - - - - Check Balance - - - View your current vault balance - - - - - - - -
-
- - {/* ── VM tab ────────────────────────────────────────────── */} - - {vms.length === 0 ? ( - - No virtual machines found. - - ) : ( - - - - Virtual Machines ( - {vms.length} total) - - - Monitor and manage all deployed virtual machines - - - -
- - - - VM ID - Name - Status - Type - Region - Resources - IP Address - Cost - Duration - Created At - - - - {paginatedVMs.map((vm) => ( - - - {vm.instanceId} - - - {vm.name} - - {getStatusBadge(vm.status)} - {vm.VMConfig.machineType} - {vm.region} - -
{vm.VMConfig.os}
-
- {vm.VMConfig.diskSize} GB -
-
- - {vm.ipAddress} - - - {Number(vm.price).toFixed(6)} SOL - - - {vm.endTime - ? `${Math.floor((new Date(vm.endTime).getTime() - new Date(vm.createdAt).getTime()) / 60000)} min` - : "N/A"} - - - {formatter.format(new Date(vm.createdAt))} - -
- ))} -
-
-
- - {totalPages > 1 && ( -
-
- Showing {startIndex + 1}– - {Math.min(startIndex + itemsPerPage, totalItems)} of{" "} - {totalItems} VMs + + {activeTab === "vault" && ( + +
+ {/* Initialize Vault */} + + + + Initialize + Vault + + + {vaultExists + ? "Vault is already initialized" + : "Create the platform vault account"} + + + + + + + + + {/* Fund Vault */} + + + + {" "} + Fund Vault + + + Add funds to your vault account + + + +
+ + { + setFundVault({ + ...fundVault, + amount: e.target.value, + }); + setErrors((prev) => ({ ...prev, fundAmount: "" })); + }} + /> + {errors.fundAmount && ( +

+ {errors.fundAmount} +

+ )} +
+ + +
+
+ + {/* Withdraw Funds */} + + + + {" "} + Withdraw Funds + + + Withdraw funds from your vault to an address + + + +
+ + { + setWithdrawVault({ + ...withdrawVault, + amount: e.target.value, + }); + setErrors((prev) => ({ + ...prev, + withdrawAmount: "", + })); + }} + /> + {errors.withdrawAmount && ( +

+ {errors.withdrawAmount} +

+ )}
-
- - {Array.from( - { length: Math.min(5, totalPages) }, - (_, i) => i + 1, - ).map((page) => ( - - ))} - {totalPages > 5 && ( + + + + + + {/* Check Balance */} + + + + Check + Balance + + + View your current vault balance + + + + + + + +
+ + )} + {activeTab === "vms" && ( + + {timedOut ? ( +
+

+ Loading is taking longer than expected. Please try again. +

+ +
+ ) : loadingVms ? ( + + + + + + +
+ + + + {[...Array(10)].map((_, i) => ( + + + + ))} + + + + {[...Array(3)].map((_, row) => ( + + {[...Array(10)].map((_, cell) => ( + + + + ))} + + ))} + +
+
+
+
+ ) : vms.length === 0 ? ( + + + No virtual machines found. + + + ) : ( + + + + Virtual Machines ( + {vms.length} total) + + + Monitor and manage all deployed virtual machines + + + +
+
+ + + + VM ID + Name + Status + Type + Region + Resources + IP Address + Cost + Duration + Created At + + + + {paginatedVMs.map((vm) => ( + + + {vm.instanceId} + + + {vm.name} + + + {getStatusBadge(vm.status)} + + + {vm.VMConfig.machineType} + + {vm.region} + +
{vm.VMConfig.os}
+
+ {vm.VMConfig.diskSize} GB +
+
+ + {vm.ipAddress} + + + {Number(vm.price).toFixed(6)} SOL + + + {vm.endTime + ? `${Math.floor((new Date(vm.endTime).getTime() - new Date(vm.createdAt).getTime()) / 60000)} min` + : "N/A"} + + + {formatter.format(new Date(vm.createdAt))} + +
+ ))} +
+
+
+
+ + {totalPages > 1 && ( +
+
+ Showing {startIndex + 1}– + {Math.min(startIndex + itemsPerPage, totalItems)} of{" "} + {totalItems} VMs +
+
+ {Array.from( + { length: Math.min(5, totalPages) }, + (_, i) => i + 1, + ).map((page) => ( + + ))} + {totalPages > 5 && ( + <> + + + + )} + - - )} - -
-
- )} -
-
+
+
+ )} + + + )} + )} - +
diff --git a/web-services/apps/frontend/src/pages/ApiReference.tsx b/web-services/apps/frontend/src/pages/ApiReference.tsx index dde02b5..98b0639 100644 --- a/web-services/apps/frontend/src/pages/ApiReference.tsx +++ b/web-services/apps/frontend/src/pages/ApiReference.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { motion, useInView } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; const METHODS = ["GET", "POST", "PUT", "DELETE"] as const; type Method = (typeof METHODS)[number]; @@ -186,13 +187,11 @@ function GroupSection({ export default function ApiReference() { return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/Billing.tsx b/web-services/apps/frontend/src/pages/Billing.tsx index d59677d..07658fa 100644 --- a/web-services/apps/frontend/src/pages/Billing.tsx +++ b/web-services/apps/frontend/src/pages/Billing.tsx @@ -1,116 +1,93 @@ import { motion } from "motion/react"; import { useWallet } from "@solana/wallet-adapter-react"; -import { Link } from "react-router-dom"; - +import { BackgroundGlow } from "@/components/BackgroundGlow"; export default function Billing() { const { publicKey } = useWallet(); - if (!publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Sign in to view billing -

- - Sign in → - -
-
- ); - } - return ( -
-
+ +
+ + +
+
+ + + + Billing + + +

+ + + Transactions + + +

+
+ + + {[ + { + label: "Total spent", + value: "—", + color: "text-zinc-900 dark:text-white", + }, + { label: "Total earned", value: "—", color: "text-emerald-500" }, + { + label: "Wallet", + value: `${publicKey?.toBase58()?.slice(0, 4) ?? "…"}…${publicKey?.toBase58()?.slice(-4) ?? ""}`, + color: "text-zinc-500 dark:text-zinc-400", + }, + ].map((s) => ( +
+ + {s.label} + + + {s.value} + +
+ ))} +
-
-
- - - Billing + + Activity +
+

+ No transactions yet. Rent a VM or host a machine to get started. +

+
-

- - - Transactions - - -

- - - {[ - { - label: "Total spent", - value: "—", - color: "text-zinc-900 dark:text-white", - }, - { label: "Total earned", value: "—", color: "text-emerald-500" }, - { - label: "Wallet", - value: `${publicKey.toBase58().slice(0, 4)}…${publicKey.toBase58().slice(-4)}`, - color: "text-zinc-500 dark:text-zinc-400", - }, - ].map((s) => ( -
- - {s.label} - - - {s.value} - -
- ))} -
- - - - Activity - -
-

- No transactions yet. Rent a VM or host a machine to get started. -

-
-
-
+ ); } diff --git a/web-services/apps/frontend/src/pages/Blog.tsx b/web-services/apps/frontend/src/pages/Blog.tsx index af54716..3a39acc 100644 --- a/web-services/apps/frontend/src/pages/Blog.tsx +++ b/web-services/apps/frontend/src/pages/Blog.tsx @@ -1,44 +1,46 @@ import { motion } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; export default function Blog() { return ( -
-
-
-
- - - - Blog - - -

- +
+ +
+
+ - Updates & insights - -

-
-
-

- No posts yet. Product updates and technical deep-dives coming soon. -

+ + + Blog + + +

+ + Updates & insights + +

+
+
+

+ No posts yet. Product updates and technical deep-dives coming + soon. +

+
-
+ ); } diff --git a/web-services/apps/frontend/src/pages/Careers.tsx b/web-services/apps/frontend/src/pages/Careers.tsx index 8a00942..5cbc14b 100644 --- a/web-services/apps/frontend/src/pages/Careers.tsx +++ b/web-services/apps/frontend/src/pages/Careers.tsx @@ -1,48 +1,49 @@ import { motion } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; export default function Careers() { return ( -
-
-
-
- - - - Careers - - -

- +
+ +
+
+ - Join the team - -

-

- We're building the future of decentralized compute. Check back soon - for open roles. -

-
-
-

- No open roles right now. Follow us on X for updates. -

+ + + Careers + + +

+ + Join the team + +

+

+ We're building the future of decentralized compute. Check back + soon for open roles. +

+
+
+

+ No open roles right now. Follow us on X for updates. +

+
-
+ ); } diff --git a/web-services/apps/frontend/src/pages/ClaimRewards.tsx b/web-services/apps/frontend/src/pages/ClaimRewards.tsx index 29a0591..99bb83a 100644 --- a/web-services/apps/frontend/src/pages/ClaimRewards.tsx +++ b/web-services/apps/frontend/src/pages/ClaimRewards.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useInView } from "motion/react"; import { useWallet } from "@solana/wallet-adapter-react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; import { Link } from "react-router-dom"; import { type Machine } from "../../types/depinMachines"; -import { BACKEND_URL } from "@/config"; import { toast } from "sonner"; -import axios from "axios"; +import { RefreshCw, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useLoadingTimeout } from "@/hooks/useLoadingTimeout"; +import { api } from "@/lib/api"; // per-machine claim status type ClaimStatus = "idle" | "submitted" | "confirmed" | "failed"; @@ -97,35 +100,41 @@ export default function ClaimRewards() { const wallet = useWallet(); const [machines, setMachines] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); // per-machine claim status const [claimStatuses, setClaimStatuses] = useState< Record >({}); - useEffect(() => { - if (!wallet.publicKey) return; - axios - .get( - `${BACKEND_URL}/user/depin/getAll?userPublicKey=${wallet.publicKey.toBase58()}`, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ) - .then((r) => setMachines(r.data)) - .catch(console.error) - .finally(() => setLoading(false)); + const fetchMachines = useCallback(async () => { + setLoading(true); + setError(false); + try { + const r = await api.get( + `/user/depin/getAll?userPublicKey=${wallet.publicKey?.toBase58() ?? ""}`, + ); + setMachines(r.data); + } catch { + setError(true); + } + setLoading(false); }, [wallet.publicKey]); + useEffect(() => { + fetchMachines(); + }, [fetchMachines]); + const setStatus = (id: string, s: ClaimStatus) => setClaimStatuses((p) => ({ ...p, [id]: s })); const handleClaim = async (id: string) => { - if (!wallet.publicKey) return; setStatus(id, "submitted"); try { - const res = await axios.post( - `${BACKEND_URL}/user/depin/claimSOL`, - { id, pubKey: wallet.publicKey.toBase58() }, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const res = await api.post("/user/depin/claimSOL", { + id, + pubKey: wallet.publicKey?.toBase58() ?? "", + }); if (res.status === 200) { setStatus(id, "confirmed"); toast.success("Claim submitted. Rewards will arrive shortly."); @@ -138,38 +147,17 @@ export default function ClaimRewards() { } }; - if (!wallet.publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Sign in to claim rewards -

- - Sign in → - -
-
- ); - } - const total = machines.reduce((s, m) => s + m.claimedSOL, 0); return ( -
-
+
@@ -216,7 +204,16 @@ export default function ClaimRewards() { {/* machines */}
- {loading ? ( + {timedOut && !error ? ( +
+

+ Loading is taking longer than expected. Please try again. +

+ +
+ ) : loading ? (
+ ) : error ? ( +
+ +

+ Failed to load machines. +

+ +
) : machines.length === 0 ? (

diff --git a/web-services/apps/frontend/src/pages/Contact.tsx b/web-services/apps/frontend/src/pages/Contact.tsx index be7438a..cb16e07 100644 --- a/web-services/apps/frontend/src/pages/Contact.tsx +++ b/web-services/apps/frontend/src/pages/Contact.tsx @@ -1,26 +1,26 @@ import { useState } from "react"; import { motion, AnimatePresence } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; export default function Contact() { const [focused, setFocused] = useState(null); const [sent, setSent] = useState(false); const [vals, setVals] = useState({ name: "", email: "", message: "" }); + const [errors, setErrors] = useState>({}); const inputCls = (field: string) => - `w-full bg-transparent border-0 border-b text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-600 py-3 focus:outline-none transition-colors duration-300 ${ + `w-full bg-transparent border-0 border-b text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-600 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors duration-300 ${ focused === field ? "border-zinc-900 dark:border-white" : "border-black/10 dark:border-white/10" - }`; + } ${errors[field] ? "animate-shake" : ""}`; return ( -

-
+
@@ -118,10 +118,21 @@ export default function Contact() { ) : ( { e.preventDefault(); + const newErrors: Record = {}; + if (!vals.name) newErrors.name = "Name is required"; + if (!vals.email) newErrors.email = "Email is required"; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(vals.email)) + newErrors.email = "Invalid email"; + if (!vals.message) + newErrors.message = "Message is required"; + setErrors(newErrors); + if (Object.keys(newErrors).length > 0) return; setSent(true); }} className="space-y-8" diff --git a/web-services/apps/frontend/src/pages/Dashboard.tsx b/web-services/apps/frontend/src/pages/Dashboard.tsx index 5711fe6..7157b7b 100644 --- a/web-services/apps/frontend/src/pages/Dashboard.tsx +++ b/web-services/apps/frontend/src/pages/Dashboard.tsx @@ -1,27 +1,60 @@ -import { motion } from "motion/react"; +import { motion, AnimatePresence } from "motion/react"; import { useEffect, useState } from "react"; +import { useDebounce } from "@/hooks/useDebounce"; import { Button } from "@/components/ui/button"; +import { useLoadingTimeout } from "@/hooks/useLoadingTimeout"; import { Input } from "@/components/ui/input"; import { StatusBadge } from "@/components/StatusBadge"; -import { Plus, Search, ExternalLink } from "lucide-react"; +import { + Plus, + Search, + ExternalLink, + RefreshCw, + AlertCircle, +} from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { type VM } from "../../types/vm"; import { useWallet } from "@solana/wallet-adapter-react"; import { formatter } from "@/lib/FormatTime"; import { getVmDetails } from "@/lib/vm"; import { toast } from "sonner"; import { useIndexerEvents } from "@/lib/useIndexerEvents"; +import { Skeleton } from "@/components/Skeleton"; + +function SkeletonCard() { + return ( +
+
+ + +
+
+ {[...Array(4)].map((_, i) => ( +
+ + +
+ ))} +
+
+ +
+
+ ); +} export function Dashboard() { const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearch = useDebounce(searchQuery, 300); const [filter, setFilter] = useState("all"); const [vms, setVMs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); const navigate = useNavigate(); const wallet = useWallet(); - // Real-time VM status updates from indexer useIndexerEvents({ account: wallet.publicKey?.toBase58(), onEvent: (event) => { @@ -58,60 +91,39 @@ export function Dashboard() { }, }); - useEffect(() => { - if (!wallet || !localStorage.getItem("token")) { - return; + const fetchVMs = async () => { + setLoading(true); + setError(false); + try { + const res = await api.get("/vmInstance/getAll"); + setVMs(res.data.vms); + } catch { + setError(true); } - const getVMs = async () => { - try { - const res = await axios.get(`${BACKEND_URL}/vmInstance/getAll`, { - headers: { - Authorization: `${localStorage.getItem("token")}`, - }, - }); - setVMs(res.data.vms); - } catch (error) { - console.error("Error fetching VMs:", error); - } - }; - getVMs(); + setLoading(false); + }; + + useEffect(() => { + fetchVMs(); }, [wallet]); const filteredVMs = vms.filter((vm) => { const matchesSearch = vm.name .toLowerCase() - .includes(searchQuery.toLowerCase()); + .includes(debouncedSearch.toLowerCase()); const matchesFilter = filter === "all" || vm.status === filter; return matchesSearch && matchesFilter; }); - if (!wallet.connected || !localStorage.getItem("token")) { - return ( -
- -

Please SignIn

-

- Please connect your wallet and signInto manage your virtual - machines. -

- - - -
-
- ); - } - return ( -
- {/* Header */} +
@@ -137,10 +149,10 @@ export function Dashboard() {
- {/* Controls */} @@ -151,6 +163,7 @@ export function Dashboard() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-10" + aria-label="Search" />
@@ -169,98 +182,173 @@ export function Dashboard() {
- {/* VM List */} -
- {filteredVMs.map((vm, index) => ( - { - if (vm.status === "RUNNING") { - navigate( - vm.provider === "LOCAL" - ? `/depin/deployment/${vm.id}` - : `/vm/${vm.id}`, - ); - } else { - toast.info("This VM is not running.", { - position: "top-right", - }); - } - }} - > -
-
-
-
- {vm.name} -
- -
- -
-
+ {error && ( + + +

Failed to load VMs

+

+ Something went wrong while fetching your virtual machines. +

+ +
+ )} -
-
- - {vm.region} - - Region -
-
- - {vm.VMConfig?.os || vm.VMImage?.os || "N/A"} - - Operating System -
-
- - {vm.provider === "LOCAL" - ? `${vm.VMImage?.cpu || 0}vCPUs • ${vm.VMImage?.ram || 0}Gb Ram` - : `${getVmDetails(vm.VMConfig?.machineType).cpu}vCPUs • ${getVmDetails(vm.VMConfig?.machineType).ram}Gb Ram`} - - Resources + {timedOut && !error && ( +
+

+ Loading is taking longer than expected. Please try again. +

+ +
+ )} + + {loading && !error && ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ )} + + {!loading && !error && ( + + {filteredVMs.map((vm, index) => ( + { + if (vm.status === "RUNNING") { + navigate( + vm.provider === "LOCAL" + ? `/depin/deployment/${vm.id}` + : `/vm/${vm.id}`, + ); + } else { + toast.info("This VM is not running.", { + position: "top-right", + }); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (vm.status === "RUNNING") { + navigate( + vm.provider === "LOCAL" + ? `/depin/deployment/${vm.id}` + : `/vm/${vm.id}`, + ); + } else { + toast.info("This VM is not running.", { + position: "top-right", + }); + } + } + }} + > +
+
+
+
+ {vm.name} +
+ +
+ +
-
- - {Number(vm.price).toFixed(6)} SOL - - Rented for + +
+
+ + {vm.region} + + Region +
+
+ + {vm.VMConfig?.os || vm.VMImage?.os || "N/A"} + + Operating System +
+
+ + {vm.provider === "LOCAL" + ? `${vm.VMImage?.cpu || 0} vCPUs • ${vm.VMImage?.ram || 0} GB RAM` + : `${getVmDetails(vm.VMConfig?.machineType).cpu} vCPUs • ${getVmDetails(vm.VMConfig?.machineType).ram} GB RAM`} + + Resources +
+
+ + {Number(vm.price).toFixed(6)} SOL + + Rented for +
-
-
- - Created On: {formatter.format(new Date(vm.createdAt))} - - {vm.provider !== "LOCAL" && ( - Instance Id: {vm.instanceId} - )} - {vm.provider === "LOCAL" && ( - - Image Deployed: {vm.VMImage?.dockerImage} +
+ + Created On: {formatter.format(new Date(vm.createdAt))} - )} -
- - ))} -
+ {vm.provider !== "LOCAL" && ( + + Instance Id: {vm.instanceId} + + )} + {vm.provider === "LOCAL" && ( + + Image Deployed: {vm.VMImage?.dockerImage} + + )} +
+ + ))} + + )} - {filteredVMs.length === 0 && ( + {!loading && !error && filteredVMs.length === 0 && ( (null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); const isTerminated = vm?.status === "DELETED" || vm?.status === "TERMINATED"; useIndexerEvents({ @@ -38,36 +42,80 @@ export function DepinDeployment() { }, }); - useEffect(() => { + const fetchDeployment = useCallback(async () => { if (!id) return; - const fetch = async () => { - try { - const res = await axios.get( - `${BACKEND_URL}/vmInstance/getDetails?id=${id}`, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); - setVm(res.data.vmInstance); - } catch { - toast.error("Failed to load deployment details", { - position: "bottom-right", - }); - } - setLoading(false); - }; - fetch(); + setLoading(true); + setError(false); + try { + const res = await api.get(`/vmInstance/getDetails?id=${id}`); + setVm(res.data.vmInstance); + } catch { + setError(true); + toast.error("Failed to load deployment details", { + position: "bottom-right", + }); + } + setLoading(false); }, [id]); + useEffect(() => { + fetchDeployment(); + }, [fetchDeployment]); + + if (timedOut && !error) { + return ( +
+
+

+ Loading is taking longer than expected. Please try again. +

+ +
+
+ ); + } + if (loading) { return ( -
-

- Loading deployment details... -

+
+
+ +
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+
+ + +
+
); } - if (!vm) { + if (error) { return (
-

Deployment Not Found

+ +

Failed to load deployment

- This deployment does not exist or has been removed. + Something went wrong while fetching this deployment.

- - - +
+ + + + +
); } - if (!wallet || !localStorage.getItem("token")) { + if (!vm) { return (
-

Please Sign In

+

Deployment Not Found

- Connect your wallet and sign in to view your deployment. + This deployment does not exist or has been removed.

- - + +
diff --git a/web-services/apps/frontend/src/pages/Docs.tsx b/web-services/apps/frontend/src/pages/Docs.tsx index 066e10e..cc2bc29 100644 --- a/web-services/apps/frontend/src/pages/Docs.tsx +++ b/web-services/apps/frontend/src/pages/Docs.tsx @@ -1,5 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; +import { ReadingProgress } from "@/components/ReadingProgress"; import { Link } from "react-router-dom"; const NAV = [ @@ -245,17 +247,16 @@ export default function Docs() { }, []); return ( -
-
+ + {/* top bar */} -
+
@@ -402,6 +405,7 @@ export default function Docs() { diff --git a/web-services/apps/frontend/src/pages/FAQ.tsx b/web-services/apps/frontend/src/pages/FAQ.tsx index 0aebb54..64351d0 100644 --- a/web-services/apps/frontend/src/pages/FAQ.tsx +++ b/web-services/apps/frontend/src/pages/FAQ.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { motion, useInView, AnimatePresence } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; const FAQS = [ { @@ -85,13 +86,11 @@ function Item({ faq, i }: { faq: (typeof FAQS)[0]; i: number }) { export default function FAQ() { return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/Host.tsx b/web-services/apps/frontend/src/pages/Host.tsx index 808b79a..aa608b1 100644 --- a/web-services/apps/frontend/src/pages/Host.tsx +++ b/web-services/apps/frontend/src/pages/Host.tsx @@ -1,5 +1,6 @@ import { motion } from "motion/react"; import { Link } from "react-router-dom"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; const OPTIONS = [ { @@ -30,13 +31,11 @@ const OPTIONS = [ export default function Host() { return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/HostDashboard.tsx b/web-services/apps/frontend/src/pages/HostDashboard.tsx index c91b2b9..7cd9df2 100644 --- a/web-services/apps/frontend/src/pages/HostDashboard.tsx +++ b/web-services/apps/frontend/src/pages/HostDashboard.tsx @@ -1,18 +1,53 @@ import { motion } from "motion/react"; -import { useEffect, useState } from "react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; +import { useCallback, useEffect, useState } from "react"; import { type Machine } from "../../types/depinMachines"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { useWallet } from "@solana/wallet-adapter-react"; -import { useNavigate, Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { DashboardTable } from "@/components/DepinHostDashboard/Table"; import { useIndexerEvents } from "@/lib/useIndexerEvents"; import { toast } from "sonner"; +import { RefreshCw, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/Skeleton"; +import { useLoadingTimeout } from "@/hooks/useLoadingTimeout"; + +function SkeletonSummary() { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+ + +
+ ))} +
+ ); +} + +function SkeletonRow() { + return ( +
+
+
+
+ + +
+
+ +
+ ); +} export function HostDashboard() { const wallet = useWallet(); const navigate = useNavigate(); const [machines, setMachines] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); useIndexerEvents({ account: wallet.publicKey?.toBase58(), @@ -43,58 +78,39 @@ export function HostDashboard() { }, }); - useEffect(() => { - if (!wallet.publicKey) return; - axios - .get( - `${BACKEND_URL}/user/depin/getAll?userPublicKey=${wallet.publicKey.toBase58()}`, - { - headers: { Authorization: `${localStorage.getItem("token")}` }, - }, - ) - .then((r) => { - if (r.status === 200) setMachines(r.data); - }) - .catch(console.error); - }, [wallet]); + const fetchMachines = useCallback(async () => { + const pubKey = wallet.publicKey?.toBase58(); + if (!pubKey) return; + setLoading(true); + setError(false); + try { + const r = await api.get(`/user/depin/getAll?userPublicKey=${pubKey}`); + if (r.status === 200) setMachines(r.data); + } catch { + setError(true); + } + setLoading(false); + }, [wallet.publicKey]); - if (!wallet.publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Sign in to view your host dashboard -

- - Sign in → - -
-
- ); - } + useEffect(() => { + fetchMachines(); + }, [fetchMachines]); const active = machines.filter((m) => m.isActive).length; const totalEarned = machines.reduce((s, m) => s + m.claimedSOL, 0); return ( -
-
+
- {/* header */}
- {/* summary strip */} - - {[ - { label: "Total machines", value: String(machines.length) }, - { label: "Active", value: String(active), accent: active > 0 }, - { - label: "Total earned", - value: `${totalEarned.toFixed(4)} SOL`, - green: true, - }, - ].map((s) => ( -
- - {s.label} - - - {s.value} - + {timedOut && !error ? ( + +

+ Loading is taking longer than expected. Please try again. +

+ +
+ ) : error ? ( + + +

+ Failed to load machines +

+

+ Something went wrong while fetching your machines. +

+ +
+ ) : loading ? ( + <> + +
+ {[...Array(3)].map((_, i) => ( + + ))}
- ))} - + + ) : ( + <> + + {[ + { label: "Total machines", value: String(machines.length) }, + { label: "Active", value: String(active), accent: active > 0 }, + { + label: "Total earned", + value: `${totalEarned.toFixed(4)} SOL`, + green: true, + }, + ].map((s) => ( +
+ + {s.label} + + + {s.value} + +
+ ))} +
- {/* table */} - - {machines.length === 0 ? ( -
-

- No machines registered yet. -

- -
- ) : ( - - )} -
+ + {machines.length === 0 ? ( +
+

+ No machines registered yet. +

+ +
+ ) : ( + + )} +
+ + )}
); diff --git a/web-services/apps/frontend/src/pages/HostMachine.tsx b/web-services/apps/frontend/src/pages/HostMachine.tsx index 224b7bc..b116698 100644 --- a/web-services/apps/frontend/src/pages/HostMachine.tsx +++ b/web-services/apps/frontend/src/pages/HostMachine.tsx @@ -1,12 +1,11 @@ import { useState, useMemo } from "react"; import { motion, AnimatePresence } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; import { toast } from "sonner"; import { Step1, type Step1FormData } from "@/components/DepinHosting/Step1"; import { Step2 } from "@/components/DepinHosting/Step2"; import { Step3 } from "@/components/DepinHosting/Step3"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; -import { Link } from "react-router-dom"; +import { api } from "@/lib/api"; import { useWallet } from "@solana/wallet-adapter-react"; import { IconCheck, IconCoins, IconTrendingUp } from "@tabler/icons-react"; @@ -227,11 +226,10 @@ export function HostRegister() { const handleStep1Submit = async () => { setIsLoading(true); try { - const res = await axios.post( - `${BACKEND_URL}/user/depin/register`, - { ...formData, userPublicKey: wallet.publicKey?.toBase58() }, - { headers: { Authorization: `${localStorage.getItem("token")}` } }, - ); + const res = await api.post("/user/depin/register", { + ...formData, + userPublicKey: wallet.publicKey?.toBase58(), + }); if (res.status === 200) { setId(res.data.vm.id); toast.success("Machine details saved. Proceed to verification."); @@ -251,12 +249,7 @@ export function HostRegister() { setIsLoading(false); return; } - const res = await axios.get( - `${BACKEND_URL}/user/depin/getById?id=${id}`, - { - headers: { Authorization: `${localStorage.getItem("token")}` }, - }, - ); + const res = await api.get(`/user/depin/getById?id=${id}`); if (res.data.verified) { toast.success("Machine verified!"); setCurrentStep(3); @@ -269,43 +262,16 @@ export function HostRegister() { setIsLoading(false); }; - if (!wallet.publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Connect your wallet to register a machine -

- - Sign in → - -
-
- ); - } - return (
- {/* Subtle radial glow */} -
diff --git a/web-services/apps/frontend/src/pages/HostMachineDetails.tsx b/web-services/apps/frontend/src/pages/HostMachineDetails.tsx index 96cee07..36081e0 100644 --- a/web-services/apps/frontend/src/pages/HostMachineDetails.tsx +++ b/web-services/apps/frontend/src/pages/HostMachineDetails.tsx @@ -1,9 +1,12 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { motion } from "motion/react"; import { useParams, Link } from "react-router-dom"; import { useWallet } from "@solana/wallet-adapter-react"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; +import { RefreshCw, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useLoadingTimeout } from "@/hooks/useLoadingTimeout"; +import { api } from "@/lib/api"; import { type Machine } from "../../types/depinMachines"; function StatRow({ @@ -34,44 +37,43 @@ export function HostMachineDetails() { const wallet = useWallet(); const [machine, setMachine] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); - useEffect(() => { - if (!wallet.publicKey) return; - axios - .get( - `${BACKEND_URL}/user/depin/getAll?userPublicKey=${wallet.publicKey.toBase58()}`, - { - headers: { Authorization: `${localStorage.getItem("token")}` }, - }, - ) - .then((r) => setMachine(r.data.find((m: Machine) => m.id === id) ?? null)) - .catch(console.error) - .finally(() => setLoading(false)); + const fetchMachine = useCallback(async () => { + setLoading(true); + setError(false); + try { + const r = await api.get( + `/user/depin/getAll?userPublicKey=${wallet.publicKey?.toBase58() ?? ""}`, + ); + setMachine(r.data.find((m: Machine) => m.id === id) ?? null); + } catch { + setError(true); + } + setLoading(false); }, [wallet.publicKey, id]); - if (!wallet.publicKey || !localStorage.getItem("token")) { + useEffect(() => { + fetchMachine(); + }, [fetchMachine]); + + if (timedOut && !error) { return ( -
- -

Sign in to view machine

- - Sign in → - -
+
+
+

+ Loading is taking longer than expected. Please try again. +

+ +
); } if (loading) { return ( -
+
+ + +

+ Failed to load machine details. +

+ +
+
+ ); + } + if (!machine) { return ( -
+
-
+
diff --git a/web-services/apps/frontend/src/pages/Hosting.tsx b/web-services/apps/frontend/src/pages/Hosting.tsx index 94d1892..4771a2d 100644 --- a/web-services/apps/frontend/src/pages/Hosting.tsx +++ b/web-services/apps/frontend/src/pages/Hosting.tsx @@ -1,6 +1,6 @@ import { motion } from "motion/react"; import { Link } from "react-router-dom"; -import { useWallet } from "@solana/wallet-adapter-react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; const STEPS = [ { @@ -27,38 +27,12 @@ const STEPS = [ ]; export function Hosting() { - const wallet = useWallet(); - - if (!wallet.publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Connect your wallet to continue -

- - Sign in → - -
-
- ); - } - return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/Legal.tsx b/web-services/apps/frontend/src/pages/Legal.tsx index 6442a0b..7aab303 100644 --- a/web-services/apps/frontend/src/pages/Legal.tsx +++ b/web-services/apps/frontend/src/pages/Legal.tsx @@ -1,4 +1,5 @@ import { motion } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; function LegalPage({ title, @@ -12,13 +13,11 @@ function LegalPage({ sections: { heading: string; body: string }[]; }) { return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/NotFound.tsx b/web-services/apps/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..62429ce --- /dev/null +++ b/web-services/apps/frontend/src/pages/NotFound.tsx @@ -0,0 +1,48 @@ +import { motion } from "motion/react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Home, SearchX } from "lucide-react"; + +export function NotFound() { + return ( +
+
+ + +
+ +
+
+

+ + 404 + +

+

+ This page doesn't exist or has been moved. +

+ + + +
+
+ ); +} diff --git a/web-services/apps/frontend/src/pages/Notifications.tsx b/web-services/apps/frontend/src/pages/Notifications.tsx index 37bfa1a..b3d1047 100644 --- a/web-services/apps/frontend/src/pages/Notifications.tsx +++ b/web-services/apps/frontend/src/pages/Notifications.tsx @@ -1,14 +1,13 @@ import { motion } from "motion/react"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; export default function Notifications() { return ( -
-
+
diff --git a/web-services/apps/frontend/src/pages/Profile.tsx b/web-services/apps/frontend/src/pages/Profile.tsx index b661f25..19d92f2 100644 --- a/web-services/apps/frontend/src/pages/Profile.tsx +++ b/web-services/apps/frontend/src/pages/Profile.tsx @@ -1,40 +1,54 @@ +import { useEffect, useState } from "react"; import { motion } from "motion/react"; import { useWallet } from "@solana/wallet-adapter-react"; -import { Link } from "react-router-dom"; +import { BackgroundGlow } from "@/components/BackgroundGlow"; +import { Check } from "lucide-react"; + +import { api } from "@/lib/api"; +import { Input } from "@/components/ui/input"; +import { showSuccess, showError } from "@/lib/toast"; import { toast } from "sonner"; +import { useLoadingTimeout } from "@/hooks/useLoadingTimeout"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/Skeleton"; function Row({ label, value, mono = false, + children, }: { label: string; - value: string; + value?: string; mono?: boolean; + children?: React.ReactNode; }) { return (
{label} - - {value} - + {children ?? ( + + {value} + + )} +
+ ); +} + +function SkeletonRow() { + return ( +
+ +
); } const SECTIONS = [ - { - label: "Identity", - rows: (pk: string, email: string) => [ - { label: "Wallet", value: pk, mono: true }, - { label: "Email", value: email || "—" }, - { label: "Network", value: "Solana Devnet" }, - ], - }, { label: "Security", rows: () => [ @@ -58,40 +72,71 @@ const SECTIONS = [ export default function Profile() { const { publicKey } = useWallet(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const timedOut = useLoadingTimeout(loading, 30000); + const [isEditing, setIsEditing] = useState(false); + const [formData, setFormData] = useState({ name: "", email: "" }); + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); - if (!publicKey || !localStorage.getItem("token")) { - return ( -
- -

- Sign in to access settings -

- - Sign in → - -
-
- ); - } + const fetchProfile = async () => { + setLoading(true); + setError(false); + try { + const res = await api.get("/user/profile"); + const d = res.data?.data ?? res.data; + setFormData({ + name: d.name ?? "", + email: d.email ?? localStorage.getItem("email") ?? "", + }); + } catch { + setError(true); + showError("Failed to load profile"); + } + setLoading(false); + }; + + useEffect(() => { + fetchProfile(); + }, [publicKey]); + + const handleSave = async () => { + setSaving(true); + try { + await api.put("/user/profile", formData); + localStorage.setItem("email", formData.email); + showSuccess("Profile updated"); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + setIsEditing(false); + } catch { + showError("Failed to update profile"); + } finally { + setSaving(false); + } + }; - const pk = publicKey.toBase58(); - const email = localStorage.getItem("email") ?? ""; + const handleCancel = () => { + setFormData({ + name: "", + email: localStorage.getItem("email") ?? "", + }); + setIsEditing(false); + }; + + const pk = publicKey?.toBase58() ?? ""; + const email = formData.email ?? localStorage.getItem("email") ?? ""; return ( -
-
+
@@ -124,18 +169,125 @@ export default function Profile() { {/* sections */}
+ {/* Identity — manually rendered for edit support */} + +
+ + Identity + + {!loading && !isEditing && ( + + )} +
+
+ {timedOut && !error ? ( +
+

+ Loading is taking longer than expected. Please try again. +

+ +
+ ) : loading ? ( + <> + + + + + ) : ( + <> + + {isEditing ? ( + <> + + + setFormData((prev) => ({ + ...prev, + name: e.target.value, + })) + } + className="h-8 w-48 text-sm text-right" + placeholder="Your name" + /> + + + + setFormData((prev) => ({ + ...prev, + email: e.target.value, + })) + } + className="h-8 w-48 text-sm text-right" + placeholder="your@email.com" + /> + + + ) : ( + <> + + + + )} + + {isEditing && ( +
+ + +
+ )} + + )} +
+
+ {SECTIONS.map((section, i) => ( {section.label}
- {section.rows(pk, email).map((r) => ( + {section.rows().map((r) => ( ))}
@@ -147,11 +299,14 @@ export default function Profile() { - - -
- ); - } - if (paymentStatus === "Pending") { return (
@@ -264,7 +261,10 @@ export const RentVM = () => { } return ( -
+
{ @@ -319,56 +320,123 @@ export const RentVM = () => { {/* Main Content */}
{/* Step 1: Configuration */} - {currentStep === 1 && ( - - setSelectedConfig(config?.id || "") - } - setStep={setCurrentStep} - selectedConfig={selectedConfig} - setSelectedConfig={setSelectedConfig} - /> - )} + + {currentStep === 1 && + (timedOut && !error ? ( +
+

+ Loading is taking longer than expected. Please try again. +

+ +
+ ) : loading ? ( +
+
+ + +
+
+ + +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+
+ + +
+
+ +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+
+ ) : ( + + setSelectedConfig(config?.id || "") + } + setStep={setCurrentStep} + selectedConfig={selectedConfig} + setSelectedConfig={setSelectedConfig} + /> + ))} +
{/* Step 2: Payment Method */} - {currentStep === 2 && ( - - )} + + {currentStep === 2 && ( + + )} + - {/* Step 2: Review */} - {currentStep === 3 && ( - - )} + {/* Step 3: Review & Deploy */} + + {currentStep === 3 && ( + + )} + + {Object.keys(errors).length > 0 && ( +
+ {Object.entries(errors).map(([key, msg]) => ( +

+ {msg} +

+ ))} +
+ )} {/* Navigation Buttons */} -
-
-
- - - - Roadmap - - -

- +
+ +
+
+ - What's next - -

-

- We're working on expanding the network, adding new regions, and - improving the developer experience. The roadmap will be published - here as milestones are defined. -

-
-
-

- Roadmap details coming soon. -

+ + + Roadmap + + +

+ + What's next + +

+

+ We're working on expanding the network, adding new regions, and + improving the developer experience. The roadmap will be published + here as milestones are defined. +

+
+
+

+ Roadmap details coming soon. +

+
-
+
); } diff --git a/web-services/apps/frontend/src/pages/Signin.tsx b/web-services/apps/frontend/src/pages/Signin.tsx index 0e11f5c..37a5c03 100644 --- a/web-services/apps/frontend/src/pages/Signin.tsx +++ b/web-services/apps/frontend/src/pages/Signin.tsx @@ -12,8 +12,7 @@ import { } from "@/components/ui/card"; import { Mail, ArrowRight, WalletIcon } from "lucide-react"; import { useWallet } from "@solana/wallet-adapter-react"; -import axios from "axios"; -import { BACKEND_URL } from "@/config"; +import { api } from "@/lib/api"; import { WalletMultiButton } from "@solana/wallet-adapter-react-ui"; import { useNavigate } from "react-router-dom"; import { AxionLogo } from "@/components/AxionLogo"; @@ -24,11 +23,34 @@ export function SignIn() { const navigate = useNavigate(); const [formData, setFormData] = useState({ email: "", + password: "", }); const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + const validate = () => { + const newErrors: Record = {}; + if (!formData.email) { + newErrors.email = "Email is required"; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "Invalid email format"; + } + if (!formData.password) { + newErrors.password = "Password is required"; + } else if (formData.password.length < 6) { + newErrors.password = "Password must be at least 6 characters"; + } + return newErrors; + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + const validationErrors = validate(); + setErrors(validationErrors); + if (Object.keys(validationErrors).length > 0) { + setIsLoading(false); + return; + } setIsLoading(true); if (!wallet?.adapter.connected) { toast.error("Please connect your wallet first."); @@ -36,7 +58,7 @@ export function SignIn() { return; } try { - const res = await axios.post(`${BACKEND_URL}/user/login`, { + const res = await api.post("/user/login", { ...formData, publicKey: wallet.adapter.publicKey?.toString(), }); @@ -44,14 +66,14 @@ export function SignIn() { toast.success("Successfully signed in!"); localStorage.setItem("token", `Bearer ${res.data.token}`); localStorage.setItem("email", formData.email); - setFormData({ email: "" }); + setFormData({ email: "", password: "" }); navigate("/dashboard"); } else { toast.error("Failed to sign in. Please try again."); - console.error("Failed to sign in:", res.data); + /* login error handled by toast above */ } - } catch (error) { - console.error("Error signing in:", error); + } catch { + /* toast handled by api interceptor */ } setIsLoading(false); }; @@ -61,6 +83,7 @@ export function SignIn() { ...prev, [e.target.name]: e.target.value, })); + setErrors((prev) => ({ ...prev, [e.target.name]: "" })); }; return ( @@ -122,7 +145,12 @@ export function SignIn() { )}
- +
+
+ + + {errors.password && ( +

{errors.password}

+ )}
- +
+ + + + +
); } - if (!wallet || !localStorage.getItem("token")) { + if (timedOut && !error) { return ( -
+
+
+

+ Loading is taking longer than expected. Please try again. +

+ +
+
+ ); + } + + if (loading && !vm) { + return ( +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); + } + + if (!vm) return null; + + if (vm.status === "DELETED") { + return ( +
-

Please SignIn

+

VM Deleted

- Please connect your wallet and ensure you are signed in to proceed. + This virtual machine has been deleted.

- - + +
@@ -115,12 +162,14 @@ export function VMDetails() { } return ( -
- {/* Header */} +
- {/* Main Content */}
{vm.PaymentType === "ESCROW" && } @@ -128,9 +177,8 @@ export function VMDetails() { {vm.provider != "LOCAL" && }
- {/* Sidebar */}
-
+ ); } diff --git a/web-services/apps/frontend/src/vite-env.d.ts b/web-services/apps/frontend/src/vite-env.d.ts index 06d2c78..63a7f1f 100644 --- a/web-services/apps/frontend/src/vite-env.d.ts +++ b/web-services/apps/frontend/src/vite-env.d.ts @@ -1,5 +1,10 @@ /// +declare module "*.json" { + const value: unknown; + export default value; +} + interface Window { __AXION_ENV__?: { VITE_BACKEND_URL?: string; diff --git a/web-services/apps/worker/contract.ts b/web-services/apps/worker/contract.ts index 6369a20..b2fae41 100644 --- a/web-services/apps/worker/contract.ts +++ b/web-services/apps/worker/contract.ts @@ -6,12 +6,25 @@ import { Transaction, } from "@solana/web3.js"; import { AnchorProvider, Program, type Idl } from "@coral-xyz/anchor"; -import idl from "./idl/contract.json"; +import { existsSync, readFileSync } from "node:fs"; import bs58 from "bs58"; import BN from "bn.js"; +import fallbackIdl from "./contractIdl"; const connection = new Connection(clusterApiUrl("devnet")); +function loadIdl(): Idl { + const idlPath = new URL("./idl/contract.json", import.meta.url); + + if (existsSync(idlPath)) { + return JSON.parse(readFileSync(idlPath, "utf8")) as Idl; + } + + return fallbackIdl; +} + +const idl = loadIdl(); + const VAULT_SEED = "axion_vault"; const privateKey = process.env.PRIVATE_KEY; diff --git a/web-services/apps/worker/contractIdl.ts b/web-services/apps/worker/contractIdl.ts new file mode 100644 index 0000000..f64b880 --- /dev/null +++ b/web-services/apps/worker/contractIdl.ts @@ -0,0 +1,353 @@ +import type { Idl } from "@coral-xyz/anchor"; + +const idl = { + address: "J7nyNjMR7p9Xi8ohzkNAFmnAeVUBb1AMpGKTFGtFvVjJ", + metadata: { + name: "contract", + version: "0.1.0", + spec: "0.1.0", + description: "Created with Anchor", + }, + instructions: [ + { + name: "activate_host", + accounts: [ + { name: "user", writable: true, signer: true }, + { name: "host" }, + { + name: "host_machine", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "host" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [{ name: "id", type: "string" }], + }, + { + name: "claim_rewards", + accounts: [ + { name: "host", writable: true, signer: true }, + { + name: "host_machine", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "host" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { name: "admin" }, + { + name: "vault_account", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 118, 97, 117, 108, 116, 95, 97, 99, 99, 111, 117, 110, 116, + ], + }, + { kind: "account", path: "admin" }, + { + kind: "const", + value: [97, 120, 105, 111, 110, 95, 118, 97, 117, 108, 116], + }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [{ name: "id", type: "string" }], + }, + { + name: "deactivate_host", + accounts: [ + { name: "user", writable: true, signer: true }, + { name: "host" }, + { + name: "host_machine", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "host" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [{ name: "id", type: "string" }], + }, + { + name: "end_rental_session", + accounts: [ + { name: "payer", writable: true, signer: true }, + { + name: "rental_session", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 114, 101, 110, 116, 97, 108, 95, 115, 101, 115, 115, 105, 111, + 110, + ], + }, + { kind: "arg", path: "_user_pub_key" }, + { kind: "arg", path: "_id" }, + ], + }, + }, + ], + args: [ + { name: "id", type: "string" }, + { name: "_user_pub_key", type: "pubkey" }, + ], + }, + { + name: "force_terminate_rental", + accounts: [ + { name: "admin", writable: true, signer: true }, + { name: "user" }, + { + name: "rental_session", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 114, 101, 110, 116, 97, 108, 95, 115, 101, 115, 115, 105, 111, + 110, + ], + }, + { kind: "account", path: "user" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "escrow_session", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 101, 115, 99, 114, 111, 119, 95, 115, 101, 115, 115, 105, 111, + 110, + ], + }, + { kind: "account", path: "user" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "escrow_vault", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 101, 115, 99, 114, 111, 119, 95, 118, 97, 117, 108, 116, + ], + }, + { kind: "account", path: "user" }, + { kind: "account", path: "admin" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "vault_account", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 118, 97, 117, 108, 116, 95, 97, 99, 99, 111, 117, 110, 116, + ], + }, + { kind: "account", path: "admin" }, + { + kind: "const", + value: [97, 120, 105, 111, 110, 95, 118, 97, 117, 108, 116], + }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [{ name: "id", type: "string" }], + }, + { + name: "initialise_host_registration", + accounts: [ + { name: "admin", writable: true, signer: true }, + { name: "user_key" }, + { + name: "host_machine_registration", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "user_key" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [ + { name: "id", type: "string" }, + { name: "host_name", type: "string" }, + { name: "machine_type", type: "string" }, + { name: "os", type: "string" }, + { name: "disk_size", type: "u64" }, + { name: "sol_per_hour", type: "u64" }, + ], + }, + { + name: "penalize_host", + accounts: [ + { name: "admin", writable: true, signer: true }, + { name: "user" }, + { + name: "host_machine", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "user" }, + { kind: "arg", path: "id" }, + ], + }, + }, + ], + args: [{ name: "id", type: "string" }], + }, + { + name: "settle_depin_job", + accounts: [ + { name: "admin", writable: true, signer: true }, + { name: "renter", writable: true }, + { name: "host", writable: true }, + { name: "platform_vault", writable: true }, + { + name: "rental_session", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 114, 101, 110, 116, 97, 108, 95, 115, 101, 115, 115, 105, 111, + 110, + ], + }, + { kind: "account", path: "renter" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "escrow_session", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 101, 115, 99, 114, 111, 119, 95, 115, 101, 115, 115, 105, 111, + 110, + ], + }, + { kind: "account", path: "renter" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "escrow_vault", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 101, 115, 99, 114, 111, 119, 95, 118, 97, 117, 108, 116, + ], + }, + { kind: "account", path: "renter" }, + { kind: "account", path: "admin" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { + name: "host_machine", + writable: true, + pda: { + seeds: [ + { + kind: "const", + value: [ + 104, 111, 115, 116, 95, 109, 97, 99, 104, 105, 110, 101, + ], + }, + { kind: "account", path: "host" }, + { kind: "arg", path: "id" }, + ], + }, + }, + { name: "system_program", address: "11111111111111111111111111111111" }, + ], + args: [ + { name: "id", type: "string" }, + { name: "host_earned", type: "u64" }, + { name: "platform_fee_bps", type: "u16" }, + ], + }, + ], +} as Idl; + +export default idl; diff --git a/web-services/apps/worker/index.ts b/web-services/apps/worker/index.ts index 2a4ca14..e14e547 100644 --- a/web-services/apps/worker/index.ts +++ b/web-services/apps/worker/index.ts @@ -1,6 +1,7 @@ import { Worker } from "bullmq"; import compute from "@google-cloud/compute"; import prisma from "@axion/db"; +import { logger } from "@axion/utilities"; import { redisConnection as connection } from "@axion/utilities/redis"; import { activateHost, @@ -14,259 +15,211 @@ import { const projectId = process.env.PROJECT_ID; const PLATFORM_VAULT_PUBKEY = process.env.PLATFORM_VAULT_PUBKEY || ""; -const PLATFORM_FEE_BPS = Number(process.env.PLATFORM_FEE_BPS || "1000"); // default 10% +const PLATFORM_FEE_BPS = Number(process.env.PLATFORM_FEE_BPS || "1000"); const ws = new WebSocket(process.env.WS_URL || "ws://localhost:8080"); const worker = new Worker( "vm-termination", async (job) => { - console.log( - `Processing job ${job.id} for VM instance with ID ${job.data.vmId}`, - ); - try { - const { instanceId, zone, pubKey, isEscrow, id } = job.data; - const vmInstance = await prisma.vMInstance.findFirst({ - where: { - id: id, - instanceId: instanceId, - }, - }); - if (!vmInstance) { - return; - } - const txn = await endRentalSession(vmInstance.id, pubKey, isEscrow); - if (!txn) { - console.error( - `Failed to end rental session for VM instance with ID ${instanceId}`, - ); - return; - } - const operationDone = await deleteInstance(zone, instanceId); - if (!operationDone) { - console.error(`Failed to delete VM instance with ID ${instanceId}`); - return; - } - await prisma.vMInstance.update({ - where: { - id: id, - instanceId: instanceId, - }, - data: { - status: "DELETED", - }, - }); - console.log( - `VM instance with ID ${instanceId} deleted and rental session ended successfully.`, - ); - } catch (error) { - console.error(`Error processing job ${job.data.vmId}:`, error); + logger.info(`Processing job ${job.id} for VM instance`, { + vmId: job.data.vmId, + }); + const { instanceId, zone, pubKey, isEscrow, id } = job.data; + const vmInstance = await prisma.vMInstance.findFirst({ + where: { id, instanceId }, + }); + if (!vmInstance) { + throw new Error(`VM instance not found: ${id}`); } + const txn = await endRentalSession(vmInstance.id, pubKey, isEscrow); + if (!txn) { + throw new Error(`Failed to end rental session for ${instanceId}`); + } + const operationDone = await deleteInstance(zone, instanceId); + if (!operationDone) { + throw new Error(`Failed to delete VM instance ${instanceId}`); + } + await prisma.vMInstance.update({ + where: { id, instanceId }, + data: { status: "DELETED" }, + }); + logger.info(`VM instance ${instanceId} deleted and rental session ended`); }, - { - connection, - }, + { connection }, ); worker.on("completed", (job) => { - console.log(`Job completed successfully: ${job.data.instanceId}`); + logger.info(`Job completed: ${job.data.instanceId}`); }); worker.on("failed", (job, err) => { - console.error(`Job ${job?.id} failed: ${err.message}`); + logger.error(`Job ${job?.id} failed`, err); }); const DepinWorker = new Worker( "initialise-host-pda", async (job) => { - try { - const { - id, - hostName, - machineType, - os, - diskSize, - pricePerHour, - userPubKey, - } = job.data; - const tx = await InitialiseHostPDA( - id, - hostName, - machineType, - os, - diskSize, - pricePerHour, - userPubKey, - ); - if (!tx) { - console.error(`Failed to initialise host PDA for job ${job.id}`); - return; - } - console.log(`Host PDA initialised successfully for job ${job.id}:`, tx); - await prisma.depinHostMachine.update({ - where: { - id: id, - }, - data: { - pdaAddress: tx.hostMachinePda.toBase58(), - }, - }); - } catch (error) { - console.error(`Error processing job ${job.id}:`, error); + const { + id, + hostName, + machineType, + os, + diskSize, + pricePerHour, + userPubKey, + } = job.data; + const tx = await InitialiseHostPDA( + id, + hostName, + machineType, + os, + diskSize, + pricePerHour, + userPubKey, + ); + if (!tx) { + throw new Error(`Failed to initialise host PDA for job ${job.id}`); } + logger.info(`Host PDA initialised for job ${job.id}`, { tx }); + await prisma.depinHostMachine.update({ + where: { id }, + data: { pdaAddress: tx.hostMachinePda.toBase58() }, + }); }, - { - connection, - }, + { connection }, ); DepinWorker.on("completed", (job) => { - console.log(`Depin job completed successfully: ${job.id}`); + logger.info(`Depin job completed: ${job.id}`); }); DepinWorker.on("failed", (job, err) => { - console.error(`Depin job ${job?.id} failed: ${err.message}`); + logger.error(`Depin job ${job?.id} failed`, err); }); const changeVmStatus = new Worker( "changeVMStatus", async (job) => { const { id, userPubKey, status } = job.data; - try { - status === false && (await deActivateHost(id, userPubKey)); - status === true && (await activateHost(id, userPubKey)); - } catch (error) { - console.error(`Error processing deactivation job ${job.id}:`, error); + if (status === false) { + await deActivateHost(id, userPubKey); + } else if (status === true) { + await activateHost(id, userPubKey); } + logger.info(`Status change processed for ${id}`, { status }); }, - { - connection, - }, + { connection }, ); changeVmStatus.on("completed", (job) => { - console.log(`Deactivation job completed successfully: ${job.id}`); + logger.info(`Status change completed: ${job.id}`); }); changeVmStatus.on("failed", (job, err) => { - console.error(`Deactivation job ${job?.id} failed: ${err.message}`); + logger.error(`Status change job ${job?.id} failed`, err); }); const terminateDepinVm = new Worker( "terminate-depin-vm", async (job) => { const { pubKey, id } = job.data; + const findVm = await prisma.depinHostMachine.findFirst({ + where: { id }, + include: { VMImage: true }, + }); + if (!findVm) { + throw new Error(`No VM found with ID ${id}`); + } - try { - const findVm = await prisma.depinHostMachine.findFirst({ - where: { id }, - include: { VMImage: true }, - }); - if (!findVm) { - console.error(`No VM found with ID ${id}`); - return; - } - - // Find the VM instance to calculate uptime - const vmInstance = await prisma.vMInstance.findFirst({ - where: { id: findVm.VMImage?.id }, - }); - - // Skip if already settled (status already TERMINATED or DELETED) - if ( - vmInstance && - vmInstance.status !== "DEPLOYING" && - vmInstance.status !== "RUNNING" && - vmInstance.status !== "BOOTING" - ) { - console.log( - `VM ${vmInstance.id} already in state ${vmInstance.status}, skipping settlement`, - ); - return; - } + const vmInstance = await prisma.vMInstance.findFirst({ + where: { id: findVm.VMImage?.id }, + }); - // Send end-job to host agent (backend may have already sent it, idempotent) - ws.send( - JSON.stringify({ - type: "end-job", - machineId: findVm.id, - jobId: findVm.VMImage?.id, - }), + if ( + vmInstance && + vmInstance.status !== "DEPLOYING" && + vmInstance.status !== "RUNNING" && + vmInstance.status !== "BOOTING" + ) { + logger.info( + `VM ${vmInstance.id} already in state ${vmInstance.status}, skipping settlement`, ); + return; + } - if (vmInstance) { - const uptimeMs = Date.now() - new Date(vmInstance.startTime).getTime(); - const uptimeHours = uptimeMs / (1000 * 60 * 60); - const hostEarned = Math.floor(uptimeHours * findVm.perHourPrice * 1e9); // lamports - const totalMs = - new Date(vmInstance.endTime).getTime() - - new Date(vmInstance.startTime).getTime(); - - // Settle: host gets paid, platform gets cut, renter gets refund - const tx = await settleDepinJob( - vmInstance.id, - pubKey, - findVm.userPublicKey, - hostEarned, - PLATFORM_FEE_BPS, - PLATFORM_VAULT_PUBKEY, - ); + ws.send( + JSON.stringify({ + type: "end-job", + machineId: findVm.id, + jobId: findVm.VMImage?.id, + }), + ); - const platformFee = (hostEarned * PLATFORM_FEE_BPS) / 10000; - const hostPayout = hostEarned - platformFee; - const escrowLamports = vmInstance.price * 1e9; - const renterRefund = Math.max(0, escrowLamports - hostEarned); + if (vmInstance) { + const uptimeMs = Date.now() - new Date(vmInstance.startTime).getTime(); + const uptimeHours = uptimeMs / (1000 * 60 * 60); + const hostEarned = Math.floor(uptimeHours * findVm.perHourPrice * 1e9); + const totalMs = + new Date(vmInstance.endTime).getTime() - + new Date(vmInstance.startTime).getTime(); - await prisma.depinSettlement.create({ - data: { - hostMachineId: findVm.id, - renterPubKey: pubKey, - jobId: vmInstance.id, - hostEarned: hostPayout / 1e9, - platformFee: platformFee / 1e9, - renterRefund: renterRefund / 1e9, - uptimeSeconds: Math.floor(uptimeMs / 1000), - totalSeconds: Math.floor(totalMs / 1000), - txSignature: tx, - }, - }); + const tx = await settleDepinJob( + vmInstance.id, + pubKey, + findVm.userPublicKey, + hostEarned, + PLATFORM_FEE_BPS, + PLATFORM_VAULT_PUBKEY, + ); - await prisma.vMInstance.update({ - where: { id: vmInstance.id }, - data: { status: "DELETED" }, - }); - } + const platformFee = (hostEarned * PLATFORM_FEE_BPS) / 10000; + const hostPayout = hostEarned - platformFee; + const escrowLamports = vmInstance.price * 1e9; + const renterRefund = Math.max(0, escrowLamports - hostEarned); - await prisma.depinHostMachine.update({ - where: { id: findVm.id }, - data: { isOccupied: false }, + await prisma.depinSettlement.create({ + data: { + hostMachineId: findVm.id, + renterPubKey: pubKey, + jobId: vmInstance.id, + hostEarned: hostPayout / 1e9, + platformFee: platformFee / 1e9, + renterRefund: renterRefund / 1e9, + uptimeSeconds: Math.floor(uptimeMs / 1000), + totalSeconds: Math.floor(totalMs / 1000), + txSignature: tx, + }, }); - console.log(`DePIN job ${id} settled successfully`); - } catch (error) { - console.error( - `Error processing terminate depin VM job ${job.id}:`, - error, - ); + await prisma.vMInstance.update({ + where: { id: vmInstance.id }, + data: { status: "DELETED" }, + }); } + + await prisma.depinHostMachine.update({ + where: { id: findVm.id }, + data: { isOccupied: false }, + }); + + logger.info(`DePIN job ${id} settled successfully`); }, { connection }, ); terminateDepinVm.on("completed", (job) => { - console.log(`Terminate depin VM job completed successfully: ${job.id}`); + logger.info(`Terminate depin VM completed: ${job.id}`); }); terminateDepinVm.on("failed", (job, err) => { - console.error(`Terminate depin VM job ${job?.id} failed: ${err.message}`); + logger.error(`Terminate depin VM ${job?.id} failed`, err); }); async function deleteInstance(zone: string, instanceId: string) { const instancesClient = new compute.InstancesClient(); - await instancesClient.delete({ project: projectId, zone, instance: instanceId, }); - return true; } @@ -274,48 +227,38 @@ const claimRewardsWorker = new Worker( "claim-rewards", async (job) => { const { id, userPubKey } = job.data; - try { - const tx = await claimRewards(id, userPubKey); - if (!tx) { - console.error(`Failed to claim rewards for ${id}`); - return; - } - console.log(`Rewards claimed for ${id}: ${tx}`); - } catch (error) { - console.error(`Error claiming rewards for ${id}:`, error); + const tx = await claimRewards(id, userPubKey); + if (!tx) { + throw new Error(`Failed to claim rewards for ${id}`); } + logger.info(`Rewards claimed for ${id}`, { tx }); }, { connection }, ); claimRewardsWorker.on("completed", (job) => { - console.log(`Claim rewards job completed: ${job.id}`); + logger.info(`Claim rewards completed: ${job.id}`); }); claimRewardsWorker.on("failed", (job, err) => { - console.error(`Claim rewards job ${job?.id} failed: ${err.message}`); + logger.error(`Claim rewards ${job?.id} failed`, err); }); const penalizeHostWorker = new Worker( "penalize-host", async (job) => { const { id, userPubKey } = job.data; - try { - const tx = await penalizeHost(id, userPubKey); - if (!tx) { - console.error(`Failed to penalize host ${id}`); - return; - } - console.log(`Host ${id} penalized: ${tx}`); - } catch (error) { - console.error(`Error penalizing host ${id}:`, error); + const tx = await penalizeHost(id, userPubKey); + if (!tx) { + throw new Error(`Failed to penalize host ${id}`); } + logger.info(`Host ${id} penalized`, { tx }); }, { connection }, ); penalizeHostWorker.on("completed", (job) => { - console.log(`Penalize host job completed: ${job.id}`); + logger.info(`Penalize host completed: ${job.id}`); }); penalizeHostWorker.on("failed", (job, err) => { - console.error(`Penalize host job ${job?.id} failed: ${err.message}`); + logger.error(`Penalize host ${job?.id} failed`, err); }); diff --git a/web-services/apps/ws-relayer/package.json b/web-services/apps/ws-relayer/package.json index 3c5abf7..79117a6 100644 --- a/web-services/apps/ws-relayer/package.json +++ b/web-services/apps/ws-relayer/package.json @@ -8,7 +8,8 @@ "lint": "tsc --noEmit" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/minimatch": "^5.1.2" }, "peerDependencies": { "typescript": "^5" diff --git a/web-services/bun.lock b/web-services/bun.lock index dcc2f65..2ef2443 100644 --- a/web-services/bun.lock +++ b/web-services/bun.lock @@ -149,6 +149,7 @@ }, "devDependencies": { "@axion/db": "workspace:*", + "@axion/utilities": "workspace:*", "@types/bun": "latest", }, "peerDependencies": { @@ -164,6 +165,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/minimatch": "^6.0.0", }, "peerDependencies": { "typescript": "^5", diff --git a/web-services/packages/types/tsconfig.json b/web-services/packages/types/tsconfig.json index 238655f..7a2be3d 100644 --- a/web-services/packages/types/tsconfig.json +++ b/web-services/packages/types/tsconfig.json @@ -18,6 +18,7 @@ "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, + "types": [], // Some stricter flags (disabled by default) "noUnusedLocals": false, diff --git a/web-services/packages/utilities/authMiddleware.ts b/web-services/packages/utilities/authMiddleware.ts index a880c04..bbed7e6 100644 --- a/web-services/packages/utilities/authMiddleware.ts +++ b/web-services/packages/utilities/authMiddleware.ts @@ -18,10 +18,14 @@ export async function authMiddleware( next: NextFunction, ) { try { - const token = req.headers["authorization"]?.split(" ")[1]; + const header = req.headers["authorization"]; + const token = header?.startsWith("Bearer ") ? header.slice(7) : header; if (!token) { - res.status(401).json({ message: "No token provided" }); + res.status(401).json({ + success: false, + error: { code: "NO_TOKEN", message: "No token provided" }, + }); return; } @@ -30,34 +34,46 @@ export async function authMiddleware( }) as { userId: string }; if (!decoded.userId) { - res.status(403).json({ message: "Invalid token payload" }); + res.status(403).json({ + success: false, + error: { code: "INVALID_PAYLOAD", message: "Invalid token payload" }, + }); return; } req.userId = decoded.userId; - next(); } catch (error) { console.error("Auth error:", error); if (error instanceof jwt.TokenExpiredError) { - res.status(401).json({ message: "Token expired" }); + res.status(401).json({ + success: false, + error: { code: "TOKEN_EXPIRED", message: "Token expired" }, + }); return; } if (error instanceof jwt.JsonWebTokenError) { res.status(403).json({ - message: "Invalid token", - details: - process.env.NODE_ENV === "development" ? error.message : undefined, + success: false, + error: { + code: "INVALID_TOKEN", + message: "Invalid token", + details: + process.env.NODE_ENV === "development" ? error.message : undefined, + }, }); return; } res.status(500).json({ - message: "Error processing authentication", - details: - process.env.NODE_ENV === "development" - ? (error as Error).message - : undefined, + success: false, + error: { + code: "AUTH_ERROR", + message: "Error processing authentication", + details: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }, }); - return; } } diff --git a/web-services/packages/utilities/errors.ts b/web-services/packages/utilities/errors.ts new file mode 100644 index 0000000..7e92a90 --- /dev/null +++ b/web-services/packages/utilities/errors.ts @@ -0,0 +1,41 @@ +export class AppError extends Error { + constructor( + public statusCode: number, + public code: string, + message: string, + public details?: unknown, + ) { + super(message); + this.name = "AppError"; + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(404, "NOT_FOUND", `${resource} not found`); + } +} + +export class ValidationError extends AppError { + constructor(message = "Validation failed", details?: unknown) { + super(400, "VALIDATION_ERROR", message, details); + } +} + +export class UnauthorizedError extends AppError { + constructor(message = "Unauthorized") { + super(401, "UNAUTHORIZED", message); + } +} + +export class ForbiddenError extends AppError { + constructor(message = "Forbidden") { + super(403, "FORBIDDEN", message); + } +} + +export class ConflictError extends AppError { + constructor(message = "Resource already exists") { + super(409, "CONFLICT", message); + } +} diff --git a/web-services/packages/utilities/index.ts b/web-services/packages/utilities/index.ts index 7f6c96a..a621968 100644 --- a/web-services/packages/utilities/index.ts +++ b/web-services/packages/utilities/index.ts @@ -1,2 +1,18 @@ export { authMiddleware } from "./authMiddleware"; export { getRedisConnection, createQueue, redisConnection } from "./redis"; +export { + AppError, + NotFoundError, + ValidationError, + UnauthorizedError, + ForbiddenError, + ConflictError, +} from "./errors"; +export { + sendSuccess, + sendError, + asyncHandler, + formatZodError, + parseOrThrow, +} from "./response"; +export { logger } from "./logger"; diff --git a/web-services/packages/utilities/logger.ts b/web-services/packages/utilities/logger.ts new file mode 100644 index 0000000..49ada92 --- /dev/null +++ b/web-services/packages/utilities/logger.ts @@ -0,0 +1,40 @@ +const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const; +type LogLevel = keyof typeof LOG_LEVELS; + +const currentLevel: LogLevel = (process.env.LOG_LEVEL as LogLevel) || "info"; + +function log(level: LogLevel, message: string, meta?: unknown) { + if (LOG_LEVELS[level] < LOG_LEVELS[currentLevel]) return; + const entry = { + level, + msg: message, + ...(meta !== undefined ? { meta } : {}), + timestamp: new Date().toISOString(), + }; + if (level === "error") { + console.error(JSON.stringify(entry)); + } else if (level === "warn") { + console.warn(JSON.stringify(entry)); + } else { + console.log(JSON.stringify(entry)); + } +} + +export const logger = { + debug: (msg: string, meta?: unknown) => log("debug", msg, meta), + info: (msg: string, meta?: unknown) => log("info", msg, meta), + warn: (msg: string, meta?: unknown) => log("warn", msg, meta), + error: (msg: string, error?: unknown, meta?: unknown) => + log("error", msg, { + ...(error instanceof Error + ? { + error: { + message: error.message, + stack: error.stack, + name: error.name, + }, + } + : { error }), + ...(meta ? { meta } : {}), + }), +}; diff --git a/web-services/packages/utilities/package.json b/web-services/packages/utilities/package.json index 0e32e7b..c1cc75f 100644 --- a/web-services/packages/utilities/package.json +++ b/web-services/packages/utilities/package.json @@ -21,6 +21,9 @@ "exports": { ".": "./index.ts", "./auth": "./authMiddleware.ts", - "./redis": "./redis.ts" + "./redis": "./redis.ts", + "./errors": "./errors.ts", + "./response": "./response.ts", + "./logger": "./logger.ts" } } diff --git a/web-services/packages/utilities/redis.ts b/web-services/packages/utilities/redis.ts index 4840af4..e6a6bce 100644 --- a/web-services/packages/utilities/redis.ts +++ b/web-services/packages/utilities/redis.ts @@ -1,5 +1,5 @@ import IORedis from "ioredis"; -import { Queue } from "bullmq"; +import { type QueueOptions, Queue } from "bullmq"; const connection = new IORedis({ host: process.env.REDIS_HOST || "localhost", @@ -11,8 +11,8 @@ export function getRedisConnection(): IORedis { return connection; } -export function createQueue(name: string): Queue { - return new Queue(name, { connection }); +export function createQueue(name: string, opts?: Partial): Queue { + return new Queue(name, { connection, ...opts }); } export { connection as redisConnection }; diff --git a/web-services/packages/utilities/response.ts b/web-services/packages/utilities/response.ts new file mode 100644 index 0000000..25f8222 --- /dev/null +++ b/web-services/packages/utilities/response.ts @@ -0,0 +1,75 @@ +import type { Response } from "express"; +import type { ZodError } from "zod"; +import { AppError, ValidationError } from "./errors"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: unknown; + }; +} + +export function sendSuccess(res: Response, data: T, status = 200) { + res.status(status).json({ success: true, data } satisfies ApiResponse); +} + +export function sendError(res: Response, err: unknown) { + if (err instanceof AppError) { + res.status(err.statusCode).json({ + success: false, + error: { code: err.code, message: err.message, details: err.details }, + } satisfies ApiResponse); + return; + } + console.error("Unhandled error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Internal server error" }, + } satisfies ApiResponse); +} + +export function asyncHandler( + fn: ( + req: import("express").Request, + res: import("express").Response, + next: import("express").NextFunction, + ) => Promise, +) { + return ( + req: import("express").Request, + res: import("express").Response, + next: import("express").NextFunction, + ) => { + fn(req, res, next).catch(next); + }; +} + +export function formatZodError(error: ZodError) { + return error.errors.map((e) => ({ + path: e.path.join("."), + message: e.message, + })); +} + +export function parseOrThrow( + schema: { + safeParse: (data: unknown) => { + success: boolean; + data?: T; + error?: ZodError; + }; + }, + data: unknown, +): T { + const result = schema.safeParse(data); + if (!result.success) { + throw new ValidationError( + "Invalid request body", + formatZodError(result.error!), + ); + } + return result.data!; +}