Add deployment scripts and integrate APIs for dashboard #1
@ -1,3 +1,5 @@
|
||||
CERC_TEST_WEBAPP_CONFIG1="this string"
|
||||
CERC_TEST_WEBAPP_CONFIG2="this different string"
|
||||
CERC_WEBAPP_DEBUG=0
|
||||
NEXT_PUBLIC_MTM_SERVICE_URL=http://localhost:3000
|
||||
|
||||
# Blockchain RPC Endpoints
|
||||
NEXT_PUBLIC_ETH_RPC_URL=
|
||||
NEXT_PUBLIC_NYX_RPC_URL=https://rpc.nymtech.net
|
||||
375
CLAUDE.md
Normal file
@ -0,0 +1,375 @@
|
||||
# MTM VPN Dashboard - Codebase Reference
|
||||
|
||||
## Project Overview
|
||||
- **Name**: @cerc-io/test-progressive-web-app
|
||||
- **Version**: 0.1.24
|
||||
- **Framework**: Next.js with React 18
|
||||
- **Language**: TypeScript
|
||||
- **PWA**: Enabled with next-pwa plugin
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
/
|
||||
├── deploy/ # Deployment configurations and scripts
|
||||
├── pages/ # Next.js pages (routing)
|
||||
│ ├── _app.tsx # App wrapper component
|
||||
│ ├── index.tsx # Home page
|
||||
│ └── api/ # API routes
|
||||
│ └── hello.ts # Sample API endpoint
|
||||
├── public/ # Static assets
|
||||
│ ├── icons/ # PWA icons (various sizes)
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── sw.js # Service worker
|
||||
│ └── workbox-*.js # Workbox files
|
||||
├── styles/ # CSS files
|
||||
│ ├── globals.css # Global styles
|
||||
│ └── Home.module.css # Home page styles
|
||||
└── scripts/ # Build/deployment scripts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
### Core Dependencies
|
||||
- **next**: "latest" - Next.js framework
|
||||
- **next-pwa**: "^5.6.0" - Progressive Web App support
|
||||
- **react**: "^18.2.0" - React library
|
||||
- **react-dom**: "^18.2.0" - React DOM
|
||||
|
||||
### Dev Dependencies
|
||||
- **@types/node**: "17.0.4"
|
||||
- **@types/react**: "17.0.38"
|
||||
- **typescript**: "4.5.4"
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### next.config.js
|
||||
- Uses next-pwa plugin for PWA functionality
|
||||
- Environment variables exposed:
|
||||
- `CERC_TEST_WEBAPP_CONFIG1`
|
||||
- `CERC_TEST_WEBAPP_CONFIG2`
|
||||
- `CERC_WEBAPP_DEBUG`
|
||||
|
||||
### tsconfig.json
|
||||
- Target: ES5
|
||||
- Strict mode enabled
|
||||
- JSX preserve mode
|
||||
- Incremental compilation enabled
|
||||
|
||||
## Current Pages & Components
|
||||
|
||||
### pages/_app.tsx
|
||||
- Root application wrapper
|
||||
- Contains PWA meta tags and configuration
|
||||
- Sets up viewport, theme color, manifest, and icons
|
||||
- Title: "Laconic Test PWA"
|
||||
|
||||
### pages/index.tsx
|
||||
- Home page component
|
||||
- Displays environment variables in cards
|
||||
- Uses CSS modules for styling
|
||||
- Contains Laconic branding and logo
|
||||
|
||||
### pages/api/hello.ts
|
||||
- Sample API route
|
||||
- Returns JSON: `{ name: 'John Doe' }`
|
||||
|
||||
## Styling System
|
||||
- **Global styles**: `/styles/globals.css`
|
||||
- **Module styles**: CSS Modules pattern
|
||||
- **Current theme**:
|
||||
- Primary blue: `#0070f3`
|
||||
- Border color: `#eaeaea`
|
||||
- Theme color: `#317EFB`
|
||||
|
||||
### Key CSS Classes (Home.module.css)
|
||||
- `.container` - Main page container (flexbox, centered)
|
||||
- `.main` - Content area
|
||||
- `.footer` - Footer with logo
|
||||
- `.title` - Large heading (4rem)
|
||||
- `.grid` - Card container (flexbox grid)
|
||||
- `.card` - Individual cards with hover effects
|
||||
|
||||
## PWA Configuration
|
||||
- Service worker enabled
|
||||
- Multiple icon sizes provided (16x16 to 512x512)
|
||||
- Manifest.json configured
|
||||
- Workbox integration for caching
|
||||
|
||||
## Environment Variables
|
||||
The app currently reads and displays three environment variables:
|
||||
1. `CERC_TEST_WEBAPP_CONFIG1`
|
||||
2. `CERC_TEST_WEBAPP_CONFIG2`
|
||||
3. `CERC_WEBAPP_DEBUG`
|
||||
|
||||
## Scripts Available
|
||||
- `npm run dev` - Development server
|
||||
- `npm run build` - Production build
|
||||
- `npm run start` - Production server
|
||||
|
||||
## Deployment
|
||||
- Contains Laconic-specific deployment scripts in `/deploy`
|
||||
- Docker support with Dockerfile
|
||||
- Shell scripts for deployment automation
|
||||
|
||||
## Development Commands
|
||||
- **Dev server**: `npm run dev`
|
||||
- **Build**: `npm run build`
|
||||
- **Lint**: Not configured
|
||||
- **Typecheck**: Not configured
|
||||
|
||||
## Dashboard Implementation Status
|
||||
✅ **Completed Features:**
|
||||
- Admin authentication UI (login/logout)
|
||||
- Responsive dashboard layout with sidebar navigation
|
||||
- Overview dashboard with metrics and charts
|
||||
- Transaction monitoring (all types: MTM-to-NYM, Bridge, Swap)
|
||||
- Failed transaction analysis and retry functionality
|
||||
- Account balance monitoring with ETH refill interface
|
||||
- App download analytics with charts and version tracking
|
||||
- PWA configuration updated for admin dashboard
|
||||
|
||||
## Dashboard Pages
|
||||
- `/login` - Admin authentication
|
||||
- `/dashboard` - Overview with stats and charts
|
||||
- `/dashboard/transactions` - All transaction monitoring
|
||||
- `/dashboard/failed` - Failed transaction analysis
|
||||
- `/dashboard/balances` - Account balance management
|
||||
- `/dashboard/downloads` - App download analytics
|
||||
|
||||
## Technical Implementation
|
||||
- **Framework**: Next.js with TypeScript
|
||||
- **UI**: Tailwind CSS + Headless UI components
|
||||
- **Charts**: Custom CSS-based charts (replaced Recharts for compatibility)
|
||||
- **Authentication**: Local storage based (ready for backend integration)
|
||||
- **Data**: Mock data based on mtm-to-nym-service entities
|
||||
- **Responsive**: Mobile-first design with collapsible sidebar
|
||||
- **Build Status**: ✅ Successfully builds and runs
|
||||
- **Dev Server**: ✅ Runs at http://localhost:3000
|
||||
|
||||
## Getting Started
|
||||
1. **Install dependencies**: `npm install`
|
||||
2. **Run development server**: `npm run dev`
|
||||
3. **Build for production**: `npm run build`
|
||||
4. **Access dashboard**: Navigate to http://localhost:3000
|
||||
5. **Login**: Use any valid email + password (6+ characters)
|
||||
|
||||
## Chart Implementation
|
||||
- Replaced Recharts with custom CSS-based visualizations for better compatibility
|
||||
- Bar charts using CSS flex and dynamic heights
|
||||
- Line charts using SVG paths and CSS positioning
|
||||
- Progress bars for platform distribution
|
||||
- All charts are animated and responsive
|
||||
|
||||
## Design Requirements
|
||||
**CRITICAL: These requirements must be followed in ALL future modifications:**
|
||||
|
||||
### Dashboard Stats Display
|
||||
- **NEVER add percentage changes, change indicators, or trend arrows to dashboard stats**
|
||||
- Dashboard stats should only show: `id`, `name`, `stat`, and `icon` properties
|
||||
- Do NOT add `change`, `changeType`, percentage indicators, or trend arrows
|
||||
- Keep stats display clean and simple without any change/trend visualization
|
||||
|
||||
### UI Components
|
||||
- No support/customer service functionality
|
||||
- Focus purely on transaction monitoring and analytics
|
||||
- Android-only for app downloads (no iOS)
|
||||
- No authentication flow (direct redirect to dashboard)
|
||||
|
||||
# MTM to NYM Service API Documentation
|
||||
|
||||
This document describes the API endpoints for the MTM to NYM conversion service that powers the dashboard.
|
||||
|
||||
## Base URLs
|
||||
|
||||
- Dashboard API: `/api/dashboard`
|
||||
- Transactions API: `/api/transactions`
|
||||
|
||||
## Dashboard Endpoints
|
||||
|
||||
### GET /api/dashboard/stats
|
||||
|
||||
Returns overall dashboard statistics with monthly conversion data.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"totalConversions": 1247,
|
||||
"successfulConversions": 1189,
|
||||
"failedConversions": 58,
|
||||
"totalDownloads": 3229,
|
||||
"monthlyData": [
|
||||
{
|
||||
"month": "Mar",
|
||||
"totalConversions": 137,
|
||||
"successfulConversions": 130,
|
||||
"failedConversions": 7
|
||||
},
|
||||
{
|
||||
"month": "Apr",
|
||||
"totalConversions": 124,
|
||||
"successfulConversions": 118,
|
||||
"failedConversions": 6
|
||||
},
|
||||
{
|
||||
"month": "May",
|
||||
"totalConversions": 149,
|
||||
"successfulConversions": 142,
|
||||
"failedConversions": 7
|
||||
},
|
||||
{
|
||||
"month": "Jun",
|
||||
"totalConversions": 162,
|
||||
"successfulConversions": 155,
|
||||
"failedConversions": 7
|
||||
},
|
||||
{
|
||||
"month": "Jul",
|
||||
"totalConversions": 149,
|
||||
"successfulConversions": 142,
|
||||
"failedConversions": 7
|
||||
},
|
||||
{
|
||||
"month": "Aug",
|
||||
"totalConversions": 311,
|
||||
"successfulConversions": 296,
|
||||
"failedConversions": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Transaction Endpoints
|
||||
|
||||
### GET /api/transactions/conversions
|
||||
|
||||
Returns paginated MTM to NYM conversion data with filtering options.
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `limit` (optional): Items per page (default: 10, max: 100)
|
||||
- `status` (optional): Filter by status (`success`, `failed`, `all`)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"transactionHash": "2AUxZpuQ...",
|
||||
"fromAddress": "HmWkGTaL...",
|
||||
"nymTransactionHash": "4E169C93...",
|
||||
"error": null,
|
||||
"createdAt": "2025-08-24T10:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 1247,
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 125
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Other Transaction Endpoints
|
||||
|
||||
### POST /api/transactions/get-nym
|
||||
|
||||
Processes MTM to NYM conversion request.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"transactionHash": "2AUxZpuQ...",
|
||||
"nyxAddress": "n1sdnrq62m07gcwzpfmdgvqfpqqjvtnnllcypur8",
|
||||
"signedTxHash": "429ameDUN..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"transactionHash": "2AUxZpuQ...",
|
||||
"fromAddress": "HmWkGTaL...",
|
||||
"nymTransactionHash": "4E169C93..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/transactions/get-nym-balance
|
||||
|
||||
Returns current NYM balance of the service wallet.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"balanceInNym": 75000.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return standard error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message description"
|
||||
}
|
||||
```
|
||||
|
||||
Common HTTP status codes:
|
||||
- `200` - Success
|
||||
- `400` - Bad Request
|
||||
- `404` - Not Found
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## Database Integration
|
||||
|
||||
The API endpoints integrate directly with the existing database models:
|
||||
- `Transaction` - MTM to NYM conversions with fields:
|
||||
- `id` (UUID)
|
||||
- `transactionHash` (Solana transaction hash)
|
||||
- `fromAddress` (Solana wallet address)
|
||||
- `nymTransactionHash` (NYM transaction hash, optional)
|
||||
- `error` (Error message if failed, optional)
|
||||
- `createdAt` (Timestamp)
|
||||
|
||||
## API Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```env
|
||||
# CORS Configuration (Required)
|
||||
CORS_ALLOWED_ORIGINS="http://localhost:3001,https://dashboard.example.com"
|
||||
|
||||
# Database and Service Configuration
|
||||
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
||||
PORT=3000
|
||||
NYX_PRIVATE_KEY=your_private_key
|
||||
NYX_RPC_ENDPOINT=https://rpc.nymtech.net
|
||||
COSMOS_GAS_PRICE=0.025
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
Start the service:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
APIs will be available at:
|
||||
- Dashboard: `http://localhost:3000/api/dashboard/`
|
||||
- Transactions: `http://localhost:3000/api/transactions/`
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The MTM VPN Dashboard connects via:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_MTM_SERVICE_URL=http://localhost:3000
|
||||
```
|
||||
150
components/Layout.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
HomeIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
WalletIcon,
|
||||
CloudArrowDownIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
||||
{ name: 'Transactions', href: '/dashboard/transactions', icon: ArrowsRightLeftIcon },
|
||||
{ name: 'Failed Transactions', href: '/dashboard/failed', icon: ExclamationTriangleIcon },
|
||||
{ name: 'App Downloads', href: '/dashboard/downloads', icon: CloudArrowDownIcon },
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 flex z-40 md:hidden ${sidebarOpen ? '' : 'pointer-events-none'}`}>
|
||||
<div className={`fixed inset-0 bg-gray-600 bg-opacity-75 ${sidebarOpen ? 'opacity-100' : 'opacity-0'} transition-opacity`} onClick={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className={`relative flex-1 flex flex-col max-w-xs w-full bg-white ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} transition-transform`}>
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MTM VPN Admin</h1>
|
||||
</div>
|
||||
<nav className="mt-5 px-2 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-base font-medium rounded-md`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon
|
||||
className={`${
|
||||
isActive ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
} mr-4 flex-shrink-0 h-6 w-6`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-white">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">MTM VPN Admin</h1>
|
||||
</div>
|
||||
<nav className="mt-5 flex-1 px-2 bg-white space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`${
|
||||
isActive
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
|
||||
>
|
||||
<item.icon
|
||||
className={`${
|
||||
isActive ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
} mr-3 flex-shrink-0 h-6 w-6`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="md:pl-64 flex flex-col flex-1">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header bar */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
{navigation.find(item => item.href === router.pathname)?.name || 'Dashboard'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const authToken = localStorage.getItem('isAdminAuthenticated');
|
||||
|
||||
if (authToken === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Only check auth on client side
|
||||
if (typeof window !== 'undefined') {
|
||||
checkAuth();
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
264
data/mockData.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { Transaction, BridgeTransaction, SwapTransaction, AccountBalance, AppDownload, DashboardStats } from '../types';
|
||||
|
||||
export const mockTransactions: Transaction[] = [
|
||||
{
|
||||
id: '1',
|
||||
transactionHash: '2AUxZpuQqR7pYyuYcYqwbGHFgJfbvGRJfrGW1D4RSbVeHBprrCoVBb8YEb7uYAiGTL7tGLWYAbaJZjmxetDCpW9o',
|
||||
fromAddress: 'HmWkGTaLQXzqDUVUThqSksp2gRYupRGKegJ4TZzBgEHi',
|
||||
nymTransactionHash: '4E169C934D2782EC35DCC1BBB578FF543B081B06E76D8C030B75ACD3EDE590F8',
|
||||
createdAt: new Date('2025-08-24T10:30:00Z')
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
transactionHash: '3bVxNqrQsR8qZzrYdYrxcGHFgKgcwGTKgrHX2E5STdWfICqssEpVCc9ZFc8vZBjHTM8uHLXYBcbKam4yguEidl9P',
|
||||
fromAddress: 'JkXlRgaQTbzfMcXvRuTyFqPmLdGhGfKjVnBxEzRgQaFi',
|
||||
nymTransactionHash: '7F259D847F3892FD46EDD2CCC689GG654C192C17F87E9D141C86BDE4FEF701G9',
|
||||
createdAt: new Date('2025-08-24T09:15:00Z')
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
transactionHash: '4CWyOrsTt9rAarZeEasydIGGhKheaHTLhsIY3F6TUeXgJDrtfFqWDd0AhGd9wCkIUN9vIMYZCdcLbm5zhvFjelqQ',
|
||||
fromAddress: 'MnYmShbRUczgOfYwSvUzGrRnHfHmGgLkWoCzF1ShRbGj',
|
||||
error: 'insufficient funds: got 100unym, required 1000unym',
|
||||
createdAt: new Date('2025-08-24T08:45:00Z')
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
transactionHash: '5DXzPstUu0rBbsaZcZsycJHIhLjdxIULhsJZ4G7TUfYhJErttGqXEd1BiIe0xDlJVnOvJNZaCeCMcm6ajvHkfmrR',
|
||||
fromAddress: 'NpZnTicSVd0gPdZyTyU0HsSmMeHnHhMlXpDzG2TicRiJ',
|
||||
nymTransactionHash: '8G360E958G4903GE47FEE3DDD7901H765D203D28G98F0E252D97CEF5GFG812H0',
|
||||
createdAt: new Date('2025-08-24T07:20:00Z')
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
transactionHash: '6EYqRtuVv1sDbtraBdatdKIJiMkeyJVMitKa5H8UVgZiKFsuuHrYFe2CjJf1yEkKWoMaKOZbDfFNdn7bkvIlgorS',
|
||||
fromAddress: 'OqAoUjdTWe1hQeaUzVV1ItTnNfIoIiNmYqEaH3UjdSjK',
|
||||
error: 'account sequence mismatch, expected 42, got 41',
|
||||
createdAt: new Date('2025-08-24T06:45:00Z')
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
transactionHash: '7FZsSwvWw2tEcusbCebudLJKjNkfzKWNjuLb6I9VWhajLGtvvIsZGf3DkKg2zFlLXqZbLPZcEgGOdo8clwJmhpsT',
|
||||
fromAddress: 'PrBpVkeUXf2iRfbV0WW2JuUoPgJpJjOnZrFbI4VkeTkL',
|
||||
nymTransactionHash: '9H471F069H5014HF58GFF4EEE8012I876E314E39H09G1F363E08DFG6HGH023I1',
|
||||
createdAt: new Date('2025-08-24T05:30:00Z')
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
transactionHash: '8GAtTxwXx3uFdvtcDfcveMLLkOlg0LXOkvMc7J0WXibkMHuwwJtaHg4ElLh3AFmMYrAcMQAdFhHPeq9dmxKniqtU',
|
||||
fromAddress: 'QsCqWlfVYg3jSgcW1XX3KvVpQhKqKkPpAsGcJ5WlfUlM',
|
||||
nymTransactionHash: 'AI582G17AI6125IG69HGG5FFF9123J987F425F4AI1AH2G474F19EGH7IHI134J2',
|
||||
createdAt: new Date('2025-08-24T04:15:00Z')
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
transactionHash: '9HBuUyxYy4vGewudEgdwfNMMlPmh1MYPlwNd8K1XYjclNIvxxKubIh5FmMi4BGnNZsBdNRBeFiIQfr0eoyLofsVU',
|
||||
fromAddress: 'RtDrXmgWZh4kThcX2YY4LwWqRiLrLlQrBtHdK6XmgVmN',
|
||||
error: 'insufficient funds: got 50unym, required 250unym',
|
||||
createdAt: new Date('2025-08-24T03:00:00Z')
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
transactionHash: 'ACVwZzyZ5wHfxveEhdxgONNmNqmiBNaQmOe9L2YZkdmOJwyxLvcJi6GnNj5CHoOAtCeOSCfGjJRgs1fqzMqgtWV',
|
||||
fromAddress: 'SuEsYnhXai5lUidY3ZZ5MxXsSkMsLmRSCuIeL7YnhWnO',
|
||||
nymTransactionHash: 'BJ693H28BJ7236JH7AIIH6GGG0234K098G536G5BJ2BI3H585G20FHI8JIJ245K3',
|
||||
createdAt: new Date('2025-08-24T02:45:00Z')
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
transactionHash: 'BDCxA00a6xIgywxfFiegGOOoOrnoDObRynPf0M3aalmPKxzMwdKj7HoOk6DIpPatDeP0TCgHkKSht2grANruuXW',
|
||||
fromAddress: 'TvFtZoiYbj6mVjeZ4Aa6NyYtTlNtMnStDvJfM8ZoiXoP',
|
||||
error: 'tx parse error: invalid transaction format',
|
||||
createdAt: new Date('2025-08-24T01:30:00Z')
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
transactionHash: 'CEDyB11b7yJhzygGjfzgGPPpPsopEPcSzoPg1N4bmpoPLyyNxeeLk8IpPl7EJqPbuEfQ1UDhIlLTiu3hsOsVwwYX',
|
||||
fromAddress: 'UwGuapjZck7nWkfa5Bb7OzZuUmOuNoTuEwKgN9apjYpQ',
|
||||
nymTransactionHash: 'CK704I39CK8347KI8BJJI7HHH1345L109H647H6CK3CJ4I696H31GJJ9KJK356L4',
|
||||
createdAt: new Date('2025-08-24T00:15:00Z')
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
transactionHash: 'DFEzC22c8zKi0zhHkgAgHQQqQtqpFQdT0qQh2O5cnpqQMzzOyfFMl9JqQm8FKrQcvFgR2VEiJmMUjv4itPtWxxZY',
|
||||
fromAddress: 'VxHvbqkai8oXlgb6CcC8P0avVnPvOpUvFxLhO0bqkZqR',
|
||||
error: 'gas wanted 200000 exceeds block gas limit 100000',
|
||||
createdAt: new Date('2025-08-23T23:00:00Z')
|
||||
}
|
||||
];
|
||||
|
||||
export const mockBridgeTransactions: BridgeTransaction[] = [
|
||||
{
|
||||
id: 1,
|
||||
nymAmount: '125.5',
|
||||
ethTransactionHash: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890ab',
|
||||
createdAt: new Date('2025-08-24T11:00:00Z')
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nymAmount: '67.25',
|
||||
ethTransactionHash: '0x2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcd',
|
||||
createdAt: new Date('2025-08-24T10:20:00Z')
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nymAmount: '200.0',
|
||||
error: 'execution reverted: insufficient allowance',
|
||||
createdAt: new Date('2025-08-24T09:30:00Z')
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nymAmount: '89.75',
|
||||
error: 'nonce too low',
|
||||
createdAt: new Date('2025-08-24T08:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
nymAmount: '156.0',
|
||||
error: 'insufficient funds for gas * price + value',
|
||||
createdAt: new Date('2025-08-24T07:45:00Z')
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
nymAmount: '78.3',
|
||||
ethTransactionHash: '0x3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
createdAt: new Date('2025-08-24T06:30:00Z')
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
nymAmount: '245.8',
|
||||
ethTransactionHash: '0x4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12',
|
||||
createdAt: new Date('2025-08-24T05:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
nymAmount: '132.4',
|
||||
error: 'execution reverted: transfer amount exceeds balance',
|
||||
createdAt: new Date('2025-08-24T04:00:00Z')
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
nymAmount: '98.7',
|
||||
ethTransactionHash: '0x5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123',
|
||||
createdAt: new Date('2025-08-24T02:45:00Z')
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
nymAmount: '187.9',
|
||||
error: 'max fee per gas less than block base fee',
|
||||
createdAt: new Date('2025-08-24T01:30:00Z')
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
nymAmount: '67.5',
|
||||
ethTransactionHash: '0x6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234',
|
||||
createdAt: new Date('2025-08-23T23:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
nymAmount: '298.2',
|
||||
ethTransactionHash: '0x7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456',
|
||||
createdAt: new Date('2025-08-23T22:00:00Z')
|
||||
}
|
||||
];
|
||||
|
||||
export const mockSwapTransactions: SwapTransaction[] = [
|
||||
{
|
||||
id: 1,
|
||||
ethAmount: '0.05',
|
||||
transactionHash: '0x3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
createdAt: new Date('2025-08-24T12:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ethAmount: '0.025',
|
||||
transactionHash: '0x4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12',
|
||||
createdAt: new Date('2025-08-24T11:40:00Z')
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
ethAmount: '0.1',
|
||||
error: 'transaction underpriced: gas price too low',
|
||||
createdAt: new Date('2025-08-24T10:55:00Z')
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
ethAmount: '0.03',
|
||||
error: 'replacement transaction underpriced',
|
||||
createdAt: new Date('2025-08-24T08:30:00Z')
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
ethAmount: '0.08',
|
||||
transactionHash: '0x8901abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567',
|
||||
createdAt: new Date('2025-08-24T07:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
ethAmount: '0.12',
|
||||
transactionHash: '0x901abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678',
|
||||
createdAt: new Date('2025-08-24T06:00:00Z')
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
ethAmount: '0.045',
|
||||
error: 'execution reverted: pool insufficient liquidity',
|
||||
createdAt: new Date('2025-08-24T04:45:00Z')
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
ethAmount: '0.09',
|
||||
transactionHash: '0xa01bcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789',
|
||||
createdAt: new Date('2025-08-24T03:30:00Z')
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
ethAmount: '0.067',
|
||||
error: 'gas limit reached',
|
||||
createdAt: new Date('2025-08-24T02:15:00Z')
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
ethAmount: '0.15',
|
||||
transactionHash: '0xb01cdef1234567890abcdef1234567890abcdef1234567890abcdef123456789a',
|
||||
createdAt: new Date('2025-08-24T01:00:00Z')
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
ethAmount: '0.055',
|
||||
error: 'execution reverted: deadline expired',
|
||||
createdAt: new Date('2025-08-23T23:45:00Z')
|
||||
}
|
||||
];
|
||||
|
||||
export const mockAccountBalances: AccountBalance[] = [
|
||||
{
|
||||
asset: 'ETH',
|
||||
balance: '2.456789',
|
||||
address: '0x742d35Cc643C0532E6A8b32F4F5bbFB6C6e13aE2'
|
||||
},
|
||||
{
|
||||
asset: 'NYM',
|
||||
balance: '15234.789123',
|
||||
address: 'n1sdnrq62m07gcwzpfmdgvqfpqqjvtnnllcypur8'
|
||||
}
|
||||
];
|
||||
|
||||
export const mockAppDownloads: AppDownload[] = [
|
||||
{
|
||||
id: '1',
|
||||
version: 'v1.8.0-mtm-0.1.2',
|
||||
platform: 'Android',
|
||||
downloads: 1247,
|
||||
releaseDate: new Date('2025-08-19'),
|
||||
fileSize: '111 MiB'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
version: 'v1.7.5-mtm-0.1.1',
|
||||
platform: 'Android',
|
||||
downloads: 892,
|
||||
releaseDate: new Date('2025-08-10'),
|
||||
fileSize: '108 MiB'
|
||||
}
|
||||
];
|
||||
3
deploy/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.registry.env
|
||||
|
||||
.app.env
|
||||
10
deploy/.registry.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
# ENV for registry operations
|
||||
|
||||
# Bond to use
|
||||
REGISTRY_BOND_ID=230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
|
||||
|
||||
# Target deployer LRN
|
||||
DEPLOYER_LRN=lrn://vaasl-provider/deployers/webapp-deployer-api.apps.vaasl.io
|
||||
|
||||
# Authority to deploy the app under
|
||||
AUTHORITY=laconic
|
||||
40
deploy/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
||||
ARG VARIANT=20-bullseye
|
||||
FROM node:${VARIANT}
|
||||
|
||||
ARG USERNAME=node
|
||||
ARG NPM_GLOBAL=/usr/local/share/npm-global
|
||||
|
||||
# Add NPM global to PATH.
|
||||
ENV PATH=${NPM_GLOBAL}/bin:${PATH}
|
||||
|
||||
RUN \
|
||||
# Configure global npm install location, use group to adapt to UID/GID changes
|
||||
if ! cat /etc/group | grep -e "^npm:" > /dev/null 2>&1; then groupadd -r npm; fi \
|
||||
&& usermod -a -G npm ${USERNAME} \
|
||||
&& umask 0002 \
|
||||
&& mkdir -p ${NPM_GLOBAL} \
|
||||
&& touch /usr/local/etc/npmrc \
|
||||
&& chown ${USERNAME}:npm ${NPM_GLOBAL} /usr/local/etc/npmrc \
|
||||
&& chmod g+s ${NPM_GLOBAL} \
|
||||
&& npm config -g set prefix ${NPM_GLOBAL} \
|
||||
&& su ${USERNAME} -c "npm config -g set prefix ${NPM_GLOBAL}" \
|
||||
# Install eslint
|
||||
&& su ${USERNAME} -c "umask 0002 && npm install -g eslint" \
|
||||
&& npm cache clean --force > /dev/null 2>&1
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends jq bash
|
||||
|
||||
# laconic-so
|
||||
RUN curl -LO https://git.vdb.to/cerc-io/stack-orchestrator/releases/download/latest/laconic-so && \
|
||||
chmod +x ./laconic-so && \
|
||||
mv ./laconic-so /usr/bin/laconic-so
|
||||
|
||||
# Configure the npm registry
|
||||
RUN npm config set @cerc-io:registry https://git.vdb.to/api/packages/cerc-io/npm/
|
||||
|
||||
# DEBUG, remove
|
||||
RUN yarn info @cerc-io/laconic-registry-cli
|
||||
|
||||
# Globally install the cli package
|
||||
RUN yarn global add @cerc-io/laconic-registry-cli
|
||||
98
deploy/README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# Deploy
|
||||
|
||||
## Setup
|
||||
|
||||
- Clone the repo and change to the [deploy](./) directory
|
||||
|
||||
```bash
|
||||
git clone git@git.vdb.to:cerc-io/mtm-vpn-dashboard.git
|
||||
cd mtm-vpn-dashboard/deploy
|
||||
```
|
||||
|
||||
- Build registry CLI image:
|
||||
|
||||
```bash
|
||||
docker build -t cerc/laconic-registry-cli .
|
||||
|
||||
# Builds image cerc/laconic-registry-cli:latest
|
||||
```
|
||||
|
||||
- Configure `userKey` in the [registry CLI config](./config.yml):
|
||||
|
||||
- User key should be of the account that owns the `laconic-deploy` authority (owner account address: `laconic1kwx2jm6vscz38qlyujvq6msujmk8l3zangqahs`)
|
||||
|
||||
- Account should also own the bond `5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2`
|
||||
|
||||
```bash
|
||||
nano config.yml
|
||||
```
|
||||
|
||||
- Add configuration for registry operations:
|
||||
|
||||
```bash
|
||||
cp .registry.env.example .registry.env
|
||||
|
||||
# Update values if required
|
||||
nano .registry.env
|
||||
```
|
||||
|
||||
- Add configuration for the app:
|
||||
|
||||
```bash
|
||||
# Create env for deployment from example env
|
||||
cp ../.env.example .app.env
|
||||
|
||||
# Fill in the required values
|
||||
nano .app.env
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
- Deploy `mtm-vpn-dashboard` App:
|
||||
|
||||
```bash
|
||||
# In mtm-vpn-dashboard/deploy dir
|
||||
docker run -it \
|
||||
-v ./:/app/deploy -w /app/deploy \
|
||||
-e DEPLOYMENT_DNS=mtm-vpn-dashboard \
|
||||
cerc/laconic-registry-cli:latest \
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
- Check deployment logs on deployer UI: <https://webapp-deployer-ui.apps.vaasl.io/>
|
||||
|
||||
- Visit deployed app: <https://gor-deploy.apps.vaasl.io>
|
||||
|
||||
### Remove deployment
|
||||
|
||||
- Remove deployment:
|
||||
|
||||
```bash
|
||||
# In gor-deploy/deploy dir
|
||||
docker run -it \
|
||||
-v ./:/app/deploy -w /app/deploy \
|
||||
-e DEPLOYMENT_RECORD_ID=<deploment-record-id-to-be-removed> \
|
||||
cerc/laconic-registry-cli:latest \
|
||||
./remove-deployment.sh
|
||||
```
|
||||
|
||||
## Troubleshoot
|
||||
|
||||
- Check records in [registry console app](https://console.laconic.com/#/registry).
|
||||
|
||||
- If deployment fails due to low bond balance
|
||||
- Check balances
|
||||
|
||||
```bash
|
||||
# Account balance
|
||||
./laconic-cli.sh account get
|
||||
|
||||
# Bond balance
|
||||
./laconic-cli.sh bond get --id 5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2
|
||||
```
|
||||
|
||||
- Command to refill bond
|
||||
|
||||
```bash
|
||||
./laconic-cli.sh bond refill --id 5d82586d156fb6671a9170d92f930a72a49a29afb45e30e16fff2100e30776e2 --type alnt --quantity 10000000
|
||||
```
|
||||
9
deploy/config.yml
Normal file
@ -0,0 +1,9 @@
|
||||
# Registry CLI config
|
||||
services:
|
||||
registry:
|
||||
rpcEndpoint: 'https://laconicd-mainnet-1.laconic.com'
|
||||
gqlEndpoint: 'https://laconicd-mainnet-1.laconic.com/api'
|
||||
userKey:
|
||||
bondId: 230cfedda15e78edc8986dfcb870e1b618f65c56e38d2735476d2a8cb3f25e38
|
||||
chainId: laconic-mainnet
|
||||
gasPrice: 0.001alnt
|
||||
133
deploy/deploy.sh
Executable file
@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fail on error
|
||||
set -e
|
||||
|
||||
source .registry.env
|
||||
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
|
||||
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
|
||||
echo "Using AUTHORITY: $AUTHORITY"
|
||||
|
||||
# Repository URL
|
||||
REPO_URL="https://git.vdb.to/cerc-io/mtm-vpn-dashboard"
|
||||
|
||||
# Get the latest commit hash for a branch
|
||||
# TODO: Change to main branch when ready
|
||||
BRANCH_NAME="ng-deploy-laconic"
|
||||
LATEST_HASH=$(git ls-remote $REPO_URL refs/heads/$BRANCH_NAME | awk '{print $1}')
|
||||
|
||||
# Gitea
|
||||
PACKAGE_VERSION=$(curl -s $REPO_URL/raw/branch/$BRANCH_NAME/package.json | jq -r .version)
|
||||
|
||||
# GitHub
|
||||
# PACKAGE_VERSION=$(curl -s $REPO_URL/raw/refs/heads/$BRANCH_NAME/package.json | jq -r .version)
|
||||
|
||||
APP_NAME=mtm-vpn-dashboard
|
||||
|
||||
echo "Repo: ${REPO_URL}"
|
||||
echo "Latest hash: ${LATEST_HASH}"
|
||||
echo "App version: ${PACKAGE_VERSION}"
|
||||
echo "Deployment DNS: ${DEPLOYMENT_DNS}"
|
||||
|
||||
# Current date and time for note
|
||||
CURRENT_DATE_TIME=$(date -u)
|
||||
|
||||
CONFIG_FILE=config.yml
|
||||
|
||||
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
||||
|
||||
# Get latest version from registry and increment application-record version
|
||||
NEW_APPLICATION_VERSION=$(laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
|
||||
|
||||
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
|
||||
# Set application-record version if no previous records were found
|
||||
NEW_APPLICATION_VERSION=0.0.1
|
||||
fi
|
||||
|
||||
# Generate application-record.yml with incremented version
|
||||
mkdir -p records
|
||||
RECORD_FILE=./records/application-record.yml
|
||||
|
||||
cat >$RECORD_FILE <<EOF
|
||||
record:
|
||||
type: ApplicationRecord
|
||||
version: $NEW_APPLICATION_VERSION
|
||||
repository_ref: $LATEST_HASH
|
||||
repository: ["$REPO_URL"]
|
||||
app_type: webapp
|
||||
name: $APP_NAME
|
||||
app_version: $PACKAGE_VERSION
|
||||
EOF
|
||||
|
||||
echo "Application record generated successfully: $RECORD_FILE"
|
||||
|
||||
# Publish ApplicationRecord
|
||||
publish_response=$(laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE)
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to publish record"
|
||||
exit $rc
|
||||
fi
|
||||
RECORD_ID=$(echo $publish_response | jq -r '.id')
|
||||
echo "ApplicationRecord published, setting names next"
|
||||
echo $RECORD_ID
|
||||
|
||||
# Set name to record
|
||||
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/$APP_NAME"
|
||||
|
||||
name1="$REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||
sleep 2
|
||||
laconic -c $CONFIG_FILE registry name set "$name1" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name1 set for ApplicationRecord"
|
||||
|
||||
name2="$REGISTRY_APP_LRN@${LATEST_HASH}"
|
||||
sleep 2
|
||||
laconic -c $CONFIG_FILE registry name set "$name2" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set hash"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name2 set for ApplicationRecord"
|
||||
|
||||
name3="$REGISTRY_APP_LRN"
|
||||
sleep 2
|
||||
# Set name if latest release
|
||||
laconic -c $CONFIG_FILE registry name set "$name3" "$RECORD_ID"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to set release"
|
||||
exit $rc
|
||||
fi
|
||||
echo "$name3 set for ApplicationRecord"
|
||||
|
||||
# Check if record found for REGISTRY_APP_LRN
|
||||
query_response=$(laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN")
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "FATAL: Failed to query name"
|
||||
exit $rc
|
||||
fi
|
||||
APP_RECORD=$(echo $query_response | jq '.[0]')
|
||||
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
|
||||
echo "No record found for $REGISTRY_APP_LRN."
|
||||
exit 1
|
||||
fi
|
||||
echo "Name resolution successful"
|
||||
|
||||
sleep 2
|
||||
echo "Requesting a webapp deployment for $name2, using deployer $DEPLOYER_LRN"
|
||||
laconic-so request-webapp-deployment \
|
||||
--laconic-config $CONFIG_FILE \
|
||||
--deployer $DEPLOYER_LRN \
|
||||
--app $name2 \
|
||||
--env-file ./.app.env \
|
||||
--dns $DEPLOYMENT_DNS \
|
||||
--make-payment auto
|
||||
|
||||
echo "Done"
|
||||
50
deploy/laconic-cli.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Laconic Registry CLI Docker wrapper script
|
||||
# This script wraps the Docker command to run laconic registry CLI commands
|
||||
# Run this script from the deploy directory
|
||||
|
||||
# Check if docker is available
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Error: Docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the cerc/laconic-registry-cli image exists
|
||||
if ! docker image inspect cerc/laconic-registry-cli &> /dev/null; then
|
||||
echo "Error: cerc/laconic-registry-cli Docker image not found"
|
||||
echo "Please build the image first: docker build -t cerc/laconic-registry-cli ."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current directory (should be deploy directory)
|
||||
CURRENT_DIR="$(pwd)"
|
||||
PROJECT_ROOT="$(dirname "$CURRENT_DIR")"
|
||||
|
||||
# Verify we're in the deploy directory
|
||||
if [ ! -f "config.yml" ] || [ ! -f "laconic-cli.sh" ]; then
|
||||
echo "Error: This script must be run from the deploy directory"
|
||||
echo "Current directory: $CURRENT_DIR"
|
||||
echo "Please cd to the deploy directory and run: ./laconic-cli.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set up volume mounts
|
||||
DEPLOY_MOUNT="-v $CURRENT_DIR:/app/deploy"
|
||||
OUT_MOUNT=""
|
||||
|
||||
# Create out directory if it doesn't exist and always mount it
|
||||
if [ ! -d "out" ]; then
|
||||
mkdir -p "out"
|
||||
fi
|
||||
OUT_MOUNT="-v $CURRENT_DIR/out:/app/out"
|
||||
|
||||
# Run the Docker command with processed arguments
|
||||
docker run --rm \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
$DEPLOY_MOUNT \
|
||||
$OUT_MOUNT \
|
||||
-w /app/deploy \
|
||||
cerc/laconic-registry-cli \
|
||||
laconic registry -c config.yml \
|
||||
"$@"
|
||||
0
deploy/records/.gitkeep
Normal file
63
deploy/remove-deployment.sh
Executable file
@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -z $DEPLOYMENT_RECORD_ID ]]; then
|
||||
echo "Error: please pass the deployment record ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .registry.env
|
||||
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
|
||||
|
||||
echo "Deployment record ID: $DEPLOYMENT_RECORD_ID"
|
||||
|
||||
# Generate application-deployment-removal-request.yml
|
||||
REMOVAL_REQUEST_RECORD_FILE=./records/application-deployment-removal-request.yml
|
||||
|
||||
cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
|
||||
record:
|
||||
deployer: $DEPLOYER_LRN
|
||||
deployment: $DEPLOYMENT_RECORD_ID
|
||||
type: ApplicationDeploymentRemovalRequest
|
||||
version: 1.0.0
|
||||
EOF
|
||||
|
||||
CONFIG_FILE=config.yml
|
||||
|
||||
sleep 2
|
||||
REMOVAL_REQUEST_ID=$(laconic -c $CONFIG_FILE registry record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
|
||||
echo "ApplicationDeploymentRemovalRequest published"
|
||||
echo $REMOVAL_REQUEST_ID
|
||||
|
||||
# Deployment checks
|
||||
RETRY_INTERVAL=30
|
||||
MAX_RETRIES=20
|
||||
|
||||
# Check that an ApplicationDeploymentRemovalRecord is published
|
||||
retry_count=0
|
||||
while true; do
|
||||
removal_records_response=$(laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
|
||||
len_removal_records=$(echo $removal_records_response | jq 'length')
|
||||
|
||||
# Check if number of records returned is 0
|
||||
if [ $len_removal_records -eq 0 ]; then
|
||||
# Check if retries are exhausted
|
||||
if [ $retry_count -eq $MAX_RETRIES ]; then
|
||||
echo "Retries exhausted"
|
||||
echo "ApplicationDeploymentRemovalRecord for deployment removal request $REMOVAL_REQUEST_ID not found"
|
||||
exit 1
|
||||
else
|
||||
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
|
||||
sleep $RETRY_INTERVAL
|
||||
retry_count=$((retry_count+1))
|
||||
fi
|
||||
else
|
||||
echo "ApplicationDeploymentRemovalRecord found"
|
||||
REMOVAL_RECORD_ID=$(echo $removal_records_response | jq -r '.[0].id')
|
||||
echo $REMOVAL_RECORD_ID
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Deployment removal successful"
|
||||
@ -5,8 +5,7 @@ const withPWA = require('next-pwa')({
|
||||
|
||||
module.exports = withPWA({
|
||||
env: {
|
||||
CERC_TEST_WEBAPP_CONFIG1: process.env.CERC_TEST_WEBAPP_CONFIG1,
|
||||
CERC_TEST_WEBAPP_CONFIG2: process.env.CERC_TEST_WEBAPP_CONFIG2,
|
||||
CERC_WEBAPP_DEBUG: process.env.CERC_WEBAPP_DEBUG,
|
||||
NEXT_PUBLIC_MTM_SERVICE_URL: process.env.NEXT_PUBLIC_MTM_SERVICE_URL,
|
||||
NEXT_PUBLIC_NYX_RPC_URL: process.env.NEXT_PUBLIC_NYX_RPC_URL,
|
||||
},
|
||||
})
|
||||
|
||||
2589
package-lock.json
generated
10
package.json
@ -9,14 +9,22 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cosmjs/math": "^0.33.1",
|
||||
"@cosmjs/stargate": "^0.33.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.6.0",
|
||||
"ethers": "^5.7.0",
|
||||
"next": "latest",
|
||||
"next-pwa": "^5.6.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.4",
|
||||
"@types/react": "17.0.38",
|
||||
"typescript": "4.5.4"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,25 +12,25 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
|
||||
/>
|
||||
<meta name="description" content="Description" />
|
||||
<meta name="keywords" content="Keywords" />
|
||||
<title>Laconic Test PWA</title>
|
||||
<meta name="description" content="MTM VPN Admin Dashboard - Monitor transactions" />
|
||||
<meta name="keywords" content="MTM, VPN, Admin, Dashboard, Blockchain, NYM" />
|
||||
<title>MTM VPN Admin Dashboard</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link
|
||||
href="/icons/favicon-16x16.png"
|
||||
href="/icons/icon-16x16.png"
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
/>
|
||||
<link
|
||||
href="/icons/favicon-32x32.png"
|
||||
href="/icons/icon-32x32.png"
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/apple-icon.png"></link>
|
||||
<meta name="theme-color" content="#317EFB" />
|
||||
<meta name="theme-color" content="#3B82F6" />
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
|
||||
35
pages/api/eth-rpc.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const ethRpcUrl = process.env.ETH_RPC_URL;
|
||||
|
||||
if (!ethRpcUrl) {
|
||||
return res.status(500).json({ error: 'ETH RPC URL not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(ethRpcUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(req.body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error('ETH RPC proxy error:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'RPC request failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
131
pages/dashboard/downloads.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import {
|
||||
CloudArrowDownIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Layout from '../../components/Layout';
|
||||
import { mockAppDownloads } from '../../data/mockData';
|
||||
|
||||
export default function Downloads() {
|
||||
// Filter to only Android apps
|
||||
const androidApps = mockAppDownloads.filter(app => app.platform === 'Android');
|
||||
const totalDownloads = androidApps.reduce((sum, app) => sum + app.downloads, 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">App Downloads</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Monitor MTM VPN Android application downloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="mt-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CloudArrowDownIcon className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Total Downloads
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{totalDownloads.toLocaleString()}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Details Table */}
|
||||
<div className="mt-8 flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Platform
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Downloads
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
File Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Release Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{androidApps
|
||||
.sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime())
|
||||
.map((app, index) => (
|
||||
<tr key={app.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{app.version}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-400 mr-2" />
|
||||
{app.platform}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<span className="text-lg font-semibold">
|
||||
{app.downloads.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{app.fileSize}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span suppressHydrationWarning>
|
||||
{app.releaseDate.toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a
|
||||
href={`https://git.vdb.to/cerc-io/mtm-vpn-client-public/releases/tag/${app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View Release
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
186
pages/dashboard/failed.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
XCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Layout from '../../components/Layout';
|
||||
import { mockTransactions, mockBridgeTransactions, mockSwapTransactions } from '../../data/mockData';
|
||||
|
||||
export default function FailedTransactions() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
// Get only failed transactions
|
||||
const failedTransactions = [
|
||||
...mockTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'MTM to NYM' as const })),
|
||||
...mockBridgeTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'ETH Bridge' as const })),
|
||||
...mockSwapTransactions.filter(tx => tx.error).map(tx => ({ ...tx, type: 'ETH Swap' as const })),
|
||||
].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(failedTransactions.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedTransactions = failedTransactions.slice(startIndex, endIndex);
|
||||
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, failedTransactions.length)} of {failedTransactions.length} results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm font-medium text-gray-700">
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getExplorerUrl = (hash: string, type: 'solana' | 'ethereum' | 'nym') => {
|
||||
switch (type) {
|
||||
case 'solana':
|
||||
return `https://explorer.solana.com/tx/${hash}`;
|
||||
case 'ethereum':
|
||||
return `https://etherscan.io/tx/${hash}`;
|
||||
case 'nym':
|
||||
return `https://explorer.nyx.net/transactions/${hash}`;
|
||||
default:
|
||||
return '#';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Failed Transactions</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Monitor failed transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Transactions Table */}
|
||||
<div className="mt-8 flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Transaction Link
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Error Details
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Failed At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedTransactions.length > 0 ? (
|
||||
paginatedTransactions.map((tx, index) => {
|
||||
const txKey = `${tx.type}-${tx.id}`;
|
||||
|
||||
return (
|
||||
<tr key={txKey} className={index % 2 === 0 ? 'bg-white' : 'bg-red-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400 mr-2" />
|
||||
{tx.type}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{'transactionHash' in tx && tx.transactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(tx.transactionHash, 'solana')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View Transaction
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{'ethTransactionHash' in tx && tx.ethTransactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(tx.ethTransactionHash, 'ethereum')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View Transaction
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="max-w-xs">
|
||||
<p className="text-red-600 break-words" title={tx.error}>
|
||||
{tx.error}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span suppressHydrationWarning>
|
||||
{new Date(tx.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No failed transactions</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
All transactions are processing successfully.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
386
pages/dashboard/index.tsx
Normal file
@ -0,0 +1,386 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ethers } from 'ethers';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
ArrowsRightLeftIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
CloudArrowDownIcon,
|
||||
WalletIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StargateClient } from '@cosmjs/stargate';
|
||||
import { Decimal } from '@cosmjs/math';
|
||||
|
||||
import Layout from '../../components/Layout';
|
||||
import dashboardApi, { ApiError, DashboardStats, TransactionData } from '../../utils/api';
|
||||
|
||||
interface BalanceData {
|
||||
address: string;
|
||||
balance: number;
|
||||
balanceUSD: number | null;
|
||||
currency: 'ETH' | 'NYM';
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [dashboardStats, setDashboardStats] = useState<DashboardStats | null>(null);
|
||||
const [recentTransactions, setRecentTransactions] = useState<TransactionData[]>([]);
|
||||
const [accountBalances, setAccountBalances] = useState<BalanceData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch dashboard data (without balances)
|
||||
const [statsData, conversionsData] = await Promise.allSettled([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getConversions({ limit: 5, status: 'all' })
|
||||
]);
|
||||
|
||||
// Handle dashboard stats
|
||||
if (statsData.status === 'fulfilled') {
|
||||
setDashboardStats(statsData.value);
|
||||
|
||||
// After getting stats, fetch balances using wallet addresses
|
||||
if (statsData.value.walletAddresses) {
|
||||
fetchAccountBalances(statsData.value.walletAddresses);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch dashboard stats:', statsData.reason);
|
||||
}
|
||||
|
||||
// Handle recent transactions
|
||||
if (conversionsData.status === 'fulfilled') {
|
||||
setRecentTransactions(conversionsData.value.transactions);
|
||||
} else {
|
||||
console.error('Failed to fetch recent transactions:', conversionsData.reason);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(`API Error: ${err.message}`);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAccountBalances = async (walletAddresses: { eth?: string; nym: string }) => {
|
||||
const balances: BalanceData[] = [];
|
||||
|
||||
try {
|
||||
// Fetch ETH balance if address is provided
|
||||
if (walletAddresses.eth) {
|
||||
try {
|
||||
// Use NextJS API proxy to bypass CORS
|
||||
const rpcResponse = await axios.post('/api/eth-rpc', {
|
||||
method: 'eth_getBalance',
|
||||
params: [walletAddresses.eth, 'latest'],
|
||||
jsonrpc: '2.0',
|
||||
id: 1
|
||||
});
|
||||
|
||||
if (rpcResponse.data.error) {
|
||||
throw new Error(`RPC Error: ${rpcResponse.data.error.message}`);
|
||||
}
|
||||
|
||||
const balance = ethers.BigNumber.from(rpcResponse.data.result);
|
||||
const ethBalance = parseFloat(ethers.utils.formatEther(balance));
|
||||
|
||||
// Get ETH price from CoinGecko (free API)
|
||||
let ethBalanceUSD = null;
|
||||
try {
|
||||
const priceResponse = await axios.get('https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd');
|
||||
const ethPrice = priceResponse.data.ethereum.usd;
|
||||
ethBalanceUSD = parseFloat((ethBalance * ethPrice).toFixed(2));
|
||||
} catch (priceError) {
|
||||
console.warn('Failed to fetch ETH price:', priceError);
|
||||
}
|
||||
|
||||
balances.push({
|
||||
address: walletAddresses.eth,
|
||||
balance: ethBalance,
|
||||
balanceUSD: ethBalanceUSD,
|
||||
currency: 'ETH'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ETH balance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch NYM balance
|
||||
try {
|
||||
// Use configurable NYX RPC endpoint
|
||||
const nyxRpcUrl = process.env.NEXT_PUBLIC_NYX_RPC_URL!;
|
||||
const client = await StargateClient.connect(nyxRpcUrl);
|
||||
const balance = await client.getBalance(walletAddresses.nym, 'unym');
|
||||
const nymBalance = parseFloat(Decimal.fromAtomics(balance.amount, 6).toString());
|
||||
|
||||
// Get NYM price from CoinGecko
|
||||
let nymBalanceUSD = null;
|
||||
try {
|
||||
const priceResponse = await axios.get('https://api.coingecko.com/api/v3/simple/price?ids=nym&vs_currencies=usd');
|
||||
const nymPrice = priceResponse.data.nym.usd;
|
||||
nymBalanceUSD = parseFloat((nymBalance * nymPrice).toFixed(2));
|
||||
} catch (priceError) {
|
||||
console.warn('Failed to fetch NYM price:', priceError);
|
||||
}
|
||||
|
||||
balances.push({
|
||||
address: walletAddresses.nym,
|
||||
balance: nymBalance,
|
||||
balanceUSD: nymBalanceUSD,
|
||||
currency: 'NYM'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch NYM balance:', error);
|
||||
}
|
||||
|
||||
setAccountBalances(balances);
|
||||
} catch (error) {
|
||||
console.error('Error fetching account balances:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStats = () => {
|
||||
if (!dashboardStats) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Total MTM to NYM Conversions',
|
||||
stat: dashboardStats.totalConversions.toLocaleString(),
|
||||
icon: ArrowsRightLeftIcon,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Successful Conversions',
|
||||
stat: dashboardStats.successfulConversions.toLocaleString(),
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Failed Conversions',
|
||||
stat: dashboardStats.failedConversions.toLocaleString(),
|
||||
icon: XCircleIcon,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Total Downloads',
|
||||
stat: dashboardStats.totalDownloads.toLocaleString(),
|
||||
icon: CloudArrowDownIcon,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading dashboard</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={fetchDashboardData}
|
||||
className="bg-red-100 px-2 py-1 text-sm text-red-800 rounded hover:bg-red-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = getStats();
|
||||
return (
|
||||
<Layout>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-8">Dashboard Overview</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-2 mb-8">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:px-6 sm:py-6"
|
||||
>
|
||||
<dt>
|
||||
<div className="absolute rounded-md bg-blue-500 p-3">
|
||||
<item.icon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="ml-16 truncate text-sm font-medium text-gray-500">{item.name}</p>
|
||||
</dt>
|
||||
<dd className="ml-16 flex items-baseline">
|
||||
<p className="text-2xl font-semibold text-gray-900">{item.stat}</p>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts and Balances */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Transaction Volume Chart */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">MTM to NYM Conversions Trend</h3>
|
||||
{dashboardStats && (
|
||||
<div className="h-80 flex items-end justify-between space-x-2 border-b border-l border-gray-200 p-4">
|
||||
{/* Monthly data from API */}
|
||||
{dashboardStats.monthlyData && dashboardStats.monthlyData.map((data, index) => {
|
||||
const maxConversions = Math.max(...dashboardStats.monthlyData.map(d => d.totalConversions));
|
||||
return (
|
||||
<div key={data.month} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t transition-all duration-500"
|
||||
style={{
|
||||
height: `${Math.max((data.totalConversions / Math.max(maxConversions, 1)) * 240, 8)}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500 font-medium">{data.month}</div>
|
||||
<div className="text-xs text-gray-400">{data.totalConversions.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Balances */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Account Balances</h3>
|
||||
<div className="space-y-4">
|
||||
{accountBalances.length > 0 ? accountBalances.map((account) => (
|
||||
<div key={account.currency} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<WalletIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{account.currency} Balance
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{account.balance.toLocaleString(undefined, {
|
||||
minimumFractionDigits: account.currency === 'ETH' ? 4 : 2,
|
||||
maximumFractionDigits: account.currency === 'ETH' ? 6 : 2,
|
||||
})} {account.currency}
|
||||
</div>
|
||||
{account.balanceUSD && (
|
||||
<div className="text-sm text-gray-500">
|
||||
${account.balanceUSD.toLocaleString()} USD
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-mono mt-1">
|
||||
{account.address.slice(0, 12)}...{account.address.slice(-12)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-right">
|
||||
<a
|
||||
href={account.currency === 'ETH' ?
|
||||
`https://etherscan.io/address/${account.address}` :
|
||||
`https://nym.com/explorer/account/${account.address}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
View on {account.currency === 'ETH' ? 'Etherscan' : 'Nym Explorer'} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Loading balance data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Conversions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md mb-8">
|
||||
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900">Recent Conversions</h3>
|
||||
<a
|
||||
href="/dashboard/transactions"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{recentTransactions.map((transaction) => (
|
||||
<li key={transaction.id}>
|
||||
<div className="px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{transaction.error ? (
|
||||
<XCircleIcon className="h-6 w-6 text-red-400" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{`${transaction.transactionHash.slice(0, 8)}...${transaction.transactionHash.slice(-8)}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{transaction.error ?
|
||||
`Error: ${transaction.error.substring(0, 40)}...` :
|
||||
`From: ${transaction.fromAddress.slice(0, 8)}...${transaction.fromAddress.slice(-8)}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-900">
|
||||
<span suppressHydrationWarning>
|
||||
{new Date(transaction.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
379
pages/dashboard/transactions.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Layout from '../../components/Layout';
|
||||
import { mockTransactions, mockBridgeTransactions, mockSwapTransactions } from '../../data/mockData';
|
||||
|
||||
export default function Transactions() {
|
||||
const [mtmCurrentPage, setMtmCurrentPage] = useState(1);
|
||||
const [otherCurrentPage, setOtherCurrentPage] = useState(1);
|
||||
const itemsPerPage = 5;
|
||||
|
||||
// MTM to NYM conversions (2-transaction structure: Solana + Nyx)
|
||||
const mtmToNymConversions = mockTransactions.map(tx => ({ ...tx, type: 'MTM to NYM' as const }));
|
||||
|
||||
// ETH to NYM conversions (each comprising of a Swap and Bridge tx)
|
||||
const ethToNymConversions = mockBridgeTransactions.map((bridgeTx, index) => ({
|
||||
id: `eth-nym-${bridgeTx.id}`,
|
||||
bridgeTransaction: bridgeTx,
|
||||
swapTransaction: mockSwapTransactions[index] || null, // Pair with corresponding swap
|
||||
createdAt: bridgeTx.createdAt,
|
||||
error: bridgeTx.error || (mockSwapTransactions[index]?.error || null),
|
||||
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
|
||||
const getExplorerUrl = (hash: string, type: 'solana' | 'ethereum' | 'nym') => {
|
||||
switch (type) {
|
||||
case 'solana':
|
||||
return `https://explorer.solana.com/tx/${hash}`;
|
||||
case 'ethereum':
|
||||
return `https://etherscan.io/tx/${hash}`;
|
||||
case 'nym':
|
||||
return `https://explorer.nyx.net/transactions/${hash}`;
|
||||
default:
|
||||
return '#';
|
||||
}
|
||||
};
|
||||
|
||||
// Pagination logic for MTM conversions
|
||||
const mtmTotalPages = Math.ceil(mtmToNymConversions.length / itemsPerPage);
|
||||
const mtmStartIndex = (mtmCurrentPage - 1) * itemsPerPage;
|
||||
const mtmEndIndex = mtmStartIndex + itemsPerPage;
|
||||
const paginatedMtmConversions = mtmToNymConversions.slice(mtmStartIndex, mtmEndIndex);
|
||||
|
||||
// Pagination logic for ETH to NYM conversions
|
||||
const ethTotalPages = Math.ceil(ethToNymConversions.length / itemsPerPage);
|
||||
const ethStartIndex = (otherCurrentPage - 1) * itemsPerPage;
|
||||
const ethEndIndex = ethStartIndex + itemsPerPage;
|
||||
const paginatedEthConversions = ethToNymConversions.slice(ethStartIndex, ethEndIndex);
|
||||
|
||||
const renderPagination = (currentPage: number, totalPages: number, setCurrentPage: (page: number) => void) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalPages * itemsPerPage)} results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm font-medium text-gray-700">
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMtmConversionRow = (tx: any, index: number) => {
|
||||
return (
|
||||
<tr key={`mtm-${tx.id}`} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<div className="flex items-center">
|
||||
{tx.error ? (
|
||||
<XCircleIcon className="h-5 w-5 text-red-400 mr-2" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400 mr-2" />
|
||||
)}
|
||||
<span className={tx.error ? 'text-red-600' : 'text-green-600'}>
|
||||
{tx.error ? 'Failed' : 'Success'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-3">
|
||||
{/* Solana Transaction */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs font-medium text-blue-600">Solana Chain:</span>
|
||||
{tx.transactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(tx.transactionHash, 'solana')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-600">
|
||||
{tx.transactionHash ? `${tx.transactionHash.slice(0, 12)}...${tx.transactionHash.slice(-12)}` : 'Pending...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NYM Transaction */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs font-medium text-purple-600">NYX Chain:</span>
|
||||
{tx.nymTransactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(tx.nymTransactionHash, 'nym')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-600">
|
||||
{tx.nymTransactionHash ? `${tx.nymTransactionHash.slice(0, 12)}...${tx.nymTransactionHash.slice(-12)}` : 'Pending...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tx.fromAddress && (
|
||||
<span className="font-mono">
|
||||
{tx.fromAddress.slice(0, 8)}...{tx.fromAddress.slice(-8)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tx.error ? (
|
||||
<div className="max-w-xs truncate text-red-600" title={tx.error}>
|
||||
{tx.error}
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span suppressHydrationWarning>
|
||||
{new Date(tx.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEthConversionRow = (conversion: any, index: number) => {
|
||||
const { bridgeTransaction, swapTransaction } = conversion;
|
||||
return (
|
||||
<tr key={conversion.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<div className="flex items-center">
|
||||
{conversion.error ? (
|
||||
<XCircleIcon className="h-5 w-5 text-red-400 mr-2" />
|
||||
) : (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-400 mr-2" />
|
||||
)}
|
||||
<span className={conversion.error ? 'text-red-600' : 'text-green-600'}>
|
||||
{conversion.error ? 'Failed' : 'Success'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-3">
|
||||
{/* Swap Transaction */}
|
||||
{swapTransaction && (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs font-medium text-orange-600">Swap:</span>
|
||||
{swapTransaction.transactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(swapTransaction.transactionHash, 'ethereum')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-600">
|
||||
{swapTransaction.transactionHash ? `${swapTransaction.transactionHash.slice(0, 12)}...${swapTransaction.transactionHash.slice(-12)}` : 'Pending...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bridge Transaction */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs font-medium text-green-600">Bridge:</span>
|
||||
{bridgeTransaction.ethTransactionHash && (
|
||||
<a
|
||||
href={getExplorerUrl(bridgeTransaction.ethTransactionHash, 'ethereum')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-600">
|
||||
{bridgeTransaction.ethTransactionHash ? `${bridgeTransaction.ethTransactionHash.slice(0, 12)}...${bridgeTransaction.ethTransactionHash.slice(-12)}` : 'Pending...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
{swapTransaction && swapTransaction.ethAmount && (
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">ETH:</span> {swapTransaction.ethAmount}
|
||||
</div>
|
||||
)}
|
||||
{bridgeTransaction.nymAmount && (
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">NYM:</span> {bridgeTransaction.nymAmount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{conversion.error ? (
|
||||
<div className="max-w-xs truncate text-red-600" title={conversion.error}>
|
||||
{conversion.error}
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span suppressHydrationWarning>
|
||||
{new Date(conversion.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Transaction Monitor</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Monitor MTM to NYM conversions and other blockchain transactions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* MTM to NYM Conversions Section */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">MTM to NYM Conversions</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">Each conversion consists of two blockchain transactions: Solana (receive MTM) and NYX (send NYM)</p>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Transaction Hashes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
From Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Error
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedMtmConversions.length > 0 ? (
|
||||
paginatedMtmConversions.map((tx, index) => renderMtmConversionRow(tx, index))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
No MTM to NYM conversions found matching your criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderPagination(mtmCurrentPage, mtmTotalPages, setMtmCurrentPage)}
|
||||
</div>
|
||||
|
||||
{/* ETH to NYM Conversions Section */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">ETH to NYM Conversions</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">Each conversion consists of a Swap transaction (ETH to intermediate token) and a Bridge transaction (to NYM)</p>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Transaction Hashes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Amounts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Error
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedEthConversions.length > 0 ? (
|
||||
paginatedEthConversions.map((conversion, index) => renderEthConversionRow(conversion, index))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
No ETH to NYM conversions found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderPagination(otherCurrentPage, ethTotalPages, setOtherCurrentPage)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -1,50 +1,17 @@
|
||||
import styles from '../styles/Home.module.css'
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect directly to dashboard
|
||||
router.push('/dashboard');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://www.laconic.com/">Laconic!</a>
|
||||
</h1>
|
||||
|
||||
<div className={styles.grid}>
|
||||
|
||||
<p className={styles.card}>
|
||||
CONFIG1 has value: {process.env.CERC_TEST_WEBAPP_CONFIG1}
|
||||
</p>
|
||||
|
||||
<p className={styles.card}>
|
||||
CONFIG2 has value: {process.env.CERC_TEST_WEBAPP_CONFIG2}
|
||||
</p>
|
||||
|
||||
<p className={styles.card}>
|
||||
WEBAPP_DEBUG has value: {process.env.CERC_WEBAPP_DEBUG}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://www.laconic.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by
|
||||
<svg width="133" height="24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M37.761 22.302h9.246v-2.704h-6.155v-17.9h-3.09v20.604ZM59.314 1.697h-5.126l-5.357 20.605h3.194l1.34-5.151h6.618l1.34 5.151h3.348L59.314 1.697Zm-5.306 12.878 2.679-10.663h.103l2.575 10.663h-5.357ZM74.337 9.682h3.606c0-5.873-1.88-8.397-6.259-8.397-4.61 0-6.593 3.194-6.593 10.689 0 7.52 1.983 10.74 6.593 10.74 4.379 0 6.259-2.447 6.285-8.139h-3.606c-.026 4.456-.567 5.563-2.679 5.563-2.42 0-3.013-1.622-2.987-8.164 0-6.516.592-8.14 2.987-8.113 2.112 0 2.653 1.159 2.653 5.82ZM86.689 1.285c4.687.026 6.696 3.245 6.696 10.715 0 7.469-2.009 10.688-6.696 10.714-4.714.026-6.723-3.194-6.723-10.714 0-7.521 2.01-10.74 6.723-10.715ZM83.572 12c0 6.516.618 8.139 3.117 8.139 2.472 0 3.09-1.623 3.09-8.14 0-6.541-.618-8.164-3.09-8.138-2.499.026-3.117 1.648-3.117 8.139ZM99.317 22.276l-3.09.026V1.697h5.434l5.074 16.793h.052V1.697h3.09v20.605h-5.099l-5.409-18.08h-.052v18.054ZM116.615 1.697h-3.091v20.605h3.091V1.697ZM128.652 9.682h3.606c0-5.873-1.881-8.397-6.259-8.397-4.61 0-6.594 3.194-6.594 10.689 0 7.52 1.984 10.74 6.594 10.74 4.378 0 6.259-2.447 6.284-8.139h-3.605c-.026 4.456-.567 5.563-2.679 5.563-2.421 0-3.014-1.622-2.988-8.164 0-6.516.593-8.14 2.988-8.113 2.112 0 2.653 1.159 2.653 5.82Z"
|
||||
fill="#000000">
|
||||
</path>
|
||||
<path fillRule="evenodd" clipRule="evenodd"
|
||||
d="M4.05 12.623A15.378 15.378 0 0 0 8.57 1.714C8.573 1.136 8.54.564 8.477 0H0v16.287c0 1.974.752 3.949 2.258 5.454A7.69 7.69 0 0 0 7.714 24L24 24v-8.477a15.636 15.636 0 0 0-1.715-.095c-4.258 0-8.115 1.73-10.908 4.523-2.032 1.981-5.291 1.982-7.299-.026-2.006-2.006-2.007-5.266-.029-7.302Zm18.192-10.86a6.004 6.004 0 0 0-8.485 0 6.003 6.003 0 0 0 0 8.484 6.003 6.003 0 0 0 8.485 0 6.002 6.002 0 0 0 0-8.485Z"
|
||||
fill="#000000">
|
||||
</path>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
28
pages/login.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Directly redirect to dashboard
|
||||
router.push('/dashboard');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Redirecting - MTM VPN Dashboard</title>
|
||||
<meta name="description" content="MTM VPN Admin Dashboard" />
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Redirecting to dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 9.9 KiB |
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Laconic Test Progressive Web App",
|
||||
"short_name": "Test PWA",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#004740",
|
||||
"display": "fullscreen",
|
||||
"name": "MTM VPN Dashboard",
|
||||
"short_name": "MTM VPN",
|
||||
"theme_color": "#54C3D4",
|
||||
"background_color": "#1e293b",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"start_url": "/dashboard",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
@ -38,11 +38,6 @@
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@ -1,16 +1,3 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@ -13,7 +13,9 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
55
types/index.ts
Normal file
@ -0,0 +1,55 @@
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
transactionHash: string;
|
||||
fromAddress: string;
|
||||
nymTransactionHash?: string;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface BridgeTransaction {
|
||||
id: number;
|
||||
nymAmount: string;
|
||||
ethTransactionHash?: string;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SwapTransaction {
|
||||
id: number;
|
||||
ethAmount: string;
|
||||
transactionHash?: string;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
asset: 'ETH' | 'NYM';
|
||||
balance: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface AppDownload {
|
||||
id: string;
|
||||
version: string;
|
||||
platform: 'Android' | 'iOS' | 'Windows' | 'macOS' | 'Linux';
|
||||
downloads: number;
|
||||
releaseDate: Date;
|
||||
fileSize: string;
|
||||
}
|
||||
|
||||
|
||||
export interface DashboardStats {
|
||||
totalConversions: number;
|
||||
successfulConversions: number;
|
||||
failedConversions: number;
|
||||
totalDownloads: number;
|
||||
monthlyData: MonthlyData[];
|
||||
}
|
||||
|
||||
export interface MonthlyData {
|
||||
month: string;
|
||||
totalConversions: number;
|
||||
successfulConversions: number;
|
||||
failedConversions: number;
|
||||
}
|
||||
114
utils/api.ts
Normal file
@ -0,0 +1,114 @@
|
||||
const MTM_SERVICE_URL = process.env.NEXT_PUBLIC_MTM_SERVICE_URL || 'http://localhost:3000';
|
||||
|
||||
export interface MonthlyData {
|
||||
month: string;
|
||||
totalConversions: number;
|
||||
successfulConversions: number;
|
||||
failedConversions: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalConversions: number;
|
||||
successfulConversions: number;
|
||||
failedConversions: number;
|
||||
totalDownloads: number;
|
||||
monthlyData: MonthlyData[];
|
||||
walletAddresses: {
|
||||
eth?: string;
|
||||
nym: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransactionData {
|
||||
id: string;
|
||||
transactionHash: string;
|
||||
fromAddress: string;
|
||||
nymTransactionHash?: string;
|
||||
error?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: TransactionData[];
|
||||
totalCount: number;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public response?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${MTM_SERVICE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Network or parsing errors
|
||||
throw new ApiError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
// Get dashboard statistics
|
||||
getStats: (): Promise<DashboardStats> => apiRequest<DashboardStats>('/api/dashboard/stats'),
|
||||
|
||||
// Get conversions with pagination and filtering
|
||||
getConversions: (params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}): Promise<TransactionsResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.set('page', params.page.toString());
|
||||
if (params?.limit) searchParams.set('limit', params.limit.toString());
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
|
||||
const query = searchParams.toString();
|
||||
const endpoint = `/api/transactions/conversions${query ? `?${query}` : ''}`;
|
||||
return apiRequest<TransactionsResponse>(endpoint);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default dashboardApi;
|
||||