refactor: remove trade-dashboard app code and rays-new-deployment.yaml
- Removed trade-dashboard/ (moved to hermes-projects/trade-dashboard/) - Removed .github/workflows/trade-dashboard.yml (CI now handled by hermes-projects root workflow) - Deleted rays-new-deployment.yaml (orphan deployment YAML) K8s manifests remain in apps/base/customer1/trade-dashboard/. See hermes-projects PR #8 for the addition side.
This commit is contained in:
parent
3a9a0476d2
commit
d89c1944e6
15 changed files with 648 additions and 955 deletions
52
.github/workflows/trade-dashboard.yml
vendored
52
.github/workflows/trade-dashboard.yml
vendored
|
|
@ -1,52 +0,0 @@
|
|||
name: Build and Push Trade Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'trade-dashboard/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/trade-dashboard
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: trade-dashboard/
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
303
analyses/telegram-webhook-container-analysis.md
Normal file
303
analyses/telegram-webhook-container-analysis.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# Telegram Webhook Failure Analysis - Deep Dive (Container-Level)
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Repo:** https://github.com/sirius0xdev/gcloud-lab
|
||||
**Cluster:** devops-lab (GKE), Namespace: customer1
|
||||
**Webhook URL:** https://ws.siriusdevops.com/telegram/webhook/default
|
||||
|
||||
---
|
||||
|
||||
## Status Update
|
||||
|
||||
**TLS certificates ARE provisioned** in GCP admin console. The Gateway certmap annotation is working.
|
||||
|
||||
The real issues are in the **container configuration** and **missing probes**.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #1: NO Liveness/Readiness Probes (CONFIRMED CRITICAL)
|
||||
|
||||
**The hermes-agent deployment has ZERO probes defined.**
|
||||
|
||||
This means:
|
||||
- Kubernetes marks pods "Ready" immediately after container start
|
||||
- The Gateway routes webhook traffic before the process has bound to port 9118
|
||||
- During restarts, there is no graceful drain
|
||||
- **A crashed or hung pod stays in the endpoint list forever**
|
||||
|
||||
### The /health endpoint problem
|
||||
|
||||
The Telegram webhook server runs on **port 9118** via python-telegram-bot's `start_webhook()`. This internally starts an aiohttp server that **ONLY registers the webhook path** (`/telegram/webhook/default`). It does NOT expose a `/health` endpoint.
|
||||
|
||||
So a probe like this would FAIL:
|
||||
```yaml
|
||||
# THIS WON'T WORK - port 9118 has no /health
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 9118
|
||||
```
|
||||
|
||||
### What DOES have a health endpoint?
|
||||
|
||||
The **generic webhook adapter** on **port 8644** IS a Hermes-managed server. From the source code, it exposes `/health`. This IS enabled via `WEBHOOK_ENABLED: "true"`.
|
||||
|
||||
### The Fix
|
||||
|
||||
Add probes targeting port 8644 (the generic webhook server that IS healthy when the gateway is running):
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8644
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8644
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
Alternatively, enable the API server on port 8642 and probe there (it also has `/health`).
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #2: NO Resource Limits (HIGH)
|
||||
|
||||
The **new deployment dropped all resource limits** that existed in the old deployment.
|
||||
|
||||
**Old deployment (`deployment.yaml`):**
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: "1"
|
||||
limits:
|
||||
memory: 3Gi
|
||||
cpu: "2"
|
||||
```
|
||||
|
||||
**New deployment (`new-deployment.yaml`):**
|
||||
```yaml
|
||||
# NO resources block for the hermes-agent container
|
||||
```
|
||||
|
||||
Only the `hermes-webui` sidecar has limits (500Mi-1Gi memory, 100m-500m CPU).
|
||||
|
||||
### Impact
|
||||
- The hermes-agent container can consume unbounded memory
|
||||
- With `agent.max_turns: 90` and `gateway_timeout: 1800` (30 min), a single agent run can eat massive memory
|
||||
- Pod may be OOMKilled by the node, but K8s won't restart it gracefully without probes
|
||||
- **This is a likely cause of intermittent failures**
|
||||
|
||||
### Fix
|
||||
Add resource limits back:
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: "1"
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: "2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #3: Path Handling Concern (MEDIUM)
|
||||
|
||||
**Source code** (`/opt/hermes/gateway/platforms/telegram.py:1213`):
|
||||
```python
|
||||
webhook_path = urlparse(webhook_url).path or "/telegram"
|
||||
```
|
||||
|
||||
For `TELEGRAM_WEBHOOK_URL=https://ws.siriusdevops.com/telegram/webhook/default`:
|
||||
- `webhook_path` = `/telegram/webhook/default`
|
||||
- PTB's aiohttp server registers a handler at exactly `/telegram/webhook/default` on port 9118
|
||||
|
||||
**HTTPRoute** (`hermes-webhook.yaml`):
|
||||
```yaml
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /telegram/webhook
|
||||
backendRefs:
|
||||
- name: http-tele-webhook
|
||||
port: 9118
|
||||
```
|
||||
|
||||
**The question:** Does the GKE Gateway strip the `/telegram/webhook` prefix before forwarding?
|
||||
|
||||
- If it does NOT strip: Pod receives `/telegram/webhook/default` -> **OK**
|
||||
- If it DOES strip to `/default`: Pod receives `/default` -> **404**
|
||||
- If it strips to `/`: Pod receives `/` -> **404**
|
||||
|
||||
Most GatewayAPI implementations pass the full original path by default, but some ingress controllers strip the matched prefix. **Verify this on the running cluster.**
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #4: API Server Not Enabled (MEDIUM)
|
||||
|
||||
The old deployment had:
|
||||
```yaml
|
||||
- name: API_SERVER_ENABLED
|
||||
value: "true"
|
||||
- name: API_SERVER_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: API_SERVER_PORT
|
||||
value: "8642"
|
||||
- name: API_SERVER_KEY
|
||||
valueFrom: {secretKeyRef: ...}
|
||||
- name: API_SERVER_MODEL_NAME
|
||||
value: "hermes-agent"
|
||||
```
|
||||
|
||||
**These are ALL absent from `new-deployment.yaml`.** Port 8642 is declared as a containerPort but nothing listens on it because `API_SERVER_ENABLED` is not set.
|
||||
|
||||
The API server exposes `/health` and `/health/detailed` endpoints. Without it, you lose a convenient health check and the API server interface.
|
||||
|
||||
---
|
||||
|
||||
## Other Findings
|
||||
|
||||
### 5. TELEGRAM_WEBHOOK_ENABLED is Ignored (INFO)
|
||||
|
||||
The code only checks if `TELEGRAM_WEBHOOK_URL` is set (non-empty). It does NOT read `TELEGRAM_WEBHOOK_ENABLED`. The env var in the deployment is a dead config — set but ignored.
|
||||
|
||||
### 6. vLLM Service Name Mismatch (INFO)
|
||||
|
||||
```yaml
|
||||
OPENAI_BASE_URL: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1"
|
||||
```
|
||||
|
||||
But the active vLLM deployment (`rtx6000-vllm.yaml`) creates a service named `rtx6000-brain-service`. If Hermes ever switches from xAI to the openai provider, local vLLM is unreachable.
|
||||
|
||||
### 7. SOPS Secrets (VERIFY)
|
||||
|
||||
Both `hermes-secret.yaml` and `tele-webhook.yaml` are SOPS-encrypted. Verify they are decrypted in the cluster:
|
||||
```bash
|
||||
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d
|
||||
kubectl get secret telegram-webhook -n customer1 -o jsonpath='{.data.TELEGRAM_WEBHOOK_SECRET}' | base64 -d
|
||||
```
|
||||
|
||||
### 8. Container Env Vars Summary
|
||||
|
||||
| Variable | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| TELEGRAM_WEBHOOK_URL | https://ws.siriusdevops.com/telegram/webhook/default | OK |
|
||||
| TELEGRAM_WEBHOOK_PORT | 9118 | OK |
|
||||
| TELEGRAM_WEBHOOK_SECRET | From SOPS secret | Verify decrypted |
|
||||
| TELEGRAM_WEBHOOK_ENABLED | "true" | **Ignored by code** |
|
||||
| TELEGRAM_BOT_TOKEN | From SOPS secret | Verify decrypted |
|
||||
| TELEGRAM_ALLOWED_USERS | 7528130947 | OK |
|
||||
| WEBHOOK_ENABLED | "true" | OK (enables port 8644 /health) |
|
||||
| WEBHOOK_PORT | 8644 | OK |
|
||||
| API_SERVER_ENABLED | **NOT SET** | Port 8642 has no listener |
|
||||
| HERMES_MODEL_PROVIDER | xai | OK (uses Grok) |
|
||||
| HERMES_MODEL | grok-4.20-0309-reasoning | OK |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Fix (Priority Order)
|
||||
|
||||
### P0 - Add Probes (will detect and restart hung/crashed pods)
|
||||
|
||||
Add to `new-deployment.yaml` under the hermes-agent container spec:
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8644
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8644
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
### P1 - Add Resource Limits (prevents OOM kills)
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: "1"
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: "2"
|
||||
```
|
||||
|
||||
### P2 - Verify Path Handling
|
||||
|
||||
Test if the Gateway passes the full path:
|
||||
```bash
|
||||
# From inside the pod, check what the webhook server receives
|
||||
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:9118/telegram/webhook/default -X POST -H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
### P3 - Enable API Server (optional, gives /health on 8642)
|
||||
|
||||
Add back the API_SERVER_ENABLED env var if you want the API server health endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Commands
|
||||
|
||||
Run these on the cluster RIGHT NOW to confirm the current state:
|
||||
|
||||
```bash
|
||||
# 1. Check pod status and restart count
|
||||
kubectl get pods -n customer1 -l app=hermes-agent -o wide
|
||||
|
||||
# 2. Check events for OOMKilled or probe failures
|
||||
kubectl describe pod -n customer1 -l app=hermes-agent | grep -A5 -i 'oom\|probe\|restart'
|
||||
|
||||
# 3. Check if the webhook port is actually listening
|
||||
kubectl exec -n customer1 deploy/hermes-agent -- ss -tlnp | grep 9118
|
||||
|
||||
# 4. Check if port 8644 health endpoint works
|
||||
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:8644/health
|
||||
|
||||
# 5. Check logs for webhook startup messages
|
||||
kubectl logs -n customer1 deploy/hermes-agent --tail=50 | grep -i 'webhook\|listening\|9118'
|
||||
|
||||
# 6. Verify SOPS secrets are decrypted
|
||||
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d && echo
|
||||
kubectl get secret telegram-webhook -n customer1 -o jsonpath='{.data.TELEGRAM_WEBHOOK_SECRET}' | base64 -d && echo
|
||||
|
||||
# 7. Check if the webhook is registered with Telegram (using the pod's bot token)
|
||||
BOT_TOKEN=$(kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d)
|
||||
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo" | python3 -m json.tool
|
||||
|
||||
# 8. Test the full path through the Gateway
|
||||
curl -v https://ws.siriusdevops.com/telegram/webhook/default -X POST -H "Content-Type: application/json" -d '{}' 2>&1
|
||||
|
||||
# 9. Check container memory usage
|
||||
kubectl top pod -n customer1 -l app=hermes-agent 2>/dev/null || echo "metrics-server not available"
|
||||
|
||||
# 10. Check resource limits on the container
|
||||
kubectl get pod -n customer1 -l app=hermes-agent -o jsonpath='{.items[0].spec.containers[0].resources}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `apps/base/customer1/hermes-agent/new-deployment.yaml` - Add probes + resource limits
|
||||
2. Optionally: Re-enable API server env vars in `new-deployment.yaml`
|
||||
345
analyses/telegram-webhook-failure-analysis.md
Normal file
345
analyses/telegram-webhook-failure-analysis.md
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
# Telegram Webhook Failure Analysis - Hermes Agent on GKE
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Repo:** https://github.com/sirius0xdev/gcloud-lab
|
||||
**Cluster:** devops-lab (GKE)
|
||||
**Namespace:** customer1
|
||||
**Webhook URL:** https://ws.siriusdevops.com/telegram/webhook/default
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Telegram webhook is failing because **there is no valid TLS certificate for `ws.siriusdevops.com`** at the Gateway layer. Telegram strictly requires HTTPS with a publicly-trusted certificate for webhook delivery. The Gateway listener declares `protocol: HTTPS` but has no `tls.certificateRefs` and relies on a GKE `CertMap` annotation referencing `gateway-cert-map` — a resource that **does not exist** in the repository. Cert-manager is configured but disconnected from the GatewayAPI setup (HTTP-01 solver points to a non-existent Traefik ingress class).
|
||||
|
||||
**TL;DR:** Telegram tries to POST to `https://ws.siriusdevops.com/...`, but the Gateway has no certificate to present during the TLS handshake. Connection fails before it ever reaches the hermes-agent pod.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #1: Missing TLS Certificate (CRITICAL)
|
||||
|
||||
### What's happening
|
||||
|
||||
**File:** `infrastructure/gatewayapi/apigateway.yaml`
|
||||
|
||||
```yaml
|
||||
listeners:
|
||||
- name: https
|
||||
protocol: HTTPS
|
||||
port: 443
|
||||
allowedRoutes:
|
||||
namespaces:
|
||||
from: All
|
||||
```
|
||||
|
||||
The listener says HTTPS but has **no `tls:` block**. It relies entirely on this annotation:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
networking.gke.io/certmap: gateway-cert-map
|
||||
```
|
||||
|
||||
### The problem
|
||||
|
||||
- **No `CertMap` or `CertMapEntry` resource** exists anywhere in the repository for `gateway-cert-map`
|
||||
- Without it, GKE has no managed certificate to attach to the Gateway
|
||||
- Telegram's webhook delivery gets a TLS handshake failure or no certificate
|
||||
|
||||
### Cert-manager is also broken
|
||||
|
||||
**File:** `infrastructure/controllers/base/certmanager/clusterissuer.yaml`
|
||||
|
||||
```yaml
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
```
|
||||
|
||||
- HTTP-01 solver references `class: traefik`, but **no Traefik ingress controller exists** in the cluster
|
||||
- **No `Certificate` CRs** exist for `ws.siriusdevops.com` or any other domain
|
||||
- Cert-manager is completely disconnected from the GatewayAPI setup
|
||||
|
||||
### How to fix (choose ONE approach)
|
||||
|
||||
**Option A: GKE Managed Certificates (recommended for GatewayAPI)**
|
||||
|
||||
Create a `ManagedCertificate` + `BackendConfig` or `CertMap`/`CertMapEntry`:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.gke.io/v1
|
||||
kind: ManagedCertificate
|
||||
metadata:
|
||||
name: hermes-webhook-cert
|
||||
namespace: customer1
|
||||
spec:
|
||||
domains:
|
||||
- ws.siriusdevops.com
|
||||
- brain.siriusdevops.com
|
||||
- paaas.siriusdevops.com
|
||||
```
|
||||
|
||||
Then add `tls.certificateRefs` to the Gateway listener:
|
||||
|
||||
```yaml
|
||||
listeners:
|
||||
- name: https
|
||||
protocol: HTTPS
|
||||
port: 443
|
||||
tls:
|
||||
certificateRefs:
|
||||
- name: hermes-webhook-cert
|
||||
group: networking.gke.io
|
||||
```
|
||||
|
||||
**Option B: Fix Cert-manager with DNS-01**
|
||||
|
||||
Switch ClusterIssuer from HTTP-01/Traefik to DNS-01 (e.g., Cloudflare, GCP DNS, or Route53), then create `Certificate` resources for each domain.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #2: No Readiness/Liveness Probes (HIGH)
|
||||
|
||||
**File:** `apps/base/customer1/hermes-agent/new-deployment.yaml`
|
||||
|
||||
No readiness or liveness probes are defined on any hermes-agent deployment.
|
||||
|
||||
### Impact
|
||||
- Pods are marked "Ready" immediately after container start
|
||||
- Gateway routes traffic to the webhook port (9118) before the process has bound to it
|
||||
- During restarts, traffic hits pods that haven't initialized
|
||||
|
||||
### Fix
|
||||
Add probes to the deployment:
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 9118
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 9118
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Root Cause #3: SOPS Encrypted Secrets (HIGH)
|
||||
|
||||
**Files:**
|
||||
- `apps/base/customer1/hermes-agent/hermes-secret.yaml` (SOPS encrypted)
|
||||
- `apps/base/customer1/hermes-agent/tele-webhook.yaml` (SOPS encrypted)
|
||||
|
||||
These contain `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET`.
|
||||
|
||||
### Risk
|
||||
- If the deployment pipeline (Flux/Kustomize controller) is **not decrypting SOPS secrets**, pods receive literal `ENC[...]` strings
|
||||
- The bot token would be invalid, so even if TLS worked, Telegram auth would fail
|
||||
- The webhook secret would not match, causing Telegram to reject payloads
|
||||
|
||||
### Verify
|
||||
Run `kubectl get secret hermes-secrets -n customer1 -o yaml` and check if values are base64-encoded real tokens or `ENC[...]` strings.
|
||||
|
||||
---
|
||||
|
||||
## Secondary Issues
|
||||
|
||||
### 3a. Service Name Mismatch (vLLM Integration)
|
||||
|
||||
**File:** `apps/base/customer1/hermes-agent/new-deployment.yaml`
|
||||
|
||||
```yaml
|
||||
OPENAI_BASE_URL: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1"
|
||||
```
|
||||
|
||||
But the active vLLM deployment (`rtx6000-vllm.yaml`) creates a service named **`rtx6000-brain-service`**.
|
||||
|
||||
- `OPENAI_BASE_URL` points to `openclaw-brain-service` which may not exist
|
||||
- If Hermes ever switches to the `openai` provider (instead of `xai`), local vLLM is unreachable
|
||||
- The model provider defaults to `HERMES_MODEL_PROVIDER: xai` (Grok external API)
|
||||
|
||||
### 3b. Duplicate HTTPRoute Deployment
|
||||
|
||||
The webhook HTTPRoute (`hermes-webhook.yaml`) is included in **two** kustomization trees:
|
||||
|
||||
1. `infrastructure/gatewayapi/gateway-routes/kustomization.yaml` -> deployed via Flux
|
||||
2. `apps/base/customer1/hermes-agent/kustomization.yaml` -> deployed via Flux
|
||||
|
||||
Same resource (`http-telegram-webhook` in `customer1`) from two sources. This may cause Flux reconciliation conflicts.
|
||||
|
||||
### 3c. Empty HF_TOKEN in vLLM Deployments
|
||||
|
||||
All vLLM deployments have:
|
||||
|
||||
```yaml
|
||||
- name: HF_TOKEN
|
||||
value: ""
|
||||
```
|
||||
|
||||
If the model `edp1096/Huihui-Qwen3.6-27B-abliterated-FP8` is a gated model on HuggingFace, it will fail to download.
|
||||
|
||||
### 3d. KEDA Scale-to-Zero
|
||||
|
||||
**File:** `infrastructure/gpus/base/keda-gpu-scaling/keda-vllm.yaml`
|
||||
|
||||
```yaml
|
||||
minReplicaCount: 0
|
||||
maxReplicaCount: 1
|
||||
```
|
||||
|
||||
- vLLM scales to **zero** when idle
|
||||
- First request after cold start incurs full model load time (30-60 seconds)
|
||||
- For real-time Telegram responses, this causes visible latency
|
||||
|
||||
### 3e. PVC Name Collision
|
||||
|
||||
Both `rtx6000-vllm.yaml` and `a100-vllm.yaml` define a PVC named `vllm-model-qwen3.6-27b-uncensored` in namespace `customer1`. If both are ever active simultaneously, they conflict.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
v
|
||||
[GKE External LB]
|
||||
|
|
||||
Gateway: external-http-gateway
|
||||
(port 443/HTTPS, NO TLS cert!)
|
||||
|
|
||||
+-----------+-----------+
|
||||
| | |
|
||||
ws.siriusdevops.com brain.siriusdevops.com paaas.siriusdevops.com
|
||||
| | |
|
||||
v v v
|
||||
/telegram/webhook / /
|
||||
| | |
|
||||
v v v
|
||||
http-tele-webhook rtx6000- paaas-landing
|
||||
:9118 brain- :8080
|
||||
service:8000
|
||||
|
|
||||
v
|
||||
hermes-agent pod
|
||||
(ports: 8642, 8644, 9118)
|
||||
|
|
||||
v
|
||||
Model Provider: xai (Grok via external API)
|
||||
Fallback: OPENAI_BASE_URL -> openclaw-brain-service (MISMATCHED)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## vLLM Server Status
|
||||
|
||||
| Server | GPU | Model | Quantization | Status |
|
||||
|--------|-----|-------|-------------|--------|
|
||||
| RTX 6000 | 1x Pro 6000 (96GB) | edp1096/Huihui-Qwen3.6-27B-abliterated-FP8 | FP8 | **ACTIVE** |
|
||||
| A100 | 1x A100 (80GB) | Youssofal/Qwen3.6-27B-Abliterated-Heretic-Uncensored-BF16 | BF16 | Commented out |
|
||||
| L4 | 1x L4 (24GB) | p-e-w/Qwen3-8B-heretic | auto | Commented out |
|
||||
| Gemma | 1x A100 (80GB) | coder3101/Qwen3.5-27B-heretic | BF16 | Not in kustomization |
|
||||
|
||||
**Note:** The architecture plan (`plans/AI_ARCHITECTURE.md`) describes a dual-tier L4 dispatcher + A100 deep thinker setup, but the active deployment only has RTX 6000.
|
||||
|
||||
---
|
||||
|
||||
## Action Plan (Priority Order)
|
||||
|
||||
### P0 - Fix TLS (will unblock Telegram webhooks)
|
||||
|
||||
1. **Create a `CertMap`/`CertMapEntry`** or **`ManagedCertificate`** resource for `ws.siriusdevops.com`
|
||||
2. **Add `tls.certificateRefs`** to the Gateway listener in `apigateway.yaml`
|
||||
3. Verify with: `curl -vI https://ws.siriusdevops.com/telegram/webhook/default`
|
||||
4. If cert is valid, Telegram should start delivering webhooks
|
||||
|
||||
### P1 - Verify Secrets
|
||||
|
||||
5. Check if SOPS secrets are actually decrypted in the cluster
|
||||
6. Run: `kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d`
|
||||
|
||||
### P2 - Add Probes
|
||||
|
||||
7. Add readiness/liveness probes to hermes-agent deployment
|
||||
8. Redeploy
|
||||
|
||||
### P3 - Clean Up GatewayAPI
|
||||
|
||||
9. Remove duplicate `hermes-webhook.yaml` reference from one kustomization
|
||||
10. Fix or remove Traefik-referencing ClusterIssuers
|
||||
|
||||
### P4 - Fix vLLM Integration
|
||||
|
||||
11. Fix `OPENAI_BASE_URL` service name or update kustomization to create `openclaw-brain-service`
|
||||
12. Set real `HF_TOKEN` in vLLM deployments
|
||||
13. Consider setting `minReplicaCount: 1` in KEDA for consistent response times
|
||||
|
||||
---
|
||||
|
||||
## Files Referenced
|
||||
|
||||
### GatewayAPI & TLS
|
||||
- `infrastructure/gatewayapi/apigateway.yaml` - Gateway definition (missing TLS)
|
||||
- `infrastructure/gatewayapi/gateway-routes/hermes-webhook.yaml` - Webhook HTTPRoute
|
||||
- `infrastructure/gatewayapi/gateway-routes/route.yaml` - General route
|
||||
- `infrastructure/controllers/base/certmanager/clusterissuer.yaml` - Cert-manager (Traefik mismatch)
|
||||
|
||||
### Hermes Agent Deployment
|
||||
- `apps/base/customer1/hermes-agent/new-deployment.yaml` - Active deployment + webhook service
|
||||
- `apps/base/customer1/hermes-agent/deployment.yaml` - Old deployment (no webhook config)
|
||||
- `apps/base/customer1/hermes-agent/configmap.yaml` - Hermes config
|
||||
- `apps/base/customer1/hermes-agent/hermes-secret.yaml` - SOPS encrypted secrets
|
||||
- `apps/base/customer1/hermes-agent/tele-webhook.yaml` - SOPS encrypted webhook secret
|
||||
- `apps/base/customer1/hermes-agent/kustomization.yaml` - Kustomize composition
|
||||
|
||||
### vLLM Infrastructure
|
||||
- `infrastructure/gpus/base/vllm-servers/rtx6000-vllm.yaml` - Active vLLM server
|
||||
- `infrastructure/gpus/base/keda-gpu-scaling/keda-vllm.yaml` - KEDA scaling
|
||||
- `infrastructure/gpus/base/keda-gpu-scaling/vllm-route.yaml` - vLLM external route
|
||||
|
||||
### Architecture Plans
|
||||
- `plans/AI_ARCHITECTURE.md` - AI architecture plan
|
||||
- `plans/models-to-try.md` - Models being considered
|
||||
|
||||
---
|
||||
|
||||
## Quick Diagnostic Commands
|
||||
|
||||
Run these on the cluster to confirm findings:
|
||||
|
||||
```bash
|
||||
# 1. Check if TLS cert exists for the webhook domain
|
||||
curl -vI https://ws.siriusdevops.com/telegram/webhook/default 2>&1 | grep -E 'SSL|certificate|subject|issuer'
|
||||
|
||||
# 2. Check Gateway status
|
||||
kubectl get gateway external-http-gateway -n customer1 -o yaml
|
||||
|
||||
# 3. Check HTTPRoute status (attached/programmed)
|
||||
kubectl get httproute http-telegram-webhook -n customer1 -o yaml
|
||||
|
||||
# 4. Verify secrets are decrypted
|
||||
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d && echo
|
||||
|
||||
# 5. Check if pods are actually ready
|
||||
kubectl get pods -n customer1 -l app=hermes-agent -o wide
|
||||
|
||||
# 6. Check CertMap exists
|
||||
kubectl get certmap gateway-cert-map -A 2>/dev/null || echo "CertMap NOT FOUND"
|
||||
|
||||
# 7. Check managed certificates
|
||||
kubectl get managedcertificate -A 2>/dev/null || echo "No ManagedCertificates"
|
||||
|
||||
# 8. Check cert-manager certificates
|
||||
kubectl get certificate -A 2>/dev/null || echo "No Certificates"
|
||||
|
||||
# 9. Check vLLM pod status
|
||||
kubectl get pods -n customer1 -l app=rtx6000-brain-vllm -o wide
|
||||
|
||||
# 10. Test internal webhook endpoint
|
||||
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:9118/health || echo "Health check failed"
|
||||
```
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: rays-hermes-agent
|
||||
namespace: customer1
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: rays-hermes-agent
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: rays-hermes-agent
|
||||
spec:
|
||||
# 1. Pod-level security context to ensure volumes inherit the right group
|
||||
shareProcessNamespace: true
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
|
||||
# 2. Define our shared bridge volumes
|
||||
volumes:
|
||||
- name: hermes-home
|
||||
persistentVolumeClaim:
|
||||
claimName: rays-hermes-agent-pvc
|
||||
- name: hermes-agent-src
|
||||
emptyDir: {}
|
||||
- name: hermes-workspace
|
||||
emptyDir: {}
|
||||
- name: hermes-webui-app
|
||||
emptyDir: {}
|
||||
- name: hermes-configmap
|
||||
configMap:
|
||||
name: hermes-config
|
||||
|
||||
initContainers:
|
||||
# 3. K8s workaround: Copy the agent source code into the shared emptyDir
|
||||
- name: copy-agent-source
|
||||
image: nousresearch/hermes-agent:latest
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- |
|
||||
cp -a /opt/hermes/. /shared-src/ && chown -R 1024:1000 /shared-src /shared-home
|
||||
|
||||
if [ -f /tmp/hermes/config.yaml ]; then
|
||||
cp -f /tmp/hermes/config.yaml /shared-home/config.yaml
|
||||
fi
|
||||
|
||||
mkdir -p /shared-home/.local/bin
|
||||
echo '#!/bin/sh' > /shared-home/.local/bin/gh
|
||||
echo 'exit 1' >> /shared-home/.local/bin/gh
|
||||
chmod +x /shared-home/.local/bin/gh
|
||||
|
||||
securityContext:
|
||||
runAsUser: 0 # Run as root briefly to copy and fix permissions
|
||||
runAsNonRoot: false
|
||||
volumeMounts:
|
||||
- name: hermes-agent-src
|
||||
mountPath: /shared-src
|
||||
- name: hermes-home
|
||||
mountPath: /shared-home
|
||||
- name: hermes-configmap
|
||||
mountPath: /tmp/hermes/config.yaml
|
||||
subPath: config.yaml
|
||||
containers:
|
||||
# ==========================================
|
||||
# CONTAINER 1: HERMES AGENT
|
||||
# ==========================================
|
||||
- name: rays-hermes-agent
|
||||
image: nousresearch/hermes-agent:latest
|
||||
args: ["gateway", "run"]
|
||||
ports:
|
||||
- containerPort: 8642
|
||||
|
||||
env:
|
||||
- name: PATH
|
||||
value: "/home/hermes/.hermes/.local/bin:/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
- name: HOME
|
||||
value: "/home/hermes/.hermes"
|
||||
- name: HERMES_HOME
|
||||
value: "/home/hermes/.hermes"
|
||||
- name: HERMES_UID
|
||||
value: "1024"
|
||||
- name: HERMES_GID
|
||||
value: "1000"
|
||||
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: hermes-secrets
|
||||
key: TELEGRAM_BOT_TOKEN
|
||||
|
||||
- name: XAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: xai-apikey
|
||||
key: XAI_API_KEY
|
||||
|
||||
- name: TELEGRAM_ALLOWED_USERS
|
||||
value: "7105451284"
|
||||
|
||||
# === Local vLLM (OpenAI-compatible) ===
|
||||
- name: OPENAI_BASE_URL
|
||||
value: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1" # ← adjust if your service name differs
|
||||
|
||||
- name: HERMES_MODEL_PROVIDER
|
||||
value: xai
|
||||
|
||||
- name: HERMES_MODEL
|
||||
value: grok-4.20-0309-reasoning
|
||||
|
||||
- name: OPENAI_API_KEY
|
||||
value: "dummy"
|
||||
|
||||
|
||||
|
||||
volumeMounts:
|
||||
- name: hermes-home
|
||||
mountPath: /home/hermes/.hermes
|
||||
- name: hermes-agent-src
|
||||
mountPath: /opt/hermes
|
||||
|
||||
securityContext:
|
||||
runAsUser: 1024
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: true
|
||||
|
||||
# ==========================================
|
||||
# CONTAINER 2: HERMES WEBUI
|
||||
# ==========================================
|
||||
- name: hermes-webui
|
||||
image: ghcr.io/nesquena/hermes-webui:latest
|
||||
ports:
|
||||
- containerPort: 8787
|
||||
|
||||
env:
|
||||
- name: HOME
|
||||
value: "/home/hermeswebui/.hermes"
|
||||
- name: HERMES_HOME
|
||||
value: "/home/hermeswebui/.hermes"
|
||||
- name: HERMES_WEBUI_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: HERMES_WEBUI_PORT
|
||||
value: "8787"
|
||||
- name: HERMES_WEBUI_STATE_DIR
|
||||
value: "/home/hermeswebui/.hermes/webui"
|
||||
- name: WANTED_UID
|
||||
value: "1024"
|
||||
- name: WANTED_GID
|
||||
value: "1000"
|
||||
- name: HERMES_SKIP_CHMOD
|
||||
value: "1"
|
||||
|
||||
volumeMounts:
|
||||
- name: hermes-home
|
||||
mountPath: /home/hermeswebui/.hermes
|
||||
# This is where the WebUI looks for the agent source code to run `uv pip install`
|
||||
- name: hermes-agent-src
|
||||
mountPath: /home/hermeswebui/.hermes/hermes-agent
|
||||
- name: hermes-workspace
|
||||
mountPath: /workspace
|
||||
- name: hermes-webui-app
|
||||
mountPath: /app
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: 500Mi
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: 1Gi
|
||||
cpu: "500m"
|
||||
|
||||
securityContext:
|
||||
runAsUser: 1024
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: true
|
||||
readOnlyRootFilesystem: false
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
---
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: rays-hermes-agent-pvc
|
||||
namespace: customer1
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 25Gi
|
||||
|
||||
---
|
||||
|
||||
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: rays-hermes-webui-service
|
||||
namespace: customer1
|
||||
annotations:
|
||||
tailscale.com/expose: "true"
|
||||
tailscale.com/hostname: "rays-hermes-webui"
|
||||
tailscale.com/tags: "tag:k8s-operator"
|
||||
tailscale.com/ports: "http:8787"
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: hermes-agent
|
||||
ports:
|
||||
- port: 8787
|
||||
targetPort: 8787
|
||||
name: http
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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
|
||||
ENV PYTHONPATH=/app/app
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"""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()
|
||||
|
||||
|
||||
import asyncio
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# Alembic migration script - DO NOT EDIT MANUALLY
|
||||
# Use: alembic revision --autogenerate -m "description"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"""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.execute('CREATE TYPE position_direction AS ENUM (\'long\', \'short\')')
|
||||
|
||||
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")
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import os
|
||||
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
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()
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
"""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():
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from pathlib import Path
|
||||
|
||||
alembic_cfg = Config(
|
||||
str(Path(__file__).parent.parent / "alembic.ini")
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
from sqlalchemy import Column, String, Numeric, Enum, DateTime, JSON, func, Table
|
||||
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),
|
||||
)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
<!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>
|
||||
Loading…
Add table
Reference in a new issue