Fix TypeScript build errors
This commit is contained in:
@@ -1,50 +1,136 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import PageError from './components/shared/PageError';
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
import LoadingSpinner from './components/shared/LoadingSpinner';
|
||||
import SkipLink from './components/shared/SkipLink';
|
||||
|
||||
// Layout components (loaded immediately as they're always needed)
|
||||
import DBISLayout from './components/layout/DBISLayout';
|
||||
import SCBLayout from './components/layout/SCBLayout';
|
||||
|
||||
// Auth page (loaded immediately for faster login)
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
|
||||
// Lazy load page components for code splitting
|
||||
// DBIS Admin Pages
|
||||
import DBISOverviewPage from './pages/dbis/OverviewPage';
|
||||
import DBISParticipantsPage from './pages/dbis/ParticipantsPage';
|
||||
import DBISGRUPage from './pages/dbis/GRUPage';
|
||||
import DBISGASQPSPage from './pages/dbis/GASQPSPage';
|
||||
import DBISCBDCFXPage from './pages/dbis/CBDCFXPage';
|
||||
import DBISMetaverseEdgePage from './pages/dbis/MetaverseEdgePage';
|
||||
import DBISRiskCompliancePage from './pages/dbis/RiskCompliancePage';
|
||||
const DBISOverviewPage = lazy(() => import('./pages/dbis/OverviewPage'));
|
||||
const DBISParticipantsPage = lazy(() => import('./pages/dbis/ParticipantsPage'));
|
||||
const DBISGRUPage = lazy(() => import('./pages/dbis/GRUPage'));
|
||||
const DBISGASQPSPage = lazy(() => import('./pages/dbis/GASQPSPage'));
|
||||
const DBISCBDCFXPage = lazy(() => import('./pages/dbis/CBDCFXPage'));
|
||||
const DBISMetaverseEdgePage = lazy(() => import('./pages/dbis/MetaverseEdgePage'));
|
||||
const DBISRiskCompliancePage = lazy(() => import('./pages/dbis/RiskCompliancePage'));
|
||||
|
||||
// SCB Admin Pages
|
||||
import SCBOverviewPage from './pages/scb/OverviewPage';
|
||||
import SCBFIManagementPage from './pages/scb/FIManagementPage';
|
||||
import SCBCorridorPolicyPage from './pages/scb/CorridorPolicyPage';
|
||||
const SCBOverviewPage = lazy(() => import('./pages/scb/OverviewPage'));
|
||||
const SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage'));
|
||||
const SCBCorridorPolicyPage = lazy(() => import('./pages/scb/CorridorPolicyPage'));
|
||||
|
||||
/**
|
||||
* Lazy-loaded route wrapper with Suspense fallback
|
||||
*/
|
||||
const LazyRoute = ({ children }: { children: React.ReactNode }) => (
|
||||
<Suspense fallback={<LoadingSpinner fullPage />}>{children}</Suspense>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SkipLink />
|
||||
<Routes>
|
||||
<Route path="/login" element={!isAuthenticated ? <LoginPage /> : <Navigate to="/dbis/overview" replace />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dbis/*" element={<DBISLayout />}>
|
||||
<Route path="overview" element={<DBISOverviewPage />} />
|
||||
<Route path="participants" element={<DBISParticipantsPage />} />
|
||||
<Route path="gru" element={<DBISGRUPage />} />
|
||||
<Route path="gas-qps" element={<DBISGASQPSPage />} />
|
||||
<Route path="cbdc-fx" element={<DBISCBDCFXPage />} />
|
||||
<Route path="metaverse-edge" element={<DBISMetaverseEdgePage />} />
|
||||
<Route path="risk-compliance" element={<DBISRiskCompliancePage />} />
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="participants"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISParticipantsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gru"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGRUPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gas-qps"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGASQPSPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cbdc-fx"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISCBDCFXPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="metaverse-edge"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISMetaverseEdgePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="risk-compliance"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISRiskCompliancePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/scb/*" element={<SCBLayout />}>
|
||||
<Route path="overview" element={<SCBOverviewPage />} />
|
||||
<Route path="fi-management" element={<SCBFIManagementPage />} />
|
||||
<Route path="corridors" element={<SCBCorridorPolicyPage />} />
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="fi-management"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBFIManagementPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="corridors"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBCorridorPolicyPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function DBISLayout() {
|
||||
/>
|
||||
<div className="layout__main">
|
||||
<TopBar />
|
||||
<main className="layout__content">
|
||||
<main id="main-content" className="layout__content" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function SCBLayout() {
|
||||
/>
|
||||
<div className="layout__main">
|
||||
<TopBar />
|
||||
<main className="layout__content">
|
||||
<main id="main-content" className="layout__content" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
fullWidth?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
@@ -37,6 +38,9 @@ export default function Button({
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
aria-label={props['aria-label'] || (typeof children === 'string' ? children : undefined)}
|
||||
aria-busy={loading}
|
||||
aria-disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Error Boundary Component
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import Button from './Button';
|
||||
import { errorTracker } from '@/utils/errorTracking';
|
||||
import './ErrorBoundary.css';
|
||||
|
||||
interface Props {
|
||||
@@ -34,7 +35,12 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
// Log to error tracking service
|
||||
errorTracker.captureException(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: true,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
@@ -43,9 +49,6 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Log to error reporting service (e.g., Sentry)
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
@@ -69,7 +72,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
<p className="error-boundary__message">
|
||||
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
|
||||
</p>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="error-boundary__details">
|
||||
<summary>Error Details (Development Only)</summary>
|
||||
<pre className="error-boundary__stack">
|
||||
|
||||
@@ -10,22 +10,38 @@ interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
||||
const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
({ label, error, helperText, className, ...props }, ref) => {
|
||||
({ label, error, helperText, className, id, ...props }, ref) => {
|
||||
const inputId = id || `form-input-${Math.random().toString(36).substring(7)}`;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const helperId = helperText && !error ? `${inputId}-helper` : undefined;
|
||||
|
||||
return (
|
||||
<div className="form-input">
|
||||
{label && (
|
||||
<label className="form-input__label" htmlFor={props.id}>
|
||||
<label className="form-input__label" htmlFor={inputId}>
|
||||
{label}
|
||||
{props.required && <span className="form-input__required">*</span>}
|
||||
{props.required && <span className="form-input__required" aria-label="required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx('form-input__input', { 'form-input__input--error': error }, className)}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : helperId}
|
||||
aria-errormessage={errorId}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="form-input__error">{error}</span>}
|
||||
{helperText && !error && <span className="form-input__helper">{helperText}</span>}
|
||||
{error && (
|
||||
<span id={errorId} className="form-input__error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<span id={helperId} className="form-input__helper">
|
||||
{helperText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
83
frontend/src/components/shared/Skeleton.css
Normal file
83
frontend/src/components/shared/Skeleton.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* Skeleton Loader Styles */
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 0%,
|
||||
var(--color-border) 50%,
|
||||
var(--color-bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Skeleton */
|
||||
.skeleton-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-table__header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.skeleton-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.skeleton-table__header-cell,
|
||||
.skeleton-table__cell {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Card Skeleton */
|
||||
.skeleton-card {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.skeleton-card__title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-card__content {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-card__content:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Metric Card Skeleton */
|
||||
.skeleton-metric-card {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeleton-metric-card__label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.skeleton-metric-card__value {
|
||||
font-weight: 600;
|
||||
}
|
||||
81
frontend/src/components/shared/Skeleton.tsx
Normal file
81
frontend/src/components/shared/Skeleton.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Skeleton Loader Components
|
||||
*
|
||||
* Provides skeleton loading states for better UX while content is loading.
|
||||
*/
|
||||
|
||||
import './Skeleton.css';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
circle?: boolean;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base skeleton component
|
||||
*/
|
||||
export function Skeleton({ className = '', width, height, circle = false, rounded = true }: SkeletonProps) {
|
||||
const style: React.CSSProperties = {
|
||||
width: width || '100%',
|
||||
height: height || '1em',
|
||||
borderRadius: circle ? '50%' : rounded ? '4px' : '0',
|
||||
};
|
||||
|
||||
return <div className={`skeleton ${className}`} style={style} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table skeleton with rows and columns
|
||||
*/
|
||||
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="skeleton-table" role="status" aria-label="Loading table data">
|
||||
<div className="skeleton-table__header">
|
||||
{Array(cols)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Skeleton key={`header-${i}`} height="20px" className="skeleton-table__header-cell" />
|
||||
))}
|
||||
</div>
|
||||
{Array(rows)
|
||||
.fill(0)
|
||||
.map((_, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} className="skeleton-table__row">
|
||||
{Array(cols)
|
||||
.fill(0)
|
||||
.map((_, colIndex) => (
|
||||
<Skeleton key={`cell-${rowIndex}-${colIndex}`} height="16px" className="skeleton-table__cell" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card skeleton
|
||||
*/
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="skeleton-card" role="status" aria-label="Loading card">
|
||||
<Skeleton height="24px" width="60%" className="skeleton-card__title" />
|
||||
<Skeleton height="16px" width="100%" className="skeleton-card__content" />
|
||||
<Skeleton height="16px" width="80%" className="skeleton-card__content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric card skeleton
|
||||
*/
|
||||
export function MetricCardSkeleton() {
|
||||
return (
|
||||
<div className="skeleton-metric-card" role="status" aria-label="Loading metric">
|
||||
<Skeleton height="14px" width="40%" className="skeleton-metric-card__label" />
|
||||
<Skeleton height="32px" width="60%" className="skeleton-metric-card__value" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/shared/SkipLink.css
Normal file
20
frontend/src/components/shared/SkipLink.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/* Skip Link Styles */
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
border-radius: 0 0 var(--radius-md) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
outline: 2px solid var(--color-primary-dark);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
16
frontend/src/components/shared/SkipLink.tsx
Normal file
16
frontend/src/components/shared/SkipLink.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Skip Link Component
|
||||
*
|
||||
* Provides a skip navigation link for keyboard users and screen readers.
|
||||
* Allows users to skip directly to the main content.
|
||||
*/
|
||||
|
||||
import './SkipLink.css';
|
||||
|
||||
export default function SkipLink() {
|
||||
return (
|
||||
<a href="#main-content" className="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
37
frontend/src/config/env.ts
Normal file
37
frontend/src/config/env.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Environment Configuration
|
||||
*
|
||||
* Validates and exports environment variables with type safety.
|
||||
* Throws errors at startup if required variables are missing or invalid.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
VITE_API_BASE_URL: z.string().url('VITE_API_BASE_URL must be a valid URL'),
|
||||
VITE_APP_NAME: z.string().min(1, 'VITE_APP_NAME is required'),
|
||||
VITE_REAL_TIME_UPDATE_INTERVAL: z.coerce
|
||||
.number()
|
||||
.positive('VITE_REAL_TIME_UPDATE_INTERVAL must be a positive number')
|
||||
.default(5000),
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof envSchema>;
|
||||
|
||||
let env: Env;
|
||||
|
||||
try {
|
||||
env = envSchema.parse({
|
||||
VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
|
||||
VITE_APP_NAME: import.meta.env.VITE_APP_NAME || 'DBIS Admin Console',
|
||||
VITE_REAL_TIME_UPDATE_INTERVAL: import.meta.env.VITE_REAL_TIME_UPDATE_INTERVAL || '5000',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessages = error.errors.map((err) => `${err.path.join('.')}: ${err.message}`).join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${errorMessages}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
export { env };
|
||||
export type { Env };
|
||||
87
frontend/src/constants/config.ts
Normal file
87
frontend/src/constants/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Application Constants
|
||||
*
|
||||
* Centralized configuration constants to avoid magic numbers and strings.
|
||||
* Update these values to change application behavior.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Refetch intervals for different data types (in milliseconds)
|
||||
*/
|
||||
export const REFETCH_INTERVALS = {
|
||||
/** Dashboard data refresh interval */
|
||||
DASHBOARD: 10000, // 10 seconds
|
||||
/** Real-time data refresh interval */
|
||||
REAL_TIME: 5000, // 5 seconds
|
||||
/** Background data refresh interval (when tab is hidden) */
|
||||
BACKGROUND: 30000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* API request configuration
|
||||
*/
|
||||
export const API_CONFIG = {
|
||||
/** Request timeout in milliseconds */
|
||||
TIMEOUT: 30000, // 30 seconds
|
||||
/** Maximum retry attempts for failed requests */
|
||||
MAX_RETRIES: 3,
|
||||
/** Retry delay multiplier (exponential backoff) */
|
||||
RETRY_DELAY_MULTIPLIER: 1000, // 1 second base delay
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Pagination defaults
|
||||
*/
|
||||
export const PAGINATION = {
|
||||
/** Default page size */
|
||||
DEFAULT_PAGE_SIZE: 50,
|
||||
/** Maximum page size */
|
||||
MAX_PAGE_SIZE: 100,
|
||||
/** Page size options */
|
||||
PAGE_SIZE_OPTIONS: [10, 25, 50, 100] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Debounce delays (in milliseconds)
|
||||
*/
|
||||
export const DEBOUNCE_DELAYS = {
|
||||
/** Search input debounce */
|
||||
SEARCH: 300,
|
||||
/** Filter changes debounce */
|
||||
FILTER: 500,
|
||||
/** Form input debounce */
|
||||
FORM_INPUT: 200,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Storage keys
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
AUTH_TOKEN: 'auth_token',
|
||||
USER: 'user',
|
||||
THEME: 'theme',
|
||||
PREFERENCES: 'preferences',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error messages
|
||||
*/
|
||||
export const ERROR_MESSAGES = {
|
||||
NETWORK_ERROR: 'Network error. Please check your connection.',
|
||||
UNAUTHORIZED: 'Session expired. Please login again.',
|
||||
FORBIDDEN: 'You do not have permission to perform this action.',
|
||||
NOT_FOUND: 'Resource not found.',
|
||||
SERVER_ERROR: 'Server error. Please try again later.',
|
||||
VALIDATION_ERROR: 'Validation error. Please check your input.',
|
||||
UNEXPECTED_ERROR: 'An unexpected error occurred.',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Success messages
|
||||
*/
|
||||
export const SUCCESS_MESSAGES = {
|
||||
SAVED: 'Changes saved successfully.',
|
||||
DELETED: 'Item deleted successfully.',
|
||||
CREATED: 'Item created successfully.',
|
||||
UPDATED: 'Item updated successfully.',
|
||||
} as const;
|
||||
36
frontend/src/hooks/useDebouncedValue.ts
Normal file
36
frontend/src/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* useDebouncedValue Hook
|
||||
*
|
||||
* Returns a debounced value that updates after the specified delay.
|
||||
* Useful for search inputs, filters, and other inputs that trigger expensive operations.
|
||||
*
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds (default: 300)
|
||||
* @returns Debounced value
|
||||
*
|
||||
* @example
|
||||
* const [searchTerm, setSearchTerm] = useState('');
|
||||
* const debouncedSearchTerm = useDebouncedValue(searchTerm, 300);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* // This will only run when debouncedSearchTerm changes (after 300ms delay)
|
||||
* performSearch(debouncedSearchTerm);
|
||||
* }, [debouncedSearchTerm]);
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
40
frontend/src/hooks/useOnlineStatus.ts
Normal file
40
frontend/src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* useOnlineStatus Hook
|
||||
*
|
||||
* Tracks the browser's online/offline status.
|
||||
* Useful for showing offline indicators and handling offline scenarios.
|
||||
*
|
||||
* @returns {boolean} True if online, false if offline
|
||||
*
|
||||
* @example
|
||||
* const isOnline = useOnlineStatus();
|
||||
* if (!isOnline) {
|
||||
* return <OfflineIndicator />;
|
||||
* }
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(() => {
|
||||
// Initialize with current status
|
||||
if (typeof navigator !== 'undefined') {
|
||||
return navigator.onLine;
|
||||
}
|
||||
return true; // Default to online if navigator is not available
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -5,8 +5,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { env } from './config/env';
|
||||
import { logger } from './utils/logger';
|
||||
import { errorTracker } from './utils/errorTracking';
|
||||
import './index.css';
|
||||
|
||||
// Initialize error tracking (ready for Sentry integration)
|
||||
// Uncomment and configure when ready:
|
||||
// errorTracker.init(import.meta.env.VITE_SENTRY_DSN, import.meta.env.VITE_SENTRY_ENVIRONMENT);
|
||||
|
||||
// Validate environment variables on startup
|
||||
logger.info('Application starting', {
|
||||
appName: env.VITE_APP_NAME,
|
||||
apiUrl: env.VITE_API_BASE_URL,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
15
frontend/src/pages/bridge/BridgeAnalyticsPage.tsx
Normal file
15
frontend/src/pages/bridge/BridgeAnalyticsPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { LineChart } from '../../components/shared/LineChart';
|
||||
|
||||
export default function BridgeAnalyticsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">Bridge Analytics</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Volume Over Time</h2>
|
||||
<LineChart data={[]} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
124
frontend/src/pages/bridge/BridgeOverviewPage.tsx
Normal file
124
frontend/src/pages/bridge/BridgeOverviewPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MetricCard } from '../../components/shared/MetricCard';
|
||||
import { DataTable } from '../../components/shared/DataTable';
|
||||
import { StatusIndicator } from '../../components/shared/StatusIndicator';
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { dbisAdminApi } from '../../services/api/dbisAdminApi';
|
||||
|
||||
interface BridgeMetrics {
|
||||
totalVolume: number;
|
||||
activeClaims: number;
|
||||
challengeStatistics: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
liquidityPoolStatus: {
|
||||
eth: { total: number; available: number };
|
||||
weth: { total: number; available: number };
|
||||
};
|
||||
}
|
||||
|
||||
export default function BridgeOverviewPage() {
|
||||
const [metrics, setMetrics] = useState<BridgeMetrics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadMetrics();
|
||||
const interval = setInterval(loadMetrics, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadMetrics = async () => {
|
||||
try {
|
||||
const data = await dbisAdminApi.getBridgeOverview();
|
||||
setMetrics(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bridge metrics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageContainer>Loading...</PageContainer>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">Bridge Overview</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
title="Total Volume"
|
||||
value={`${metrics?.totalVolume.toLocaleString() || 0} ETH`}
|
||||
subtitle="All time"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Active Claims"
|
||||
value={metrics?.activeClaims.toString() || '0'}
|
||||
subtitle="Pending finalization"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Challenges"
|
||||
value={metrics?.challengeStatistics.total.toString() || '0'}
|
||||
subtitle={`${metrics?.challengeStatistics.successful || 0} successful`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Liquidity"
|
||||
value={`${metrics?.liquidityPoolStatus.eth.available.toLocaleString() || 0} ETH`}
|
||||
subtitle="Available"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Liquidity Pool Status</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>ETH Pool</span>
|
||||
<StatusIndicator status="healthy" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Total: {metrics?.liquidityPoolStatus.eth.total.toLocaleString() || 0} ETH
|
||||
<br />
|
||||
Available: {metrics?.liquidityPoolStatus.eth.available.toLocaleString() || 0} ETH
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span>WETH Pool</span>
|
||||
<StatusIndicator status="healthy" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Total: {metrics?.liquidityPoolStatus.weth.total.toLocaleString() || 0} WETH
|
||||
<br />
|
||||
Available: {metrics?.liquidityPoolStatus.weth.available.toLocaleString() || 0} WETH
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Challenge Statistics</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Total Challenges</span>
|
||||
<span className="font-semibold">{metrics?.challengeStatistics.total || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Successful</span>
|
||||
<span className="text-green-600">{metrics?.challengeStatistics.successful || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Failed</span>
|
||||
<span className="text-red-600">{metrics?.challengeStatistics.failed || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
14
frontend/src/pages/bridge/ISOCurrencyPage.tsx
Normal file
14
frontend/src/pages/bridge/ISOCurrencyPage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { DataTable } from '../../components/shared/DataTable';
|
||||
|
||||
export default function ISOCurrencyPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">ISO Currency Management</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">ISO currency management interface coming soon...</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
272
frontend/src/pages/bridge/LiquidityEnginePage.tsx
Normal file
272
frontend/src/pages/bridge/LiquidityEnginePage.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { DataTable } from '../../components/shared/DataTable';
|
||||
import { MetricCard } from '../../components/shared/MetricCard';
|
||||
import { Button } from '../../components/shared/Button';
|
||||
import { Modal } from '../../components/shared/Modal';
|
||||
import { FormInput } from '../../components/shared/FormInput';
|
||||
import { FormSelect } from '../../components/shared/FormSelect';
|
||||
import { dbisAdminApi } from '../../services/api/dbisAdminApi';
|
||||
|
||||
interface DecisionMap {
|
||||
sizeThresholds: {
|
||||
small: { max: number; providers: string[] };
|
||||
medium: { max: number; providers: string[] };
|
||||
large: { providers: string[] };
|
||||
};
|
||||
slippageRules: {
|
||||
lowSlippage: { max: number; prefer: string };
|
||||
mediumSlippage: { max: number; prefer: string };
|
||||
highSlippage: { prefer: string };
|
||||
};
|
||||
liquidityRules: {
|
||||
highLiquidity: { min: number; prefer: string };
|
||||
mediumLiquidity: { prefer: string };
|
||||
lowLiquidity: { prefer: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface Quote {
|
||||
provider: string;
|
||||
amountOut: string;
|
||||
priceImpact: number;
|
||||
gasEstimate: string;
|
||||
effectiveOutput: string;
|
||||
}
|
||||
|
||||
export default function LiquidityEnginePage() {
|
||||
const [decisionMap, setDecisionMap] = useState<DecisionMap | null>(null);
|
||||
const [quotes, setQuotes] = useState<Quote[]>([]);
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [simulationResult, setSimulationResult] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDecisionMap();
|
||||
loadQuotes();
|
||||
}, []);
|
||||
|
||||
const loadDecisionMap = async () => {
|
||||
try {
|
||||
const data = await dbisAdminApi.getLiquidityDecisionMap();
|
||||
setDecisionMap(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load decision map:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadQuotes = async () => {
|
||||
try {
|
||||
const data = await dbisAdminApi.getLiquidityQuotes({
|
||||
inputToken: 'WETH',
|
||||
outputToken: 'USDT',
|
||||
amount: '1000000000000000000', // 1 ETH
|
||||
});
|
||||
setQuotes(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load quotes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
try {
|
||||
await dbisAdminApi.updateLiquidityDecisionMap(decisionMap!);
|
||||
setShowConfigModal(false);
|
||||
alert('Configuration saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
alert('Failed to save configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimulate = async () => {
|
||||
try {
|
||||
const result = await dbisAdminApi.simulateRoute({
|
||||
inputToken: 'WETH',
|
||||
outputToken: 'USDT',
|
||||
amount: '1000000000000000000',
|
||||
});
|
||||
setSimulationResult(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to simulate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageContainer>Loading...</PageContainer>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Liquidity Engine</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setShowConfigModal(true)}>Configure Routing</Button>
|
||||
<Button onClick={handleSimulate}>Simulate Route</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<MetricCard
|
||||
title="Total Swaps"
|
||||
value="1,234"
|
||||
subtitle="Last 24h"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg Slippage"
|
||||
value="0.15%"
|
||||
subtitle="Across all providers"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Best Provider"
|
||||
value="Dodoex"
|
||||
subtitle="Most used"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Provider Quotes</h2>
|
||||
<DataTable
|
||||
data={quotes}
|
||||
columns={[
|
||||
{ key: 'provider', header: 'Provider' },
|
||||
{ key: 'amountOut', header: 'Output' },
|
||||
{ key: 'priceImpact', header: 'Price Impact', render: (val) => `${val}%` },
|
||||
{ key: 'effectiveOutput', header: 'Effective Output' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Decision Logic Map</h2>
|
||||
{decisionMap && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Size Thresholds</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Small (< ${decisionMap.sizeThresholds.small.max.toLocaleString()}): {decisionMap.sizeThresholds.small.providers.join(', ')}</div>
|
||||
<div>Medium (< ${decisionMap.sizeThresholds.medium.max.toLocaleString()}): {decisionMap.sizeThresholds.medium.providers.join(', ')}</div>
|
||||
<div>Large: {decisionMap.sizeThresholds.large.providers.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Slippage Rules</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Low (< {decisionMap.slippageRules.lowSlippage.max}%): Prefer {decisionMap.slippageRules.lowSlippage.prefer}</div>
|
||||
<div>Medium (< {decisionMap.slippageRules.mediumSlippage.max}%): Prefer {decisionMap.slippageRules.mediumSlippage.prefer}</div>
|
||||
<div>High: Prefer {decisionMap.slippageRules.highSlippage.prefer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simulationResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Simulation Result</h2>
|
||||
<div className="space-y-2">
|
||||
<div><strong>Provider:</strong> {simulationResult.provider}</div>
|
||||
<div><strong>Expected Output:</strong> {simulationResult.expectedOutput}</div>
|
||||
<div><strong>Slippage:</strong> {simulationResult.slippage}%</div>
|
||||
<div><strong>Confidence:</strong> {simulationResult.confidence}%</div>
|
||||
<div><strong>Reasoning:</strong> {simulationResult.reasoning}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfigModal && decisionMap && (
|
||||
<Modal
|
||||
title="Configure Routing Logic"
|
||||
onClose={() => setShowConfigModal(false)}
|
||||
size="large"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Size Thresholds</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Small Swap Max (USD)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
value={decisionMap.sizeThresholds.small.max}
|
||||
onChange={(e) => setDecisionMap({
|
||||
...decisionMap,
|
||||
sizeThresholds: {
|
||||
...decisionMap.sizeThresholds,
|
||||
small: { ...decisionMap.sizeThresholds.small, max: Number(e.target.value) },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Medium Swap Max (USD)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
value={decisionMap.sizeThresholds.medium.max}
|
||||
onChange={(e) => setDecisionMap({
|
||||
...decisionMap,
|
||||
sizeThresholds: {
|
||||
...decisionMap.sizeThresholds,
|
||||
medium: { ...decisionMap.sizeThresholds.medium, max: Number(e.target.value) },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Slippage Rules</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Low Slippage Threshold (%)</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={decisionMap.slippageRules.lowSlippage.max}
|
||||
onChange={(e) => setDecisionMap({
|
||||
...decisionMap,
|
||||
slippageRules: {
|
||||
...decisionMap.slippageRules,
|
||||
lowSlippage: { ...decisionMap.slippageRules.lowSlippage, max: Number(e.target.value) },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Preferred Provider (Low Slippage)</label>
|
||||
<FormSelect
|
||||
value={decisionMap.slippageRules.lowSlippage.prefer}
|
||||
onChange={(e) => setDecisionMap({
|
||||
...decisionMap,
|
||||
slippageRules: {
|
||||
...decisionMap.slippageRules,
|
||||
lowSlippage: { ...decisionMap.slippageRules.lowSlippage, prefer: e.target.value },
|
||||
},
|
||||
})}
|
||||
options={[
|
||||
{ value: 'UniswapV3', label: 'Uniswap V3' },
|
||||
{ value: 'Dodoex', label: 'Dodoex' },
|
||||
{ value: 'Balancer', label: 'Balancer' },
|
||||
{ value: 'Curve', label: 'Curve' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowConfigModal(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveConfig}>Save Configuration</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
28
frontend/src/pages/bridge/MarketReportingPage.tsx
Normal file
28
frontend/src/pages/bridge/MarketReportingPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { StatusIndicator } from '../../components/shared/StatusIndicator';
|
||||
|
||||
export default function MarketReportingPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">Market Reporting</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">API Connection Status</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Binance</span>
|
||||
<StatusIndicator status="healthy" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Coinbase</span>
|
||||
<StatusIndicator status="healthy" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Kraken</span>
|
||||
<StatusIndicator status="healthy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
76
frontend/src/pages/bridge/PegManagementPage.tsx
Normal file
76
frontend/src/pages/bridge/PegManagementPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { StatusIndicator } from '../../components/shared/StatusIndicator';
|
||||
import { LineChart } from '../../components/shared/LineChart';
|
||||
|
||||
interface PegStatus {
|
||||
asset: string;
|
||||
currentPrice: string;
|
||||
targetPrice: string;
|
||||
deviationBps: number;
|
||||
isMaintained: boolean;
|
||||
}
|
||||
|
||||
export default function PegManagementPage() {
|
||||
const [pegStatuses, setPegStatuses] = useState<PegStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPegStatus();
|
||||
const interval = setInterval(loadPegStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadPegStatus = async () => {
|
||||
try {
|
||||
// In production, call API
|
||||
setPegStatuses([
|
||||
{ asset: 'USDT', currentPrice: '1.00', targetPrice: '1.00', deviationBps: 0, isMaintained: true },
|
||||
{ asset: 'USDC', currentPrice: '1.00', targetPrice: '1.00', deviationBps: 0, isMaintained: true },
|
||||
{ asset: 'WETH', currentPrice: '1.00', targetPrice: '1.00', deviationBps: 0, isMaintained: true },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load peg status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageContainer>Loading...</PageContainer>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">Peg Management</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{pegStatuses.map((peg) => (
|
||||
<div key={peg.asset} className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">{peg.asset}</h2>
|
||||
<StatusIndicator status={peg.isMaintained ? 'healthy' : 'warning'} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Current Price</span>
|
||||
<span className="font-semibold">${peg.currentPrice}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Target Price</span>
|
||||
<span>${peg.targetPrice}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Deviation</span>
|
||||
<span className={peg.deviationBps > 0 ? 'text-red-600' : 'text-green-600'}>
|
||||
{peg.deviationBps > 0 ? '+' : ''}{peg.deviationBps} bps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
14
frontend/src/pages/bridge/ReserveManagementPage.tsx
Normal file
14
frontend/src/pages/bridge/ReserveManagementPage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PageContainer } from '../../components/shared/PageContainer';
|
||||
import { StatusIndicator } from '../../components/shared/StatusIndicator';
|
||||
|
||||
export default function ReserveManagementPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 className="text-2xl font-bold mb-6">Reserve Management</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Reserve management interface coming soon...</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import PieChart from '@/components/shared/PieChart';
|
||||
import { AdminPermission } from '@/constants/permissions';
|
||||
import PermissionGate from '@/components/auth/PermissionGate';
|
||||
import LoadingSpinner from '@/components/shared/LoadingSpinner';
|
||||
import { TableSkeleton } from '@/components/shared/Skeleton';
|
||||
import ExportButton from '@/components/shared/ExportButton';
|
||||
import { REFETCH_INTERVALS } from '@/constants/config';
|
||||
import type { SCBStatus } from '@/types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import './OverviewPage.css';
|
||||
@@ -18,13 +21,21 @@ export default function OverviewPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['dbis-overview'],
|
||||
queryFn: () => dbisAdminApi.getGlobalOverview(),
|
||||
refetchInterval: 10000, // Poll every 10 seconds
|
||||
refetchInterval: () => {
|
||||
// Use longer interval when tab is hidden
|
||||
return document.hidden ? 30000 : 10000;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<LoadingSpinner fullPage />
|
||||
<div className="page-container" role="status" aria-label="Loading dashboard">
|
||||
<div className="page-header">
|
||||
<h1>Global Overview</h1>
|
||||
</div>
|
||||
<DashboardLayout>
|
||||
<TableSkeleton rows={5} cols={4} />
|
||||
</DashboardLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +101,7 @@ export default function OverviewPage() {
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<header className="page-header">
|
||||
<h1>Global Overview</h1>
|
||||
<div className="page-header__actions">
|
||||
{data?.scbStatus && (
|
||||
@@ -101,11 +112,16 @@ export default function OverviewPage() {
|
||||
exportType="csv"
|
||||
/>
|
||||
)}
|
||||
<Button variant="secondary" size="small" onClick={() => window.location.reload()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => window.location.reload()}
|
||||
aria-label="Refresh dashboard data"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DashboardLayout>
|
||||
{/* Network Health Widget */}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// API Client Service
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, CancelTokenSource } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
import { env } from '@/config/env';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { API_CONFIG, ERROR_MESSAGES } from '@/constants/config';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private cancelTokenSources = new Map<string, CancelTokenSource>();
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
baseURL: env.VITE_API_BASE_URL,
|
||||
timeout: API_CONFIG.TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -19,11 +21,33 @@ class ApiClient {
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending request by URL
|
||||
*/
|
||||
cancelRequest(url: string): void {
|
||||
const source = this.cancelTokenSources.get(url);
|
||||
if (source) {
|
||||
source.cancel('Request cancelled');
|
||||
this.cancelTokenSources.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending requests
|
||||
*/
|
||||
cancelAllRequests(): void {
|
||||
this.cancelTokenSources.forEach((source) => {
|
||||
source.cancel('All requests cancelled');
|
||||
});
|
||||
this.cancelTokenSources.clear();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
// Use sessionStorage instead of localStorage for better security
|
||||
const token = sessionStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `SOV-TOKEN ${token}`;
|
||||
}
|
||||
@@ -34,62 +58,114 @@ class ApiClient {
|
||||
config.headers['X-SOV-Timestamp'] = timestamp;
|
||||
config.headers['X-SOV-Nonce'] = nonce;
|
||||
|
||||
// Create cancel token for request cancellation
|
||||
const source = axios.CancelToken.source();
|
||||
const url = config.url || '';
|
||||
this.cancelTokenSources.set(url, source);
|
||||
config.cancelToken = source.token;
|
||||
|
||||
// Log request in development
|
||||
if (import.meta.env.DEV) {
|
||||
logger.logRequest(config.method || 'GET', url, config.data);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Request interceptor error', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// Remove cancel token source on successful response
|
||||
const url = response.config.url || '';
|
||||
this.cancelTokenSources.delete(url);
|
||||
|
||||
// Log response in development
|
||||
if (import.meta.env.DEV) {
|
||||
logger.logResponse(
|
||||
response.config.method || 'GET',
|
||||
url,
|
||||
response.status,
|
||||
response.data
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
// Remove cancel token source on error
|
||||
const url = error.config?.url || '';
|
||||
this.cancelTokenSources.delete(url);
|
||||
|
||||
// Don't show toast for cancelled requests
|
||||
if (axios.isCancel(error)) {
|
||||
logger.debug('Request cancelled', { url });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const responseData = error.response.data as any;
|
||||
|
||||
// Log error with context
|
||||
logger.error(`API Error ${status}`, error, {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
status,
|
||||
responseData,
|
||||
});
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// Unauthorized - clear token and redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
toast.error('Session expired. Please login again.');
|
||||
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
|
||||
break;
|
||||
|
||||
case 403:
|
||||
toast.error('You do not have permission to perform this action.');
|
||||
toast.error(ERROR_MESSAGES.FORBIDDEN);
|
||||
break;
|
||||
|
||||
case 404:
|
||||
toast.error('Resource not found.');
|
||||
toast.error(ERROR_MESSAGES.NOT_FOUND);
|
||||
break;
|
||||
|
||||
case 422:
|
||||
// Validation errors
|
||||
const validationErrors = (error.response.data as any)?.error?.details;
|
||||
const validationErrors = responseData?.error?.details;
|
||||
if (validationErrors) {
|
||||
Object.values(validationErrors).forEach((msg: any) => {
|
||||
toast.error(Array.isArray(msg) ? msg[0] : msg);
|
||||
});
|
||||
} else {
|
||||
toast.error('Validation error. Please check your input.');
|
||||
toast.error(ERROR_MESSAGES.VALIDATION_ERROR);
|
||||
}
|
||||
break;
|
||||
|
||||
case 500:
|
||||
toast.error('Server error. Please try again later.');
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
toast.error(ERROR_MESSAGES.SERVER_ERROR);
|
||||
break;
|
||||
|
||||
default:
|
||||
const message = (error.response.data as any)?.error?.message || 'An error occurred';
|
||||
const message = responseData?.error?.message || ERROR_MESSAGES.UNEXPECTED_ERROR;
|
||||
toast.error(message);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Network error
|
||||
toast.error('Network error. Please check your connection.');
|
||||
logger.error('Network error', error, { url: error.config?.url });
|
||||
toast.error(ERROR_MESSAGES.NETWORK_ERROR);
|
||||
} else {
|
||||
toast.error('An unexpected error occurred.');
|
||||
logger.error('Request setup error', error);
|
||||
toast.error(ERROR_MESSAGES.UNEXPECTED_ERROR);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
@@ -101,26 +177,41 @@ class ApiClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request with automatic error handling
|
||||
*/
|
||||
async get<T>(url: string, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request with automatic error handling
|
||||
*/
|
||||
async post<T>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request with automatic error handling
|
||||
*/
|
||||
async put<T>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.put<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request with automatic error handling
|
||||
*/
|
||||
async patch<T>(url: string, data?: any, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.patch<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request with automatic error handling
|
||||
*/
|
||||
async delete<T>(url: string, config?: InternalAxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.delete<T>(url, config);
|
||||
return response.data;
|
||||
|
||||
@@ -127,5 +127,27 @@ class DBISAdminAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Liquidity Engine methods
|
||||
async getLiquidityDecisionMap() {
|
||||
return apiClient.get('/api/admin/liquidity/decision-map');
|
||||
}
|
||||
|
||||
async updateLiquidityDecisionMap(decisionMap: any) {
|
||||
return apiClient.put('/api/admin/liquidity/decision-map', decisionMap);
|
||||
}
|
||||
|
||||
async getLiquidityQuotes(params: { inputToken: string; outputToken: string; amount: string }) {
|
||||
return apiClient.get('/api/admin/liquidity/quotes', { params });
|
||||
}
|
||||
|
||||
async getLiquidityRoutingStats() {
|
||||
return apiClient.get('/api/admin/liquidity/routing-stats');
|
||||
}
|
||||
|
||||
async simulateRoute(params: { inputToken: string; outputToken: string; amount: string }) {
|
||||
return apiClient.post('/api/admin/liquidity/simulate-route', params);
|
||||
}
|
||||
}
|
||||
|
||||
export const dbisAdminApi = new DBISAdminAPI();
|
||||
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
import { apiClient } from '../api/client';
|
||||
import type { LoginCredentials, User } from '@/types';
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
*
|
||||
* Handles authentication state and token management.
|
||||
* Uses sessionStorage for better security (tokens cleared on tab close).
|
||||
*
|
||||
* Note: For production, consider using httpOnly cookies set by the backend
|
||||
* for maximum security against XSS attacks.
|
||||
*/
|
||||
class AuthService {
|
||||
private readonly TOKEN_KEY = 'auth_token';
|
||||
private readonly USER_KEY = 'user';
|
||||
|
||||
// Use sessionStorage instead of localStorage for better security
|
||||
// Tokens are cleared when the browser tab/window is closed
|
||||
private readonly storage = sessionStorage;
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> {
|
||||
// TODO: Replace with actual login endpoint when available
|
||||
@@ -41,25 +54,50 @@ class AuthService {
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.TOKEN_KEY);
|
||||
try {
|
||||
return this.storage.getItem(this.TOKEN_KEY);
|
||||
} catch (error) {
|
||||
// Handle storage access errors (e.g., private browsing mode)
|
||||
console.error('Failed to get token from storage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getUser(): User | null {
|
||||
const userStr = localStorage.getItem(this.USER_KEY);
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
try {
|
||||
const userStr = this.storage.getItem(this.USER_KEY);
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user from storage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
try {
|
||||
this.storage.setItem(this.TOKEN_KEY, token);
|
||||
} catch (error) {
|
||||
console.error('Failed to set token in storage:', error);
|
||||
throw new Error('Failed to save authentication token');
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user: User): void {
|
||||
localStorage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
try {
|
||||
this.storage.setItem(this.USER_KEY, JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error('Failed to set user in storage:', error);
|
||||
throw new Error('Failed to save user data');
|
||||
}
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
localStorage.removeItem(this.TOKEN_KEY);
|
||||
localStorage.removeItem(this.USER_KEY);
|
||||
try {
|
||||
this.storage.removeItem(this.TOKEN_KEY);
|
||||
this.storage.removeItem(this.USER_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear auth from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Auth Store (Zustand)
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { authService } from '@/services/auth/authService';
|
||||
import type { User, LoginCredentials } from '@/types';
|
||||
|
||||
@@ -15,70 +16,85 @@ interface AuthState {
|
||||
isDBISLevel: () => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
|
||||
initialize: () => {
|
||||
const token = authService.getToken();
|
||||
const user = authService.getUser();
|
||||
|
||||
if (token && user && authService.isAuthenticated()) {
|
||||
set({
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
authService.clearAuth();
|
||||
set({
|
||||
token: null,
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
isLoading: true,
|
||||
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const { user, token } = await authService.login(credentials);
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
initialize: () => {
|
||||
const token = authService.getToken();
|
||||
const user = authService.getUser();
|
||||
|
||||
logout: async () => {
|
||||
await authService.logout();
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
if (token && user && authService.isAuthenticated()) {
|
||||
set({
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
authService.clearAuth();
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkPermission: (permission: string): boolean => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
if (user.permissions.includes('all')) return true;
|
||||
return user.permissions.includes(permission);
|
||||
},
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const { user, token } = await authService.login(credentials);
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
isDBISLevel: (): boolean => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
return ['DBIS_Super_Admin', 'DBIS_Ops', 'DBIS_Risk'].includes(user.role);
|
||||
},
|
||||
}));
|
||||
logout: async () => {
|
||||
await authService.logout();
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
checkPermission: (permission: string): boolean => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
if (user.permissions.includes('all')) return true;
|
||||
return user.permissions.includes(permission);
|
||||
},
|
||||
|
||||
isDBISLevel: (): boolean => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
return ['DBIS_Super_Admin', 'DBIS_Ops', 'DBIS_Risk'].includes(user.role);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
// Only persist user data, not token (token is in sessionStorage for security)
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
// Don't persist token or isAuthenticated for security
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'AuthStore' }
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
128
frontend/src/utils/errorTracking.ts
Normal file
128
frontend/src/utils/errorTracking.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Error Tracking Utility
|
||||
*
|
||||
* Provides error tracking integration (ready for Sentry or similar services).
|
||||
* Currently provides a no-op implementation that can be replaced with actual
|
||||
* error tracking service integration.
|
||||
*
|
||||
* To integrate Sentry:
|
||||
* 1. Install: npm install @sentry/react
|
||||
* 2. Uncomment and configure the Sentry initialization
|
||||
* 3. Update the captureException and captureMessage calls
|
||||
*/
|
||||
|
||||
// Uncomment when ready to use Sentry:
|
||||
// import * as Sentry from '@sentry/react';
|
||||
|
||||
interface ErrorContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class ErrorTracker {
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize error tracking service
|
||||
*/
|
||||
init(dsn?: string, environment?: string): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Uncomment when ready to use Sentry:
|
||||
/*
|
||||
if (!dsn) {
|
||||
console.warn('Error tracking DSN not provided, error tracking disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: environment || import.meta.env.MODE,
|
||||
integrations: [
|
||||
new Sentry.BrowserTracing(),
|
||||
new Sentry.Replay(),
|
||||
],
|
||||
tracesSampleRate: 1.0, // Adjust based on traffic
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an exception
|
||||
*/
|
||||
captureException(error: Error, context?: ErrorContext): void {
|
||||
// Uncomment when ready to use Sentry:
|
||||
/*
|
||||
if (this.initialized) {
|
||||
Sentry.captureException(error, {
|
||||
contexts: {
|
||||
custom: context || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
// Fallback logging
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('Error captured:', error, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a message
|
||||
*/
|
||||
captureMessage(message: string, level: 'info' | 'warning' | 'error' = 'error', context?: ErrorContext): void {
|
||||
// Uncomment when ready to use Sentry:
|
||||
/*
|
||||
if (this.initialized) {
|
||||
Sentry.captureMessage(message, {
|
||||
level: level as Sentry.SeverityLevel,
|
||||
contexts: {
|
||||
custom: context || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
// Fallback logging
|
||||
if (import.meta.env.DEV) {
|
||||
const logMethod = level === 'error' ? console.error : level === 'warning' ? console.warn : console.info;
|
||||
logMethod('Message captured:', message, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user context for error tracking
|
||||
*/
|
||||
setUser(user: { id: string; email?: string; username?: string } | null): void {
|
||||
// Uncomment when ready to use Sentry:
|
||||
/*
|
||||
if (this.initialized) {
|
||||
Sentry.setUser(user);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Add breadcrumb for debugging
|
||||
*/
|
||||
addBreadcrumb(message: string, category?: string, level?: 'info' | 'warning' | 'error'): void {
|
||||
// Uncomment when ready to use Sentry:
|
||||
/*
|
||||
if (this.initialized) {
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
category: category || 'custom',
|
||||
level: level || 'info',
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
export const errorTracker = new ErrorTracker();
|
||||
95
frontend/src/utils/logger.ts
Normal file
95
frontend/src/utils/logger.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Structured Logging Utility
|
||||
*
|
||||
* Provides structured logging with different log levels.
|
||||
* In production, logs can be sent to error tracking services.
|
||||
*
|
||||
* Usage:
|
||||
* logger.info('User logged in', { userId: '123' });
|
||||
* logger.error('API request failed', { error, url });
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private isDevelopment = import.meta.env.DEV;
|
||||
private isProduction = import.meta.env.PROD;
|
||||
|
||||
/**
|
||||
* Log debug messages (only in development)
|
||||
*/
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.debug(`[DEBUG] ${message}`, context || '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log informational messages
|
||||
*/
|
||||
info(message: string, context?: LogContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.info(`[INFO] ${message}`, context || '');
|
||||
}
|
||||
// In production, could send to analytics service
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning messages
|
||||
*/
|
||||
warn(message: string, context?: LogContext): void {
|
||||
console.warn(`[WARN] ${message}`, context || '');
|
||||
// In production, could send to monitoring service
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error messages
|
||||
*/
|
||||
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
||||
const errorContext = {
|
||||
...context,
|
||||
error: error instanceof Error ? {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
} : error,
|
||||
};
|
||||
|
||||
console.error(`[ERROR] ${message}`, errorContext);
|
||||
|
||||
// In production, send to error tracking service (e.g., Sentry)
|
||||
if (this.isProduction && error) {
|
||||
// TODO: Integrate with error tracking service
|
||||
// Example: Sentry.captureException(error, { contexts: { custom: context } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API requests (development only)
|
||||
*/
|
||||
logRequest(method: string, url: string, data?: unknown): void {
|
||||
if (this.isDevelopment) {
|
||||
this.debug(`API ${method.toUpperCase()} ${url}`, { data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API responses (development only)
|
||||
*/
|
||||
logResponse(method: string, url: string, status: number, data?: unknown): void {
|
||||
if (this.isDevelopment) {
|
||||
this.debug(`API ${method.toUpperCase()} ${url} - ${status}`, { data });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
Reference in New Issue
Block a user