Initial commit: Trading Portal - FastAPI + React + PostgreSQL

This commit is contained in:
2026-07-03 13:08:09 +00:00
commit 34a1e91541
198 changed files with 35110 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
.DS_Store
*.local
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+36
View File
@@ -0,0 +1,36 @@
# Trading Portal — Frontend Dockerfile
# Multi-stage: Vite build → Nginx serve
# ---- Build stage ----
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# ---- Serve stage ----
FROM nginx:1.27-alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Fix permissions so nginx can read all files
RUN chmod -R 644 /usr/share/nginx/html/*.json /usr/share/nginx/html/*.js /usr/share/nginx/html/*.html /usr/share/nginx/html/*.svg && \
chmod -R 644 /usr/share/nginx/html/assets/* && \
find /usr/share/nginx/html -type d -exec chmod 755 {} \;
# Copy nginx config (full http wrapper with rate limiting)
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<title>Trading Portal</title>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Performance
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
server {
listen 80;
server_name _;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 10m;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|json|webmanifest)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
location /api/v1/backtest/ {
limit_req zone=api burst=5 nodelay;
proxy_pass http://trading-backend-api:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://trading-backend-api:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
location /ws/ {
proxy_pass http://trading-backend-api:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /health {
proxy_pass http://trading-backend-api:8001/health;
proxy_set_header Host $host;
}
}
}
Generated Executable
+6480
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"lightweight-charts": "^5.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-redux": "^9.3.0",
"react-router": "^8.1.0",
"react-router-dom": "^7.18.0",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"oxlint": "^1.69.0",
"typescript": "~6.0.2",
"vite": "^8.1.0"
}
}
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Trading Portal",
"short_name": "Trading",
"description": "Trading Portal - Real-time trading signals and charts",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+61
View File
@@ -0,0 +1,61 @@
// Trading Portal - Minimal Service Worker
const CACHE_NAME = 'trading-portal-v1';
// Assets to cache on install
const PRECACHE_URLS = [
'/',
'/dashboard',
'/favicon.svg',
'/manifest.json',
];
// Install: pre-cache key assets
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS).catch(() => {
// Non-critical; proceed even if some assets fail
});
})
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
return self.clients.claim();
});
// Fetch: network-first, fallback to cache
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
// Skip non-http(s) requests (chrome-extension, etc.)
if (!event.request.url.startsWith('http')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful responses
if (response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(() => {
// Offline fallback
return caches.match(event.request).then((cached) => {
return cached || new Response('Offline', { status: 503 });
});
})
);
});
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './app/store';
import LoginPage from './features/auth/LoginPage';
// import RegisterPage from './features/auth/RegisterPage'; // Temporarily hidden
import DashboardPage from './features/dashboard/DashboardPage';
import AdminPage from './features/admin/AdminPage';
import ProfilePage from './features/profile/ProfilePage';
import BacktestPage from './features/backtest/BacktestPage';
import AnalyticsPage from './features/analytics/AnalyticsPage';
import AlertsPage from './features/alerts/AlertsPage';
import AuditLogPage from './pages/AuditLogPage';
import { TProvider } from './translations';
import './index.css';
function App() {
const [lang, setLangState] = useState<'en' | 'vi'>(() => {
return (localStorage.getItem('lang') as 'en' | 'vi') || 'vi';
});
const setLang = useCallback((l: 'en' | 'vi') => {
setLangState(l);
localStorage.setItem('lang', l);
}, []);
return (
<Provider store={store}>
<TProvider lang={lang} setLang={setLang}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<LoginPage />} />
{/* <Route path="/register" element={<RegisterPage />} /> */}
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/backtest" element={<BacktestPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/audit" element={<AuditLogPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
</TProvider>
</Provider>
);
}
export default App;
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
+11
View File
@@ -0,0 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+55
View File
@@ -0,0 +1,55 @@
// P3-2: Error boundary for React app
import React from 'react';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div style={{
padding: 40, textAlign: 'center', color: '#e74c3c',
background: '#1a1a2e', minHeight: '100vh',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}>
<h1 style={{ fontSize: 28, marginBottom: 12 }}>⚠️ Something went wrong</h1>
<p style={{ color: '#aaa', fontSize: 14, maxWidth: 500 }}>
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
style={{
marginTop: 20, padding: '10px 24px', borderRadius: 6, border: 'none',
background: '#3498db', color: '#fff', cursor: 'pointer', fontSize: 14,
}}
>
Reload App
</button>
</div>
);
}
return this.props.children;
}
}
+62
View File
@@ -0,0 +1,62 @@
// P3-8: Loading skeleton component
import React from 'react';
interface SkeletonProps {
width?: string | number;
height?: string | number;
borderRadius?: number;
count?: number;
style?: React.CSSProperties;
}
export const Skeleton: React.FC<SkeletonProps> = ({
width = '100%',
height = 20,
borderRadius = 4,
count = 1,
style = {},
}) => {
const baseStyle: React.CSSProperties = {
width,
height,
borderRadius,
background: 'linear-gradient(90deg, #2a2a3e 25%, #3a3a4e 50%, #2a2a3e 75%)',
backgroundSize: '200% 100%',
animation: 'skeleton-shimmer 1.5s ease-in-out infinite',
marginBottom: count > 1 ? 8 : 0,
};
if (typeof document !== 'undefined' && !document.getElementById('skeleton-keyframes')) {
const styleEl = document.createElement('style');
styleEl.id = 'skeleton-keyframes';
styleEl.textContent = `
@keyframes skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`;
document.head.appendChild(styleEl);
}
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} style={{ ...baseStyle, ...style }} />
))}
</>
);
};
interface DashboardSkeletonProps {
lines?: number;
}
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({ lines = 5 }) => (
<div style={{ padding: 20 }}>
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} />
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} height={16} width={`${70 + Math.random() * 30}%`} />
))}
</div>
);
+822
View File
@@ -0,0 +1,822 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { fetchCurrentUser, logoutThunk } from '../auth/authSlice';
import {
adminGetUsers,
adminUpdateUser,
adminCreateUser,
adminDeleteUser,
adminResetPassword,
adminGetExchanges,
adminCreateExchange,
adminUpdateExchange,
adminGetHealth,
} from '../api/apiService';
import type { User, Exchange } from '../../types/trading';
interface HealthData {
status: string;
version: string;
uptime: number;
db: { connected: boolean; latency_ms: number; pool_size: number };
exchange_connections: { exchange: string; connected: boolean }[];
}
// ── Modal helper ──
interface ModalState {
show: boolean;
type: 'add' | 'edit' | 'reset' | 'delete';
targetUser: User | null;
}
export default function AdminPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
const [activeTab, setActiveTab] = useState<'users' | 'exchanges' | 'health'>('users');
// Users
const [users, setUsers] = useState<User[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState('');
const [userSearch, setUserSearch] = useState('');
// Modal
const [modal, setModal] = useState<ModalState>({ show: false, type: 'add', targetUser: null });
const [formData, setFormData] = useState({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
const [formError, setFormError] = useState('');
// Exchanges
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const [exLoading, setExLoading] = useState(true);
const [showAddEx, setShowAddEx] = useState(false);
const [newEx, setNewEx] = useState({ name: '', display_name: '', base_url: '', ws_url: '' });
// Health
const [health, setHealth] = useState<HealthData | null>(null);
const [healthLoading, setHealthLoading] = useState(true);
// ── Auth guard ──
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', { replace: true });
return;
}
if (!user) {
dispatch(fetchCurrentUser())
.unwrap()
.catch(() => navigate('/login', { replace: true }))
.finally(() => setInitializing(false));
} else {
setInitializing(false);
}
}, [isAuthenticated, user, dispatch, navigate]);
useEffect(() => {
if (!initializing && user && !user.is_admin) {
navigate('/dashboard', { replace: true });
}
}, [initializing, user, navigate]);
// ── Data loaders ──
useEffect(() => {
if (user?.is_admin && activeTab === 'users') {
setUsersLoading(true);
setUsersError('');
adminGetUsers()
.then(setUsers)
.catch((err) => setUsersError(err.message || 'Failed to load users'))
.finally(() => setUsersLoading(false));
}
}, [activeTab, user?.is_admin]);
useEffect(() => {
if (user?.is_admin && activeTab === 'exchanges') {
setExLoading(true);
adminGetExchanges()
.then(setExchanges)
.catch(() => {})
.finally(() => setExLoading(false));
}
}, [activeTab, user?.is_admin]);
useEffect(() => {
if (user?.is_admin && activeTab === 'health') {
setHealthLoading(true);
adminGetHealth()
.then(setHealth)
.catch(() => {})
.finally(() => setHealthLoading(false));
}
}, [activeTab, user?.is_admin]);
// ── User actions ──
const toggleUserField = useCallback(async (u: User, field: 'is_active' | 'is_admin') => {
try {
const payload: any = { [field]: !u[field] };
if (field === 'is_admin') {
payload.role = !u[field] ? 'admin' : 'trader';
}
const updated = await adminUpdateUser(String(u.id), payload);
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...updated } : x)));
} catch (err: any) {
setFormError(err.message || 'Update failed');
}
}, []);
const openModal = useCallback((type: ModalState['type'], targetUser?: User) => {
setFormError('');
if (type === 'add') {
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
setModal({ show: true, type, targetUser: null });
} else if (type === 'edit' && targetUser) {
setFormData({
username: targetUser.username,
email: targetUser.email,
password: '',
display_name: targetUser.display_name || '',
is_admin: targetUser.is_admin || false,
role: targetUser.role || (targetUser.is_admin ? 'admin' : 'trader'),
});
setModal({ show: true, type, targetUser });
} else if (type === 'reset' && targetUser) {
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
setModal({ show: true, type, targetUser });
} else if (type === 'delete' && targetUser) {
setFormData({ username: '', email: '', password: '', display_name: '', is_admin: false, role: 'trader' });
setModal({ show: true, type, targetUser });
}
}, []);
const closeModal = useCallback(() => {
setModal({ show: false, type: 'add', targetUser: null });
setFormError('');
}, []);
const handleAddUser = useCallback(async () => {
setFormError('');
// Client-side validation
if (!formData.username.trim()) {
setFormError('Username is required');
return;
}
const emailRe = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRe.test(formData.email)) {
setFormError('Invalid email format');
return;
}
if (formData.password.length < 8) {
setFormError('Password must be at least 8 characters');
return;
}
if (!/\d/.test(formData.password)) {
setFormError('Password must contain at least 1 number');
return;
}
if (!/[^a-zA-Z0-9]/.test(formData.password)) {
setFormError('Password must contain at least 1 special character');
return;
}
try {
const created = await adminCreateUser({
username: formData.username,
email: formData.email,
password: formData.password,
display_name: formData.display_name || undefined,
is_admin: formData.role === 'admin',
role: formData.role,
});
setUsers((prev) => [...prev, created]);
closeModal();
} catch (err: any) {
setFormError(err.message || 'Failed to create user');
}
}, [formData, closeModal]);
const handleEditUser = useCallback(async () => {
if (!modal.targetUser) return;
setFormError('');
try {
const updates: any = {};
if (formData.email !== modal.targetUser.email) updates.email = formData.email;
if (formData.display_name !== (modal.targetUser.display_name || '')) updates.display_name = formData.display_name || null;
const newRole = formData.role;
if (newRole !== (modal.targetUser.role || (modal.targetUser.is_admin ? 'admin' : 'trader'))) {
updates.role = newRole;
updates.is_admin = newRole === 'admin';
}
if (Object.keys(updates).length > 0) {
const updated = await adminUpdateUser(String(modal.targetUser.id), updates);
setUsers((prev) => prev.map((x) => (x.id === modal.targetUser!.id ? { ...x, ...updated } : x)));
}
closeModal();
} catch (err: any) {
setFormError(err.message || 'Failed to update user');
}
}, [modal.targetUser, formData, closeModal]);
const handleResetPassword = useCallback(async () => {
if (!modal.targetUser) return;
setFormError('');
if (formData.password.length < 8) {
setFormError('Password must be at least 8 characters');
return;
}
if (!/\d/.test(formData.password)) {
setFormError('Password must contain at least 1 number');
return;
}
if (!/[^a-zA-Z0-9]/.test(formData.password)) {
setFormError('Password must contain at least 1 special character');
return;
}
try {
await adminResetPassword(String(modal.targetUser.id), formData.password);
closeModal();
} catch (err: any) {
setFormError(err.message || 'Failed to reset password');
}
}, [modal.targetUser, formData.password, closeModal]);
const handleDeleteUser = useCallback(async () => {
if (!modal.targetUser) return;
setFormError('');
try {
await adminDeleteUser(String(modal.targetUser.id));
setUsers((prev) => prev.filter((x) => x.id !== modal.targetUser!.id));
closeModal();
} catch (err: any) {
setFormError(err.message || 'Failed to delete user');
}
}, [modal.targetUser, closeModal]);
// ── Exchange actions ──
const handleAddExchange = useCallback(async () => {
try {
await adminCreateExchange(newEx);
setShowAddEx(false);
setNewEx({ name: '', display_name: '', base_url: '', ws_url: '' });
const data = await adminGetExchanges();
setExchanges(data);
} catch (err: any) {
alert('Error: ' + (err.message || 'Unknown error'));
}
}, [newEx]);
const toggleExchange = useCallback(async (ex: Exchange) => {
try {
await adminUpdateExchange(ex.id, { is_active: !ex.is_active });
setExchanges((prev) => prev.map((x) => (x.id === ex.id ? { ...x, is_active: !x.is_active } : x)));
} catch (err: any) {
alert('Error: ' + (err.message || 'Unknown error'));
}
}, []);
// ── Filtered users ──
const filteredUsers = users.filter((u) => {
if (!userSearch) return true;
const q = userSearch.toLowerCase();
return (
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.display_name || '').toLowerCase().includes(q)
);
});
// Stats
const totalUsers = users.length;
const activeUsers = users.filter((u) => u.is_active).length;
const adminUsers = users.filter((u) => u.is_admin).length;
// ── Helpers ──
const formatUptime = (seconds: number) => {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${d}d ${h}h ${m}m`;
};
if (!user?.is_admin) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading admin panel...</div>
</div>
);
}
// ── Styles ──
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
touchAction: 'manipulation', minHeight: 36,
};
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
const cellBtn: React.CSSProperties = {
padding: '3px 10px', fontSize: 11, border: '1px solid #30363d',
borderRadius: 4, background: 'transparent', color: '#58a6ff', cursor: 'pointer',
touchAction: 'manipulation', minHeight: 36,
};
const dangerBtn: React.CSSProperties = { ...cellBtn, color: '#f85149', borderColor: '#f8514933' };
const badgeGreen: React.CSSProperties = { color: '#3fb950', fontSize: 12, fontWeight: 600 };
const badgeRed: React.CSSProperties = { color: '#f85149', fontSize: 12, fontWeight: 600 };
const inputStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none', width: '100%', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
};
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
{/* ═══ Navbar ═══ */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ fontSize: 18 }}>🛡️</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Admin Panel</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href="/profile" style={{ fontSize: 12, color: '#8b949e', textDecoration: 'none', whiteSpace: 'nowrap' }}>👤 {user.username}</a>
<button style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button
style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</nav>
{/* ═══ Tabs ═══ */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px', borderBottom: '1px solid #21262d' }}>
{(['users', 'exchanges', 'health'] as const).map((tab) => (
<button key={tab} style={activeTab === tab ? activeBtn : btnStyle} onClick={() => setActiveTab(tab)}>
{tab === 'users' ? '👥 Users' : tab === 'exchanges' ? '🔌 Exchanges' : '❤️ Health'}
</button>
))}
</div>
{/* ════════════════════════════════════════════════════ */}
{/* ═══ USERS TAB ═══ */}
{/* ════════════════════════════════════════════════════ */}
{activeTab === 'users' && (
<div style={{ padding: 16 }}>
{/* Stats bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>TOTAL</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{totalUsers}</div>
</div>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ACTIVE</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{activeUsers}</div>
</div>
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ADMINS</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{adminUsers}</div>
</div>
</div>
{/* ═══ Role Permission Legend ═══ */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#d29922', fontWeight: 700, marginBottom: 6 }}>🛡️ Admin</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
<strong style={{ color: '#f0f6fc' }}>Toàn quyền</strong> — mọi tính năng.<br />
• Quản lý user (thêm/sửa/xóa, đặt role)<br />
Quản lý exchange & symbol (thêm, sửa, active/deactive)<br />
Xem audit log toàn bộ hệ thống<br />
Quản lý system config, health check<br />
Xem tất cả trades, signals, backtest của mọi user<br />
Đặt lệnh real trade (place order)<br />
Quản lý strategies, alerts, notification channels
</div>
</div>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#8b949e', fontWeight: 700, marginBottom: 6 }}>👤 Trader</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
• Xem dashboard, signals & multi-timeframe chart<br />
Chạy backtest & lưu kết quả<br />
Xem & quản lý hypothetical trades cá nhân<br />
<strong style={{ color: '#f0f6fc' }}>Đặt lệnh real trade</strong> (place order)<br />
Quản lý watchlist, API credentials, personal alerts<br />
Quản lý strategies cá nhân<br />
Xem analytics & performance cá nhân
</div>
</div>
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 11, color: '#58a6ff', fontWeight: 700, marginBottom: 6 }}>👁️ Viewer</div>
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
<strong style={{ color: '#f85149' }}>Chỉ xem (read-only)</strong><br />
• Xem dashboard & real-time chart<br />
Xem signals, trades, backtest history<br />
Xem analytics, alerts, watchlist<br />
<strong style={{ color: '#f85149' }}>Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
&nbsp;&nbsp;&nbsp;chạy backtest mới, chỉnh sửa credentials
</div>
</div>
</div>
{/* Search + Add bar */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
<input
style={{ ...inputStyle, maxWidth: 300 }}
placeholder="🔍 Search by username, email, display name..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
/>
<button
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', display: 'flex', alignItems: 'center', gap: 4 }}
onClick={() => openModal('add')}
>
+ Add User
</button>
</div>
{/* User table */}
{usersLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading users...</p>}
{usersError && <p style={{ color: '#f85149', fontSize: 13 }}>{usersError}</p>}
{!usersLoading && !usersError && (
<>
{filteredUsers.length === 0 ? (
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
{userSearch ? 'No users match your search.' : 'No users yet.'}
</div>
) : (
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -12px', padding: '0 12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 600 }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Username</th>
<th style={thStyle}>Display Name</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Role</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={String(u.id)} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ ...tdStyle, fontWeight: 600, color: '#f0f6fc' }}>{u.username}</td>
<td style={tdStyle}>{u.display_name || <span style={{ color: '#484f58' }}>—</span>}</td>
<td style={tdStyle}>{u.email}</td>
<td style={tdStyle}>
{(() => {
const role = u.role || (u.is_admin ? 'admin' : 'trader');
if (role === 'admin') return <span style={{ color: '#d29922', fontWeight: 600 }}>🛡️ Admin</span>;
if (role === 'viewer') return <span style={{ color: '#58a6ff', fontWeight: 500 }}>👁️ Viewer</span>;
return <span style={{ color: '#8b949e' }}>👤 Trader</span>;
})()}
</td>
<td style={tdStyle}>
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_active')}>
{u.is_active ? <span style={badgeGreen}>● Active</span> : <span style={badgeRed}>● Inactive</span>}
</button>
</td>
<td style={{ ...tdStyle, color: '#484f58' }}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_admin')}>
{u.is_admin ? 'Demote' : 'Promote'}
</button>
<button style={cellBtn} onClick={() => openModal('edit', u)}>
✏️ Edit
</button>
<button style={cellBtn} onClick={() => openModal('reset', u)}>
🔑 Reset
</button>
<button style={dangerBtn} onClick={() => openModal('delete', u)}>
🗑️ Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ fontSize: 11, color: '#484f58', marginTop: 8 }}>
Showing {filteredUsers.length} of {totalUsers} users
</div>
</>
)}
{/* ═══ MODAL ═══ */}
{modal.show && (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
}}
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
>
<div
style={{
background: '#161b22', border: '1px solid #30363d', borderRadius: 12,
padding: 24, maxWidth: 440, width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
}}
>
{/* Title */}
<h3 style={{ fontSize: 15, color: '#f0f6fc', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
{modal.type === 'add' && '➕ Add User'}
{modal.type === 'edit' && '✏️ Edit User'}
{modal.type === 'reset' && '🔑 Reset Password'}
{modal.type === 'delete' && '🗑️ Delete User'}
</h3>
{/* Form fields */}
{modal.type === 'add' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={labelStyle}>Username</div>
<input style={inputStyle} value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Email</div>
<input style={inputStyle} type="email" value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Password (8+ chars, 1 number, 1 special)</div>
<input style={inputStyle} type="password" value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Display Name</div>
<input style={inputStyle} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Role</div>
<select style={{ ...inputStyle, cursor: 'pointer' }}
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
<option value="admin">🛡️ Admin</option>
<option value="trader">👤 Trader</option>
<option value="viewer">👁️ Viewer</option>
</select>
</div>
</div>
)}
{modal.type === 'edit' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={labelStyle}>Username</div>
<input style={{ ...inputStyle, opacity: 0.5 }} value={formData.username} disabled />
</div>
<div>
<div style={labelStyle}>Email</div>
<input style={inputStyle} type="email" value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Display Name</div>
<input style={inputStyle} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
</div>
<div>
<div style={labelStyle}>Role</div>
<select style={{ ...inputStyle, cursor: 'pointer' }}
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
<option value="admin">🛡️ Admin</option>
<option value="trader">👤 Trader</option>
<option value="viewer">👁️ Viewer</option>
</select>
</div>
</div>
)}
{modal.type === 'reset' && (
<div>
<p style={{ fontSize: 12, color: '#8b949e', margin: '0 0 12px 0' }}>
Reset password for <strong style={{ color: '#f0f6fc' }}>{modal.targetUser?.username}</strong>.
All active sessions will be revoked.
</p>
<div>
<div style={labelStyle}>New Password (8+ chars, 1 number, 1 special)</div>
<input style={inputStyle} type="password" value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
</div>
</div>
)}
{modal.type === 'delete' && (
<div>
<p style={{ fontSize: 12, color: '#f85149', margin: '0 0 12px 0', background: 'rgba(248,81,73,0.1)', padding: 10, borderRadius: 6, border: '1px solid rgba(248,81,73,0.2)' }}>
⚠️ This will permanently delete user <strong>{modal.targetUser?.username}</strong>.
Watchlist, credentials, and sessions will be removed. This cannot be undone!
</p>
</div>
)}
{/* Error */}
{formError && <p style={{ color: '#f85149', fontSize: 12, marginTop: 8 }}>{formError}</p>}
{/* Buttons */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
<button style={btnStyle} onClick={closeModal}>Cancel</button>
{modal.type === 'add' && (
<button
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636' }}
onClick={handleAddUser}
disabled={!formData.username || !formData.email || formData.password.length < 8}
>
✅ Create User
</button>
)}
{modal.type === 'edit' && (
<button
style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}
onClick={handleEditUser}
>
💾 Save Changes
</button>
)}
{modal.type === 'reset' && (
<button
style={{ ...btnStyle, background: '#d29922', color: '#fff', borderColor: '#d29922' }}
onClick={handleResetPassword}
disabled={!formData.password || formData.password.length < 8}
>
🔑 Reset Password
</button>
)}
{modal.type === 'delete' && (
<button
style={{ ...btnStyle, background: '#da3633', color: '#fff', borderColor: '#da3633' }}
onClick={handleDeleteUser}
>
🗑️ Delete Forever
</button>
)}
</div>
</div>
</div>
)}
</div>
)}
{/* ════════════════════════════════════════════════════ */}
{/* ═══ EXCHANGES TAB ═══ */}
{/* ════════════════════════════════════════════════════ */}
{activeTab === 'exchanges' && (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: 0 }}>Exchange Management</h3>
<button style={btnStyle} onClick={() => setShowAddEx(!showAddEx)}>
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
</button>
</div>
{showAddEx && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 12, background: '#161b22', borderRadius: 8, marginBottom: 12, border: '1px solid #30363d' }}>
<input style={inputStyle} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
<input style={inputStyle} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
<input style={inputStyle} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
<input style={inputStyle} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
<button style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', marginTop: 4 }} onClick={handleAddExchange}>
✅ Create Exchange
</button>
</div>
)}
{exLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading exchanges...</p>}
{!exLoading && (
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Name</th>
<th style={thStyle}>Display</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Action</th>
</tr>
</thead>
<tbody>
{exchanges.map((ex) => (
<tr key={ex.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={tdStyle}>{ex.name}</td>
<td style={tdStyle}>{ex.display_name || ex.name}</td>
<td style={tdStyle}>
{ex.is_active ? <span style={badgeGreen}>Active</span> : <span style={badgeRed}>Inactive</span>}
</td>
<td style={tdStyle}>
<button style={cellBtn} onClick={() => toggleExchange(ex)}>
{ex.is_active ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ═══ HEALTH TAB ═══ */}
{activeTab === 'health' && (
<div style={{ padding: 16 }}>
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: '0 0 12px 0' }}>System Health</h3>
{healthLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading health data...</p>}
{!healthLoading && !health && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#484f58', fontSize: 13, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<div style={{ fontSize: 24, marginBottom: 8 }}>⚠️</div>
Unable to fetch health data. The health endpoint may not be available.
</div>
)}
{health && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>STATUS</div>
<div style={{ fontSize: 18, fontWeight: 700, color: health.status === 'healthy' ? '#3fb950' : '#f85149' }}>
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
</div>
</div>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>UPTIME</div>
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{formatUptime(health.uptime)}</div>
</div>
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>VERSION</div>
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{health.version}</div>
</div>
</div>
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Database</h4>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Connected</th>
<th style={thStyle}>Latency</th>
<th style={thStyle}>Pool Size</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}>{health.db.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}</td>
<td style={tdStyle}>{health.db.latency_ms} ms</td>
<td style={tdStyle}>{health.db.pool_size} connections</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Exchange Connections</h4>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Exchange</th>
<th style={thStyle}>Status</th>
</tr>
</thead>
<tbody>
{health.exchange_connections.map((ec) => (
<tr key={ec.exchange} style={{ borderBottom: '1px solid #21262d' }}>
<td style={tdStyle}>{ec.exchange}</td>
<td style={tdStyle}>
{ec.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
};
const tdStyle: React.CSSProperties = {
padding: '8px 12px', verticalAlign: 'middle',
};
+507
View File
@@ -0,0 +1,507 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { fetchCurrentUser, logoutThunk } from '../auth/authSlice';
import {
fetchAlerts,
createAlert,
updateAlert,
deleteAlert,
type AlertItem,
type ConditionObject,
} from '../api/alertApi';
// ── Styles ──
const pageStyle: React.CSSProperties = {
minHeight: '100vh', background: '#0d1117', color: '#c9d1d9',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
padding: '24px',
};
const headerStyle: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, flexWrap: 'wrap', gap: 12,
};
const cardStyle: React.CSSProperties = {
padding: 16, background: '#161b22', borderRadius: 8,
border: '1px solid #30363d', marginBottom: 12,
};
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9',
cursor: 'pointer', touchAction: 'manipulation', minHeight: 32,
};
const primaryBtn: React.CSSProperties = {
...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb',
};
const dangerBtn: React.CSSProperties = {
...btnStyle, color: '#f85149', borderColor: '#f8514933',
};
const inputStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none',
width: '100%', boxSizing: 'border-box',
};
const selectStyle: React.CSSProperties = { ...inputStyle, width: '100%' };
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
};
const modalOverlay: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
};
const modalContent: React.CSSProperties = {
background: '#161b22', borderRadius: 8, border: '1px solid #30363d',
padding: 24, width: '90%', maxWidth: 600, maxHeight: '80vh',
overflow: 'auto', color: '#c9d1d9',
};
const toggleStyle: React.CSSProperties = {
width: 36, height: 20, borderRadius: 10, border: 'none',
cursor: 'pointer', position: 'relative', transition: 'background 0.2s',
};
const INDICATOR_OPTIONS = [
{ value: 'rsi', label: 'RSI' },
{ value: 'macd', label: 'MACD Histogram' },
{ value: 'bb_width', label: 'BB Width' },
{ value: 'volume', label: 'Volume' },
{ value: 'price', label: 'Price' },
{ value: 'sma', label: 'SMA' },
{ value: 'ema', label: 'EMA' },
{ value: 'momentum', label: 'Momentum' },
];
const OPERATOR_OPTIONS = [
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '>=' },
{ value: '<=', label: '<=' },
{ value: '==', label: '==' },
{ value: 'cross_above', label: 'Cross Above' },
{ value: 'cross_below', label: 'Cross Below' },
];
const TIMEFRAME_OPTIONS = [
{ value: '', label: 'Any' },
{ value: '15m', label: '15m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '1d', label: '1d' },
];
const PLATFORM_OPTIONS = [
{ value: 'telegram', label: 'Telegram' },
{ value: 'discord', label: 'Discord' },
{ value: 'both', label: 'Both' },
];
export default function AlertsPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user, isAuthenticated } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Modal state
const [showModal, setShowModal] = useState(false);
const [editTarget, setEditTarget] = useState<AlertItem | null>(null);
const [formName, setFormName] = useState('');
const [formPlatform, setFormPlatform] = useState('telegram');
const [formConditions, setFormConditions] = useState<ConditionObject[]>([
{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' },
]);
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
// Auth guard
useEffect(() => {
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
if (!user) {
dispatch(fetchCurrentUser())
.unwrap()
.catch(() => navigate('/login', { replace: true }))
.finally(() => setInitializing(false));
} else {
setInitializing(false);
}
}, [isAuthenticated, user, dispatch, navigate]);
const loadAlerts = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await fetchAlerts();
setAlerts(data);
} catch (err: any) {
setError(err.message || 'Failed to load alerts');
}
setLoading(false);
}, []);
useEffect(() => {
if (!initializing) loadAlerts();
}, [initializing, loadAlerts]);
// ── Modal handlers ──
const openCreateModal = () => {
setEditTarget(null);
setFormName('');
setFormPlatform('telegram');
setFormConditions([{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
setFormError('');
setShowModal(true);
};
const openEditModal = (alert: AlertItem) => {
setEditTarget(alert);
setFormName(alert.name);
setFormPlatform(alert.notify_platform);
setFormConditions(alert.conditions.length > 0 ? alert.conditions : [{ indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
setFormError('');
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditTarget(null);
};
const addCondition = () => {
setFormConditions([...formConditions, { indicator: 'rsi', operator: '>', value: 70, timeframe: '1h' }]);
};
const removeCondition = (idx: number) => {
if (formConditions.length <= 1) return;
setFormConditions(formConditions.filter((_, i) => i !== idx));
};
const updateCondition = (idx: number, field: keyof ConditionObject, value: any) => {
const updated = formConditions.map((c, i) =>
i === idx ? { ...c, [field]: value } : c,
);
setFormConditions(updated);
};
const handleSave = async () => {
setFormError('');
if (!formName.trim()) { setFormError('Name is required'); return; }
if (formConditions.length === 0) { setFormError('At least one condition required'); return; }
setSaving(true);
try {
if (editTarget) {
await updateAlert(editTarget.id, {
name: formName.trim(),
conditions: formConditions,
notify_platform: formPlatform,
});
} else {
await createAlert({
name: formName.trim(),
conditions: formConditions,
notify_platform: formPlatform,
});
}
closeModal();
loadAlerts();
} catch (err: any) {
setFormError(err.message || 'Save failed');
}
setSaving(false);
};
const handleDelete = async (alert: AlertItem) => {
if (!confirm(`Delete alert "${alert.name}"?`)) return;
try {
await deleteAlert(alert.id);
loadAlerts();
} catch (err: any) {
setError(err.message || 'Delete failed');
}
};
const handleToggle = async (alert: AlertItem) => {
try {
await updateAlert(alert.id, { is_active: !alert.is_active });
loadAlerts();
} catch (err: any) {
setError(err.message || 'Toggle failed');
}
};
// ── Render ──
if (initializing) {
return <div style={pageStyle}><p style={{ textAlign: 'center', paddingTop: 80 }}>Loading...</p></div>;
}
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
{/* ─── NAV ─── */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 18 }}>🔔</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Smart Alerts</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button
style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</nav>
{/* Content */}
<div style={{ padding: '16px 24px' }}>
{/* Header */}
<div style={headerStyle}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>🔔 Smart Alerts</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={primaryBtn} onClick={openCreateModal}>+ New Alert</button>
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
</div>
</div>
{/* Error */}
{error && (
<div style={{ padding: '8px 12px', background: 'rgba(248,81,73,0.15)', borderRadius: 6, marginBottom: 16, fontSize: 12, color: '#f85149' }}>
{error}
</div>
)}
{/* Loading */}
{loading && <p style={{ textAlign: 'center', color: '#8b949e' }}>Loading alerts...</p>}
{/* Empty state */}
{!loading && alerts.length === 0 && (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#8b949e' }}>
<p style={{ fontSize: 40, margin: 0 }}>🔔</p>
<p>No alerts configured yet.</p>
<button style={primaryBtn} onClick={openCreateModal}>Create your first alert</button>
</div>
)}
{/* Alert cards */}
{alerts.map((alert) => (
<div key={alert.id} style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{alert.name}</h3>
<span style={{
fontSize: 10, padding: '1px 6px', borderRadius: 4,
background: alert.is_active ? 'rgba(63,185,80,0.15)' : 'rgba(72,79,88,0.3)',
color: alert.is_active ? '#3fb950' : '#484f58',
}}>
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
</span>
<span style={{ fontSize: 10, color: '#8b949e' }}>via {alert.notify_platform}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/* Toggle */}
<button
style={{
...toggleStyle,
background: alert.is_active ? '#1f6feb' : '#21262d',
}}
onClick={() => handleToggle(alert)}
title={alert.is_active ? 'Pause' : 'Activate'}
>
<span style={{
position: 'absolute', top: 2, width: 16, height: 16,
borderRadius: '50%', background: '#fff',
left: alert.is_active ? 18 : 2,
transition: 'left 0.2s',
}} />
</button>
<button style={btnStyle} onClick={() => openEditModal(alert)}>Edit</button>
<button style={dangerBtn} onClick={() => handleDelete(alert)}>Delete</button>
</div>
</div>
{/* Conditions */}
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>Conditions:</div>
{alert.conditions.map((cond, idx) => (
<div key={idx} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', background: '#0d1117', borderRadius: 4,
margin: '2px 4px 2px 0', fontSize: 11, color: '#c9d1d9',
border: '1px solid #30363d',
}}>
<span style={{ color: '#58a6ff' }}>{cond.indicator}</span>
<span style={{ color: '#d29922' }}>{cond.operator}</span>
<span style={{ color: '#3fb950' }}>{cond.value}</span>
{cond.timeframe && <span style={{ color: '#8b949e' }}>({cond.timeframe})</span>}
</div>
))}
</div>
))}
{/* ── Create/Edit Modal ── */}
{showModal && (
<div style={modalOverlay} onClick={closeModal}>
<div style={modalContent} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
{editTarget ? 'Edit Alert' : 'New Alert'}
</h3>
{/* Name */}
<div style={{ marginBottom: 12 }}>
<div style={labelStyle}>Name</div>
<input
style={inputStyle}
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. RSI overbought + volume spike"
/>
</div>
{/* Platform */}
<div style={{ marginBottom: 12 }}>
<div style={labelStyle}>Notify via</div>
<select
style={selectStyle}
value={formPlatform}
onChange={(e) => setFormPlatform(e.target.value)}
>
{PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Conditions */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={labelStyle}>Conditions (all must pass)</span>
<button style={btnStyle} onClick={addCondition}>+ Add Condition</button>
</div>
{formConditions.map((cond, idx) => (
<div key={idx} style={{
display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap',
padding: '8px 0', borderBottom: idx < formConditions.length - 1 ? '1px solid #21262d' : 'none',
}}>
{/* Indicator */}
<select
style={{ ...selectStyle, width: 110 }}
value={cond.indicator}
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
>
{INDICATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Operator */}
<select
style={{ ...selectStyle, width: 100 }}
value={cond.operator}
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
>
{OPERATOR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Value */}
<input
style={{ ...inputStyle, width: 80 }}
type="number"
step="any"
value={cond.value}
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
/>
{/* Timeframe */}
<select
style={{ ...selectStyle, width: 70 }}
value={cond.timeframe || ''}
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
>
{TIMEFRAME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{/* Extra fields for volume */}
{cond.indicator === 'volume' && (
<>
<select
style={{ ...selectStyle, width: 110 }}
value={cond.type || 'absolute'}
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
>
<option value="absolute">Absolute</option>
<option value="avg_multiplier">Avg Multiplier</option>
</select>
{cond.type === 'avg_multiplier' && (
<input
style={{ ...inputStyle, width: 60 }}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
</>
)}
{/* Extra for sma/ema */}
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
<input
style={{ ...inputStyle, width: 60 }}
type="number"
placeholder="Period"
value={cond.period || 20}
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
/>
)}
{/* Remove */}
<button
style={{ ...dangerBtn, padding: '2px 8px', fontSize: 10 }}
onClick={() => removeCondition(idx)}
disabled={formConditions.length <= 1}
>
✕
</button>
</div>
))}
</div>
{/* Form error */}
{formError && (
<div style={{ padding: '6px 10px', background: 'rgba(248,81,73,0.15)', borderRadius: 4, marginBottom: 12, fontSize: 11, color: '#f85149' }}>
{formError}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button style={btnStyle} onClick={closeModal}>Cancel</button>
<button
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+461
View File
@@ -0,0 +1,461 @@
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { fetchCurrentUser } from '../auth/authSlice';
import { logoutThunk } from '../auth/authSlice';
import { useT } from '../../translations';
// ── Mock/temporary analytics data ──
// These would be replaced by real API calls. For now, generate realistic demo data.
interface DailyPnL {
date: string;
pnl: number;
isSignal: boolean;
}
interface AnalyticsStats {
totalPnL: number;
winRate: number;
bestTrade: number;
worstTrade: number;
profitFactor: number;
maxDrawdown: number;
totalTrades: number;
wins: number;
losses: number;
}
function generateDemoData(isSignal: boolean): { dailyPnL: DailyPnL[]; stats: AnalyticsStats } {
const dailyPnL: DailyPnL[] = [];
let equity = 10000;
let peak = 10000;
let maxDd = 0;
let totalWins = 0;
let totalLosses = 0;
let totalWinAmount = 0;
let totalLossAmount = 0;
let bestTrade = 0;
let worstTrade = 0;
for (let i = 29; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const basePnL = (Math.random() - 0.48) * (isSignal ? 300 : 200);
const pnl = Math.round(basePnL * 100) / 100;
dailyPnL.push({ date: date.toISOString().slice(0, 10), pnl, isSignal });
equity += pnl;
if (equity > peak) peak = equity;
const dd = ((peak - equity) / peak) * 100;
if (dd > maxDd) maxDd = dd;
if (pnl >= 0) {
totalWins++;
totalWinAmount += pnl;
if (pnl > bestTrade) bestTrade = pnl;
} else {
totalLosses++;
totalLossAmount += Math.abs(pnl);
if (pnl < worstTrade) worstTrade = pnl;
}
}
const totalTrades = totalWins + totalLosses;
const winRate = totalTrades > 0 ? (totalWins / totalTrades) * 100 : 0;
const profitFactor = totalLossAmount > 0 ? totalWinAmount / totalLossAmount : totalWinAmount > 0 ? Infinity : 0;
return {
dailyPnL,
stats: {
totalPnL: Math.round((equity - 10000) * 100) / 100,
winRate: Math.round(winRate * 10) / 10,
bestTrade: Math.round(bestTrade * 100) / 100,
worstTrade: Math.round(worstTrade * 100) / 100,
profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100,
maxDrawdown: Math.round(maxDd * 10) / 10,
totalTrades,
wins: totalWins,
losses: totalLosses,
},
};
}
// ── Simple SVG Bar Chart ──
function BarChart({
data,
height = 200,
colorUp = '#3fb950',
colorDown = '#f85149',
}: {
data: DailyPnL[];
height?: number;
colorUp?: string;
colorDown?: string;
}) {
if (data.length === 0) return null;
const maxAbs = Math.max(...data.map((d) => Math.abs(d.pnl)), 1);
const barWidth = Math.max(4, Math.min(12, (800 - 40) / data.length - 2));
const chartWidth = Math.max(800, data.length * (barWidth + 2) + 40);
return (
<svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
{/* Zero line */}
<line x1={0} y1={height / 2} x2={chartWidth} y2={height / 2} stroke="#30363d" strokeWidth={1} />
{/* Bars */}
{data.map((d, i) => {
const barHeight = (Math.abs(d.pnl) / maxAbs) * (height / 2 - 4);
const y = d.pnl >= 0 ? height / 2 - barHeight : height / 2;
return (
<rect
key={i}
x={20 + i * (barWidth + 2)}
y={y}
width={barWidth}
height={Math.max(barHeight, 1)}
fill={d.pnl >= 0 ? colorUp : colorDown}
rx={1}
opacity={0.85}
>
<title>{`${d.date}: ${d.pnl >= 0 ? '+' : ''}$${d.pnl.toFixed(2)}`}</title>
</rect>
);
})}
</svg>
);
}
// ── Simple SVG Line Chart (Equity Curve) ──
function LineChart({
equityData,
height = 200,
lineColor = '#58a6ff',
}: {
equityData: number[];
height?: number;
lineColor?: string;
}) {
if (equityData.length < 2) return null;
const min = Math.min(...equityData);
const max = Math.max(...equityData);
const range = max - min || 1;
const padding = 20;
const chartWidth = Math.max(800, equityData.length * 4);
const points = equityData
.map((val, i) => {
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
const y = height - padding - ((val - min) / range) * (height - padding * 2);
return `${x},${y}`;
})
.join(' ');
// Area under the curve
const areaPoints = `${padding},${height - padding} ${points} ${chartWidth - padding},${height - padding}`;
return (
<svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="equity-gradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={lineColor} stopOpacity={0.25} />
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
</linearGradient>
</defs>
{/* Area fill */}
<polygon points={areaPoints} fill="url(#equity-gradient)" />
{/* Line */}
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
{/* Dots on line */}
{equityData.map((val, i) => {
if (i % 5 !== 0 && i !== equityData.length - 1) return null;
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
const y = height - padding - ((val - min) / range) * (height - padding * 2);
return (
<circle key={i} cx={x} cy={y} r={3} fill={lineColor} opacity={0.8}>
<title>{`Day ${i + 1}: $${val.toFixed(2)}`}</title>
</circle>
);
})}
</svg>
);
}
// ── Stat Card ──
function StatCard({ label, value, color, isCurrency }: {
label: string;
value: string | number;
color?: string;
isCurrency?: boolean;
}) {
const displayValue = isCurrency
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
: value;
return (
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 8,
padding: '12px 14px',
minWidth: 0,
}}>
<div style={{ fontSize: 11, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
{label}
</div>
<div style={{
fontSize: 18,
fontWeight: 700,
fontFamily: "'SF Mono', 'Fira Code', monospace",
color: color || '#f0f6fc',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
}}>
{displayValue}
</div>
</div>
);
}
// =============================================
// MAIN COMPONENT
// =============================================
export default function AnalyticsPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
const [dataMode, setDataMode] = useState<'signal' | 'real'>('signal');
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Responsive
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Auth guard
useEffect(() => {
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
if (!user) {
dispatch(fetchCurrentUser())
.unwrap()
.catch(() => navigate('/login', { replace: true }));
return;
}
setInitializing(false);
}, [isAuthenticated, user, dispatch, navigate]);
// Generate demo data based on toggle
const demoData = useMemo(() => generateDemoData(dataMode === 'signal'), [dataMode]);
const equityCurve = useMemo(() => {
let eq = 10000;
return demoData.dailyPnL.map((d) => {
eq += d.pnl;
return Math.round(eq * 100) / 100;
});
}, [demoData]);
const { t } = useT(); // eslint-disable-line
if (!isAuthenticated) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#8b949e' }}>
Loading analytics...
</div>
);
}
const navBtn: React.CSSProperties = {
padding: '6px 16px',
fontSize: 12,
border: '1px solid #30363d',
borderRadius: 6,
background: '#21262d',
color: '#c9d1d9',
cursor: 'pointer',
};
const toggleBtn = (active: boolean): React.CSSProperties => ({
padding: '6px 16px',
fontSize: 12,
border: '1px solid #30363d',
borderRadius: 6,
background: active ? '#1f6feb' : '#21262d',
color: active ? '#fff' : '#c9d1d9',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
transition: 'all 0.15s ease',
minHeight: 36,
touchAction: 'manipulation',
display: 'inline-flex',
alignItems: 'center',
});
const s = demoData.stats;
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
{/* ─── NAV ─── */}
<nav style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
background: '#161b22',
borderBottom: '1px solid #30363d',
flexWrap: 'wrap',
gap: 8,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', flexShrink: 0 }} onClick={() => navigate('/dashboard')}>
<span style={{ fontSize: 18 }}>📈</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Analytics</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>👤 {user?.username}</span>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/profile')}>Profile</button>
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }} onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}>
🚪 Logout
</button>
</div>
</nav>
{/* ─── PAGE CONTENT ─── */}
<div style={{ padding: isMobile ? 12 : 20, maxWidth: 960, margin: '0 auto' }}>
{/* Header + Toggle */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap' as const,
gap: 8,
marginBottom: 20,
}}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc', margin: 0 }}>
📊 Performance Overview
</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button
style={toggleBtn(dataMode === 'signal')}
onClick={() => setDataMode('signal')}
>
📈 Signal
</button>
<button
style={toggleBtn(dataMode === 'real')}
onClick={() => setDataMode('real')}
>
💹 Real
</button>
</div>
</div>
{/* ─── STATS CARDS GRID ─── */}
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(6, 1fr)',
gap: isMobile ? 8 : 10,
marginBottom: 24,
}}>
<StatCard label="Total P&L" value={s.totalPnL} color={s.totalPnL >= 0 ? '#3fb950' : '#f85149'} isCurrency />
<StatCard label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? '#3fb950' : '#f85149'} />
<StatCard label="Best Trade" value={s.bestTrade} color="#3fb950" isCurrency />
<StatCard label="Worst Trade" value={s.worstTrade} color="#f85149" isCurrency />
<StatCard label="Profit Factor" value={s.profitFactor} color={s.profitFactor >= 1 ? '#3fb950' : '#f85149'} />
<StatCard label="Max Drawdown" value={`${s.maxDrawdown}%`} color="#d29922" />
</div>
{/* ─── PnL Bar Chart ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px 12px',
marginBottom: 16,
overflow: 'hidden',
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
📊 Daily P&L (Last 30 Days)
</h3>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<BarChart data={demoData.dailyPnL} height={180} />
</div>
</div>
{/* ─── Equity Curve ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px 12px',
marginBottom: 16,
overflow: 'hidden',
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
📈 Equity Curve
</h3>
<div style={{ display: 'flex', gap: 12, alignItems: 'baseline', marginBottom: 8, marginLeft: 4 }}>
<span style={{ fontSize: 12, color: '#8b949e' }}>Starting: <strong style={{ color: '#c9d1d9' }}>$10,000.00</strong></span>
<span style={{ fontSize: 14, fontWeight: 700, color: s.totalPnL >= 0 ? '#3fb950' : '#f85149' }}>
{s.totalPnL >= 0 ? '+' : ''}${s.totalPnL.toFixed(2)}
</span>
</div>
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
<LineChart equityData={equityCurve} height={200} />
</div>
</div>
{/* ─── Summary Trades Table ─── */}
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 10,
padding: '16px',
marginBottom: 16,
}}>
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 0' }}>
📋 Trade Summary
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(4, 1fr)',
gap: 12,
}}>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Total Trades</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{s.totalTrades}</div>
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Wins</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{s.wins}</div>
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Losses</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#f85149' }}>{s.losses}</div>
</div>
<div>
<div style={{ fontSize: 11, color: '#6e7681' }}>Data Source</div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#58a6ff' }}>
{dataMode === 'signal' ? '📈 Signal' : '💹 Real'}
</div>
</div>
</div>
</div>
{/* ─── Footer note ─── */}
<div style={{ fontSize: 11, color: '#484f58', textAlign: 'center' as const, padding: '16px 0' }}>
Analytics data is based on {dataMode === 'signal' ? 'simulated signal' : 'real exchange'} trades.
Charts update in real-time as new trades are executed.
</div>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
/** API functions for user-defined multi-condition alerts. */
export interface ConditionObject {
indicator: string;
operator: string;
value: number;
timeframe?: string;
type?: string;
period?: number;
}
export interface AlertItem {
id: number;
user_id: string;
name: string;
conditions: ConditionObject[];
notify_platform: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface AlertListResponse {
alerts: AlertItem[];
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchAlerts(): Promise<AlertItem[]> {
const res = await fetch('/api/v1/alerts', { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch alerts: ${res.status}`);
const data: AlertListResponse = await res.json();
return data.alerts;
}
export async function createAlert(data: {
name: string;
conditions: ConditionObject[];
notify_platform: string;
}): Promise<AlertItem> {
const res = await fetch('/api/v1/alerts', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export async function updateAlert(
id: number,
data: {
name?: string;
conditions?: ConditionObject[];
notify_platform?: string;
is_active?: boolean;
},
): Promise<AlertItem> {
const res = await fetch(`/api/v1/alerts/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export async function deleteAlert(id: number): Promise<void> {
const res = await fetch(`/api/v1/alerts/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
}
+266
View File
@@ -0,0 +1,266 @@
import type { Exchange, Symbol, Candle, Indicators, User } from '../../types/trading';
const BASE_URL = '/api/v1';
interface ApiError {
detail?: string;
message?: string;
}
class ApiServiceError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data: unknown) {
super(message);
this.name = 'ApiServiceError';
this.status = status;
this.data = data;
}
}
function getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token');
}
function setTokens(access: string, refresh: string): void {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
function clearTokens(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;
try {
const response = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
return null;
}
const data = await response.json();
const newAccess = data.access_token || data.accessToken;
const newRefresh = data.refresh_token || data.refreshToken;
if (newAccess) {
setTokens(newAccess, newRefresh || refreshToken);
return newAccess;
}
return null;
} catch {
clearTokens();
return null;
}
}
export async function apiFetch<T = unknown>(
url: string,
options: RequestInit = {},
): Promise<T> {
const token = getAccessToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string> | undefined),
} as Record<string, string>;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
let response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
// If 401, try refreshing the token
if (response.status === 401 && token) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
}
}
if (!response.ok) {
let errorData: ApiError | null = null;
try {
errorData = await response.json() as ApiError;
} catch {
// ignore parse failure
}
throw new ApiServiceError(
errorData?.detail || errorData?.message || `HTTP ${response.status}`,
response.status,
errorData,
);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
// --- Auth endpoints ---
export async function login(username: string, password: string): Promise<{ access_token: string; refresh_token: string; token_type?: string }> {
const result = await apiFetch<{ access_token: string; refresh_token: string; token_type?: string }>('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
setTokens(result.access_token, result.refresh_token);
return result;
}
export async function register(username: string, email: string, password: string): Promise<{ id: number; username: string; email: string }> {
return apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password }),
});
}
export async function getMe(): Promise<{ id: number; username: string; email: string; is_active: boolean }> {
return apiFetch('/auth/me');
}
export async function logout(): Promise<void> {
try {
const refreshToken = getRefreshToken();
await apiFetch('/auth/logout', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken || '' }),
});
} finally {
clearTokens();
}
}
// --- Exchange/Symbol endpoints ---
export async function getExchanges(): Promise<Exchange[]> {
return apiFetch<Exchange[]>('/exchanges');
}
export async function getSymbols(exchange: number, activeOnly = true): Promise<Symbol[]> {
const params = new URLSearchParams();
params.append('exchange_id', String(exchange));
if (activeOnly) params.append('active', 'true');
return apiFetch<Symbol[]>(`/symbols?${params.toString()}`);
}
export async function getCandles(
symbol: string,
exchange: number,
timeframe = '1h',
limit = 200,
): Promise<Candle[]> {
const params = new URLSearchParams({
exchange_id: String(exchange),
timeframe,
limit: String(limit),
});
return apiFetch<Candle[]>(`/symbols/${symbol}/candles?${params.toString()}`);
}
export async function getIndicators(
symbol: string,
exchange: number,
timeframe = '1h',
): Promise<Indicators> {
const params = new URLSearchParams({
exchange_id: String(exchange),
timeframe,
});
return apiFetch<Indicators>(`/symbols/${symbol}/indicators?${params.toString()}`);
}
// --- Admin endpoints ---
export async function adminGetUsers(): Promise<User[]> {
return apiFetch<User[]>('/admin/users');
}
export async function adminUpdateUser(userId: string, data: { is_active?: boolean; is_admin?: boolean; email?: string; display_name?: string | null }): Promise<User> {
return apiFetch<User>(`/admin/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function adminGetExchanges(): Promise<Exchange[]> {
return apiFetch<Exchange[]>('/admin/exchanges');
}
export async function adminCreateExchange(data: { name: string; display_name: string; base_url: string; ws_url: string }): Promise<Exchange> {
return apiFetch<Exchange>('/admin/exchanges', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function adminUpdateExchange(id: number, data: { display_name?: string; is_active?: boolean; base_url?: string; ws_url?: string }): Promise<Exchange> {
return apiFetch<Exchange>(`/admin/exchanges/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function adminGetHealth(): Promise<{ status: string; version: string; uptime: number; db: { connected: boolean; latency_ms: number; pool_size: number }; exchange_connections: { exchange: string; connected: boolean }[] }> {
return apiFetch('/admin/health/detailed');
}
// --- User Management ---
export async function adminCreateUser(data: {
username: string;
email: string;
password: string;
display_name?: string;
is_admin?: boolean;
role?: string;
}): Promise<User> {
return apiFetch<User>('/admin/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function adminDeleteUser(userId: string): Promise<{ message: string }> {
return apiFetch<{ message: string }>(`/admin/users/${userId}`, { method: 'DELETE' });
}
export async function adminResetPassword(userId: string, newPassword: string): Promise<{ message: string }> {
return apiFetch<{ message: string }>(`/admin/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
});
}
export { ApiServiceError, clearTokens, setTokens };
+71
View File
@@ -0,0 +1,71 @@
/** API functions for real trades. */
export interface RealTrade {
id: number;
user_id: number;
exchange: string;
symbol: string;
side: string;
order_type: string;
amount: number;
price: number | null;
filled_amount: number;
status: string;
pnl: number | null;
pnl_percent: number | null;
order_id: string | null;
created_at: string;
closed_at: string | null;
}
export interface RealTradeListResponse {
trades: RealTrade[];
total: number;
total_pnl: number | null;
win_rate: number | null;
}
export interface WinRatePeriod {
trades: number;
wins: number;
win_rate: number;
}
export interface WinRateResponse {
daily: WinRatePeriod;
weekly: WinRatePeriod;
monthly: WinRatePeriod;
}
export async function fetchRealTrades(
symbol?: string,
status?: string,
limit = 100,
): Promise<RealTradeListResponse> {
const params = new URLSearchParams();
if (symbol) params.set('symbol', symbol);
if (status) params.set('status', status);
params.set('limit', String(limit));
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/v1/real-trades?${params}`, {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) throw new Error(`Failed to fetch real trades: ${res.status}`);
return res.json();
}
export async function fetchWinRate(): Promise<WinRateResponse> {
const token = localStorage.getItem('access_token');
const res = await fetch('/api/v1/real-trades/win-rate', {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) throw new Error(`Failed to fetch win rate: ${res.status}`);
return res.json();
}
+107
View File
@@ -0,0 +1,107 @@
/** API functions for trading signals and hypothetical trades. */
export interface Signal {
id: number;
symbol: string;
exchange: string;
timeframe: string;
signal_type: string;
strength: string;
price: number;
timestamp: string;
indicators_snapshot: string | null;
status: string;
note: string | null;
created_at: string;
}
export interface Trade {
id: number;
signal_id: number | null;
symbol: string;
exchange: string;
timeframe: string;
direction: string;
entry_price: number;
entry_time: string;
entry_reason: string | null;
exit_price: number | null;
exit_time: string | null;
exit_reason: string | null;
quantity: number;
pnl: number | null;
pnl_percent: number | null;
status: string;
created_at: string;
closed_at: string | null;
}
export interface SignalListResponse {
signals: Signal[];
total: number;
}
export interface TradeListResponse {
trades: Trade[];
total: number;
total_pnl: number | null;
win_rate: number | null;
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchSignals(symbol?: string, limit = 50): Promise<SignalListResponse> {
const params = new URLSearchParams();
if (symbol) params.set('symbol', symbol);
params.set('limit', String(limit));
const res = await fetch(`/api/v1/signals?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch signals: ${res.status}`);
return res.json();
}
export async function fetchTrades(symbol?: string, status?: string, limit = 100): Promise<TradeListResponse> {
const params = new URLSearchParams();
if (symbol) params.set('symbol', symbol);
if (status) params.set('status', status);
params.set('limit', String(limit));
const res = await fetch(`/api/v1/signals/trades?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch trades: ${res.status}`);
return res.json();
}
/** Signal type display config */
export const SIGNAL_CONFIG: Record<string, { label: string; icon: string; color: string; bg: string }> = {
STRONG_BUY: { label: 'Strong Buy', icon: '🟢', color: '#3fb950', bg: 'rgba(63, 185, 80, 0.15)' },
BUY: { label: 'Buy', icon: '✅', color: '#26a69a', bg: 'rgba(38, 166, 154, 0.12)' },
STRONG_SELL: { label: 'Strong Sell', icon: '🔴', color: '#f85149', bg: 'rgba(248, 81, 73, 0.15)' },
SELL: { label: 'Sell', icon: '❌', color: '#ef5350', bg: 'rgba(239, 83, 80, 0.12)' },
CAUTION_LONG: { label: 'Caution Long', icon: '⚠️', color: '#d29922', bg: 'rgba(210, 153, 34, 0.15)' },
CAUTION_SHORT: { label: 'Caution Short', icon: '⚠️', color: '#d29922', bg: 'rgba(210, 153, 34, 0.15)' },
SQUEEZE_ALERT: { label: 'BB Squeeze', icon: '🔥', color: '#ff6b35', bg: 'rgba(255, 107, 53, 0.15)' },
};
export function formatPrice(price: number): string {
if (price >= 1000) return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (price >= 1) return price.toFixed(4);
return price.toFixed(6);
}
export function formatPnL(pnl: number | null): string {
if (pnl === null) return '—';
const prefix = pnl >= 0 ? '+' : '';
return `${prefix}$${pnl.toFixed(2)}`;
}
export function formatPnLPercent(pct: number | null): string {
if (pct === null) return '—';
const prefix = pct >= 0 ? '+' : '';
return `${prefix}${pct.toFixed(2)}%`;
}
+62
View File
@@ -0,0 +1,62 @@
/** API functions for user watchlist management. */
export interface WatchlistItem {
id: string;
symbol_id: number;
symbol: string;
exchange: string;
label: string | null;
sort_order: number;
}
export interface SymbolOption {
id: number;
exchange_id: number;
symbol: string;
base: string;
quote: string;
is_active: boolean;
}
function getHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function fetchWatchlist(): Promise<WatchlistItem[]> {
const res = await fetch('/api/v1/watchlist', { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch watchlist: ${res.status}`);
return res.json();
}
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
const res = await fetch('/api/v1/watchlist', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ symbol_id: symbolId, label: label || null }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Failed to add: ${res.status}`);
}
return res.json();
}
export async function removeFromWatchlist(id: string): Promise<void> {
const res = await fetch(`/api/v1/watchlist/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!res.ok) throw new Error(`Failed to remove: ${res.status}`);
}
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
const params = new URLSearchParams({ exchange });
if (q) params.set('q', q);
const res = await fetch(`/api/v1/watchlist/all-symbols?${params}`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Failed to fetch symbols: ${res.status}`);
return res.json();
}
+172
View File
@@ -0,0 +1,172 @@
type CandleCallback = (data: {
symbol: string;
exchange: string;
timeframe: string;
timestamp: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}) => void;
type SignalCallback = (data: {
id: number;
symbol: string;
signal_type: string;
strength: string;
price: number;
timestamp: string;
indicators_snapshot: string | null;
}) => void;
class WebSocketService {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = false;
private candleCallback: CandleCallback | null = null;
private signalCallback: SignalCallback | null = null;
private subscriptions: Array<{ symbol: string; timeframe: string; exchange: string }> = [];
private token = '';
connect(token: string): void {
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
return;
}
this.token = token;
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.doConnect();
}
private doConnect(): void {
if (!this.token) return;
const wsUrl = `/ws/v1/candles?token=${encodeURIComponent(this.token)}`;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const fullUrl = `${protocol}//${window.location.host}${wsUrl}`;
try {
this.ws = new WebSocket(fullUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
// Re-subscribe to all channels
for (const sub of this.subscriptions) {
this.sendSubscribe(sub);
}
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'candle' && msg.data && this.candleCallback) {
this.candleCallback(msg.data);
}
if (msg.type === 'signal' && msg.data && this.signalCallback) {
this.signalCallback(msg.data);
}
} catch {
// ignore parse errors
}
};
this.ws.onclose = () => {
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onclose fires after onerror
};
} catch {
if (this.shouldReconnect) {
this.scheduleReconnect();
}
}
}
disconnect(): void {
this.shouldReconnect = false;
this.subscriptions = [];
this.candleCallback = null;
this.signalCallback = null;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
this.reconnectAttempts = 0;
}
subscribe(symbol: string, timeframe: string, exchange: string): void {
const sub = { symbol, timeframe, exchange };
this.subscriptions.push(sub);
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendSubscribe(sub);
}
}
unsubscribe(symbol: string, timeframe: string, exchange: string): void {
this.subscriptions = this.subscriptions.filter(
(s) => !(s.symbol === symbol && s.timeframe === timeframe && s.exchange === exchange)
);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'unsubscribe',
symbol,
timeframe,
exchange,
}));
}
}
onCandle(callback: CandleCallback): void {
this.candleCallback = callback;
}
onSignal(callback: SignalCallback): void {
this.signalCallback = callback;
}
private sendSubscribe(sub: { symbol: string; timeframe: string; exchange: string }): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'subscribe',
symbol: sub.symbol,
timeframe: sub.timeframe,
exchange: sub.exchange,
}));
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.shouldReconnect = false;
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
if (this.shouldReconnect) {
this.doConnect();
}
}, delay);
}
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}
export const wsService = new WebSocketService();
export default WebSocketService;
+70
View File
@@ -0,0 +1,70 @@
import { useAppDispatch } from '../../app/hooks';
import { loginThunk } from './authSlice';
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../translations';
export default function LoginPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { t } = useT();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); // P2-12
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true); // P2-12: show loading state
try {
await dispatch(loginThunk({ username, password })).unwrap();
navigate('/dashboard');
} catch (err) {
setError(typeof err === 'string' ? err : t('login.error'));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="auth-page">
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
<h1>{t('login.title')}</h1>
<h2>{t('login.subtitle')}</h2>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">{t('login.username')}</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('login.username')}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">{t('login.password')}</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('login.password')}
required
/>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={isSubmitting} style={{ minHeight: 44, touchAction: 'manipulation' }}>
{isSubmitting ? t('login.signing') : t('login.submit')}
</button>
</form>
{/* Register temporarily hidden while features stabilize */}
{/* <p className="auth-link"> */}
{/* Don't have an account? <a href="/register">Register</a> */}
{/* </p> */}
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useAppDispatch } from '../../app/hooks';
import { registerThunk } from './authSlice';
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
export default function RegisterPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
try {
await dispatch(registerThunk({ username, email, password })).unwrap();
navigate('/dashboard');
} catch (err) {
setError(typeof err === 'string' ? err : 'Registration failed');
}
};
return (
<div className="auth-page">
<div className="auth-card" style={{ maxWidth: '100vw', boxSizing: 'border-box' }}>
<h1>Trading Portal</h1>
<h2>Create Account</h2>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
required
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
/>
</div>
<button type="submit" className="btn btn-primary btn-full" style={{ minHeight: 44, touchAction: 'manipulation' }}>
Create Account
</button>
</form>
<p className="auth-link">
Already have an account? <a href="/login">Sign In</a>
</p>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, User } from '../../types/trading';
import * as api from '../api/apiService';
function loadInitialState(): AuthState {
const token = localStorage.getItem('access_token');
return {
user: null,
accessToken: token,
refreshToken: localStorage.getItem('refresh_token'),
isAuthenticated: !!token,
isLoading: false,
error: null,
};
}
export const loginThunk = createAsyncThunk(
'auth/login',
async ({ username, password }: { username: string; password: string }, { rejectWithValue }) => {
try {
const result = await api.login(username, password);
localStorage.setItem('access_token', result.access_token);
localStorage.setItem('refresh_token', result.refresh_token);
// Fetch user info after login
const user = await api.getMe();
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
} catch (err: unknown) {
if (err instanceof Error) {
return rejectWithValue(err.message);
}
return rejectWithValue('Login failed');
}
},
);
export const registerThunk = createAsyncThunk(
'auth/register',
async (
{ username, email, password }: { username: string; email: string; password: string },
{ rejectWithValue },
) => {
try {
await api.register(username, email, password);
// Auto-login after registration
const result = await api.login(username, password);
localStorage.setItem('access_token', result.access_token);
localStorage.setItem('refresh_token', result.refresh_token);
const user = await api.getMe();
return { user, accessToken: result.access_token, refreshToken: result.refresh_token };
} catch (err: unknown) {
if (err instanceof Error) {
return rejectWithValue(err.message);
}
return rejectWithValue('Registration failed');
}
},
);
export const fetchCurrentUser = createAsyncThunk(
'auth/fetchCurrentUser',
async (_, { rejectWithValue }) => {
try {
const user = await api.getMe();
return user as User;
} catch (err: unknown) {
if (err instanceof Error) {
return rejectWithValue(err.message);
}
return rejectWithValue('Failed to fetch user');
}
},
);
export const logoutThunk = createAsyncThunk(
'auth/logout',
async () => {
await api.logout();
},
);
const authSlice = createSlice({
name: 'auth',
initialState: loadInitialState(),
reducers: {
setCredentials(state, action: PayloadAction<{ user: User; accessToken: string; refreshToken: string }>) {
state.user = action.payload.user;
state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.isAuthenticated = true;
state.error = null;
},
clearCredentials(state) {
state.user = null;
state.accessToken = null;
state.refreshToken = null;
state.isAuthenticated = false;
state.error = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
},
clearError(state) {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Login
.addCase(loginThunk.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(loginThunk.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload.user;
state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.isAuthenticated = true;
})
.addCase(loginThunk.rejected, (state, action) => {
state.isLoading = false;
state.error = (action.payload as string) || 'Login failed';
})
// Register
.addCase(registerThunk.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(registerThunk.fulfilled, (state) => {
state.isLoading = false;
})
.addCase(registerThunk.rejected, (state, action) => {
state.isLoading = false;
state.error = (action.payload as string) || 'Registration failed';
})
// Fetch current user
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
state.user = action.payload;
state.isAuthenticated = true;
})
.addCase(fetchCurrentUser.rejected, (state) => {
state.user = null;
state.accessToken = null;
state.refreshToken = null;
state.isAuthenticated = false;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
})
// Logout
.addCase(logoutThunk.fulfilled, (state) => {
state.user = null;
state.accessToken = null;
state.refreshToken = null;
state.isAuthenticated = false;
});
},
});
export const { setCredentials, clearCredentials, clearError } = authSlice.actions;
export default authSlice.reducer;
+323
View File
@@ -0,0 +1,323 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useT } from '../../translations';
interface SignalCounts {
[key: string]: number;
}
interface TradeInfo {
direction: string;
entry_price: number;
exit_price: number;
pnl: number;
entry_signal: string;
}
interface Trade {
direction: string;
entry_price: number;
exit_price?: number;
entry_time?: string;
pnl?: number;
entry_signal: string;
exit_reason?: string;
status?: string;
}
interface BacktestResult {
symbol: string;
exchange: string;
timeframe: string;
days: number;
candles_count: number;
signal_counts: SignalCounts;
total_signals: number;
recent_signals: Array<{ time: string; signal: string; strength: string; price: number }>;
trades: {
total: number;
wins: number;
losses: number;
win_rate: number;
total_pnl: number;
profit_factor: number | null;
avg_win: number | null;
avg_loss: number | null;
best_trade: TradeInfo | null;
worst_trade: TradeInfo | null;
per_signal: Record<string, { trades: number; wins: number; pnl: number }>;
recent: Trade[];
};
}
const SIGNAL_ICONS: Record<string, string> = {
'STRONG_BUY': '🚀', 'BUY': '📈',
'STRONG_SELL': '🔻', 'SELL': '📉',
'CAUTION_LONG': '⚠️', 'CAUTION_SHORT': '⚠️',
'SQUEEZE_ALERT': '⚡',
};
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
export default function BacktestPage() {
const { t } = useT();
const [exchange, setExchange] = useState('binance');
const [symbol, setSymbol] = useState('BTC/USDT');
const [symbols, setSymbols] = useState<string[]>([]);
const [timeframe, setTimeframe] = useState('30m');
const [days, setDays] = useState(7);
const [tradeSize, setTradeSize] = useState(10);
const [result, setResult] = useState<BacktestResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Load symbols when exchange changes
useEffect(() => {
let cancelled = false;
async function loadSymbols() {
try {
const res = await fetch(
`/api/v1/backtest/symbols?exchange=${encodeURIComponent(exchange)}`,
{ credentials: 'include' }
);
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
const names = (data.symbols || []).map((s: any) => s.symbol);
setSymbols(names);
// Keep current symbol if in list, else pick first
if (names.length > 0 && !names.includes(symbol)) {
setSymbol(names[0]);
}
} catch {}
}
loadSymbols();
return () => { cancelled = true; };
}, [exchange]);
const runBacktest = useCallback(async () => {
setLoading(true);
setError('');
setResult(null);
try {
const res = await fetch(
`/api/v1/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
{ credentials: 'include' }
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
setResult(data);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [symbol, timeframe, days, tradeSize]);
return (
<div style={{ padding: '20px', maxWidth: '1000px', margin: '0 auto' }}>
<h1>📊 {t('Backtest')}</h1>
{/* Controls */}
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '20px', alignItems: 'end' }}>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Exchange</label><br />
<select value={exchange} onChange={e => setExchange(e.target.value)}
style={selectStyle}>
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Symbol ({symbols.length})</label><br />
<select value={symbol} onChange={e => setSymbol(e.target.value)}
style={{ ...selectStyle, minWidth: '140px' }}>
{symbols.length > 0
? symbols.map(s => <option key={s} value={s}>{s}</option>)
: <option value="">Loading...</option>}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Timeframe</label><br />
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} style={selectStyle}>
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Days</label><br />
<select value={days} onChange={e => setDays(Number(e.target.value))} style={selectStyle}>
{[3, 7, 14, 30, 90, 180, 365].map(d => <option key={d} value={d}>{d}d</option>)}
</select>
</div>
<div>
<label style={{ fontSize: '12px', color: '#888' }}>Trade Size (USDT)</label><br />
<input type="number" value={tradeSize} onChange={e => setTradeSize(Number(e.target.value))}
style={{ ...selectStyle, width: '80px' }} min={1} />
</div>
<button onClick={runBacktest} disabled={loading}
style={{
padding: '8px 24px', background: '#2563eb', color: '#fff', border: 'none',
borderRadius: '6px', cursor: loading ? 'wait' : 'pointer', fontWeight: 600,
}}>
{loading ? '⏳ Running...' : '▶ Run Backtest'}
</button>
</div>
{error && <div style={{ color: '#ef4444', marginBottom: '16px' }}>❌ {error}</div>}
{result && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
<SummaryCard label="Trades" value={result.trades.total.toString()} />
<SummaryCard label="Win Rate" value={`${result.trades.win_rate}%`}
color={result.trades.win_rate >= 50 ? '#22c55e' : '#ef4444'} />
<SummaryCard label="Total PnL" value={`$${result.trades.total_pnl.toFixed(2)}`}
color={result.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444'} />
<SummaryCard label="Profit Factor" value={result.trades.profit_factor?.toFixed(2) ?? '∞'}
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? '#22c55e' : '#ef4444'} />
<SummaryCard label="Wins/Losses" value={`${result.trades.wins}W / ${result.trades.losses}L`} />
<SummaryCard label="Candles" value={result.candles_count.toString()} />
</div>
{/* Signal Breakdown */}
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Signal Breakdown</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{Object.entries(result.signal_counts).map(([sig, count]) => {
const icon = SIGNAL_ICONS[sig] || '❓';
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
return (
<div key={sig} style={{
padding: '8px 14px', background: '#16213e', borderRadius: '6px',
border: '1px solid #0f3460',
}}>
<div style={{ fontSize: '14px', fontWeight: 600 }}>
{icon} {sig.replace('_', ' ')}
</div>
<div style={{ fontSize: '12px', color: '#aaa' }}>
{count} signals → {sigTrades.trades} trades
</div>
<div style={{
fontSize: '12px', fontWeight: 600,
color: sigTrades.pnl >= 0 ? '#22c55e' : '#ef4444'
}}>
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
</div>
</div>
);
})}
</div>
</div>
{/* Best/Worst Trades */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
{result.trades.best_trade && (
<div style={{ background: '#064e3b', borderRadius: '8px', padding: '12px' }}>
<div style={{ fontSize: '12px', color: '#6ee7b7' }}>🏆 Best Trade</div>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
<div style={{ color: '#22c55e', fontWeight: 700 }}>+${result.trades.best_trade.pnl.toFixed(2)}</div>
</div>
)}
{result.trades.worst_trade && (
<div style={{ background: '#450a0a', borderRadius: '8px', padding: '12px' }}>
<div style={{ fontSize: '12px', color: '#fca5a5' }}>💸 Worst Trade</div>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
<div style={{ color: '#ef4444', fontWeight: 700 }}>{result.trades.worst_trade.pnl.toFixed(2)}$</div>
</div>
)}
</div>
{/* Recent Signals */}
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Signals (last 15)</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '1px solid #333' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>Signal</th>
<th style={thStyle}>Price</th>
</tr>
</thead>
<tbody>
{result.recent_signals.map((s, i) => (
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
<td style={tdStyle}>{s.time.slice(5, 16)}</td>
<td style={tdStyle}>
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
</td>
<td style={tdStyle}>${s.price.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Recent Trades */}
{result.trades.recent.length > 0 && (
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px' }}>
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Trades (last 10)</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '1px solid #333' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>Dir</th>
<th style={thStyle}>Entry</th>
<th style={thStyle}>Exit</th>
<th style={thStyle}>PnL</th>
<th style={thStyle}>Reason</th>
</tr>
</thead>
<tbody>
{result.trades.recent.slice().reverse().map((t, i) => (
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
<td style={tdStyle}>{t.entry_time?.slice(5, 16) || '-'}</td>
<td style={tdStyle}>{t.direction}</td>
<td style={tdStyle}>${t.entry_price.toFixed(2)}</td>
<td style={tdStyle}>${t.exit_price?.toFixed(2) || '-'}</td>
<td style={{ ...tdStyle, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>
${t.pnl?.toFixed(2) || '0.00'}
</td>
<td style={tdStyle}>{t.exit_reason || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
}
const selectStyle: React.CSSProperties = {
padding: '6px 10px', borderRadius: '6px', border: '1px solid #333',
background: '#1a1a2e', color: '#e0e0e0', fontSize: '14px',
};
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '8px 12px', color: '#888', fontWeight: 500,
};
const tdStyle: React.CSSProperties = {
padding: '6px 12px',
};
function SummaryCard({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div style={{
background: '#1a1a2e', borderRadius: '8px', padding: '14px',
border: '1px solid #0f3460',
}}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>{label}</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: color || '#e0e0e0' }}>{value}</div>
</div>
);
}
+588
View File
@@ -0,0 +1,588 @@
import { useEffect, useRef, useState } from 'react';
import {
createChart,
createSeriesMarkers,
CrosshairMode,
CandlestickSeries,
HistogramSeries,
LineSeries,
ColorType,
LineStyle,
} from 'lightweight-charts';
import type {
IChartApi,
ISeriesApi,
CandlestickData,
HistogramData,
LineData,
UTCTimestamp,
} from 'lightweight-charts';
interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
interface MarketStructure {
swing_highs: (number | null)[];
swing_lows: (number | null)[];
bos: string | null;
choch: string | null;
order_blocks: any[];
trend: string;
last_swing_high: number | null;
last_swing_low: number | null;
}
interface IndicatorResponse {
sma_20?: number[];
sma_50?: number[];
ema_12?: number[];
ema_26?: number[];
rsi_14?: number[];
stoch_rsi?: { k: number[]; d: number[] };
macd?: { macd_line: number[]; signal_line: number[]; histogram: number[] };
bollinger_bands?: {
upper: number[];
middle: number[];
lower: number[];
upper_1?: number[];
lower_1?: number[];
};
vwap?: number[];
market_structure?: MarketStructure;
}
interface Props {
symbol: string;
exchange: string;
timeframe: string;
onPriceUpdate?: (price: number | null) => void;
}
export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpdate }: Props) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const seriesRef = useRef<{
candle?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
sma?: ISeriesApi<'Line'>;
ema?: ISeriesApi<'Line'>;
bbUpper2?: ISeriesApi<'Line'>;
bbLower2?: ISeriesApi<'Line'>;
bbUpper1?: ISeriesApi<'Line'>;
bbLower1?: ISeriesApi<'Line'>;
bbMiddle?: ISeriesApi<'Line'>;
stochK?: ISeriesApi<'Line'>;
stochD?: ISeriesApi<'Line'>;
obLine?: ISeriesApi<'Line'>;
osLine?: ISeriesApi<'Line'>;
markersPlugin?: { setMarkers: (m: any[]) => void; detach: () => void };
}>({});
useEffect(() => {
if (!chartContainerRef.current) return;
// Use explicit dimensions from container (important for iOS Safari)
const containerRect = chartContainerRef.current.getBoundingClientRect();
const chart = createChart(chartContainerRef.current, {
width: Math.max(containerRect.width, 400),
height: Math.max(containerRect.height, 300),
layout: {
background: { type: ColorType.Solid, color: '#0d1117' },
textColor: '#c9d1d9',
},
grid: {
vertLines: { color: '#1a2332' },
horzLines: { color: '#1a2332' },
},
crosshair: {
mode: CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: '#30363d',
},
timeScale: {
borderColor: '#30363d',
timeVisible: true,
secondsVisible: false,
},
handleScroll: true,
handleScale: true,
});
chartRef.current = chart;
// Reserve space for Stoch RSI + Volume panes below candlesticks
chart.priceScale('right').applyOptions({
scaleMargins: {
top: 0, // candlesticks start at top
bottom: 0.45, // leave bottom 45% for Stoch RSI (25%) + Volume (20%)
},
});
// Candlestick series (main pane)
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
});
// Volume histogram (separate pane)
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
chart.priceScale('volume').applyOptions({
scaleMargins: {
top: 0.8,
bottom: 0,
},
});
// SMA line series
const smaSeries = chart.addSeries(LineSeries, {
color: '#f0b90b',
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: false,
title: 'SMA 20',
});
// EMA line series
const emaSeries = chart.addSeries(LineSeries, {
color: '#ff9800',
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: false,
title: 'EMA 12',
});
// Bollinger Bands: 2-sigma (red) & 1-sigma (yellow)
const bbUpper2 = chart.addSeries(LineSeries, {
color: '#ef5350',
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
title: 'BB 2σ Upper',
});
const bbLower2 = chart.addSeries(LineSeries, {
color: '#ef5350',
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
title: 'BB 2σ Lower',
});
const bbUpper1 = chart.addSeries(LineSeries, {
color: '#ffd54f',
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
title: 'BB 1σ Upper',
});
const bbLower1 = chart.addSeries(LineSeries, {
color: '#ffd54f',
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
title: 'BB 1σ Lower',
});
const bbMiddle = chart.addSeries(LineSeries, {
color: '#90caf9',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
title: 'BB Middle (SMA 20)',
});
// Stoch RSI series (separate pane) — %K and %D lines
const stochKSeries = chart.addSeries(LineSeries, {
color: '#7c3aed',
lineWidth: 2,
priceScaleId: 'stoch_rsi',
priceLineVisible: false,
lastValueVisible: false,
title: 'Stoch RSI %K',
});
const stochDSeries = chart.addSeries(LineSeries, {
color: '#ff9800',
lineWidth: 2,
priceScaleId: 'stoch_rsi',
priceLineVisible: false,
lastValueVisible: false,
title: 'Stoch RSI %D',
});
chart.priceScale('stoch_rsi').applyOptions({
scaleMargins: {
top: 0.55, // Stoch RSI in 55%-75% region
bottom: 0.25, // leave bottom 25% for volume
},
});
// Overbought / Oversold lines for Stoch RSI
const obLine = chart.addSeries(LineSeries, {
color: '#ef5350',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceScaleId: 'stoch_rsi',
priceLineVisible: false,
lastValueVisible: false,
title: 'Overbought 80',
});
const osLine = chart.addSeries(LineSeries, {
color: '#26a69a',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceScaleId: 'stoch_rsi',
priceLineVisible: false,
lastValueVisible: false,
title: 'Oversold 20',
});
seriesRef.current = {
candle: candleSeries as ISeriesApi<'Candlestick'>,
volume: volumeSeries as ISeriesApi<'Histogram'>,
sma: smaSeries as ISeriesApi<'Line'>,
ema: emaSeries as ISeriesApi<'Line'>,
bbUpper2: bbUpper2 as ISeriesApi<'Line'>,
bbLower2: bbLower2 as ISeriesApi<'Line'>,
bbUpper1: bbUpper1 as ISeriesApi<'Line'>,
bbLower1: bbLower1 as ISeriesApi<'Line'>,
bbMiddle: bbMiddle as ISeriesApi<'Line'>,
stochK: stochKSeries as ISeriesApi<'Line'>,
stochD: stochDSeries as ISeriesApi<'Line'>,
obLine: obLine as ISeriesApi<'Line'>,
osLine: osLine as ISeriesApi<'Line'>,
};
// Resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
chart.applyOptions({
width,
height,
});
}
});
resizeObserver.observe(chartContainerRef.current);
return () => {
resizeObserver.disconnect();
if (seriesRef.current.markersPlugin) {
try { seriesRef.current.markersPlugin.detach(); } catch {}
}
chart.remove();
chartRef.current = null;
seriesRef.current = {};
};
}, []);
// Fetch candles & indicators on symbol/exchange/timeframe change
useEffect(() => {
if (!symbol || !exchange || !timeframe) return;
setLoading(true);
setError(null);
const token = localStorage.getItem('access_token');
const headers = { Authorization: `Bearer ${token}` };
async function fetchData() {
try {
// Fetch candles — use query params to avoid encoding issues
const candleRes = await fetch(
`/api/v1/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
{ headers }
);
if (!candleRes.ok) throw new Error(`Candle fetch error: ${candleRes.status}`);
const candleResponse = await candleRes.json();
// API returns {candles: [...], cursor: "...", has_more: bool} — unwrap it
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
// Reverse so oldest candle comes first (lightweight-charts needs ASC order)
rawCandles.reverse();
const candleData: Candle[] = rawCandles.map((c: any) => ({
time: Math.floor(new Date(c.timestamp).getTime() / 1000) as UTCTimestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
}));
// Update candle series
const candleSeries = seriesRef.current.candle;
if (candleSeries && candleData.length > 0) {
const candlestickData: CandlestickData<UTCTimestamp>[] = candleData.map((c) => ({
time: c.time as UTCTimestamp,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
candleSeries.setData(candlestickData);
// Notify parent of latest close price
const latest = candlestickData[candlestickData.length - 1];
if (onPriceUpdate) {
onPriceUpdate(latest.close);
}
}
// Update volume series
const volumeSeries = seriesRef.current.volume;
if (volumeSeries && candleData.length > 0) {
const volumeData: HistogramData<UTCTimestamp>[] = candleData.map((c) => ({
time: c.time as UTCTimestamp,
value: c.volume,
color: c.close >= c.open ? '#26a69a66' : '#ef535066',
}));
volumeSeries.setData(volumeData);
}
// Fetch indicators
const indicatorsRes = await fetch(
`/api/v1/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
{ headers }
);
if (!indicatorsRes.ok) throw new Error(`Indicator fetch error: ${indicatorsRes.status}`);
const indicatorData: IndicatorResponse = await indicatorsRes.json();
// Helper to build time-annotated line data from indicator array
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
const sliced = values.slice(-times.length);
const result: LineData<UTCTimestamp>[] = [];
for (let i = 0; i < sliced.length; i++) {
const val = sliced[i];
if (val !== null && val !== undefined) {
result.push({ time: times[i].time as UTCTimestamp, value: val });
}
}
return result;
}
// SMA
const smaSeries = seriesRef.current.sma;
if (smaSeries && indicatorData.sma_20 && candleData.length > 0) {
smaSeries.setData(buildLineData(indicatorData.sma_20, candleData));
}
// EMA
const emaSeries = seriesRef.current.ema;
if (emaSeries && indicatorData.ema_12 && candleData.length > 0) {
emaSeries.setData(buildLineData(indicatorData.ema_12, candleData));
}
// Bollinger Bands (double BB)
const bb = indicatorData.bollinger_bands;
if (bb && candleData.length > 0) {
const bbUpper2 = seriesRef.current.bbUpper2;
const bbLower2 = seriesRef.current.bbLower2;
const bbUpper1 = seriesRef.current.bbUpper1;
const bbLower1 = seriesRef.current.bbLower1;
const bbMiddle = seriesRef.current.bbMiddle;
if (bbUpper2) bbUpper2.setData(buildLineData(bb.upper, candleData));
if (bbLower2) bbLower2.setData(buildLineData(bb.lower, candleData));
if (bbMiddle) bbMiddle.setData(buildLineData(bb.middle, candleData));
if (bbUpper1 && bb.upper_1) bbUpper1.setData(buildLineData(bb.upper_1, candleData));
if (bbLower1 && bb.lower_1) bbLower1.setData(buildLineData(bb.lower_1, candleData));
}
// Stoch RSI
const stochKSeries = seriesRef.current.stochK;
const stochDSeries = seriesRef.current.stochD;
const obLine = seriesRef.current.obLine;
const osLine = seriesRef.current.osLine;
const srsi = indicatorData.stoch_rsi;
if (stochKSeries && stochDSeries && srsi && srsi.k && srsi.d && candleData.length > 0) {
const kData = buildLineData(srsi.k, candleData);
const dData = buildLineData(srsi.d, candleData);
stochKSeries.setData(kData);
stochDSeries.setData(dData);
// Set overbought/oversold lines at constant 80/20
if (kData.length > 0 && obLine && osLine) {
const obData: LineData<UTCTimestamp>[] = kData.map((d) => ({
time: d.time,
value: 80,
}));
const osData: LineData<UTCTimestamp>[] = kData.map((d) => ({
time: d.time,
value: 20,
}));
obLine.setData(obData);
osLine.setData(osData);
}
}
// Fit content
chartRef.current?.timeScale().fitContent();
// SMC: Market Structure markers on candles
const ms = indicatorData.market_structure;
if (ms && seriesRef.current.candle && candleData.length > 0) {
const smcCandleSeries = seriesRef.current.candle!;
const smcMarkers: any[] = [];
// Swing Highs (red arrow above candle)
const sh_aligned = ms.swing_highs.slice(-candleData.length);
for (let i = 0; i < sh_aligned.length; i++) {
if (sh_aligned[i] !== null) {
smcMarkers.push({
time: candleData[i].time as UTCTimestamp,
position: 'aboveBar' as const,
color: '#ef5350',
shape: 'arrowDown' as const,
size: 1,
});
}
}
// Swing Lows (green arrow below candle)
const sl_aligned = ms.swing_lows.slice(-candleData.length);
for (let i = 0; i < sl_aligned.length; i++) {
if (sl_aligned[i] !== null) {
smcMarkers.push({
time: candleData[i].time as UTCTimestamp,
position: 'belowBar' as const,
color: '#26a69a',
shape: 'arrowUp' as const,
size: 1,
});
}
}
if (!seriesRef.current.markersPlugin) {
seriesRef.current.markersPlugin = createSeriesMarkers(smcCandleSeries, smcMarkers, {});
} else {
seriesRef.current.markersPlugin.setMarkers(smcMarkers);
}
// Log trend + BOS/CHoCH to console for reference
if (ms.trend !== 'NEUTRAL') {
const bosText = ms.bos ? ` BOS:${ms.bos}` : '';
const chochText = ms.choch ? ` CHoCH:${ms.choch}` : '';
console.log(`[SMC] ${symbol} trend=${ms.trend}${bosText}${chochText}`);
}
}
setLoading(false);
} catch (err) {
console.error('fetchData error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
fetchData();
}, [symbol, exchange, timeframe, onPriceUpdate]);
// WebSocket real-time updates
useEffect(() => {
const token = localStorage.getItem('access_token');
if (!token) return;
import('../api/websocketService').then(({ wsService }) => {
wsService.connect(token);
wsService.onCandle((data) => {
// Only process candles for the current symbol/exchange/timeframe
if (data.symbol !== symbol || data.exchange !== exchange || data.timeframe !== timeframe) {
return;
}
const candleSeries = seriesRef.current.candle;
const volumeSeries = seriesRef.current.volume;
if (candleSeries) {
const time = Math.floor(new Date(data.timestamp).getTime() / 1000) as UTCTimestamp;
candleSeries.update({
time,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
});
if (onPriceUpdate) {
onPriceUpdate(data.close);
}
}
if (volumeSeries) {
const time = Math.floor(new Date(data.timestamp).getTime() / 1000) as UTCTimestamp;
volumeSeries.update({
time,
value: data.volume,
color: data.close >= data.open ? '#26a69a66' : '#ef535066',
});
}
});
wsService.subscribe(symbol, timeframe, exchange);
});
return () => {
import('../api/websocketService').then(({ wsService }) => {
wsService.unsubscribe(symbol, timeframe, exchange);
});
};
}, [symbol, exchange, timeframe, onPriceUpdate]);
return (
<div style={styles.wrapper}>
{loading && <div style={styles.overlay}>Loading chart data...</div>}
{error && <div style={styles.errorOverlay}>Error: {error}</div>}
<div ref={chartContainerRef} style={styles.chartContainer} />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
wrapper: {
position: 'relative',
flex: 1,
minHeight: 0,
},
chartContainer: {
width: '100%',
height: '100%',
},
overlay: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#8b949e',
fontSize: 14,
zIndex: 10,
background: '#0d1117cc',
padding: '8px 16px',
borderRadius: 6,
},
errorOverlay: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#ef5350',
fontSize: 14,
zIndex: 10,
background: '#0d1117cc',
padding: '8px 16px',
borderRadius: 6,
},
};
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react';
interface Exchange {
id: number;
name: string;
active: boolean;
}
interface SymbolData {
id: number;
symbol: string;
base: string;
quote: string;
active: boolean;
exchange_id: number;
}
interface Props {
exchanges: Exchange[];
symbol: string;
exchange: string;
timeframe: string;
onSymbolChange: (symbol: string) => void;
onExchangeChange: (exchange: string) => void;
onTimeframeChange: (timeframe: string) => void;
}
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
export default function ChartToolbar({
exchanges,
symbol,
exchange,
timeframe,
onSymbolChange,
onExchangeChange,
onTimeframeChange,
}: Props) {
const [symbols, setSymbols] = useState<SymbolData[]>([]);
const [loadingSymbols, setLoadingSymbols] = useState(false);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch symbols when exchange changes
useEffect(() => {
if (exchange) {
fetchSymbols(exchange);
}
}, [exchange]);
async function fetchSymbols(exch: string) {
setLoadingSymbols(true);
try {
const res = await fetch(
`/api/v1/symbols?exchange=${exch}&active_only=true`,
{ headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` } }
);
if (!res.ok) throw new Error('Failed to fetch symbols');
const data: SymbolData[] = await res.json();
setSymbols(data);
} catch (err) {
console.error('fetchSymbols error:', err);
} finally {
setLoadingSymbols(false);
}
}
return (
<div style={{ ...styles.toolbar, gap: isMobile ? 8 : 16 }}>
{/* Exchange + Symbol row */}
<div style={{ ...styles.selectRow, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 6 : 12 }}>
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
<label style={styles.label}>Exchange</label>
<select
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
value={exchange}
onChange={(e) => onExchangeChange(e.target.value)}
>
{exchanges.map((ex) => (
<option key={ex.name} value={ex.name.toLowerCase()}>
{ex.name}
</option>
))}
</select>
</div>
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
<label style={styles.label}>Symbol</label>
<select
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
value={symbol}
onChange={(e) => onSymbolChange(e.target.value)}
disabled={loadingSymbols}
>
{symbols.map((s) => (
<option key={s.id} value={s.symbol}>
{s.symbol}
</option>
))}
</select>
</div>
</div>
{/* Timeframe - horizontal scroll on mobile */}
<div style={{ ...styles.timeframeSection, flex: isMobile ? 'none' : 1 }}>
<label style={styles.label}>TF</label>
<div style={styles.timeframeScroll} className="scrollable-x">
<div style={styles.timeframeGroup}>
{TIMEFRAMES.map((tf) => (
<button
key={tf}
style={{
...styles.timeframeButton,
...(tf === timeframe ? styles.timeframeActive : {}),
padding: isMobile ? '2px 6px' : '3px 8px',
fontSize: isMobile ? 10 : 11,
}}
onClick={() => onTimeframeChange(tf)}
>
{tf}
</button>
))}
</div>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
toolbar: {
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '6px 16px',
background: '#161b22',
borderBottom: '1px solid #30363d',
flexWrap: 'wrap',
// Responsive: on small screens, items stack
flexDirection: 'row' as const,
},
selectRow: {
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
},
group: {
display: 'flex',
alignItems: 'center',
gap: 6,
},
label: {
fontSize: 11,
fontWeight: 500,
color: '#8b949e',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
flexShrink: 0,
},
select: {
padding: '3px 8px',
fontSize: 11,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
cursor: 'pointer',
minWidth: 100,
maxWidth: 140,
height: 24,
boxSizing: 'border-box' as const,
lineHeight: '16px',
},
timeframeSection: {
display: 'flex',
alignItems: 'center',
gap: 6,
flex: 1,
minWidth: 0,
},
timeframeScroll: {
overflowX: 'auto' as const,
overflowY: 'hidden',
WebkitOverflowScrolling: 'touch' as any,
scrollBehavior: 'smooth',
paddingBottom: 2,
flex: 1,
minWidth: 0,
},
timeframeGroup: {
display: 'flex',
gap: 2,
flexShrink: 0,
alignItems: 'center',
},
timeframeButton: {
padding: '3px 6px',
fontSize: 11,
fontWeight: 500,
color: '#8b949e',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 4,
cursor: 'pointer',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap' as const,
minWidth: 32,
textAlign: 'center' as const,
height: 24,
lineHeight: '16px',
boxSizing: 'border-box' as const,
verticalAlign: 'middle',
},
timeframeActive: {
color: '#f0f6fc',
background: '#1f6feb',
borderColor: '#1f6feb',
},
};
+390
View File
@@ -0,0 +1,390 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { logoutThunk } from '../auth/authSlice';
import { fetchCurrentUser } from '../auth/authSlice';
import { apiFetch } from '../api/apiService';
import { useT } from '../../translations';
import ChartContainer from './ChartContainer';
import ChartToolbar from './ChartToolbar';
import WatchlistPanel from './WatchlistPanel';
import OrderPanel from './OrderPanel';
import SignalPanel from './SignalPanel';
import { DashboardSkeleton } from '../../components/Skeleton'; // P3-8
// ── Types matching backend API ──
interface Exchange {
id: number;
name: string;
active: boolean;
}
interface BalanceData {
asset: string;
free: number;
used: number;
total: number;
}
interface BalanceResponse {
exchange: string;
balances: BalanceData[];
timestamp: string;
}
// ── Styles (kept from original) ──
const styles: Record<string, React.CSSProperties> = {
page: { minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' },
nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 },
grid: { display: 'grid', gridTemplateColumns: '1fr 340px', gap: 0, height: 'calc(100vh - 44px)' },
sidePanel: { background: '#0d1117', borderLeft: '1px solid #30363d', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
mainContent: { display: 'flex', flexDirection: 'column', overflow: 'hidden' },
btn: { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 },
activeBtn: { padding: '6px 16px', fontSize: 12, borderRadius: 6, background: '#1f6feb', color: '#fff', border: '1px solid #1f6feb', cursor: 'pointer' },
sectionTitle: { fontSize: 12, fontWeight: 600, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
};
export default function DashboardPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
// Chart state — use user's default exchange from preferences
const defaultExchange = user?.preferences?.default_exchange || 'mexc';
const [symbol, setSymbol] = useState('BTC/USDT');
const [exchange, setExchange] = useState(defaultExchange);
const [timeframe, setTimeframe] = useState(user?.preferences?.default_timeframe || '1h');
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const [lastPrice, setLastPrice] = useState<number | null>(null);
// Balance state
const [balance, setBalance] = useState<BalanceData[] | null>(null);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidePanel, setShowSidePanel] = useState(false);
const [showNavDropdown, setShowNavDropdown] = useState(false);
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (!mobile) setShowSidePanel(false);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Auth guard + fetch user on refresh
useEffect(() => {
if (!isAuthenticated) { navigate('/login', { replace: true }); return; }
if (!user) {
dispatch(fetchCurrentUser())
.unwrap()
.catch(() => navigate('/login', { replace: true }));
return;
}
setInitializing(false);
}, [isAuthenticated, user, dispatch, navigate]);
// Fetch available exchanges for ChartToolbar
useEffect(() => {
const loadExchanges = async () => {
try {
const data = await apiFetch<Exchange[]>('/exchanges');
setExchanges(data);
if (data.length > 0) {
// Use user's saved default exchange, or fall back to first available
const savedExchange = user?.preferences?.default_exchange;
const match = savedExchange
? data.find((e: Exchange) => e.name.toLowerCase() === savedExchange.toLowerCase())
: null;
setExchange(match ? match.name.toLowerCase() : data[0].name.toLowerCase());
}
} catch (err) {
console.error('Failed to load exchanges:', err);
}
};
loadExchanges();
}, []);
// Fetch real balance for current exchange
useEffect(() => {
if (!exchange) return;
const loadBalance = async () => {
try {
const data = await apiFetch<BalanceResponse>(`/orders/balance?exchange_name=${exchange}`);
setBalance(data.balances);
} catch (err) {
console.error('Failed to load balance (may not have API key):', err);
setBalance(null);
}
};
loadBalance();
}, [exchange]);
if (!isAuthenticated) return null;
if (initializing || isLoading) {
return <DashboardSkeleton />;
}
const userName = user?.username || '?';
const { t } = useT();
return (
<div style={styles.page}>
{/* ─── NAV ─── */}
<nav style={styles.nav}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }} onClick={() => navigate('/')}>
<span style={{ fontSize: 18 }}>📊</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>{t('nav.tradingPortal')}</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{isMobile ? (
<>
{/* Hamburger: toggle side panel */}
<button
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
style={{
...styles.btn,
fontSize: 18,
padding: '8px 14px',
lineHeight: 1,
minHeight: 36,
minWidth: 36,
touchAction: 'manipulation',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title={t('nav.panelToggle')}
>
{showSidePanel ? '✕' : '☰'}
</button>
{/* Dropdown toggle for nav links */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowNavDropdown(!showNavDropdown)}
style={{
...styles.btn,
fontSize: 16,
padding: '8px 14px',
lineHeight: 1,
minHeight: 36,
minWidth: 36,
touchAction: 'manipulation',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
background: showNavDropdown ? '#1f6feb' : '#21262d',
borderColor: showNavDropdown ? '#1f6feb' : '#30363d',
}}
title="Menu"
>
<span style={{ fontSize: 14 }}>☰</span>
<span style={{ fontSize: 10 }}>▼</span>
</button>
{showNavDropdown && (
<>
{/* Backdrop to close on outside click */}
<div
onClick={() => setShowNavDropdown(false)}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
zIndex: 90, background: 'transparent',
}}
/>
<div style={{
position: 'absolute', top: '100%', right: 0,
marginTop: 4, zIndex: 100,
background: '#161b22', border: '1px solid #30363d',
borderRadius: 8, padding: 4,
display: 'flex', flexDirection: 'column', gap: 2,
minWidth: 160,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
}}>
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
onClick={() => { navigate('/profile'); setShowNavDropdown(false); }}
>
{t('nav.profile', { name: userName })}
</button>
{(user?.role === 'admin' || user?.is_admin) && (
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
onClick={() => { navigate('/admin'); setShowNavDropdown(false); }}
>
🛡️ Admin
</button>
)}
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
onClick={() => { navigate('/analytics'); setShowNavDropdown(false); }}
>
📈 Analytics
</button>
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
<button
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%', color: '#f85149' }}
onClick={async () => {
setShowNavDropdown(false);
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</div>
</>
)}
</div>
</>
) : (
<>
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/profile')}>
{t('nav.profile', { name: userName })}
</button>
{(user?.role === 'admin' || user?.is_admin) && (
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/admin')}>
🛡️ Admin
</button>
)}
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/analytics')}>
📈 Analytics
</button>
<button
style={{
...styles.btn,
touchAction: 'manipulation',
minHeight: isMobile ? 36 : undefined,
color: '#f85149',
borderColor: '#f8514933',
}}
onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}
>
🚪 Logout
</button>
</>
)}
</div>
</nav>
{/* ─── GRID LAYOUT: 1fr (main) | 280px (side) ─── */}
<div style={{ ...styles.grid, gridTemplateColumns: isMobile ? '1fr' : '1fr 340px' }}>
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
<div style={styles.mainContent}>
{/* Toolbar: exchange / symbol / timeframe selectors */}
<ChartToolbar
exchanges={exchanges}
symbol={symbol}
exchange={exchange}
timeframe={timeframe}
onSymbolChange={setSymbol}
onExchangeChange={setExchange}
onTimeframeChange={setTimeframe}
/>
{/* Chart area: fills remaining vertical space */}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{symbol ? (
<ChartContainer
symbol={symbol}
exchange={exchange}
timeframe={timeframe}
onPriceUpdate={setLastPrice}
/>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#484f58',
fontSize: 14,
}}>
Select a symbol from watchlist
</div>
)}
</div>
{/* Signal panel: below chart, only when symbol selected */}
{symbol && (
<div style={{
borderTop: '1px solid #30363d',
height: isMobile ? 200 : 280,
minHeight: 0,
overflow: 'hidden',
}}>
<SignalPanel symbol={symbol} />
</div>
)}
</div>
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
{!isMobile && (
<div style={styles.sidePanel}>
{/* Watchlist at top of side panel */}
<WatchlistPanel
currentSymbol={symbol}
onSymbolChange={setSymbol}
exchange={exchange}
/>
{/* Order panel below watchlist */}
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
{user?.role === 'viewer' ? (
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
👁️ View-only mode — trading is disabled
</div>
) : (
<OrderPanel
symbol={symbol || '—'}
lastPrice={lastPrice}
balance={balance}
isConnected={balance !== null}
/>
)}
</div>
</div>
)}
{/* ═══ MOBILE SIDE PANEL (overlay slide-in) ═══ */}
{isMobile && showSidePanel && (
<>
<div className="dashboard-side-overlay" onClick={() => setShowSidePanel(false)} />
<div className="dashboard-side-panel-mobile">
<WatchlistPanel
currentSymbol={symbol}
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
exchange={exchange}
/>
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
{user?.role === 'viewer' ? (
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
👁️ View-only mode — trading is disabled
</div>
) : (
<OrderPanel
symbol={symbol || '—'}
lastPrice={lastPrice}
balance={balance}
isConnected={balance !== null}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
+467
View File
@@ -0,0 +1,467 @@
import { useEffect, useRef, useState } from 'react';
import { apiFetch } from '../api/apiService';
import { useAppSelector } from '../../app/hooks';
interface Props {
symbol: string;
lastPrice: number | null;
balance: {asset: string; free: number; used: number; total: number}[] | null;
isConnected: boolean;
}
// Format price: fewer decimals for very small prices to avoid overflow
function formatPrice(price: number): string {
if (price === 0) return '$0.00';
const abs = Math.abs(price);
// < 0.0001: show 8 decimals (scientific-style)
if (abs < 0.0001) return '$' + price.toFixed(8);
// < 0.01: show 6 decimals
if (abs < 0.01) return '$' + price.toFixed(6);
// < 1: show 4 decimals
if (abs < 1) return '$' + price.toFixed(4);
// normal: 2 decimals
return '$' + price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function OrderPanel({ symbol, lastPrice, balance, isConnected }: Props) {
const { user } = useAppSelector((state) => state.auth);
const defaultTradeSize = user?.preferences?.trade_size || 10;
const [currentTime, setCurrentTime] = useState(new Date());
const [amount, setAmount] = useState(String(defaultTradeSize));
const [orderLoading, setOrderLoading] = useState(false);
const [orderError, setOrderError] = useState<string | null>(null);
const [orderSuccess, setOrderSuccess] = useState<string | null>(null);
// P1-21: Confirmation state before placing trade
const [pendingOrder, setPendingOrder] = useState<{ side: 'buy' | 'sell'; amount: number } | null>(null);
// Parse symbol: "BTC/USDT" or "BTCUSDT" → base="BTC", quote="USDT"
const slashIdx = symbol.indexOf('/');
const baseToken = slashIdx >= 0 ? symbol.slice(0, slashIdx) : symbol.replace(/USDT|USD|BUSD|USDC$/, '');
const quoteToken = slashIdx >= 0 ? symbol.slice(slashIdx + 1) : 'USDT';
// Price change direction tracking
const prevPrice = useRef<number | null>(null);
const [priceDirection, setPriceDirection] = useState<'up' | 'down' | 'same' | null>(null);
useEffect(() => {
if (lastPrice !== null && prevPrice.current !== null && prevPrice.current !== lastPrice) {
if (lastPrice > prevPrice.current) {
setPriceDirection('up');
} else if (lastPrice < prevPrice.current) {
setPriceDirection('down');
}
} else if (lastPrice !== null && prevPrice.current === null) {
setPriceDirection(null);
}
if (lastPrice !== null) {
prevPrice.current = lastPrice;
}
}, [lastPrice]);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Auto-update clock every second
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
const placeOrder = async (side: 'buy' | 'sell') => {
const amt = parseFloat(amount);
if (!amount || isNaN(amt) || amt <= 0) {
setOrderError('Please enter a valid amount');
return;
}
// P1-21: Require confirmation before placing real trade
setPendingOrder({ side, amount: amt });
};
const confirmOrder = async () => {
if (!pendingOrder) return;
const { side, amount: amt } = pendingOrder;
setPendingOrder(null);
setOrderLoading(true);
setOrderError(null);
setOrderSuccess(null);
try {
const data = await apiFetch<any>('/orders/place', {
method: 'POST',
body: JSON.stringify({
symbol,
side,
order_type: 'market',
amount: amt,
}),
});
setOrderSuccess(`${side === 'buy' ? 'Buy' : 'Sell'} order placed! ID: ${data.id}`);
setAmount('');
} catch (err: any) {
const msg = err?.message || 'Failed to place order';
setOrderError(msg);
} finally {
setOrderLoading(false);
}
};
const btnCursor = isConnected ? 'pointer' : 'not-allowed';
const btnOpacity = isConnected ? 1 : 0.5;
return (
<div style={{ ...styles.panel, padding: isMobile ? '8px 12px' : '16px 20px' }}>
{/* Symbol info */}
<div style={styles.symbolSection}>
<h2 style={{ ...styles.symbolTitle, fontSize: isMobile ? 18 : 22 }}>{symbol}</h2>
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 8 }}>
Ordering <strong style={{ color: '#8b949e' }}>{baseToken}</strong> with <strong style={{ color: '#8b949e' }}>{quoteToken}</strong>
</div>
<div style={styles.priceRow}>
<span style={styles.priceLabel}>Last Price</span>
{lastPrice !== null ? (
<span style={{
...styles.priceValue,
fontSize: isMobile ? 15 : 18,
color: priceDirection === 'up' ? '#26a69a' : priceDirection === 'down' ? '#ef5350' : '#26a69a',
}}>
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
{formatPrice(lastPrice)}
</span>
) : (
<span style={{ ...styles.priceLoading, fontSize: isMobile ? 13 : 14 }}>Loading price...</span>
)}
</div>
</div>
{/* Time + Balance row (50/50) - stacks on mobile */}
<div style={{ ...styles.infoRow, flexDirection: isMobile ? 'column' : 'row' }}>
<div style={styles.infoBlock}>
<h3 style={styles.sectionTitle}>Time</h3>
<div style={{ ...styles.clock, fontSize: isMobile ? 14 : 15 }}>{currentTime.toLocaleTimeString()}</div>
<div style={styles.date}>
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
<div style={{ ...styles.infoDivider, width: isMobile ? 'auto' : 1, height: isMobile ? 1 : 'auto', margin: isMobile ? '6px 0' : 0 }} />
<div style={styles.infoBlock}>
<h3 style={styles.sectionTitle}>Account</h3>
<div style={{
...styles.balanceValue,
fontSize: isMobile ? 14 : 15,
color: (balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? '#3fb950' : '#8b949e',
}}>
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div style={styles.balanceLabel}>Available</div>
</div>
</div>
{/* Quick Order */}
<div style={styles.section}>
<h3 style={styles.sectionTitle}>Quick Order · {baseToken}/{quoteToken}</h3>
{/* Amount input */}
<div style={styles.inputRow}>
<input
type="number"
step="any"
min="0"
placeholder={`Amount in ${quoteToken}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ ...styles.amountInput, padding: isMobile ? '8px 10px' : '10px 12px', fontSize: isMobile ? 13 : 14 }}
disabled={!isConnected}
/>
<div style={{ fontSize: 11, color: '#484f58', marginTop: 4, fontStyle: 'italic' }}>
{quoteToken} amount · Buy = spend {quoteToken} · Sell = sell {baseToken}
</div>
</div>
{/* Buy / Sell buttons */}
<div style={styles.orderButtons}>
<button
style={{ ...styles.buyButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
disabled={!isConnected || orderLoading}
onClick={() => placeOrder('buy')}
>
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
</button>
<button
style={{ ...styles.sellButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
disabled={!isConnected || orderLoading}
onClick={() => placeOrder('sell')}
>
{orderLoading ? 'Placing...' : `Sell ${baseToken}`}
</button>
</div>
{/* Connection hint */}
{!isConnected && (
<p style={styles.placeholder}>Connect exchange API in Profile → API Keys to enable trading</p>
)}
{/* Success message */}
{orderSuccess && (
<div style={styles.successBanner}>
✅ {orderSuccess}
<button style={styles.dismissBtn} onClick={() => setOrderSuccess(null)}>✕</button>
</div>
)}
{/* Error message */}
{orderError && (
<div style={styles.errorBanner}>
❌ {orderError}
<button style={styles.dismissBtn} onClick={() => setOrderError(null)}>✕</button>
</div>
)}
{/* P1-21: Confirmation dialog before placing real trade */}
{pendingOrder && (
<div style={styles.confirmOverlay}>
<div style={styles.confirmDialog}>
<div style={{ fontSize: 14, color: '#f0f6fc', marginBottom: 8 }}>
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
</div>
<div style={{ fontSize: 12, color: '#8b949e', marginBottom: 12 }}>
{pendingOrder.amount} {quoteToken} {pendingOrder.side === 'buy' ? '→' : '←'} {symbol}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={styles.confirmBtn} onClick={confirmOrder}>
✅ {pendingOrder.side === 'buy' ? 'Buy' : 'Sell'}
</button>
<button style={styles.cancelBtn} onClick={() => setPendingOrder(null)}>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: 20,
},
symbolSection: {
paddingBottom: 16,
borderBottom: '1px solid #30363d',
},
symbolTitle: {
fontSize: 22,
fontWeight: 600,
color: '#f0f6fc',
margin: '0 0 8px',
},
priceRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
priceLabel: {
fontSize: 13,
color: '#8b949e',
},
priceValue: {
fontSize: 18,
fontWeight: 600,
color: '#26a69a',
},
priceLoading: {
fontSize: 13,
color: '#8b949e',
fontStyle: 'italic',
},
section: {
paddingBottom: 16,
borderBottom: '1px solid #30363d',
},
sectionTitle: {
fontSize: 12,
fontWeight: 500,
color: '#8b949e',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
margin: '0 0 8px',
},
infoRow: {
display: 'flex',
alignItems: 'stretch',
gap: 0,
background: '#161b22',
borderRadius: 8,
border: '1px solid #30363d',
overflow: 'hidden',
},
infoBlock: {
flex: 1,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column' as const,
justifyContent: 'center',
},
infoDivider: {
width: 1,
background: '#30363d',
flexShrink: 0,
},
clock: {
fontSize: 15,
fontWeight: 500,
color: '#c9d1d9',
fontFamily: 'monospace',
marginBottom: 4,
},
date: {
fontSize: 11,
color: '#8b949e',
},
balanceValue: {
fontSize: 15,
fontWeight: 600,
color: '#f0f6fc',
marginBottom: 2,
},
balanceLabel: {
fontSize: 12,
color: '#8b949e',
},
inputRow: {
marginBottom: 8,
},
amountInput: {
width: '100%',
padding: '10px 12px',
fontSize: 14,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
boxSizing: 'border-box' as const,
fontFamily: 'inherit',
},
orderButtons: {
display: 'flex',
gap: 8,
},
buyButton: {
flex: 1,
padding: '10px 0',
fontSize: 14,
fontWeight: 600,
color: '#fff',
background: '#26a69a',
border: 'none',
borderRadius: 6,
cursor: 'not-allowed',
opacity: 0.5,
},
sellButton: {
flex: 1,
padding: '10px 0',
fontSize: 14,
fontWeight: 600,
color: '#fff',
background: '#ef5350',
border: 'none',
borderRadius: 6,
cursor: 'not-allowed',
opacity: 0.5,
},
placeholder: {
fontSize: 11,
color: '#484f58',
textAlign: 'center',
margin: '6px 0 0',
fontStyle: 'italic',
},
successBanner: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 12,
color: '#26a69a',
background: '#0a2e29',
border: '1px solid #26a69a',
borderRadius: 6,
padding: '8px 10px',
marginTop: 8,
},
errorBanner: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 12,
color: '#f85149',
background: '#2d0e0e',
border: '1px solid #f85149',
borderRadius: 6,
padding: '8px 10px',
marginTop: 8,
},
dismissBtn: {
background: 'transparent',
border: 'none',
color: 'inherit',
cursor: 'pointer',
fontSize: 14,
lineHeight: 1,
padding: 0,
marginLeft: 8,
},
// P1-21: Order confirmation dialog styles
confirmOverlay: {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
confirmDialog: {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 12,
padding: 24,
maxWidth: 360,
width: '90%',
textAlign: 'center' as const,
},
confirmBtn: {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
fontSize: 13,
},
cancelBtn: {
background: '#21262d',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 6,
padding: '8px 16px',
cursor: 'pointer',
fontSize: 13,
},
};
+322
View File
@@ -0,0 +1,322 @@
import { useEffect, useState, useCallback } from 'react';
import { fetchSignals, fetchTrades, SIGNAL_CONFIG, formatPrice, formatPnL, formatPnLPercent } from '../api/signalApi';
import type { Signal, Trade } from '../api/signalApi';
interface Props {
symbol: string;
}
type Tab = 'signals' | 'trades';
export default function SignalPanel({ symbol }: Props) {
const [activeTab, setActiveTab] = useState<Tab>('signals');
const [signals, setSignals] = useState<Signal[]>([]);
const [trades, setTrades] = useState<Trade[]>([]);
const [loading, setLoading] = useState(false);
const [totalPnl, setTotalPnl] = useState<number | null>(null);
const [winRate, setWinRate] = useState<number | null>(null);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [sigRes, tradeRes] = await Promise.all([
fetchSignals(symbol, 20),
fetchTrades(symbol, undefined, 50),
]);
setSignals(sigRes.signals);
setTrades(tradeRes.trades);
setTotalPnl(tradeRes.total_pnl);
setWinRate(tradeRes.win_rate);
} catch (err) {
console.error('Failed to load signal data:', err);
} finally {
setLoading(false);
}
}, [symbol]);
useEffect(() => {
loadData();
// Auto-refresh every 60s
const interval = setInterval(loadData, 60000);
return () => clearInterval(interval);
}, [loadData]);
return (
<div style={styles.panel}>
{/* Header + Summary */}
<div style={styles.header}>
<h3 style={styles.title}>📊 Signals & Trades</h3>
<button style={styles.refreshBtn} onClick={loadData} disabled={loading}>
{loading ? '…' : '↻'}
</button>
</div>
{/* PnL Summary */}
{totalPnl !== null && (
<div style={styles.summary}>
<span style={styles.summaryLabel}>Total P&L</span>
<span style={{ ...styles.summaryValue, color: totalPnl >= 0 ? '#3fb950' : '#f85149' }}>
{formatPnL(totalPnl)}
</span>
{winRate !== null && (
<span style={styles.winRate}>
Win Rate: {winRate.toFixed(1)}%
</span>
)}
</div>
)}
{/* Tabs */}
<div style={styles.tabs}>
<button
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'signals' ? styles.tabActive : {}) }}
onClick={() => setActiveTab('signals')}
>
Signals ({signals.length})
</button>
<button
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'trades' ? styles.tabActive : {}) }}
onClick={() => setActiveTab('trades')}
>
Trades ({trades.length})
</button>
</div>
{/* Content */}
<div style={styles.content}>
{activeTab === 'signals' ? (
signals.length === 0 ? (
<div style={styles.empty}>No signals yet — waiting for candle data</div>
) : (
signals.map((sig) => {
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
return (
<div key={sig.id} style={styles.signalRow}>
<div style={styles.signalLeft}>
<span style={{ fontSize: 16 }}>{cfg.icon}</span>
<div>
<div style={{ ...styles.signalType, color: cfg.color }}>{cfg.label}</div>
<div style={styles.signalMeta}>
{sig.symbol} · {sig.timeframe} · {sig.strength}
</div>
</div>
</div>
<div style={styles.signalRight}>
<div style={styles.signalPrice}>${formatPrice(sig.price)}</div>
<div style={styles.signalTime}>
{new Date(sig.created_at).toLocaleTimeString()}
</div>
</div>
</div>
);
})
)
) : (
trades.length === 0 ? (
<div style={styles.empty}>No trades yet</div>
) : (
trades.map((trade) => (
<div key={trade.id} style={styles.tradeRow}>
<div style={styles.tradeHeader}>
<span style={{ ...styles.tradeDirection, color: trade.direction === 'LONG' ? '#3fb950' : '#f85149' }}>
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
</span>
<span style={styles.tradeSymbol}>{trade.symbol}</span>
<span style={{
...styles.tradeStatus,
color: trade.status === 'OPEN' ? '#58a6ff' : (trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149'),
}}>
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
</span>
</div>
<div style={styles.tradeDetails}>
<span>Entry: ${formatPrice(trade.entry_price)}</span>
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
{trade.pnl_percent !== null && (
<span style={{ color: trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149', fontWeight: 600 }}>
{formatPnLPercent(trade.pnl_percent)}
</span>
)}
</div>
<div style={styles.tradeReason}>
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
</div>
</div>
))
)
)}
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px 8px',
},
title: {
fontSize: 14,
fontWeight: 600,
color: '#f0f6fc',
margin: 0,
},
refreshBtn: {
width: 36,
height: 36,
border: '1px solid #30363d',
borderRadius: 6,
background: '#161b22',
color: '#8b949e',
cursor: 'pointer',
fontSize: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'manipulation',
minWidth: 36,
minHeight: 36,
},
summary: {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 16px 8px',
borderBottom: '1px solid #21262d',
flexWrap: 'wrap' as const,
},
summaryLabel: {
fontSize: 12,
color: '#8b949e',
},
summaryValue: {
fontSize: 15,
fontWeight: 700,
fontFamily: "'SF Mono', monospace",
},
winRate: {
fontSize: 12,
color: '#8b949e',
marginLeft: 'auto' as const,
},
tabs: {
display: 'flex',
borderBottom: '1px solid #21262d',
},
tab: {
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 500,
color: '#8b949e',
background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s ease',
minHeight: 36,
touchAction: 'manipulation',
},
tabActive: {
color: '#58a6ff',
borderBottomColor: '#58a6ff',
},
content: {
flex: 1,
overflowY: 'auto' as const,
padding: '4px 0',
},
empty: {
padding: '24px 16px',
textAlign: 'center' as const,
color: '#484f58',
fontSize: 13,
},
signalRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid #21262d',
cursor: 'pointer',
transition: 'background 0.15s ease',
},
signalLeft: {
display: 'flex',
alignItems: 'center',
gap: 8,
},
signalType: {
fontSize: 13,
fontWeight: 600,
},
signalMeta: {
fontSize: 11,
color: '#6e7681',
marginTop: 1,
},
signalRight: {
textAlign: 'right' as const,
},
signalPrice: {
fontSize: 13,
fontWeight: 500,
color: '#c9d1d9',
fontFamily: "'SF Mono', monospace",
},
signalTime: {
fontSize: 11,
color: '#484f58',
},
tradeRow: {
padding: '8px 12px',
borderBottom: '1px solid #21262d',
},
tradeHeader: {
display: 'flex',
alignItems: 'center',
gap: 8,
},
tradeDirection: {
fontSize: 12,
fontWeight: 700,
},
tradeSymbol: {
fontSize: 12,
color: '#c9d1d9',
},
tradeStatus: {
fontSize: 11,
fontWeight: 600,
marginLeft: 'auto' as const,
},
tradeDetails: {
fontSize: 11,
color: '#8b949e',
marginTop: 4,
fontFamily: "'SF Mono', monospace",
display: 'flex',
gap: 4,
flexWrap: 'wrap' as const,
},
tradeReason: {
fontSize: 10,
color: '#484f58',
marginTop: 2,
},
};
+363
View File
@@ -0,0 +1,363 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { fetchWatchlist, addToWatchlist, removeFromWatchlist, fetchAllSymbols } from '../api/watchlistApi';
import type { WatchlistItem, SymbolOption } from '../api/watchlistApi';
import { fetchSignals, SIGNAL_CONFIG } from '../api/signalApi';
import type { Signal } from '../api/signalApi';
interface Props {
currentSymbol: string;
onSymbolChange: (symbol: string) => void;
/** Currently selected exchange for adding new symbols */
exchange: string;
}
export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange }: Props) {
const [items, setItems] = useState<WatchlistItem[]>([]);
const [loading, setLoading] = useState(true);
const [signals, setSignals] = useState<Signal[]>([]);
// Responsive state
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Add symbol state
const [showAdd, setShowAdd] = useState(false);
const [search, setSearch] = useState('');
const [symbols, setSymbols] = useState<SymbolOption[]>([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const searchRef = useRef<HTMLInputElement>(null);
const loadWatchlist = useCallback(async () => {
try {
const data = await fetchWatchlist();
setItems(data);
} catch (err) {
console.error('Failed to load watchlist:', err);
} finally {
setLoading(false);
}
}, []);
const loadSignals = useCallback(async () => {
try {
const sigData = await fetchSignals(undefined, 50);
setSignals(sigData.signals);
} catch {
// ignore
}
}, []);
useEffect(() => {
loadWatchlist();
loadSignals();
const interval = setInterval(() => {
loadSignals();
}, 60000);
return () => clearInterval(interval);
}, [loadWatchlist, loadSignals]);
// Search symbols when add panel opens
useEffect(() => {
if (showAdd) {
searchSymbols('');
setTimeout(() => searchRef.current?.focus(), 100);
}
}, [showAdd]);
async function searchSymbols(q: string) {
setSearching(true);
try {
const data = await fetchAllSymbols(exchange, q);
setSymbols(data);
} catch (err) {
console.error('Search error:', err);
} finally {
setSearching(false);
}
}
async function handleAdd(sym: SymbolOption) {
try {
setError('');
await addToWatchlist(sym.id);
await loadWatchlist();
setShowAdd(false);
setSearch('');
} catch (err: any) {
setError(err.message || 'Failed to add symbol');
}
}
async function handleRemove(id: string) {
try {
await removeFromWatchlist(id);
setItems((prev) => prev.filter((i) => i.id !== id));
} catch (err) {
console.error('Remove error:', err);
}
}
function getSignalForSymbol(sym: string): Signal | undefined {
return signals.find((s) => s.symbol === sym && s.status === 'ACTIVE');
}
// Filter out symbols already in watchlist
const watchlistSymbolIds = new Set(items.map((i) => i.symbol_id));
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
return (
<div style={styles.panel}>
<style>{`
.wl-remove-btn:hover { opacity: 1 !important; color: #f85149 !important; background: rgba(248,81,73,0.1) !important; }
.wl-item:hover .wl-remove-btn { opacity: 0.65 !important; }
`}</style>
{/* Header */}
<div style={styles.header}>
<h3 style={styles.title}>👁️ Watchlist</h3>
<button style={styles.addBtn} onClick={() => setShowAdd(!showAdd)}>
{showAdd ? '✕' : '+'}
</button>
</div>
{/* Add Symbol Panel */}
{showAdd && (
<div style={styles.addPanel}>
<input
ref={searchRef}
style={styles.searchInput}
placeholder="Search symbols..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
searchSymbols(e.target.value);
}}
/>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.symbolList}>
{searching ? (
<div style={styles.loadingText}>Searching...</div>
) : availableSymbols.length === 0 ? (
<div style={styles.emptyText}>No symbols found</div>
) : (
availableSymbols.slice(0, 30).map((sym) => (
<button
key={sym.id}
style={styles.symbolOption}
onClick={() => handleAdd(sym)}
>
<span style={styles.symName}>{sym.symbol}</span>
<span style={styles.symExchange}>{exchange}</span>
</button>
))
)}
</div>
</div>
)}
{/* Watchlist Items */}
<div style={styles.list}>
{loading ? (
<div style={styles.loadingText}>Loading...</div>
) : items.length === 0 ? (
<div style={styles.emptyText}>
No symbols watched yet. Click + to add.
</div>
) : (
items.map((item) => {
const sig = getSignalForSymbol(item.symbol);
const cfg = sig ? SIGNAL_CONFIG[sig.signal_type] : null;
const isActive = item.symbol === currentSymbol;
return (
<div
key={item.id}
className="wl-item"
style={{
...styles.item,
...(isActive ? styles.itemActive : {}),
padding: isMobile ? '5px 10px' : '7px 14px',
}}
onClick={() => onSymbolChange(item.symbol)}
>
{/* Symbol info */}
<div style={styles.itemLeft}>
<div style={{ ...styles.itemSymbol, fontSize: isMobile ? 12 : 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{item.symbol}</div>
{item.label && <div style={styles.itemLabel}>{item.label}</div>}
</div>
{/* Signal indicator */}
<div style={styles.itemRight}>
{cfg && (
<span style={{ fontSize: 14, marginRight: 4 }} title={cfg.label}>
{cfg.icon}
</span>
)}
<button
className="wl-remove-btn"
style={styles.removeBtn}
onClick={(e) => {
e.stopPropagation();
handleRemove(item.id);
}}
title="Remove from watchlist"
>
✕
</button>
</div>
</div>
);
})
)}
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
panel: {
display: 'flex',
flexDirection: 'column',
borderBottom: '1px solid #21262d',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px 6px',
},
title: {
fontSize: 13,
fontWeight: 600,
color: '#f0f6fc',
margin: 0,
},
addBtn: {
width: 26,
height: 26,
border: '1px solid #30363d',
borderRadius: 6,
background: '#161b22',
color: '#58a6ff',
cursor: 'pointer',
fontSize: 16,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
},
addPanel: {
padding: '6px 14px 10px',
borderBottom: '1px solid #21262d',
},
searchInput: {
width: '100%',
padding: '6px 10px',
fontSize: 12,
color: '#c9d1d9',
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
outline: 'none',
marginBottom: 6,
},
error: {
fontSize: 11,
color: '#f85149',
marginBottom: 4,
},
symbolList: {
maxHeight: 180,
overflowY: 'auto' as const,
},
symbolOption: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '5px 8px',
border: 'none',
background: 'transparent',
color: '#c9d1d9',
fontSize: 12,
cursor: 'pointer',
borderRadius: 4,
textAlign: 'left' as const,
},
symName: {
fontWeight: 500,
},
symExchange: {
fontSize: 10,
color: '#484f58',
},
list: {
maxHeight: 300,
overflowY: 'auto' as const,
},
loadingText: {
padding: '12px 14px',
fontSize: 12,
color: '#484f58',
textAlign: 'center' as const,
},
emptyText: {
padding: '12px 14px',
fontSize: 12,
color: '#484f58',
textAlign: 'center' as const,
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '7px 14px',
cursor: 'pointer',
borderLeft: '3px solid transparent',
transition: 'all 0.15s ease',
},
itemActive: {
borderLeftColor: '#58a6ff',
background: 'rgba(88, 166, 255, 0.08)',
},
itemLeft: {
display: 'flex',
flexDirection: 'column' as const,
gap: 1,
minWidth: 0,
},
itemSymbol: {
fontSize: 13,
fontWeight: 500,
color: '#c9d1d9',
},
itemLabel: {
fontSize: 10,
color: '#6e7681',
},
itemRight: {
display: 'flex',
alignItems: 'center',
gap: 4,
},
removeBtn: {
width: 20,
height: 20,
border: 'none',
background: 'transparent',
color: '#484f58',
cursor: 'pointer',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
opacity: 0.4,
transition: 'opacity 0.15s ease, color 0.15s ease, background 0.15s ease',
zIndex: 5,
},
};
+716
View File
@@ -0,0 +1,716 @@
import { useState, useEffect, useCallback } from 'react';
import { useAppSelector } from '../../app/hooks';
import { apiFetch } from '../api/apiService';
import { useT } from '../../translations';
// ── Shared ──
// Thin wrapper that strips the /api/v1 prefix and delegates to the canonical apiFetch
// which handles token refresh, consistent error formatting, and proper BASE_URL prefix.
function authFetch<T = any>(url: string, options: RequestInit = {}): Promise<T> {
const apiUrl = url.replace(/^\/api\/v1/, '');
return apiFetch<T>(apiUrl, options);
}
const btnStyle: React.CSSProperties = { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 };
const inputStyle: React.CSSProperties = { padding: '6px 10px', borderRadius: 6, border: '1px solid #30363d', background: '#0d1117', color: '#c9d1d9', fontSize: 13 };
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#8b949e', marginBottom: 4 };
const cardStyle: React.CSSProperties = { background: '#161b22', borderRadius: 8, padding: 14, border: '1px solid #21262d' };
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TFS_BT = ['15m', '30m', '1h', '4h'];
const DAYS = [3, 7, 14, 30, 90, 180, 365];
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
const LANGUAGE_OPTIONS = [
{ code: 'vi', label: '🇻🇳 Tiếng Việt' },
{ code: 'en', label: '🇺🇸 English' },
{ code: 'zh', label: '🇨🇳 中文' },
{ code: 'ko', label: '🇰🇷 한국어' },
];
function parseUA(ua: string | null): { browser: string; os: string; device: 'desktop' | 'mobile' | 'unknown' } {
if (!ua) return { browser: 'Unknown', os: 'Unknown', device: 'unknown' };
const lower = ua.toLowerCase();
let browser = 'Unknown';
if (lower.includes('edg/')) browser = 'Edge';
else if (lower.includes('chrome/')) browser = 'Chrome';
else if (lower.includes('firefox/')) browser = 'Firefox';
else if (lower.includes('safari/') && !lower.includes('chrome/')) browser = 'Safari';
else if (lower.includes('opr/')) browser = 'Opera';
let os = 'Unknown';
if (lower.includes('windows nt 10')) os = 'Windows 10';
else if (lower.includes('windows nt 11')) os = 'Windows 11';
else if (lower.includes('mac os x')) os = 'macOS';
else if (lower.includes('android')) os = 'Android';
else if (lower.includes('iphone') || lower.includes('ipad')) os = 'iOS';
else if (lower.includes('linux')) os = 'Linux';
const device = lower.includes('mobile') || lower.includes('iphone') || lower.includes('android') ? 'mobile' : 'desktop';
return { browser, os, device };
}
const deviceIcon: Record<string, string> = { desktop: '🖥️', mobile: '📱', unknown: '🌐' };
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return (
<div onClick={() => onChange(!value)} style={{ width: 40, height: 22, borderRadius: 11, cursor: 'pointer', position: 'relative', background: value ? '#238636' : '#21262d', transition: 'background 0.2s' }}>
<div style={{ width: 18, height: 18, borderRadius: '50%', background: '#fff', position: 'absolute', top: 2, left: value ? 20 : 2, transition: 'all 0.2s' }} />
</div>
);
}
// ═══════════════ INFO TAB ═══════════════
function InfoTab() {
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({ display_name: '', email: '' });
const [pwModal, setPwModal] = useState(false);
const [pwForm, setPwForm] = useState({ old_password: '', new_password: '', confirm: '' });
const [pwMsg, setPwMsg] = useState('');
const load = useCallback(async () => {
setLoading(true);
try { const u = await authFetch('/api/v1/auth/me'); setProfile(u); setForm({ display_name: u.display_name || '', email: u.email || '' }); } catch { setProfile(null); }
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => { setMsg(''); try { const u = await authFetch('/api/v1/auth/me', { method: 'PUT', body: JSON.stringify(form) }); setProfile(u); setEditing(false); setMsg('✅ Saved'); } catch (e: any) { setMsg('❌ ' + e.message); } };
const changePw = async () => {
setPwMsg(''); if (pwForm.new_password !== pwForm.confirm) { setPwMsg('❌ Passwords do not match'); return; }
try { await authFetch('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password: pwForm.old_password, new_password: pwForm.new_password }) }); setPwMsg('✅ Password changed'); setPwModal(false); setPwForm({ old_password: '', new_password: '', confirm: '' }); } catch (e: any) { setPwMsg('❌ ' + e.message); }
};
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (!profile) return <div style={{ color: '#ef4444' }}>❌ Không tải được profile</div>;
return (
<div>
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('✅') ? '#22c55e' : '#ef4444' }}>{msg}</div>}
<div style={{ display: 'grid', gap: 10, maxWidth: 400 }}>
<div><div style={labelStyle}>Username</div><input disabled value={profile.username} style={{ ...inputStyle, width: '100%', opacity: 0.6 }} /></div>
{editing ? (<>
<div><div style={labelStyle}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={save} style={btnStyle}>💾 Save</button><button onClick={() => setEditing(false)} style={btnStyle}>Cancel</button></div>
</>) : (<>
<div><div style={labelStyle}>Display Name</div><div style={{ color: '#c9d1d9' }}>{profile.display_name || '-'}</div></div>
<div><div style={labelStyle}>Email</div><div style={{ color: '#c9d1d9' }}>{profile.email}</div></div>
<div><div style={labelStyle}>Role</div><div style={{ color: '#c9d1d9' }}>{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
<div><div style={labelStyle}>Member since</div><div style={{ color: '#c9d1d9' }}>{profile.created_at?.slice(0, 10)}</div></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={() => setEditing(true)} style={btnStyle}>✏️ Edit</button><button onClick={() => setPwModal(true)} style={{ ...btnStyle, color: '#d29922', borderColor: '#d29922' }}>🔑 Change Password</button></div>
</>)}
</div>
{pwModal && (
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#161b22', borderRadius: 12, padding: 24, border: '1px solid #30363d', maxWidth: 360, width: '90%' }}>
<h3 style={{ color: '#f0f6fc', margin: '0 0 16px' }}>🔑 Change Password</h3>
{pwMsg && <div style={{ marginBottom: 10, color: pwMsg.startsWith('✅') ? '#22c55e' : '#ef4444', fontSize: 12 }}>{pwMsg}</div>}
<div style={{ display: 'grid', gap: 10 }}>
<div><div style={labelStyle}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div><div style={labelStyle}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
<div style={{ display: 'flex', gap: 8 }}><button onClick={changePw} style={{ ...btnStyle, background: '#d29922', color: '#000', borderColor: '#d29922' }}>Change Password</button><button onClick={() => setPwModal(false)} style={btnStyle}>Cancel</button></div>
</div>
</div>
</div>
)}
</div>
);
}
// ═══════════════ KEYS TAB ═══════════════
function KeysTab() {
const [keys, setKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ exchange_name: 'binance', api_key: '', api_secret: '', passphrase: '' });
const [msg, setMsg] = useState('');
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({});
const [expandedId, setExpandedId] = useState<string | null>(null);
const [keyDetails, setKeyDetails] = useState<Record<string, { balances: any[]; usdt_balance: number; orders: any[]; positions: any[]; loading: boolean; error: string }>>({});
const load = useCallback(async () => { setLoading(true); try { setKeys(await authFetch('/api/v1/credentials')); } catch {} setLoading(false); }, []);
useEffect(() => { load(); }, [load]);
const addKey = async () => { setMsg(''); try { await authFetch('/api/v1/credentials', { method: 'POST', body: JSON.stringify(form) }); setShowAdd(false); setForm({ exchange_name: 'binance', api_key: '', api_secret: '', passphrase: '' }); load(); } catch (e: any) { setMsg('❌ ' + e.message); } };
const deleteKey = async (id: string) => { try { await authFetch(`/api/v1/credentials/${id}`, { method: 'DELETE' }); load(); } catch {} };
const testKey = async (id: string) => {
try { const r = await authFetch<{ success: boolean; message: string }>(`/api/v1/credentials/${id}/test`, { method: 'POST' }); setTestResults(prev => ({ ...prev, [id]: r })); setTimeout(() => setTestResults(prev => { const n = { ...prev }; delete n[id]; return n; }), 8000); } catch (e: any) { setTestResults(prev => ({ ...prev, [id]: { success: false, message: e.message } })); }
};
const toggleExpand = async (id: string) => {
if (expandedId === id) { setExpandedId(null); return; }
setExpandedId(id);
if (keyDetails[id]?.balances) return;
setKeyDetails(prev => ({ ...prev, [id]: { balances: [], usdt_balance: 0, orders: [], positions: [], loading: true, error: '' } }));
try {
const [balData, ordData] = await Promise.all([
authFetch<any>(`/api/v1/credentials/${id}/balance`).catch(() => null),
authFetch<any>(`/api/v1/credentials/${id}/orders`).catch(() => null),
]);
setKeyDetails(prev => ({
...prev,
[id]: {
balances: balData?.balances || [],
usdt_balance: balData?.usdt_balance || 0,
orders: ordData?.orders || [],
positions: [],
loading: false,
error: '',
},
}));
} catch (e: any) {
setKeyDetails(prev => ({ ...prev, [id]: { ...prev[id], loading: false, error: e.message } }));
}
};
const activeExchanges = keys.filter(k => k.is_active).map(k => k.exchange_name).filter((v, i, a) => a.indexOf(v) === i);
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
return (
<div>
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('❌') ? '#ef4444' : '#22c55e', fontSize: 12 }}>{msg}</div>}
<div style={{ marginBottom: 12, color: activeExchanges.length > 0 ? '#3fb950' : '#8b949e', fontWeight: 600 }}>
{activeExchanges.length > 0 ? `🔗 Connected to ${activeExchanges.map(e => e?.toUpperCase()).join(', ')}` : '🔌 No exchanges connected'}
</div>
<div style={{ marginBottom: 12 }}>
<button onClick={() => setShowAdd(!showAdd)} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
</div>
{showAdd && (
<div style={{ ...cardStyle, marginBottom: 16, maxWidth: 400 }}>
<div style={{ display: 'grid', gap: 8 }}>
<div><div style={labelStyle}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div style={labelStyle}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API key" /></div>
<div><div style={labelStyle}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API secret" /></div>
<div><div style={labelStyle}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Exchange passphrase" /></div>
<button onClick={addKey} style={{ ...btnStyle, background: '#22c55e', color: '#000', borderColor: '#22c55e' }}>💾 Save Key</button>
</div>
</div>
)}
{keys.length === 0 ? (
<div style={{ color: '#8b949e' }}>Chưa có API key nào. Thêm key để bot có thể trade.</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{keys.map((k: any) => (
<div key={k.id} style={{ ...cardStyle }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleExpand(k.id)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#8b949e', fontSize: 10, transition: 'transform 0.2s', transform: expandedId === k.id ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
<span style={{ color: '#f0f6fc', fontWeight: 600 }}>{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
</div>
<div style={{ color: '#8b949e', fontSize: 12, marginLeft: 16 }}>Key: {k.api_key}</div>
<div style={{ fontSize: 11, color: k.is_active ? '#3fb950' : '#484f58', marginTop: 2, marginLeft: 16 }}>
{k.is_active ? '● Connected' : '● Disabled'}
</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => testKey(k.id)} style={{ ...btnStyle, padding: '4px 10px', fontSize: 11 }}>🔍 Test</button>
<button onClick={() => deleteKey(k.id)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', padding: '4px 10px', fontSize: 11 }}>🗑</button>
</div>
</div>
{testResults[k.id] && (
<div style={{ marginTop: 6, fontSize: 11, color: testResults[k.id].success ? '#3fb950' : '#ef4444' }}>
{testResults[k.id].success ? '✅' : '❌'} {testResults[k.id].message}
</div>
)}
{/* Expandable details: token balances + open orders */}
{expandedId === k.id && (
<div style={{ marginTop: 12, padding: '10px 14px', borderTop: '1px solid #21262d', background: '#0d1117', borderRadius: '0 0 8px 8px' }}>
{keyDetails[k.id]?.loading ? (
<div style={{ color: '#8b949e', fontSize: 12 }}>⏳ Loading balances & orders...</div>
) : keyDetails[k.id]?.error ? (
<div style={{ color: '#f85149', fontSize: 12 }}>❌ {keyDetails[k.id].error}</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{/* Balances */}
<div>
<div style={{ color: '#58a6ff', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
💰 Balances — ≈ ${keyDetails[k.id]?.usdt_balance?.toFixed(2) || '0.00'} USDT
</div>
{keyDetails[k.id]?.balances?.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).slice(0, 30).map((b: any) => (
<span key={b.asset} style={{ padding: '3px 8px', background: '#21262d', borderRadius: 6, fontSize: 11, color: '#c9d1d9', border: '1px solid #30363d', whiteSpace: 'nowrap' }}>
<span style={{ color: '#58a6ff' }}>{b.asset}</span>{' '}
<span style={{ color: '#8b949e' }}>{Number(b.total).toFixed(4)}</span>
</span>
))}
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).length === 0 && (
<span style={{ color: '#484f58', fontSize: 11 }}>No non-zero balances</span>
)}
</div>
) : (
<span style={{ color: '#484f58', fontSize: 11 }}>No balance data available</span>
)}
</div>
{/* Open Orders */}
<div>
<div style={{ color: '#d29922', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
📋 Open Orders — {keyDetails[k.id]?.orders?.length || 0}
</div>
{keyDetails[k.id]?.orders?.length > 0 ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}>
<th style={{ textAlign: 'left', padding: 2 }}>Symbol</th>
<th style={{ textAlign: 'left', padding: 2 }}>Side</th>
<th style={{ textAlign: 'left', padding: 2 }}>Type</th>
<th style={{ textAlign: 'right', padding: 2 }}>Amount</th>
<th style={{ textAlign: 'right', padding: 2 }}>Price</th>
<th style={{ textAlign: 'left', padding: 2 }}>Status</th>
</tr></thead>
<tbody>
{keyDetails[k.id].orders.map((o: any, idx: number) => (
<tr key={o.order_id || idx} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 2, color: '#c9d1d9' }}>{o.symbol}</td>
<td style={{ padding: 2, color: o.side === 'buy' ? '#3fb950' : '#f85149' }}>{o.side}</td>
<td style={{ padding: 2, color: '#8b949e' }}>{o.order_type}</td>
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.amount}</td>
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
<td style={{ padding: 2, color: o.status === 'open' ? '#d29922' : '#8b949e' }}>{o.status}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<span style={{ color: '#484f58', fontSize: 11 }}>No open orders</span>
)}
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ═══════════════ SESSIONS TAB ═══════════════
function SessionsTab() {
const [sessions, setSessions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => { setLoading(true); try { setSessions(await authFetch('/api/v1/auth/sessions')); } catch {} setLoading(false); }, []);
useEffect(() => { load(); }, [load]);
const revoke = async (hash: string) => { try { await authFetch(`/api/v1/auth/sessions/${hash}`, { method: 'DELETE' }); load(); } catch {} };
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
if (sessions.length === 0) return <div style={{ color: '#8b949e', padding: 40, textAlign: 'center' }}>🔓 Không có session nào.</div>;
return (
<div>
<div style={{ color: '#8b949e', fontSize: 12, marginBottom: 12 }}>🛡️ Active Sessions — {sessions.length}</div>
<div style={{ display: 'grid', gap: 10 }}>
{sessions.map((s: any) => {
const ua = parseUA(s.user_agent);
const icon = deviceIcon[ua.device];
const isCurrent = s.is_current;
return (
<div key={s.token_hash} style={{ ...cardStyle, border: isCurrent ? '1px solid #1f6feb44' : '1px solid #21262d', background: isCurrent ? 'linear-gradient(135deg, #161b22 0%, #0d1a28 100%)' : '#161b22', position: 'relative' }}>
{isCurrent && <span style={{ position: 'absolute', top: 8, right: 10, fontSize: 10, fontWeight: 700, color: '#1f6feb', background: '#1f6feb15', padding: '2px 8px', borderRadius: 4 }}>CURRENT</span>}
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, background: isCurrent ? '#1f6feb20' : '#21262d' }}>{icon}</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>{ua.browser} · {ua.os}{isCurrent && <span style={{ color: '#1f6feb', fontSize: 11, marginLeft: 8 }}>● Active now</span>}</div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
</div>
{!isCurrent && <button onClick={() => revoke(s.token_hash)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', background: '#ef444410', padding: '4px 12px', fontSize: 11 }}>🚫 Revoke</button>}
</div>
</div>
);
})}
</div>
</div>
);
}
// ═══════════════ SETTINGS TAB ═══════════════
function SettingsTab() {
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState('');
const [prefExchange, setPrefExchange] = useState('mexc');
const [prefTimeframe, setPrefTimeframe] = useState('1h');
const [prefTheme, setPrefTheme] = useState('dark');
const [prefUpColor, setPrefUpColor] = useState('#3fb950');
const [prefDownColor, setPrefDownColor] = useState('#f85149');
const [prefNotifSignal, setPrefNotifSignal] = useState(true);
const [prefNotifTrade, setPrefNotifTrade] = useState(false);
const [prefLanguage, setPrefLanguage] = useState('vi');
const [prefTradeSize, setPrefTradeSize] = useState('10');
const [prefAutoTrade, setPrefAutoTrade] = useState(false);
const [prefAutoTradeTokens, setPrefAutoTradeTokens] = useState<string[]>([]);
const [tokenSearch, setTokenSearch] = useState('');
const [allSymbols, setAllSymbols] = useState<{ symbol: string; base: string }[]>([]);
const [strategyList, setStrategyList] = useState<{ name: string; display_name: string; enabled: boolean }[]>([]);
const [enabledStrategies, setEnabledStrategies] = useState<string[]>([]);
const load = useCallback(async () => {
setLoading(true);
try {
const u = await authFetch('/api/v1/auth/me');
if (u.preferences) {
setPrefExchange(u.preferences.default_exchange || 'mexc');
setPrefTimeframe(u.preferences.default_timeframe || '1h');
setPrefTheme(u.preferences.theme || 'dark');
setPrefUpColor(u.preferences.up_color || '#3fb950');
setPrefDownColor(u.preferences.down_color || '#f85149');
setPrefNotifSignal(u.preferences.notif_signal !== false);
setPrefNotifTrade(u.preferences.notif_trade === true);
setPrefLanguage(u.preferences.language || 'vi');
setPrefTradeSize(String(u.preferences.trade_size ?? 10));
setPrefAutoTrade(u.preferences.auto_trade === true);
setPrefAutoTradeTokens(u.preferences.auto_trade_tokens || []);
}
} catch {}
setLoading(false);
}, []);
useEffect(() => { load(); authFetch<{ symbol: string; base: string }[]>('/watchlist/all-symbols').then(d => setAllSymbols(d || [])).catch(() => {}); }, []);
useEffect(() => { authFetch<{ strategies: { name: string; display_name: string; enabled: boolean }[] }>('/api/v1/strategies').then(d => { setStrategyList(d.strategies || []); setEnabledStrategies((d.strategies || []).filter(s => s.enabled).map(s => s.name)); }).catch(() => {}); }, []);
const save = async () => {
setMsg('');
try {
await authFetch('/api/v1/auth/me', { method: 'PUT', body: JSON.stringify({ preferences: { default_exchange: prefExchange, default_timeframe: prefTimeframe, theme: prefTheme, up_color: prefUpColor, down_color: prefDownColor, notif_signal: prefNotifSignal, notif_trade: prefNotifTrade, language: prefLanguage, trade_size: Number(prefTradeSize) || 10, auto_trade: prefAutoTrade, auto_trade_tokens: prefAutoTradeTokens } }) });
await authFetch('/api/v1/strategies', { method: 'PUT', body: JSON.stringify({ enabled_strategies: enabledStrategies }) });
setMsg('✅ Settings saved ✓');
setTimeout(() => setMsg(''), 3000);
} catch (e: any) { setMsg('❌ ' + e.message); }
};
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
const filteredSymbols = allSymbols.filter(s => !tokenSearch || s.base.toLowerCase().includes(tokenSearch.toLowerCase()));
return (
<div>
{msg && <div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 9999, padding: '20px 36px', borderRadius: 12, fontSize: 15, fontWeight: 600, color: msg.startsWith('✅') ? '#3fb950' : '#f85149', background: msg.startsWith('✅') ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', border: `1px solid ${msg.startsWith('✅') ? '#3fb95044' : '#f8514944'}`, backdropFilter: 'blur(8px)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', textAlign: 'center' }}>{msg}</div>}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button onClick={save} style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', fontWeight: 600 }}>💾 Save Settings</button>
</div>
{/* Row 1: Exchange + Timeframe */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
</div>
{/* Row 2: Theme + Language */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎨 Theme</div><div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setPrefTheme('dark')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'dark' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'dark' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'dark' ? 700 : 400 }}>🌙 Dark</button>
<button onClick={() => setPrefTheme('light')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'light' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'light' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'light' ? 700 : 400 }}>☀️ Light</button>
</div></div>
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
</div>
{/* Chart Colors */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>📊 Chart Colors</div>
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefUpColor }} /><input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🟢 Up</span></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefDownColor }} /><input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🔴 Down</span></div>
</div>
</div>
{/* Notifications */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🔔 Notifications</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
</div>
</div>
{/* Strategy Toggles */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8 }}>📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{strategyList.map(s => (
<label key={s.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '6px 10px', borderRadius: 6, background: enabledStrategies.includes(s.name) ? '#1f6feb10' : '#0d1117', border: `1px solid ${enabledStrategies.includes(s.name) ? '#1f6feb44' : '#21262d'}`, transition: 'all 0.2s' }}>
<span style={{ color: enabledStrategies.includes(s.name) ? '#c9d1d9' : '#484f58', fontSize: 12 }}>{s.display_name}</span>
<ToggleSwitch
value={enabledStrategies.includes(s.name)}
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
/>
</label>
))}
</div>
</div>
{/* Trade Size */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>💰 Trade Size (USDT)</div>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8, fontStyle: 'italic' }}>Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} style={{ ...inputStyle, width: 80 }} /><span style={{ color: '#8b949e', fontSize: 13 }}>USDT</span></div>
</div>
{/* Auto Trade */}
<div style={{ ...cardStyle, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>🤖 Auto Trade <span style={{ color: '#8b949e', fontSize: 10, background: '#21262d', padding: '1px 6px', borderRadius: 4 }}>SPOT</span></div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>Bot tự động trade tín hiệu cho các token đã chọn</div>
</div>
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
</div>
{prefAutoTrade && <div style={{ marginTop: 8, color: '#d29922', fontSize: 11 }}>⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
{/* Token Summary */}
{prefAutoTradeTokens.length > 0 && (
<div style={{ marginTop: 12, borderLeft: `3px solid ${prefAutoTrade ? '#238636' : '#30363d'}`, padding: '8px 12px', borderRadius: 4, background: '#0d1117', opacity: prefAutoTrade ? 1 : 0.4, transition: 'all 0.3s' }}>
<div style={{ color: prefAutoTrade ? '#3fb950' : '#8b949e', fontSize: 12, fontWeight: 600 }}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>
{prefAutoTrade ? `${prefAutoTradeTokens.length} cặp — click ✕ để bỏ theo dõi` : `Auto trade đang tắt — ${prefAutoTradeTokens.length} cặp đã lưu`}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
{prefAutoTradeTokens.map(token => (
<span key={token} style={{ padding: '3px 8px 3px 10px', background: '#23863622', color: '#3fb950', border: '1px solid #23863644', borderRadius: 12, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
{token}
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} style={{ cursor: 'pointer', color: '#58a6ff', opacity: 0.6 }}>✕</span>}
</span>
))}
</div>
</div>
)}
{/* Token Selector */}
<div style={{ marginTop: 12 }}>
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎯 Chọn token cho Auto Trade</div>
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} style={{ ...inputStyle, width: '100%', marginBottom: 8 }} />
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{filteredSymbols.slice(0, 100).map(s => (
<button key={s.symbol} onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6, border: `1px solid ${prefAutoTradeTokens.includes(s.symbol) ? '#238636' : '#30363d'}`, background: prefAutoTradeTokens.includes(s.symbol) ? '#23863622' : '#21262d', color: prefAutoTradeTokens.includes(s.symbol) ? '#3fb950' : '#8b949e', cursor: 'pointer' }}>
{s.base} {prefAutoTradeTokens.includes(s.symbol) && '✓'}
</button>
))}
</div>
</div>
</div>
</div>
);
}
// ═══════════════ HISTORY TAB ═══════════════
function HistoryTab() {
const [subTab, setSubTab] = useState<'signals' | 'real'>('signals');
const [signalTrades, setSignalTrades] = useState<any[]>([]);
const [signalWinRate, setSignalWinRate] = useState<number | null>(null);
const [signalTotalPnl, setSignalTotalPnl] = useState<number | null>(null);
const [realTrades, setRealTrades] = useState<any[]>([]);
const [winRate, setWinRate] = useState<any>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
// Signal trades
const st = await authFetch<any>('/api/v1/signals/trades?limit=100');
setSignalTrades(st.trades || []);
setSignalWinRate(st.win_rate ?? null);
setSignalTotalPnl(st.total_pnl ?? null);
} catch {}
try {
// Real trades
const rt = await authFetch<any>('/api/v1/real-trades?limit=100');
setRealTrades(rt.trades || rt || []);
} catch {}
try {
const wr = await authFetch<any>('/api/v1/real-trades/win-rate');
setWinRate(wr);
} catch {}
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const renderWinCard = (label: string, data: any) => (
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: (data?.win_rate ?? 0) >= 50 ? '#3fb950' : '#f85149' }}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
<div style={{ fontSize: 11, color: '#484f58' }}>{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
</div>
);
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
return (
<div>
{/* Sub-tab bar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
<button onClick={() => setSubTab('signals')} style={{ ...btnStyle, background: subTab === 'signals' ? '#1f6feb' : '#21262d', color: subTab === 'signals' ? '#fff' : '#8b949e', borderColor: subTab === 'signals' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'signals' ? 700 : 400 }}>📈 Signal Trades</button>
<button onClick={() => setSubTab('real')} style={{ ...btnStyle, background: subTab === 'real' ? '#1f6feb' : '#21262d', color: subTab === 'real' ? '#fff' : '#8b949e', borderColor: subTab === 'real' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'real' ? 700 : 400 }}>💹 Real Trades</button>
</div>
{subTab === 'signals' && (<>
{signalWinRate !== null && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{renderWinCard('Win Rate', { win_rate: signalWinRate, wins: signalWinRate !== null ? Math.round((signalWinRate / 100) * signalTrades.length) : 0, trades: signalTrades.length })}
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>TOTAL P&L</div>
<div style={{ fontSize: 24, fontWeight: 700, color: (signalTotalPnl ?? 0) >= 0 ? '#3fb950' : '#f85149' }}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
</div>
</div>
)}
{signalTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No signal trades yet.</div> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
<tbody>
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 4 }}>{t.symbol}</td>
<td style={{ padding: 4, color: t.direction === 'LONG' ? '#3fb950' : '#f85149' }}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
<td style={{ padding: 4 }}>${t.entry_price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
<td style={{ padding: 4, color: t.status === 'OPEN' ? '#d29922' : '#8b949e' }}>{t.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>)}
{subTab === 'real' && (<>
{winRate && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{renderWinCard('Daily', winRate.daily)}
{renderWinCard('Weekly', winRate.weekly)}
{renderWinCard('Monthly', winRate.monthly)}
</div>
)}
{realTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No real trades yet. Place an order from the dashboard.</div> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Side</th><th style={{ textAlign: 'left', padding: 4 }}>Amount</th><th style={{ textAlign: 'left', padding: 4 }}>Price</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
<tbody>
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ padding: 4 }}>{t.symbol}</td>
<td style={{ padding: 4 }}><span style={{ color: t.side === 'buy' ? '#3fb950' : '#f85149', fontWeight: 600 }}>{t.side?.toUpperCase()}</span></td>
<td style={{ padding: 4 }}>{t.amount}</td>
<td style={{ padding: 4 }}>${t.price?.toFixed(2) || '-'}</td>
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
<td style={{ padding: 4, color: t.status === 'open' ? '#d29922' : '#8b949e' }}>{t.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>)}
</div>
);
}
// ═══════════════ BACKTEST TAB ═══════════════
function BacktestTab() {
const [btExchange, setBtExchange] = useState('binance');
const [btSymbols, setBtSymbols] = useState<string[]>([]);
const [btSymbol, setBtSymbol] = useState('BTC/USDT');
const [btTf, setBtTf] = useState('1h');
const [btDays, setBtDays] = useState(30);
const [btTradeSize, setBtTradeSize] = useState(100);
const [btResult, setBtResult] = useState<any>(null);
const [btLoading, setBtLoading] = useState(false);
const [btError, setBtError] = useState('');
const [btSaved, setBtSaved] = useState(false);
const [btSaveError, setBtSaveError] = useState('');
useEffect(() => {
let cancelled = false;
apiFetch<{symbols: Array<{symbol: string}>}>(`/backtest/symbols?exchange=${btExchange}`)
.then(data => { if (cancelled || !data?.symbols?.length) return; const names = data.symbols.map(s => s.symbol); setBtSymbols(names); if (!names.includes(btSymbol)) setBtSymbol(names[0]); }).catch(() => {});
return () => { cancelled = true; };
}, [btExchange]);
const runBacktest = async () => { setBtLoading(true); setBtError(''); setBtResult(null); setBtSaved(false); try { const r = await authFetch<any>(`/api/v1/backtest/run?symbol=${encodeURIComponent(btSymbol)}&exchange=${btExchange}&timeframe=${btTf}&days=${btDays}&trade_size=${btTradeSize}`); setBtResult(r); } catch (e: any) { setBtError(e.message); } setBtLoading(false); };
const saveBacktest = async () => { if (!btResult) return; setBtSaveError(''); try { const t = btResult.trades || {}; await authFetch(`/api/v1/backtest/save?symbol=${encodeURIComponent(btSymbol)}&exchange=${btExchange}&timeframe=${btTf}&days=${btDays}&trade_size=${btTradeSize}&total_trades=${t.total || 0}&wins=${t.wins || 0}&losses=${t.losses || 0}&win_rate=${t.win_rate || 0}&total_pnl=${t.total_pnl || 0}&profit_factor=${t.profit_factor || 0}&avg_win=${t.avg_win || 0}&avg_loss=${t.avg_loss || 0}`, { method: 'POST' }); setBtSaved(true); } catch (e: any) { setBtSaveError(e.message || 'Save failed'); } };
return (
<div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, alignItems: 'end' }}>
<div><div style={labelStyle}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 100 }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
<div><div style={labelStyle}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 140 }}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
<div><div style={labelStyle}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 70 }}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
<div><div style={labelStyle}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} style={{ ...inputStyle, cursor: 'pointer', width: 65 }}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
<div><div style={labelStyle}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} style={{ ...inputStyle, width: 70 }} min={1} /></div>
<button onClick={runBacktest} disabled={btLoading} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb', padding: '6px 20px', fontWeight: 600 }}>{btLoading ? '⏳...' : '▶ Run'}</button>
</div>
{btError && <div style={{ color: '#ef4444', marginBottom: 12 }}>❌ {btError}</div>}
{btResult && (<>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}><span style={{ color: btSaved ? '#22c55e' : '#8b949e', fontSize: 12 }}>{btSaved ? '✅ Saved to history' : '💾'}</span>{!btSaved && <button onClick={saveBacktest} style={{ ...btnStyle, fontSize: 11, padding: '3px 10px' }}>Save to History</button>}{btSaveError && <span style={{ color: '#ef4444', fontSize: 11 }}>❌ {btSaveError}</span>}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 16 }}>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>{btResult.symbol} @ {btResult.exchange}</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.timeframe} {btResult.days}d</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Candles</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.candles_count?.toLocaleString()}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Signals</div><div style={{ fontSize: 18, fontWeight: 700, color: '#58a6ff' }}>{btResult.total_signals}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Trades</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.trades.total}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Wins</div><div style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{btResult.trades.wins}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Losses</div><div style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{btResult.trades.losses}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Win Rate</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.win_rate || 0) >= 50 ? '#22c55e' : '#ef4444' }}>{btResult.trades.win_rate}%</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>PnL</div><div style={{ fontSize: 18, fontWeight: 700, color: btResult.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444' }}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Profit Factor</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.profit_factor || 0) >= 1 ? '#22c55e' : '#ef4444' }}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
</div>
{btResult?.trades?.recent?.length > 0 && (
<div style={cardStyle}><h4 style={{ margin: '0 0 10px', color: '#f0f6fc', fontSize: 14 }}>📋 Recent Trades</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Time</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Reason</th></tr></thead>
<tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}><td style={{ padding: 4 }}>{t.entry_time?.slice(5, 16)}</td><td style={{ padding: 4, color: t.direction === 'LONG' ? '#22c55e' : '#ef4444' }}>{t.direction}</td><td style={{ padding: 4 }}>${t.entry_price?.toFixed(2)}</td><td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td><td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>${t.pnl?.toFixed(2)}</td><td style={{ padding: 4, color: '#8b949e' }}>{t.exit_reason}</td></tr>
))}</tbody>
</table>
</div>
)}
</>)}
</div>
);
}
// ═══════════════ MAIN ═══════════════
export default function ProfilePage() {
const { t } = useT();
const user = useAppSelector(s => s.auth.user);
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
return (
<div style={{ padding: 20, maxWidth: 1100, margin: '0 auto' }}>
<h2 style={{ color: '#f0f6fc', marginBottom: 16 }}>👤 {user?.username || 'Profile'}</h2>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
{tabs.map(tb => (
<button key={tb} onClick={() => setTab(tb)} style={{ ...btnStyle, fontWeight: tab === tb ? 700 : 400, background: tab === tb ? '#1f6feb' : '#21262d', color: tab === tb ? '#fff' : '#8b949e', borderColor: tab === tb ? '#1f6feb' : '#30363d' }}>
{tb === 'info' ? 'ℹ️ Info' : tb === 'keys' ? '🔑 API Keys' : tb === 'sessions' ? '🔐 Sessions' : tb === 'settings' ? '⚙️ Settings' : tb === 'history' ? '📈 History' : '📊 Backtest'}
</button>
))}
</div>
{tab === 'info' && <InfoTab />}
{tab === 'keys' && <KeysTab />}
{tab === 'sessions' && <SessionsTab />}
{tab === 'settings' && <SettingsTab />}
{tab === 'history' && <HistoryTab />}
{tab === 'backtest' && <BacktestTab />}
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
/* ===== Trading Portal - Global Styles ===== */
/* --- CSS Variables (Dark Theme) --- */
:root {
--bg-primary: #0d1117;
--bg-surface: #161b22;
--bg-elevated: #1c2128;
--bg-hover: #21262d;
--border-default: #30363d;
--border-muted: #21262d;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--green: #3fb950;
--green-bg: rgba(63, 185, 80, 0.15);
--red: #f85149;
--red-bg: rgba(248, 81, 73, 0.15);
--yellow: #d29922;
--yellow-bg: rgba(210, 153, 34, 0.15);
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--text-link: #58a6ff;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
--mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Consolas, monospace;
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
color-scheme: dark;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* --- Global Reset --- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100%;
margin: 0;
background-color: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
/* Fix iOS Safari scroll issues */
-webkit-overflow-scrolling: touch;
}
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
a {
color: var(--text-link);
text-decoration: none;
transition: color 0.15s ease;
}
a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
}
code {
font-family: var(--mono);
font-size: 0.875em;
padding: 0.2em 0.4em;
background: var(--bg-hover);
border-radius: 4px;
}
pre {
font-family: var(--mono);
font-size: 0.875rem;
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: 6px;
padding: 16px;
overflow-x: auto;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
h1 { font-size: 1.75rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p {
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
/* --- Dark Scrollbar --- */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-default);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-default) var(--bg-primary);
}
/* Hide scrollbar for horizontal scrollable containers */
.scrollable-x {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollable-x::-webkit-scrollbar {
display: none;
}
/* --- Form Styles --- */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
font-family: var(--sans);
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: 6px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-group input::placeholder {
color: var(--text-muted);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
.form-group input.error,
.form-group select.error {
border-color: var(--red);
box-shadow: 0 0 0 3px var(--red-bg);
}
.error-message {
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
background: var(--red-bg);
border: 1px solid var(--red);
border-radius: 6px;
color: var(--red);
font-size: 0.875rem;
}
/* --- Button Styles --- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: var(--sans);
line-height: 1.25;
border: 1px solid var(--border-default);
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
user-select: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
color: #ffffff;
background: #238636;
border-color: rgba(240, 246, 252, 0.1);
}
.btn-primary:hover:not(:disabled) {
background: #2ea043;
}
.btn-secondary {
color: var(--text-primary);
background: var(--bg-surface);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-muted);
}
.btn-danger {
color: var(--red);
background: transparent;
border-color: var(--red);
}
.btn-danger:hover:not(:disabled) {
background: var(--red-bg);
}
.btn-sm {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
}
.btn-full {
width: 100%;
padding: 0.625rem 1rem;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
font-size: 1.1rem;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
padding: 0;
line-height: 1;
}
.btn-icon:hover {
background: var(--bg-hover);
border-color: var(--text-muted);
color: var(--text-primary);
}
/* --- Navbar --- */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 56px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-default);
flex-shrink: 0;
z-index: 100;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.navbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.navbar-user {
font-size: 0.875rem;
color: var(--text-secondary);
}
.navbar-user-link:hover .navbar-user {
color: #58a6ff;
}
.navbar-toggle {
display: none;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.navbar-toggle:hover {
color: var(--text-primary);
}
/* --- Card Styles --- */
.card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: 8px;
padding: 1.25rem;
box-shadow: var(--shadow);
}
.card:hover {
border-color: var(--accent);
}
.card h3 {
margin-bottom: 0.5rem;
}
.card p {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0;
}
/* --- Table Styles --- */
.table-container {
overflow-x: auto;
border: 1px solid var(--border-default);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
thead {
background: var(--bg-surface);
}
th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
white-space: nowrap;
}
td {
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border-muted);
color: var(--text-primary);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: var(--bg-hover);
}
/* Price colors */
.price-up { color: var(--green); }
.price-down { color: var(--red); }
/* --- Authentication Pages --- */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
background: var(--bg-primary);
}
.auth-card {
width: 100%;
max-width: 400px;
padding: 2rem;
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-lg);
}
.auth-card h1 {
text-align: center;
margin-bottom: 0.25rem;
font-size: 1.5rem;
}
.auth-card h2 {
text-align: center;
margin-bottom: 1.5rem;
font-size: 1rem;
font-weight: 400;
color: var(--text-secondary);
}
.auth-link {
text-align: center;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* --- Dashboard --- */
.dashboard-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-layout {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.chart-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.chart-wrapper {
flex: 1;
position: relative;
min-height: 200px;
/* Prevent iOS from intercepting touch events */
touch-action: none;
}
.chart-wrapper canvas {
touch-action: none;
}
.side-panel {
width: 380px;
flex-shrink: 0;
border-left: 1px solid var(--border-default);
background: var(--bg-surface);
overflow-y: auto;
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
-webkit-overflow-scrolling: touch;
}
/* Show remove button on watchlist item hover */
.side-panel [class*="item"]:hover [class*="removeBtn"] {
opacity: 1 !important;
}
/* --- Bottom Sheet (Order Panel on Mobile) --- */
.bottom-sheet-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
}
.bottom-sheet-overlay.open {
display: block;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 70vh;
background: var(--bg-surface);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
z-index: 210;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.bottom-sheet.open {
transform: translateY(0);
}
.bottom-sheet-handle {
display: flex;
justify-content: center;
padding: 12px 0 4px;
cursor: grab;
}
.bottom-sheet-handle::before {
content: '';
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--border-default);
}
.bottom-sheet-content {
padding: 8px 20px 24px;
}
/* --- Loading / Spinner --- */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
height: 100dvh;
gap: 1rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-default);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Pulse loading for placeholders */
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Fade-in animation */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* --- Mobile Nav Drawer --- */
.mobile-nav-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 150;
}
.mobile-nav-overlay.open {
display: block;
}
.mobile-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
max-width: 80vw;
background: var(--bg-surface);
border-right: 1px solid var(--border-default);
z-index: 160;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1rem;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
}
.mobile-nav.open {
transform: translateX(0);
}
.mobile-nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-default);
}
.mobile-nav-user {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--text-primary);
font-weight: 500;
}
.mobile-nav-close {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.mobile-nav-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mobile-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9375rem;
cursor: pointer;
transition: background 0.15s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.mobile-nav-item:hover {
background: var(--bg-hover);
}
.mobile-nav-item.logout {
color: var(--red);
}
/* --- Extra Responsive Improvements --- */
/* Navbar toggle: only show on mobile */
@media (max-width: 768px) {
.navbar-toggle {
display: flex;
align-items: center;
justify-content: center;
}
}
.mobile-order-btn {
display: none;
}
@media (max-width: 768px) {
:root {
font-size: 15px;
}
.navbar {
padding: 0 1rem;
height: 50px;
}
.navbar-user {
display: none;
}
.side-panel {
display: none;
}
.chart-area {
flex: 1;
min-height: 60vh;
}
.chart-wrapper {
min-height: 350px;
}
.mobile-order-btn {
display: inline-flex;
}
.auth-card {
padding: 1.5rem;
border-radius: 10px;
}
.table-container {
border-radius: 0;
border-left: none;
border-right: none;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.bottom-sheet-overlay {
display: none;
}
}
@media (max-width: 480px) {
:root {
font-size: 14px;
}
.navbar {
padding: 0 0.5rem;
height: 46px;
}
.navbar-brand {
font-size: 0.9375rem;
}
.navbar-right .btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.chart-area {
min-height: 50vh;
}
.chart-wrapper {
min-height: 300px;
}
.mobile-order-btn {
display: inline-flex;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.auth-card {
padding: 1rem;
margin: 0 0.25rem;
border-radius: 8px;
}
.auth-card h1 {
font-size: 1.375rem;
}
.auth-card h2 {
font-size: 0.9375rem;
margin-bottom: 1rem;
}
.loading-container p {
font-size: 0.875rem;
}
}
/* ===== Responsive: Table → Card on mobile ===== */
@media (max-width: 768px) {
.responsive-table table thead {
display: none;
}
.responsive-table table tbody tr {
display: block;
border: 1px solid var(--border-default);
border-radius: 8px;
margin-bottom: 8px;
padding: 10px 12px;
background: var(--bg-surface);
}
.responsive-table table tbody td {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 5px 0 !important;
border-bottom: 1px solid var(--border-muted) !important;
text-align: right !important;
font-size: 12px !important;
}
.responsive-table table tbody td:last-child {
border-bottom: none !important;
}
.responsive-table table tbody td::before {
content: attr(data-label);
font-weight: 500;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
}
/* Dashboard side panel overlay on mobile */
@media (max-width: 768px) {
.dashboard-side-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
}
.dashboard-side-panel-mobile {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 300px;
max-width: 85vw;
background: #0d1117;
border-left: 1px solid #30363d;
z-index: 110;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: -4px 0 24px rgba(0,0,0,0.5);
animation: slideInRight 0.25s ease-out;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
}
/* Profile tab bar responsive */
@media (max-width: 768px) {
.profile-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid #21262d;
}
.profile-tabs button {
font-size: 11px !important;
padding: 4px 10px !important;
}
}
@media (max-width: 480px) {
.profile-tabs button {
font-size: 10px !important;
padding: 3px 8px !important;
}
.responsive-table table tbody tr {
padding: 8px 10px;
}
.responsive-table table tbody td {
font-size: 11px !important;
}
}
/* ── Dark scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0d1117;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
/* Firefox dark scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #30363d #0d1117;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
+313
View File
@@ -0,0 +1,313 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppSelector, useAppDispatch } from '../app/hooks';
import { fetchCurrentUser, logoutThunk } from '../features/auth/authSlice';
import { apiFetch } from '../features/api/apiService';
// ── Types ──
interface AuditLogEntry {
id: number;
user_id: string | null;
action: string;
resource: string;
details: Record<string, unknown> | null;
created_at: string;
}
interface AuditLogListResponse {
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}
// ── API calls ──
async function getAuditLogs(
limit = 100,
offset = 0,
action?: string,
): Promise<AuditLogListResponse> {
const params = new URLSearchParams();
params.append('limit', String(limit));
params.append('offset', String(offset));
if (action) params.append('action', action);
return apiFetch<AuditLogListResponse>(`/audit?${params.toString()}`);
}
// ── Constants ──
const ACTION_OPTIONS = [
{ value: '', label: 'All Actions' },
{ value: 'trade_open', label: 'Trade Open' },
{ value: 'trade_close', label: 'Trade Close' },
{ value: 'signal_generated', label: 'Signal Generated' },
{ value: 'user_login', label: 'User Login' },
{ value: 'config_change', label: 'Config Change' },
{ value: 'strategy_toggle', label: 'Strategy Toggle' },
{ value: 'permission_change', label: 'Permission Change' },
];
const PAGE_SIZE = 50;
export default function AuditLogPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const [initializing, setInitializing] = useState(true);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionFilter, setActionFilter] = useState('');
// ── Auth guard ──
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', { replace: true });
return;
}
if (!user) {
dispatch(fetchCurrentUser())
.unwrap()
.catch(() => navigate('/login', { replace: true }))
.finally(() => setInitializing(false));
} else {
setInitializing(false);
}
}, [isAuthenticated, user, dispatch, navigate]);
useEffect(() => {
if (!initializing && user && !user.is_admin) {
navigate('/dashboard', { replace: true });
}
}, [initializing, user, navigate]);
// ── Data loader ──
const loadLogs = useCallback(async () => {
if (!user?.is_admin) return;
setLoading(true);
setError('');
try {
const resp = await getAuditLogs(PAGE_SIZE, offset, actionFilter || undefined);
setLogs(resp.items);
setTotal(resp.total);
} catch (err: any) {
setError(err.message || 'Failed to load audit logs');
} finally {
setLoading(false);
}
}, [user?.is_admin, offset, actionFilter]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
// ── Handlers ──
const handleFilterChange = (newAction: string) => {
setActionFilter(newAction);
setOffset(0);
};
const totalPages = Math.ceil(total / PAGE_SIZE);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
const goToPage = (page: number) => {
setOffset((page - 1) * PAGE_SIZE);
};
// ── Helpers ──
const formatTime = (iso: string) => {
const d = new Date(iso);
return d.toLocaleString();
};
const actionColors: Record<string, string> = {
trade_open: '#3fb950',
trade_close: '#f85149',
signal_generated: '#d29922',
user_login: '#58a6ff',
config_change: '#bc8cff',
strategy_toggle: '#79c0ff',
permission_change: '#ff7b72',
};
const getActionBadge = (action: string) => {
const color = actionColors[action] || '#8b949e';
return { color, fontWeight: 600 };
};
const formatDetails = (details: Record<string, unknown> | null) => {
if (!details) return <span style={{ color: '#484f58' }}>—</span>;
const entries = Object.entries(details);
if (entries.length === 0) return <span style={{ color: '#484f58' }}>—</span>;
return (
<span style={{ fontSize: 11, color: '#8b949e', wordBreak: 'break-all' }}>
{entries.map(([k, v]) => `${k}: ${String(v ?? '')}`).join(', ')}
</span>
);
};
// ── Guard ──
if (!user?.is_admin) return null;
if (initializing || isLoading) {
return (
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading audit logs...</div>
</div>
);
}
// ── Styles ──
const btnStyle: React.CSSProperties = {
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
minHeight: 36, touchAction: 'manipulation',
};
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
const selectStyle: React.CSSProperties = {
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
border: '1px solid #30363d', borderRadius: 4, outline: 'none', cursor: 'pointer',
minWidth: 160,
};
return (
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
{/* ═══ Navbar ═══ */}
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 18 }}>📋</span>
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Audit Log</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button style={btnStyle} onClick={() => navigate('/admin')}>← Admin Panel</button>
<button style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }} onClick={async () => {
await dispatch(logoutThunk());
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
navigate('/login', { replace: true });
}}>
🚪 Logout
</button>
</div>
</nav>
{/* ═══ Controls ═══ */}
<div style={{ display: 'flex', gap: 12, padding: '12px 16px', borderBottom: '1px solid #21262d', alignItems: 'center', flexWrap: 'wrap' }}>
<select
style={selectStyle}
value={actionFilter}
onChange={(e) => handleFilterChange(e.target.value)}
>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<span style={{ fontSize: 12, color: '#484f58' }}>
{total > 0 ? `${total} entries` : ''}
</span>
</div>
{/* ═══ Table ═══ */}
<div style={{ padding: 16, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading audit logs...</p>}
{error && <p style={{ color: '#f85149', fontSize: 13 }}>{error}</p>}
{!loading && !error && (
<>
{logs.length === 0 ? (
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
No audit log entries found.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid #30363d' }}>
<th style={thStyle}>Time</th>
<th style={thStyle}>User</th>
<th style={thStyle}>Action</th>
<th style={thStyle}>Resource</th>
<th style={thStyle}>Details</th>
</tr>
</thead>
<tbody>
{logs.map((entry) => (
<tr key={entry.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={{ ...tdStyle, color: '#484f58', whiteSpace: 'nowrap' }}>
{formatTime(entry.created_at)}
</td>
<td style={tdStyle}>
{entry.user_id ? (
<span style={{ color: '#8b949e', fontSize: 11, fontFamily: 'monospace' }}>
{entry.user_id.substring(0, 8)}...
</span>
) : (
<span style={{ color: '#484f58' }}>System</span>
)}
</td>
<td style={tdStyle}>
<span style={getActionBadge(entry.action)}>{entry.action}</span>
</td>
<td style={{ ...tdStyle, color: '#f0f6fc', fontFamily: 'monospace', fontSize: 11 }}>
{entry.resource}
</td>
<td style={tdStyle}>
{formatDetails(entry.details)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* ═══ Pagination ═══ */}
{totalPages > 1 && (
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', alignItems: 'center', marginTop: 16 }}>
<button
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
disabled={currentPage <= 1}
onClick={() => goToPage(1)}
>
««
</button>
<button
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
disabled={currentPage <= 1}
onClick={() => goToPage(currentPage - 1)}
>
«
</button>
<span style={{ fontSize: 12, color: '#8b949e', padding: '0 8px' }}>
Page {currentPage} of {totalPages}
</span>
<button
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
disabled={currentPage >= totalPages}
onClick={() => goToPage(currentPage + 1)}
>
»
</button>
<button
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
disabled={currentPage >= totalPages}
onClick={() => goToPage(totalPages)}
>
»»
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500,
fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
};
const tdStyle: React.CSSProperties = {
padding: '8px 12px', verticalAlign: 'middle',
};
+170
View File
@@ -0,0 +1,170 @@
const en: Record<string, string> = {
// ── Nav ──
'nav.tradingPortal': 'Trading Portal',
'nav.profile': '👤 {name}',
'nav.panelToggle': 'Toggle panel',
'nav.logout': '🚪 Logout',
// ── Login ──
'login.title': 'Trading Portal',
'login.subtitle': 'Sign in to your account',
'login.username': 'Username',
'login.password': 'Password',
'login.submit': 'Sign In',
'login.signing': 'Signing in...',
'login.error': 'Invalid username or password',
// ── Dashboard ──
'dash.chart': '📈 Chart',
'dash.watchlist': '👁️ Watchlist',
'dash.order': '📝 Order',
'dash.signals': '📡 Signals',
'dash.quickBuy': '⚡ Quick Buy',
'dash.quickSell': '⚡ Quick Sell',
'dash.tradeSize': 'Trade Size (USDT)',
// ── ChartToolbar ──
'chart.exchange': 'Exchange',
'chart.symbol': 'Symbol',
'chart.search': 'Search symbols...',
'chart.loading': 'Loading...',
'chart.noData': 'No data',
'chart.error': 'Error loading symbols',
// ── Watchlist ──
'wl.title': '👁️ Watchlist',
'wl.search': 'Search symbols...',
'wl.add': '+',
'wl.remove': '✕',
'wl.loading': 'Loading...',
'wl.empty': 'No symbols in watchlist. Click + to add.',
'wl.error': 'Failed to load watchlist',
// ── Order Panel ──
'order.buy': 'Buy',
'order.sell': 'Sell',
'order.limit': 'Limit',
'order.market': 'Market',
'order.stop': 'Stop',
'order.amount': 'Amount',
'order.price': 'Price',
'order.total': 'Total',
'order.place': 'Place Order',
'order.placing': 'Placing...',
'order.success': '✅ Order placed successfully',
'order.loginRequired': '🔒 Login required',
'order.noKey': '⚠️ No API key configured',
'order.balance': 'Balance: {amount}',
'order.available': 'Available: {amount}',
// ── Signals ──
'sig.title': '📡 Live Signals',
'sig.strongBuy': '🚀 STRONG BUY',
'sig.buy': '📈 BUY',
'sig.strongSell': '🔻 STRONG SELL',
'sig.sell': '📉 SELL',
'sig.cautionLong': '⚠️ CAUTION (LONG)',
'sig.cautionShort': '⚠️ CAUTION (SHORT)',
'sig.squeeze': '⚡ SQUEEZE ALERT',
'sig.noSignal': 'No signals yet — waiting for new candles...',
'sig.loading': 'Loading signals...',
'sig.price': 'Price',
'sig.rsi': 'RSI',
'sig.bb': 'BB',
'sig.signal': 'Signal',
'sig.time': 'Time',
// ── Profile ──
'profile.tabs.info': 'ℹ️ Info',
'profile.tabs.keys': '🔑 API Keys',
'profile.tabs.history': '📜 History',
'profile.tabs.prefs': '⚙️ Settings',
'profile.tabs.sessions': '🔐 Sessions',
'profile.info.title': 'Profile Info',
'profile.info.email': 'Email',
'profile.info.displayName': 'Display Name',
'profile.info.save': 'Save Profile',
'profile.info.saved': '✅ Profile saved ✓',
'profile.info.error': 'Save failed',
'profile.password.title': 'Change Password',
'profile.password.old': 'Current Password',
'profile.password.new': 'New Password',
'profile.password.confirm': 'Confirm Password',
'profile.password.btn': 'Change Password',
'profile.password.validLength': '8+ characters',
'profile.password.validNumber': 'Need 1 number',
'profile.password.validSpecial': 'Need 1 special char',
'profile.password.fillAll': 'Fill all fields',
'profile.password.success': '✅ Password changed ✓',
'profile.keys.title': 'API Keys',
'profile.keys.add': '+ Add Key',
'profile.keys.edit': '✏️',
'profile.keys.delete': '🗑️',
'profile.keys.test': 'Test',
'profile.keys.empty': 'No API keys configured',
'profile.keys.exchange': 'Exchange',
'profile.keys.key': 'API Key',
'profile.keys.secret': 'Secret',
'profile.keys.passphrase': 'Passphrase',
'profile.keys.saving': 'Saving...',
'profile.keys.addTitle': 'Add API Key',
'profile.keys.editTitle': 'Edit API Key',
'profile.keys.deleteConfirm': 'Are you sure you want to delete this key?',
'profile.prefs.title': '⚙️ Settings',
'profile.prefs.exchange': 'Default Exchange',
'profile.prefs.timeframe': 'Default Timeframe',
'profile.prefs.theme': 'Theme',
'profile.prefs.upColor': 'Up Color',
'profile.prefs.downColor': 'Down Color',
'profile.prefs.notifSignal': '🔔 Signal Notifications',
'profile.prefs.notifTrade': '💬 Trade Execution',
'profile.prefs.notifTradeDesc': 'Gửi thông báo Telegram khi có lệnh thật được đặt',
'profile.prefs.language': 'Language',
'profile.prefs.tradeSize': '💰 Trade Size (USDT)',
'profile.prefs.tradeSizeDesc': 'Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade',
'profile.prefs.autoTrade': '🤖 Auto Trade',
'profile.prefs.autoTradeDesc': 'Tự động đặt lệnh Spot mua/bán thật — cần chọn token bên dưới',
'profile.prefs.autoTradeWarning': '⚠️ Auto Trade đang BẬT — hệ thống sẽ tự động đặt lệnh market thật khi có signal',
'profile.prefs.autoTradeTokens': '🪙 Auto Trade Tokens',
'profile.prefs.autoTradeTokensDesc': 'Chọn token muốn Auto Trade — chỉ những token được chọn mới được auto trade',
'profile.prefs.searchToken': '🔍 Tìm token...',
'profile.prefs.noTokens': '⚠️ Chưa chọn token nào — Auto Trade sẽ không hoạt động',
'profile.prefs.tracking': '📊 Auto Trades đang theo dõi',
'profile.prefs.trackingDesc': '{n} cặp — click ✕ để bỏ theo dõi',
'profile.prefs.trackingPaused': '📊 Auto Trades (tạm dừng)',
'profile.prefs.trackingPausedDesc': 'Auto trade đang tắt — {n} cặp đã lưu',
'profile.prefs.save': '💾 Save Settings',
'profile.prefs.saved': '✅ Settings saved ✓',
'profile.prefs.spot': 'SPOT',
'profile.sessions.title': 'Active Sessions',
'profile.sessions.current': '(current)',
'profile.sessions.revoke': 'Revoke',
'profile.sessions.empty': 'No active sessions',
'profile.sessions.browser': 'Browser',
'profile.sessions.os': 'OS',
'profile.sessions.device': 'Device',
'profile.sessions.ip': 'IP',
'profile.sessions.created': 'Created',
'profile.history.signals': '📈 Signal Trades',
'profile.history.real': '💹 Real Trades',
'profile.history.empty': 'No trades yet',
'profile.history.side': 'Side',
'profile.history.symbol': 'Symbol',
'profile.history.price': 'Price',
'profile.history.qty': 'Qty',
'profile.history.pnl': 'PnL',
'profile.history.time': 'Time',
// ── Signal History ──
'sigHistory.pnl': 'Total PnL',
'sigHistory.winRate': 'Win Rate',
'sigHistory.trades': '{n} trades',
};
export default en;
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, type ReactNode, type FC } from 'react';
import en from './en';
import vi from './vi';
const locales = { en, vi } as const;
type Lang = keyof typeof locales;
type TContext = {
lang: Lang;
t: (key: string, vars?: Record<string, string | number>) => string;
setLang: (l: Lang) => void;
};
const Ctx = createContext<TContext>({
lang: 'en',
t: (k) => k,
setLang: () => {},
});
export const useT = () => useContext(Ctx);
type Props = { lang: Lang; setLang: (l: Lang) => void; children: ReactNode };
export const TProvider: FC<Props> = ({ lang, setLang, children }) => {
const t = (key: string, vars?: Record<string, string | number>): string => {
const dict = locales[lang] ?? en;
let msg = (dict as Record<string, string>)[key];
if (msg === undefined) msg = (en as Record<string, string>)[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
msg = msg.replace(`{${k}}`, String(v));
}
}
return msg;
};
return <Ctx.Provider value={{ lang, t, setLang }}>{children}</Ctx.Provider>;
};
+170
View File
@@ -0,0 +1,170 @@
const vi: Record<string, string> = {
// ── Nav ──
'nav.tradingPortal': '📊 Trading Portal',
'nav.profile': '👤 {name}',
'nav.panelToggle': 'Mở/tắt bảng',
'nav.logout': '🚪 Đăng xuất',
// ── Login ──
'login.title': 'Trading Portal',
'login.subtitle': 'Đăng nhập tài khoản',
'login.username': 'Tên đăng nhập',
'login.password': 'Mật khẩu',
'login.submit': 'Đăng nhập',
'login.signing': 'Đang đăng nhập...',
'login.error': 'Sai tên đăng nhập hoặc mật khẩu',
// ── Dashboard ──
'dash.chart': '📈 Biểu đồ',
'dash.watchlist': '👁️ Danh sách',
'dash.order': '📝 Đặt lệnh',
'dash.signals': '📡 Tín hiệu',
'dash.quickBuy': '⚡ Mua nhanh',
'dash.quickSell': '⚡ Bán nhanh',
'dash.tradeSize': 'Khối lượng (USDT)',
// ── ChartToolbar ──
'chart.exchange': 'Sàn',
'chart.symbol': 'Cặp',
'chart.search': 'Tìm symbol...',
'chart.loading': 'Đang tải...',
'chart.noData': 'Không có dữ liệu',
'chart.error': 'Lỗi tải symbol',
// ── Watchlist ──
'wl.title': '👁️ Danh sách',
'wl.search': 'Tìm symbol...',
'wl.add': '+',
'wl.remove': '✕',
'wl.loading': 'Đang tải...',
'wl.empty': 'Chưa có symbol nào. Click + để thêm.',
'wl.error': 'Lỗi tải danh sách',
// ── Order Panel ──
'order.buy': 'Mua',
'order.sell': 'Bán',
'order.limit': 'Giới hạn',
'order.market': 'Thị trường',
'order.stop': 'Dừng',
'order.amount': 'Số lượng',
'order.price': 'Giá',
'order.total': 'Tổng',
'order.place': 'Đặt lệnh',
'order.placing': 'Đang đặt...',
'order.success': '✅ Đặt lệnh thành công',
'order.loginRequired': '🔒 Cần đăng nhập',
'order.noKey': '⚠️ Chưa cấu hình API key',
'order.balance': 'Số dư: {amount}',
'order.available': 'Khả dụng: {amount}',
// ── Signals ──
'sig.title': '📡 Tín hiệu trực tiếp',
'sig.strongBuy': '🚀 MUA MẠNH',
'sig.buy': '📈 MUA',
'sig.strongSell': '🔻 BÁN MẠNH',
'sig.sell': '📉 BÁN',
'sig.cautionLong': '⚠️ THẬN TRỌNG (LONG)',
'sig.cautionShort': '⚠️ THẬN TRỌNG (SHORT)',
'sig.squeeze': '⚡ SQUEEZE ALERT',
'sig.noSignal': 'Chưa có tín hiệu — đang chờ nến mới...',
'sig.loading': 'Đang tải tín hiệu...',
'sig.price': 'Giá',
'sig.rsi': 'RSI',
'sig.bb': 'BB',
'sig.signal': 'Tín hiệu',
'sig.time': 'Thời gian',
// ── Profile ──
'profile.tabs.info': 'ℹ️ Thông tin',
'profile.tabs.keys': '🔑 API Keys',
'profile.tabs.history': '📜 Lịch sử',
'profile.tabs.prefs': '⚙️ Cài đặt',
'profile.tabs.sessions': '🔐 Phiên đăng nhập',
'profile.info.title': 'Thông tin cá nhân',
'profile.info.email': 'Email',
'profile.info.displayName': 'Tên hiển thị',
'profile.info.save': 'Lưu thông tin',
'profile.info.saved': '✅ Đã lưu thông tin ✓',
'profile.info.error': 'Lỗi lưu thông tin',
'profile.password.title': 'Đổi mật khẩu',
'profile.password.old': 'Mật khẩu hiện tại',
'profile.password.new': 'Mật khẩu mới',
'profile.password.confirm': 'Xác nhận mật khẩu',
'profile.password.btn': 'Đổi mật khẩu',
'profile.password.validLength': 'Tối thiểu 8 ký tự',
'profile.password.validNumber': 'Cần ít nhất 1 số',
'profile.password.validSpecial': 'Cần ít nhất 1 ký tự đặc biệt',
'profile.password.fillAll': 'Điền đầy đủ thông tin',
'profile.password.success': '✅ Đổi mật khẩu thành công ✓',
'profile.keys.title': 'API Keys',
'profile.keys.add': '+ Thêm Key',
'profile.keys.edit': '✏️',
'profile.keys.delete': '🗑️',
'profile.keys.test': 'Kiểm tra',
'profile.keys.empty': 'Chưa có API key nào',
'profile.keys.exchange': 'Sàn',
'profile.keys.key': 'API Key',
'profile.keys.secret': 'Secret Key',
'profile.keys.passphrase': 'Passphrase',
'profile.keys.saving': 'Đang lưu...',
'profile.keys.addTitle': 'Thêm API Key',
'profile.keys.editTitle': 'Sửa API Key',
'profile.keys.deleteConfirm': 'Bạn chắc chắn muốn xoá key này?',
'profile.prefs.title': '⚙️ Cài đặt',
'profile.prefs.exchange': 'Sàn mặc định',
'profile.prefs.timeframe': 'Khung thời gian',
'profile.prefs.theme': 'Giao diện',
'profile.prefs.upColor': 'Màu tăng',
'profile.prefs.downColor': 'Màu giảm',
'profile.prefs.notifSignal': '🔔 Thông báo tín hiệu',
'profile.prefs.notifTrade': '💬 Thực hiện lệnh',
'profile.prefs.notifTradeDesc': 'Gửi thông báo Telegram khi có lệnh thật được đặt',
'profile.prefs.language': 'Ngôn ngữ',
'profile.prefs.tradeSize': '💰 Khối lượng (USDT)',
'profile.prefs.tradeSizeDesc': 'Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade',
'profile.prefs.autoTrade': '🤖 Auto Trade',
'profile.prefs.autoTradeDesc': 'Tự động đặt lệnh Spot mua/bán thật — cần chọn token bên dưới',
'profile.prefs.autoTradeWarning': '⚠️ Auto Trade đang BẬT — hệ thống sẽ tự động đặt lệnh market thật khi có signal',
'profile.prefs.autoTradeTokens': '🪙 Token Auto Trade',
'profile.prefs.autoTradeTokensDesc': 'Chọn token muốn Auto Trade — chỉ những token được chọn mới được auto trade',
'profile.prefs.searchToken': '🔍 Tìm token...',
'profile.prefs.noTokens': '⚠️ Chưa chọn token nào — Auto Trade sẽ không hoạt động',
'profile.prefs.tracking': '📊 Auto Trades đang theo dõi',
'profile.prefs.trackingDesc': '{n} cặp — click ✕ để bỏ theo dõi',
'profile.prefs.trackingPaused': '📊 Auto Trades (tạm dừng)',
'profile.prefs.trackingPausedDesc': 'Auto trade đang tắt — {n} cặp đã lưu',
'profile.prefs.save': '💾 Lưu cài đặt',
'profile.prefs.saved': '✅ Đã lưu cài đặt ✓',
'profile.prefs.spot': 'SPOT',
'profile.sessions.title': 'Phiên đăng nhập',
'profile.sessions.current': '(hiện tại)',
'profile.sessions.revoke': 'Thu hồi',
'profile.sessions.empty': 'Không có phiên nào',
'profile.sessions.browser': 'Trình duyệt',
'profile.sessions.os': 'HĐH',
'profile.sessions.device': 'Thiết bị',
'profile.sessions.ip': 'IP',
'profile.sessions.created': 'Tạo lúc',
'profile.history.signals': '📈 Lệnh tín hiệu',
'profile.history.real': '💹 Lệnh thật',
'profile.history.empty': 'Chưa có lệnh nào',
'profile.history.side': 'Lệnh',
'profile.history.symbol': 'Cặp',
'profile.history.price': 'Giá',
'profile.history.qty': 'SL',
'profile.history.pnl': 'PnL',
'profile.history.time': 'Thời gian',
// ── Signal History ──
'sigHistory.pnl': 'Tổng PnL',
'sigHistory.winRate': 'Tỉ lệ thắng',
'sigHistory.trades': '{n} lệnh',
};
export default vi;
+51
View File
@@ -0,0 +1,51 @@
export interface Candle {
time: number; // Unix timestamp in seconds
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface Symbol {
id: number;
symbol: string;
base: string;
quote: string;
exchange_id: number;
active: boolean;
}
export interface Exchange {
id: number;
name: string;
active: boolean;
connected?: boolean;
display_name?: string;
is_active?: boolean;
}
export interface Indicators {
[key: string]: number[] | number[][];
}
export interface User {
id: number;
username: string;
email: string;
is_active: boolean;
is_admin?: boolean;
role?: string;
preferences?: Record<string, any>;
created_at?: string;
display_name?: string | null;
}
export interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
Vendored Executable
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+57
View File
@@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons.svg'],
manifest: {
name: 'Trading Portal',
short_name: 'Trading',
description: 'Trading Portal - Real-time trading signals and charts',
theme_color: '#0d1117',
background_color: '#0d1117',
display: 'standalone',
start_url: '/dashboard',
icons: [
{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
runtimeCaching: [
{
urlPattern: /^https?:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
},
},
],
},
}),
],
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8001',
ws: true,
},
},
},
})