From 9bdafaa51e296c91124f437f20d8cb2a5dbc1672 Mon Sep 17 00:00:00 2001 From: chemavx Date: Tue, 14 Apr 2026 17:18:32 +0000 Subject: [PATCH] feat: add dashboard source code with Vite + React + Recharts and CI/CD build - Reconstruct dashboard from compiled container: App.jsx, main.jsx, index.css - nginx.conf with SPA routing and /api proxy to api:8000 - Multi-stage Dockerfile: node:20-alpine build + nginx:alpine serve - Add third kaniko build step in ci.yml for chemavx/polymarket-bot-dashboard - Update k8s manifest sed to patch deployment-dashboard.yaml image on each push Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 19 ++- dashboard/Dockerfile | 19 +++ dashboard/index.html | 12 ++ dashboard/nginx.conf | 18 +++ dashboard/package.json | 20 +++ dashboard/src/App.jsx | 310 +++++++++++++++++++++++++++++++++++++++ dashboard/src/index.css | 257 ++++++++++++++++++++++++++++++++ dashboard/src/main.jsx | 10 ++ dashboard/vite.config.js | 14 ++ 9 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/index.html create mode 100644 dashboard/nginx.conf create mode 100644 dashboard/package.json create mode 100644 dashboard/src/App.jsx create mode 100644 dashboard/src/index.css create mode 100644 dashboard/src/main.jsx create mode 100644 dashboard/vite.config.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f4be67b..f7f2461 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -47,6 +47,18 @@ jobs: build_file: Dockerfile.api extra_args: --insecure --skip-tls-verify + - name: Build and push dashboard image + uses: aevea/action-kaniko@master + with: + registry: git.chemavx.xyz + username: chemavx + password: ${{ secrets.CI_TOKEN }} + image: chemavx/polymarket-bot-dashboard + tag: ${{ steps.tag.outputs.TAG }} + path: dashboard + build_file: Dockerfile + extra_args: --insecure --skip-tls-verify + - name: Update k8s manifests run: | TAG=${{ steps.tag.outputs.TAG }} @@ -61,10 +73,13 @@ jobs: polymarket-bot/deployment-bot.yaml sed -i "s|image: .*polymarket-bot-api.*|image: git.chemavx.xyz/chemavx/polymarket-bot-api:${TAG}|g" \ polymarket-bot/deployment-api.yaml + sed -i "s|image: .*polymarket-bot-dashboard.*|image: git.chemavx.xyz/chemavx/polymarket-bot-dashboard:${TAG}|g" \ + polymarket-bot/deployment-dashboard.yaml sed -i "s|imagePullPolicy: Never|imagePullPolicy: Always|g" \ polymarket-bot/deployment-bot.yaml \ - polymarket-bot/deployment-api.yaml + polymarket-bot/deployment-api.yaml \ + polymarket-bot/deployment-dashboard.yaml - git add polymarket-bot/deployment-bot.yaml polymarket-bot/deployment-api.yaml + git add polymarket-bot/deployment-bot.yaml polymarket-bot/deployment-api.yaml polymarket-bot/deployment-dashboard.yaml git diff --cached --quiet || git commit -m "ci: update polymarket-bot images to ${TAG} [skip ci]" git push diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..788115a --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY index.html . +COPY vite.config.js . +COPY src/ ./src/ + +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..522eab7 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + Polymarket Bot + + +
+ + + diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf new file mode 100644 index 0000000..14b54a4 --- /dev/null +++ b/dashboard/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript; +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..86428ae --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,20 @@ +{ + "name": "polymarket-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.4" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.2.0" + } +} diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx new file mode 100644 index 0000000..6c3bac5 --- /dev/null +++ b/dashboard/src/App.jsx @@ -0,0 +1,310 @@ +import { useEffect, useState, useCallback } from 'react' +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from 'recharts' + +const REFRESH_MS = 30_000 + +function fmt(n, decimals = 2) { + if (n == null) return '—' + return Number(n).toFixed(decimals) +} + +function fmtUSD(n) { + if (n == null) return '—' + const abs = Math.abs(n) + const sign = n < 0 ? '-' : '' + return `${sign}$${abs.toFixed(2)}` +} + +function fmtPct(n) { + if (n == null) return '—' + return `${(n * 100).toFixed(1)}%` +} + +function fmtTime(ts) { + if (!ts) return '—' + const d = new Date(ts) + return d.toLocaleString('es-ES', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function MetricCard({ title, value, subtitle, progress, progressColor }) { + return ( +
+
+ {title} +
+
{value}
+ {subtitle &&
{subtitle}
} + {progress != null && ( +
+
+
+ )} +
+ ) +} + +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null + return ( +
+
{label}
+ {payload.map((p) => ( +
+ P&L: {fmtUSD(p.value)} +
+ ))} +
+ ) +} + +export default function App() { + const [summary, setSummary] = useState(null) + const [trades, setTrades] = useState([]) + const [history, setHistory] = useState([]) + const [lastUpdate, setLastUpdate] = useState(null) + const [error, setError] = useState(null) + + const fetchAll = useCallback(async () => { + try { + const [sumRes, tradesRes, metricsRes] = await Promise.all([ + fetch('/api/summary'), + fetch('/api/trades?limit=50'), + fetch('/api/metrics'), + ]) + if (!sumRes.ok || !tradesRes.ok || !metricsRes.ok) throw new Error('API error') + + const [sumData, tradesData, metricsData] = await Promise.all([ + sumRes.json(), + tradesRes.json(), + metricsRes.json(), + ]) + + setSummary(sumData) + setTrades(tradesData.trades || []) + + const raw = (metricsData.history || []).slice().reverse() + setHistory( + raw.map((r) => ({ + date: new Date(r.timestamp).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }), + pnl: r.total_pnl, + })) + ) + setLastUpdate(new Date()) + setError(null) + } catch (e) { + setError(e.message) + } + }, []) + + useEffect(() => { + fetchAll() + const id = setInterval(fetchAll, REFRESH_MS) + return () => clearInterval(id) + }, [fetchAll]) + + if (!summary && !error) { + return ( +
+
+ Cargando... +
+ ) + } + + if (error && !summary) { + return ( +
+ Error conectando con la API: {error} +
+ ) + } + + const pnlColor = summary.total_pnl >= 0 ? 'var(--green)' : 'var(--red)' + + return ( +
+ {/* Header */} +
+
+

+ Polymarket Bot + {summary.paper_mode && PAPER} +

+
+
+ {lastUpdate && ( + + Actualizado: {lastUpdate.toLocaleTimeString('es-ES')} + + )} +
+
+ + {/* Promotion banner */} + {summary.promotion_ready && ( +
+ 🚀 +
+ Listo para producción — El bot cumple los criterios de + rendimiento. Considera desactivar PAPER_MODE. +
+
+ )} + + {/* Metrics grid */} +
+ + {fmtUSD(summary.total_pnl)}} + subtitle={`${fmtPct(summary.total_pnl / summary.paper_bankroll)} sobre bankroll`} + progress={summary.total_pnl / summary.paper_bankroll + 0.5} + progressColor={pnlColor} + /> + = 0.52 ? 'var(--green)' : 'var(--amber)'} + /> + = 0.5 ? 'var(--green)' : 'var(--amber)'} + /> + = 0.7 ? 'var(--green)' : 'var(--amber)'} + /> + +
+ + {/* Performance chart */} +
+

Performance Over Time

+ {history.length === 0 ? ( +
Sin datos históricos aún
+ ) : ( +
+ + + + + + + + + + + `$${v}`} + tick={{ fill: 'var(--text-muted)', fontSize: 11 }} + axisLine={false} + tickLine={false} + width={60} + /> + } /> + + + +
+ )} +
+ + {/* Recent trades table */} +
+

Recent Trades

+ {trades.length === 0 ? ( +
Sin trades registrados aún
+ ) : ( +
+ + + + + + + + + + + + + + {trades.map((t) => ( + + + + + + + + + + ))} + +
TimeMarketDirSizePriceSharesFee
+ {fmtTime(t.timestamp)} + {t.question} + + {t.direction} + + {fmtUSD(t.size_usdc)}{fmt(t.entry_price, 3)}{fmt(t.shares, 2)}{fmtUSD(t.fee_usdc)}
+
+ )} +
+
+ ) +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..2dc8d3d --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,257 @@ +:root { + --bg: #0F1117; + --surface: #1A1D27; + --surface2: #232635; + --border: rgba(255, 255, 255, 0.08); + --text: #F1F5F9; + --text-muted: #94A3B8; + --blue: #3B82F6; + --green: #22C55E; + --red: #EF4444; + --amber: #F59E0B; + --purple: #8B5CF6; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, Inter, sans-serif; + font-size: 14px; + min-height: 100vh; +} + +.app { + max-width: 1280px; + margin: 0 auto; + padding: 24px; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.header h1 { + font-size: 22px; + font-weight: 600; +} + +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + margin-left: 12px; +} + +.badge.paper { + background: #f59e0b26; + color: var(--amber); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.last-update { + color: var(--text-muted); + font-size: 12px; +} + +/* Promotion banner */ +.promotion-banner { + display: flex; + align-items: center; + gap: 16px; + background: #22c55e1a; + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 24px; +} + +.promotion-banner .banner-icon { + font-size: 24px; +} + +.promotion-banner code { + background: #ffffff1a; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; +} + +/* Metrics grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.metric-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 20px; +} + +.metric-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} + +.metric-title { + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.metric-value { + font-size: 22px; + font-weight: 600; +} + +.metric-subtitle { + color: var(--text-muted); + font-size: 11px; +} + +.progress-bar { + height: 3px; + background: #ffffff14; + border-radius: 2px; + margin-top: 10px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 2px; + transition: width 0.5s ease; +} + +/* Sections */ +.section { + margin-bottom: 32px; +} + +.section h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 16px; + color: var(--text-muted); +} + +.chart-container { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.empty-chart, +.empty-table { + text-align: center; + padding: 48px; + color: var(--text-muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; +} + +/* Trade table */ +.table-wrapper { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow-x: auto; +} + +.trade-table { + width: 100%; + border-collapse: collapse; +} + +.trade-table th { + padding: 12px 16px; + text-align: left; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +.trade-table td { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.trade-table tr:last-child td { + border-bottom: none; +} + +.trade-table tr:hover td { + background: #ffffff05; +} + +.market-question { + max-width: 360px; + color: var(--text-muted); +} + +.direction-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; +} + +.direction-badge.yes { + background: #22c55e1a; + color: var(--green); +} + +.direction-badge.no { + background: #ef44441a; + color: var(--red); +} + +/* Loading */ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: 16px; + color: var(--text-muted); +} + +.spinner { + width: 32px; + height: 32px; + border: 2px solid var(--border); + border-top-color: var(--blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx new file mode 100644 index 0000000..5e8d112 --- /dev/null +++ b/dashboard/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js new file mode 100644 index 0000000..510593b --- /dev/null +++ b/dashboard/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://api:8000', + changeOrigin: true, + }, + }, + }, +})