Add deployment scripts and integrate APIs for dashboard #1

Merged
nabarun merged 6 commits from ng-deploy-laconic into main 2025-09-02 11:53:55 +00:00
42 changed files with 5105 additions and 187 deletions

View File

@ -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
View 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
View 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>
);
}

View 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
View 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
View File

@ -0,0 +1,3 @@
.registry.env
.app.env

View 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
View 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
View 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
View 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
View 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
View 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
View File

63
deploy/remove-deployment.sh Executable file
View 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"

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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
View 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'
});
}
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View File

@ -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 &nbsp;
<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
View 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
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -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",

View File

@ -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
View 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: [],
}

View File

@ -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
View 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
View 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;