feat: add trade dashboard service (FastAPI + Tailscale)

- FastAPI app with async SQLAlchemy + Alembic migrations
- API: positions CRUD, PnL summary, trade history, webhook endpoint
- Dark-themed SPA frontend (vanilla HTML/JS)
- K8s: deployment, service, configmap, TsProxy for Tailscale access
- Backed by hermes-pgdb / trading_data database
- Wired into Flux staging pipeline
This commit is contained in:
sirius0xdev 2026-05-02 21:47:27 +00:00
parent 1668575d1a
commit 7df60cbeb9
17 changed files with 792 additions and 0 deletions

View file

@ -0,0 +1,18 @@
FROM python:3.13-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY alembic.ini ./alembic.ini
COPY alembic/ ./alembic/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://trading:CHANGE_ME@hermes-pgdb-rw.customer1.svc.cluster.local:5432/trading_data
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View file

@ -0,0 +1,61 @@
"""Alembic environment configuration."""
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from models import metadata # noqa: E402
target_metadata = metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = AsyncEngine(
engine_from_config(
config.get_section(config.config_ini_section) or {},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
)
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
from sqlalchemy.ext.asyncio import run_async # noqa: E402
run_async(run_migrations_online())

View file

@ -0,0 +1,2 @@
# Alembic migration script - DO NOT EDIT MANUALLY
# Use: alembic revision --autogenerate -m "description"

View file

@ -0,0 +1,43 @@
"""initial schema — positions table
Revision ID: 001_initial
Create Date: 2026-05-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_enum("position_direction", "long", "short", schema="public", create_type=True)
op.create_table(
"positions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("symbol", sa.String(32), nullable=False),
sa.Column("direction", postgresql.ENUM("long", "short", name="position_direction", create_type=False), nullable=False),
sa.Column("entry_price", sa.Numeric(precision=16, scale=8), nullable=False),
sa.Column("exit_price", sa.Numeric(precision=16, scale=8)),
sa.Column("quantity", sa.Numeric(precision=16, scale=8), nullable=False),
sa.Column("exchange", sa.String(32), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("closed_at", sa.DateTime(timezone=True)),
sa.Column("pnl", sa.Numeric(precision=16, scale=2)),
sa.Column("metadata", sa.JSON),
)
op.create_index(op.f("ix_positions_symbol"), "positions", ["symbol"])
op.create_index(op.f("ix_positions_exchange"), "positions", ["exchange"])
def downgrade() -> None:
op.drop_index(op.f("ix_positions_exchange"), table_name="positions")
op.drop_index(op.f("ix_positions_symbol"), table_name="positions")
op.drop_table("positions")
op.execute("DROP TYPE IF EXISTS position_direction")

View file

@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy import MetaData
# Connection to hermes-pgdb CNPG cluster
DATABASE_URL = (
f"postgresql+asyncpg://{db_user}:{db_pass}"
f"@hermes-pgdb-rw.customer1.svc.cluster.local:5432/trading_data"
).format(
db_user="trading",
db_pass="TRADING_DB_PASSWORD", # overridden by env
)
import os
DB_USER = os.getenv("DB_USER", "trading")
DB_PASS = os.getenv("DB_PASSWORD", "")
DB_HOST = os.getenv("DB_HOST", "hermes-pgdb-rw.customer1.svc.cluster.local")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "trading_data")
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=5, max_overflow=10)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
metadata = MetaData()

View file

@ -0,0 +1,253 @@
"""Trade Dashboard — FastAPI service for tracking PnL and open positions."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from uuid import UUID
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session
from models import positions
from schemas import (
Direction,
PnLSnapshot,
PositionCreate,
PositionOut,
PositionUpdate,
WebhookTrade,
)
app = FastAPI(title="Trade Dashboard", version="0.1.0")
STATIC_DIR = Path(__file__).parent / "static"
# ── Helpers ─────────────────────────────────────────────────────────────
def position_to_out(row: dict) -> PositionOut:
return PositionOut(
id=row["id"],
symbol=row["symbol"],
direction=row["direction"],
entry_price=row["entry_price"],
exit_price=row["exit_price"],
quantity=row["quantity"],
exchange=row["exchange"],
opened_at=row["opened_at"],
closed_at=row["closed_at"],
pnl=row["pnl"],
metadata=row["metadata"],
)
# ── Health ─────────────────────────────────────────────────────────────
@app.get("/api/health")
async def health():
async with async_session() as session:
result = await session.execute(select(func.now()))
db_time = result.scalar()
return {"status": "ok", "db_time": db_time.isoformat()}
# ── Positions ────────────────────────────────────────────────────────────
@app.get("/api/positions", response_model=list[PositionOut])
async def list_positions(
open_only: bool = Query(True, description="Only show open positions"),
):
async with async_session() as session:
if open_only:
stmt = select(positions).where(positions.c.closed_at.is_(None)).order_by(positions.c.opened_at.desc())
else:
stmt = select(positions).order_by(positions.c.opened_at.desc())
rows = (await session.execute(stmt)).mappings().all()
return [position_to_out(r) for r in rows]
@app.post("/api/positions", status_code=201)
async def create_position(payload: PositionCreate):
async with async_session() as session:
values = payload.model_dump()
result = await session.execute(positions.insert().values(**values))
session.commit()
pk = result.inserted_primary_key[0]
return {"id": str(pk)}
@app.patch("/api/positions/{position_id}")
async def update_position(position_id: UUID, payload: PositionUpdate):
async with async_session() as session:
row = await session.execute(
select(positions).where(positions.c.id == position_id)
)
row = row.mappings().one_or_none()
if not row:
raise HTTPException(404, "Position not found")
updates = payload.model_dump(exclude_unset=True)
# Auto-compute PnL if closing
if "exit_price" in updates:
entry = row["entry_price"]
qty = row["quantity"]
exit_p = updates["exit_price"]
direction = row["direction"]
if direction == "long":
updates["pnl"] = float((exit_p - entry) * qty)
else:
updates["pnl"] = float((entry - exit_p) * qty)
updates["closed_at"] = datetime.now(timezone.utc)
await session.execute(
positions.update().where(positions.c.id == position_id).values(**updates)
)
session.commit()
return {"ok": True}
@app.delete("/api/positions/{position_id}")
async def close_position(position_id: UUID, exit_price: Decimal = Query(None)):
async with async_session() as session:
row = await session.execute(
select(positions).where(positions.c.id == position_id)
)
row = row.mappings().one_or_none()
if not row:
raise HTTPException(404, "Position not found")
if row["closed_at"]:
raise HTTPException(400, "Position already closed")
exit_p = exit_price or row["entry_price"] # breakeven default
entry = row["entry_price"]
qty = row["quantity"]
direction = row["direction"]
if direction == "long":
pnl = float((exit_p - entry) * qty)
else:
pnl = float((entry - exit_p) * qty)
await session.execute(
positions.update()
.where(positions.c.id == position_id)
.values(exit_price=exit_p, closed_at=datetime.now(timezone.utc), pnl=pnl)
)
session.commit()
return {"ok": True, "pnl": pnl, "exit_price": float(exit_p)}
# ── PnL ─────────────────────────────────────────────────────────────────
@app.get("/api/pnl", response_model=PnLSnapshot)
async def get_pnl():
async with async_session() as session:
now = datetime.now(timezone.utc)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today - timedelta(days=now.weekday())
month_start = today.replace(day=1)
# Summary for closed trades
closed = select(
func.coalesce(func.sum(positions.c.pnl), 0).label("total"),
func.count(positions.c.id).label("count"),
).where(positions.c.closed_at.isnot(None))
result = (await session.execute(closed)).mappings().one()
all_time_pnl = float(result["total"])
total_trades = result["count"]
# PnL by period
def period_query(start):
return select(
func.coalesce(func.sum(positions.c.pnl), 0)
).where(
and_(
positions.c.closed_at.isnot(None),
positions.c.closed_at >= start,
)
)
today_pnl = float((await session.execute(period_query(today))).scalar())
week_pnl = float((await session.execute(period_query(week_start))).scalar())
month_pnl = float((await session.execute(period_query(month_start))).scalar())
# Open count
open_count = (await session.execute(
select(func.count()).where(positions.c.closed_at.is_(None))
)).scalar()
return PnLSnapshot(
today_pnl=Decimal(str(today_pnl)),
week_pnl=Decimal(str(week_pnl)),
month_pnl=Decimal(str(month_pnl)),
all_time_pnl=Decimal(str(all_time_pnl)),
total_trades=total_trades,
open_positions=open_count,
)
@app.get("/api/pnl/history", response_model=list[PositionOut])
async def pnl_history(
limit: int = Query(50, ge=1, le=500),
):
async with async_session() as session:
stmt = (
select(positions)
.where(positions.c.closed_at.isnot(None))
.order_by(positions.c.closed_at.desc())
.limit(limit)
)
rows = (await session.execute(stmt)).mappings().all()
return [position_to_out(r) for r in rows]
# ── Webhook (for scanner scripts) ───────────────────────────────────────
@app.post("/webhook/trade", status_code=201)
async def webhook_trade(payload: WebhookTrade):
meta = {"strategy": payload.strategy} if payload.strategy else {}
async with async_session() as session:
result = await session.execute(positions.insert().values(**{
"symbol": payload.symbol,
"direction": payload.direction,
"entry_price": payload.entry_price,
"quantity": payload.quantity,
"exchange": payload.exchange,
"metadata": meta,
}))
session.commit()
pk = result.inserted_primary_key[0]
return {"id": str(pk)}
# ── Frontend ────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
return FileResponse(str(STATIC_DIR / "index.html"))
# ── Startup: run Alembic migrations ──────────────────────────────────────
@app.on_event("startup")
async def startup():
import alembic.config
cfg = alembic.config.AlembicConfig(
str(Path(__file__).parent.parent / "alembic.ini")
)
alembic.config.main.main(command="upgrade", args=["head"], config=cfg)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -0,0 +1,21 @@
from sqlalchemy import Column, String, Numeric, Enum, DateTime, JSON, func
from sqlalchemy.dialects.postgresql import UUID
import uuid
from database import metadata
positions = Table(
"positions",
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
Column("symbol", String(32), nullable=False, index=True),
Column("direction", Enum("long", "short", name="position_direction"), nullable=False),
Column("entry_price", Numeric(precision=16, scale=8), nullable=False),
Column("exit_price", Numeric(precision=16, scale=8)),
Column("quantity", Numeric(precision=16, scale=8), nullable=False),
Column("exchange", String(32), nullable=False, index=True),
Column("opened_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
Column("closed_at", DateTime(timezone=True)),
Column("pnl", Numeric(precision=16, scale=2)),
Column("metadata", JSON),
)

View file

@ -0,0 +1,7 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.9.2
python-dotenv==1.0.1

View file

@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class Direction(str, Enum):
long = "long"
short = "short"
# ─── Request schemas ──────────────────────────────────────────────
class PositionCreate(BaseModel):
symbol: str
direction: Direction
entry_price: Decimal
quantity: Decimal
exchange: str
metadata: Optional[dict] = None
class PositionUpdate(BaseModel):
entry_price: Optional[Decimal] = None
exit_price: Optional[Decimal] = None
quantity: Optional[Decimal] = None
metadata: Optional[dict] = None
class WebhookTrade(BaseModel):
"""Payload from automated scanner scripts."""
symbol: str
direction: Direction
entry_price: Decimal
quantity: Decimal
exchange: str
strategy: Optional[str] = None
# ─── Response schemas ─────────────────────────────────────────────
class PositionOut(BaseModel):
id: UUID
symbol: str
direction: Direction
entry_price: Decimal
exit_price: Optional[Decimal]
quantity: Decimal
exchange: str
opened_at: datetime
closed_at: Optional[datetime]
pnl: Optional[Decimal]
metadata: Optional[dict]
model_config = {"from_attributes": True}
class PnLSnapshot(BaseModel):
today_pnl: Decimal
week_pnl: Decimal
month_pnl: Decimal
all_time_pnl: Decimal
total_trades: int
open_positions: int

View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trade Dashboard</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #c9d1d9; --muted: #8b949e; --green: #3fb950;
--red: #f85149; --blue: #58a6ff; --accent: #1f6feb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 1rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--blue); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.card .label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
.card .value { font-size: 1.5rem; font-weight: 600; margin-top: 0.25rem; }
.card .value.positive { color: var(--green); }
.card .value.negative { color: var(--red); }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
th { color: var(--muted); font-weight: 500; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.badge.long { background: #12261e; color: var(--green); }
.badge.short { background: #2a1215; color: var(--red); }
.btn { background: var(--accent); color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn:hover { opacity: 0.9; }
.btn.close { background: var(--red); font-size: 0.75rem; padding: 0.25rem 0.5rem; }
.section { margin-top: 2rem; }
.section h2 { font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--blue); }
form { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
input, select { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 6px; font-size: 0.85rem; }
input:focus, select:focus { outline: 1px solid var(--accent); }
.tab-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.tab { padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; background: var(--card); border: 1px solid var(--border); }
.tab.active { border-color: var(--accent); color: var(--blue); }
#loading { color: var(--muted); font-size: 0.85rem; }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr 1fr; } }
</style>
</head>
<body>
<h1>📊 Trade Dashboard</h1>
<!-- PnL Summary Cards -->
<div class="grid" id="pnl-cards">
<div class="card"><div class="label">Today PnL</div><div class="value" id="today-pnl"></div></div>
<div class="card"><div class="label">Week PnL</div><div class="value" id="week-pnl"></div></div>
<div class="card"><div class="label">Month PnL</div><div class="value" id="month-pnl"></div></div>
<div class="card"><div class="label">All Time</div><div class="value" id="all-pnl"></div></div>
</div>
<!-- Open Positions -->
<div class="section">
<h2>Open Positions <span id="open-count" class="badge long">0</span></h2>
<table>
<thead><tr><th>Symbol</th><th>Dir</th><th>Entry</th><th>Qty</th><th>Exchange</th><th>Opened</th><th>Action</th></tr></thead>
<tbody id="positions-body"></tbody>
</table>
</div>
<!-- New Position Form -->
<div class="section">
<h2>Open New Position</h2>
<form id="open-form">
<input type="text" id="sym" placeholder="Symbol (e.g. SOL)" required>
<select id="dir"><option value="long">Long</option><option value="short">Short</option></select>
<input type="number" step="any" id="entry" placeholder="Entry Price" required>
<input type="number" step="any" id="qty" placeholder="Quantity" required>
<input type="text" id="exch" placeholder="Exchange (OKX, Bybit…)" required>
<button type="submit" class="btn">Open Position</button>
</form>
</div>
<!-- Trade History -->
<div class="section">
<h2>Trade History</h2>
<table>
<thead><tr><th>Symbol</th><th>Dir</th><th>Entry</th><th>Exit</th><th>Qty</th><th>PnL</th><th>Exchange</th><th>Closed</th></tr></thead>
<tbody id="history-body"></tbody>
</table>
</div>
<script>
const fmt = (n) => {
const v = parseFloat(n);
const cls = v >= 0 ? 'positive' : 'negative';
return `<span class="${cls}">${v >= 0 ? '+' : ''}${v.toFixed(2)}</span>`;
};
async function loadPnL() {
const r = await fetch('/api/pnl').then(x => x.json());
document.getElementById('today-pnl').innerHTML = fmt(r.today_pnl);
document.getElementById('week-pnl').innerHTML = fmt(r.week_pnl);
document.getElementById('month-pnl').innerHTML = fmt(r.month_pnl);
document.getElementById('all-pnl').innerHTML = fmt(r.all_time_pnl);
}
async function loadPositions() {
const positions = await fetch('/api/positions?open_only=true').then(x => x.json());
document.getElementById('open-count').textContent = positions.length;
const body = document.getElementById('positions-body');
body.innerHTML = positions.map(p => `
<tr>
<td>${p.symbol}</td>
<td><span class="badge ${p.direction}">${p.direction.toUpperCase()}</span></td>
<td>${p.entry_price}</td>
<td>${p.quantity}</td>
<td>${p.exchange}</td>
<td>${new Date(p.opened_at).toLocaleString()}</td>
<td><button class="btn close" onclick="closePos('${p.id}')">Close</button></td>
</tr>`).join('');
}
async function loadHistory() {
const history = await fetch('/api/pnl/history?limit=100').then(x => x.json());
const body = document.getElementById('history-body');
body.innerHTML = history.map(p => `
<tr>
<td>${p.symbol}</td>
<td><span class="badge ${p.direction}">${p.direction.toUpperCase()}</span></td>
<td>${p.entry_price}</td>
<td>${p.exit_price ?? '—'}</td>
<td>${p.quantity}</td>
<td>${p.pnl != null ? (parseFloat(p.pnl) >= 0 ? '+' : '') + parseFloat(p.pnl).toFixed(2) : '—'}</td>
<td>${p.exchange}</td>
<td>${p.closed_at ? new Date(p.closed_at).toLocaleString() : '—'}</td>
</tr>`).join('');
}
async function closePos(id) {
const price = prompt('Exit price:');
if (!price) return;
await fetch(`/api/positions/${id}?exit_price=${price}`, { method: 'DELETE' });
refresh();
}
document.getElementById('open-form').onsubmit = async (e) => {
e.preventDefault();
const body = {
symbol: document.getElementById('sym').value,
direction: document.getElementById('dir').value,
entry_price: parseFloat(document.getElementById('entry').value),
quantity: parseFloat(document.getElementById('qty').value),
exchange: document.getElementById('exch').value,
};
await fetch('/api/positions', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
e.target.reset();
refresh();
};
async function refresh() { await loadPnL(); await loadPositions(); await loadHistory(); }
refresh();
setInterval(refresh, 30000); // auto-refresh every 30s
</script>
</body>
</html>

View file

@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: trade-dashboard-config
namespace: customer1
data:
DB_USER: "trading"
DB_HOST: "hermes-pgdb-rw.customer1.svc.cluster.local"
DB_PORT: "5432"
DB_NAME: "trading_data"

View file

@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: trade-dashboard
namespace: customer1
labels:
app: trade-dashboard
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: trade-dashboard
template:
metadata:
labels:
app: trade-dashboard
annotations:
checksum/config: trade-dashboard-config
spec:
terminationGracePeriodSeconds: 30
containers:
- name: dashboard
image: us-central1-docker.pkg.dev/devops-lab-cluster/customer1/trade-dashboard:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
envFrom:
- configMapRef:
name: trade-dashboard-config
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: trading-db-credentials
key: password
startupProbe:
httpGet:
path: /api/health
port: 8000
initialDelaySeconds: 15
periodSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /api/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 30

View file

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- tsproxy.yaml

View file

@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: trade-dashboard
namespace: customer1
spec:
selector:
app: trade-dashboard
ports:
- port: 80
targetPort: 8000
name: http

View file

@ -0,0 +1,9 @@
apiVersion: tailscale.com/v1
kind: TsProxy
metadata:
name: trade-dashboard
namespace: customer1
spec:
serviceName: trade-dashboard
servicePort: 80
hostname: trade-dashboard

View file

@ -9,3 +9,4 @@ resources:
- ../../base/customer1/paaas-landing/
- ../../base/customer1/hermes-agent/
- ../../base/customer1/hermes-db/
- ../../base/customer1/trade-dashboard/