feat: add dashboard source code with Vite + React + Recharts and CI/CD build
CI/CD / build-and-push (push) Successful in 2m24s

- 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 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-14 17:18:32 +00:00
parent 324edbe4c8
commit 9bdafaa51e
9 changed files with 677 additions and 2 deletions
+17 -2
View File
@@ -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
+19
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polymarket Bot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+18
View File
@@ -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;
}
+20
View File
@@ -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"
}
}
+310
View File
@@ -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 (
<div className="metric-card">
<div className="metric-header">
<span className="metric-title">{title}</span>
</div>
<div className="metric-value">{value}</div>
{subtitle && <div className="metric-subtitle">{subtitle}</div>}
{progress != null && (
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${Math.min(100, Math.max(0, progress * 100))}%`,
background: progressColor || 'var(--blue)',
}}
/>
</div>
)}
</div>
)
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div style={{
background: 'var(--surface2)',
border: '1px solid var(--border)',
borderRadius: 8,
padding: '10px 14px',
fontSize: 12,
}}>
<div style={{ color: 'var(--text-muted)', marginBottom: 4 }}>{label}</div>
{payload.map((p) => (
<div key={p.dataKey} style={{ color: p.color }}>
P&amp;L: {fmtUSD(p.value)}
</div>
))}
</div>
)
}
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 (
<div className="loading">
<div className="spinner" />
<span>Cargando...</span>
</div>
)
}
if (error && !summary) {
return (
<div className="loading">
<span>Error conectando con la API: {error}</span>
</div>
)
}
const pnlColor = summary.total_pnl >= 0 ? 'var(--green)' : 'var(--red)'
return (
<div className="app">
{/* Header */}
<div className="header">
<div className="header-left">
<h1>
Polymarket Bot
{summary.paper_mode && <span className="badge paper">PAPER</span>}
</h1>
</div>
<div className="header-right">
{lastUpdate && (
<span className="last-update">
Actualizado: {lastUpdate.toLocaleTimeString('es-ES')}
</span>
)}
</div>
</div>
{/* Promotion banner */}
{summary.promotion_ready && (
<div className="promotion-banner">
<span className="banner-icon">🚀</span>
<div>
<strong>Listo para producción</strong> El bot cumple los criterios de
rendimiento. Considera desactivar <code>PAPER_MODE</code>.
</div>
</div>
)}
{/* Metrics grid */}
<div className="metrics-grid">
<MetricCard
title="Bankroll"
value={fmtUSD(summary.paper_bankroll)}
subtitle="Capital inicial (paper)"
/>
<MetricCard
title="P&L Total"
value={<span style={{ color: pnlColor }}>{fmtUSD(summary.total_pnl)}</span>}
subtitle={`${fmtPct(summary.total_pnl / summary.paper_bankroll)} sobre bankroll`}
progress={summary.total_pnl / summary.paper_bankroll + 0.5}
progressColor={pnlColor}
/>
<MetricCard
title="Win Rate"
value={fmtPct(summary.win_rate)}
subtitle="Objetivo ≥ 52%"
progress={summary.win_rate}
progressColor={summary.win_rate >= 0.52 ? 'var(--green)' : 'var(--amber)'}
/>
<MetricCard
title="Sharpe"
value={fmt(summary.sharpe_ratio)}
subtitle="Objetivo ≥ 0.5"
progress={Math.min(1, summary.sharpe_ratio / 2)}
progressColor={summary.sharpe_ratio >= 0.5 ? 'var(--green)' : 'var(--amber)'}
/>
<MetricCard
title="Calibration"
value={fmt(summary.calibration_score)}
subtitle="Objetivo ≥ 0.7"
progress={summary.calibration_score}
progressColor={summary.calibration_score >= 0.7 ? 'var(--green)' : 'var(--amber)'}
/>
<MetricCard
title="Capital Deployed"
value={fmtUSD(summary.total_deployed)}
subtitle={`${summary.total_trades} trades`}
/>
</div>
{/* Performance chart */}
<div className="section">
<h2>Performance Over Time</h2>
{history.length === 0 ? (
<div className="empty-chart">Sin datos históricos aún</div>
) : (
<div className="chart-container">
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={history} margin={{ top: 4, right: 16, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="pnlGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--blue)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--blue)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fill: 'var(--text-muted)', fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => `$${v}`}
tick={{ fill: 'var(--text-muted)', fontSize: 11 }}
axisLine={false}
tickLine={false}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="pnl"
stroke="var(--blue)"
strokeWidth={2}
fill="url(#pnlGrad)"
dot={false}
activeDot={{ r: 4, fill: 'var(--blue)' }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Recent trades table */}
<div className="section">
<h2>Recent Trades</h2>
{trades.length === 0 ? (
<div className="empty-table">Sin trades registrados aún</div>
) : (
<div className="table-wrapper">
<table className="trade-table">
<thead>
<tr>
<th>Time</th>
<th>Market</th>
<th>Dir</th>
<th>Size</th>
<th>Price</th>
<th>Shares</th>
<th>Fee</th>
</tr>
</thead>
<tbody>
{trades.map((t) => (
<tr key={t.id}>
<td style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
{fmtTime(t.timestamp)}
</td>
<td className="market-question">{t.question}</td>
<td>
<span className={`direction-badge ${t.direction?.toLowerCase()}`}>
{t.direction}
</span>
</td>
<td>{fmtUSD(t.size_usdc)}</td>
<td>{fmt(t.entry_price, 3)}</td>
<td>{fmt(t.shares, 2)}</td>
<td style={{ color: 'var(--text-muted)' }}>{fmtUSD(t.fee_usdc)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
+257
View File
@@ -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); }
}
+10
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)
+14
View File
@@ -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,
},
},
},
})