Compare commits
2 Commits
snapshot-m
...
667df582bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 667df582bd | |||
| a188308013 |
@@ -1,12 +1,15 @@
|
||||
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Fallback (optional): query Hasura directly (not recommended in browser).
|
||||
VITE_HASURA_URL=http://localhost:8080/v1/graphql
|
||||
# Optional (only if you intentionally query Hasura directly from the browser):
|
||||
# Hasura GraphQL endpoint (supports subscriptions via WS).
|
||||
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||
VITE_HASURA_URL=/graphql
|
||||
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
|
||||
# VITE_HASURA_WS_URL=/graphql-ws
|
||||
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||
# VITE_HASURA_ADMIN_SECRET=devsecret
|
||||
VITE_SYMBOL=PUMP-PERP
|
||||
VITE_SYMBOL=SOL-PERP
|
||||
# Optional: filter by source (leave empty for all)
|
||||
# VITE_SOURCE=drift_oracle
|
||||
VITE_POLL_MS=1000
|
||||
|
||||
112
apps/visualizer/__r
Executable file
112
apps/visualizer/__r
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
SSH_TUNNEL_TARGET="${SSH_TUNNEL_TARGET:-mevnode_bot}"
|
||||
K8S_NAMESPACE="${K8S_NAMESPACE:-trade-staging}"
|
||||
|
||||
SSH_FRONTEND_LOCAL_PORT="${SSH_FRONTEND_LOCAL_PORT:-13081}"
|
||||
SSH_HASURA_LOCAL_PORT="${SSH_HASURA_LOCAL_PORT:-18080}"
|
||||
|
||||
SSH_FRONTEND_REMOTE_HOST="${SSH_FRONTEND_REMOTE_HOST:-127.0.0.1}"
|
||||
SSH_FRONTEND_REMOTE_PORT="${SSH_FRONTEND_REMOTE_PORT:-30081}"
|
||||
SSH_HASURA_REMOTE_PORT="${SSH_HASURA_REMOTE_PORT:-8080}"
|
||||
|
||||
VITE_DEV_PORT="${VITE_DEV_PORT:-5174}"
|
||||
|
||||
SSH_PID=""
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_listen() {
|
||||
local port="$1"
|
||||
local label="$2"
|
||||
local tries="${3:-80}"
|
||||
local delay="${4:-0.25}"
|
||||
local i
|
||||
|
||||
for ((i = 0; i < tries; i += 1)); do
|
||||
if ss -ltnH | awk '{print $4}' | grep -Eq "(^|[:])${port}$"; then
|
||||
return 0
|
||||
fi
|
||||
sleep "${delay}"
|
||||
done
|
||||
|
||||
echo "Timed out waiting for ${label} on port ${port}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
get_cluster_ip() {
|
||||
local service="$1"
|
||||
ssh \
|
||||
-o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-T "${SSH_TUNNEL_TARGET}" \
|
||||
"kubectl -n ${K8S_NAMESPACE} get svc ${service} -o jsonpath='{.spec.clusterIP}'"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local code=$?
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [[ -n "${SSH_PID}" ]] && kill -0 "${SSH_PID}" 2>/dev/null; then
|
||||
kill "${SSH_PID}" 2>/dev/null || true
|
||||
wait "${SSH_PID}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit "${code}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
require_cmd ssh
|
||||
require_cmd npm
|
||||
require_cmd ss
|
||||
|
||||
HASURA_CLUSTER_IP="${HASURA_CLUSTER_IP:-$(get_cluster_ip hasura)}"
|
||||
|
||||
if [[ -z "${HASURA_CLUSTER_IP}" ]]; then
|
||||
echo "Could not resolve ClusterIP for hasura in namespace ${K8S_NAMESPACE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SSH target: ${SSH_TUNNEL_TARGET}"
|
||||
echo "Namespace: ${K8S_NAMESPACE}"
|
||||
echo "hasura ClusterIP: ${HASURA_CLUSTER_IP}"
|
||||
|
||||
echo "Starting SSH tunnel..."
|
||||
ssh \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-N \
|
||||
-L "${SSH_FRONTEND_LOCAL_PORT}:${SSH_FRONTEND_REMOTE_HOST}:${SSH_FRONTEND_REMOTE_PORT}" \
|
||||
-L "${SSH_HASURA_LOCAL_PORT}:${HASURA_CLUSTER_IP}:${SSH_HASURA_REMOTE_PORT}" \
|
||||
"${SSH_TUNNEL_TARGET}" &
|
||||
SSH_PID=$!
|
||||
|
||||
wait_for_listen "${SSH_FRONTEND_LOCAL_PORT}" "frontend SSH tunnel"
|
||||
wait_for_listen "${SSH_HASURA_LOCAL_PORT}" "Hasura SSH tunnel"
|
||||
|
||||
echo "Starting Vite on :${VITE_DEV_PORT}..."
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
export VITE_DEV_PORT
|
||||
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-ws://127.0.0.1:${SSH_HASURA_LOCAL_PORT}/v1/graphql}"
|
||||
export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_FRONTEND_LOCAL_PORT}}"
|
||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_HASURA_LOCAL_PORT}}"
|
||||
export GRAPHQL_PROXY_PATH="${GRAPHQL_PROXY_PATH:-/v1/graphql}"
|
||||
|
||||
echo "Frontend auth proxy: ${FRONTEND_PROXY_TARGET}"
|
||||
echo "GraphQL proxy: ${GRAPHQL_PROXY_TARGET}${GRAPHQL_PROXY_PATH}"
|
||||
echo "Open: http://localhost:${VITE_DEV_PORT}/"
|
||||
|
||||
exec npm run dev
|
||||
29
apps/visualizer/__start
Normal file
29
apps/visualizer/__start
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
if [[ -n "${SSH_TUNNEL_PORT:-}" ]]; then
|
||||
export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}"
|
||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}"
|
||||
else
|
||||
export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||
fi
|
||||
|
||||
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
||||
|
||||
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
|
||||
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
|
||||
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
|
||||
else
|
||||
echo "Missing basic auth config for VPS proxy."
|
||||
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
npm run dev
|
||||
12
apps/visualizer/diagram.html
Normal file
12
apps/visualizer/diagram.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trade system diagram</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/diagram-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
216
apps/visualizer/package-lock.json
generated
216
apps/visualizer/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "trade-visualizer",
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"lightweight-charts": "^5.0.8",
|
||||
@@ -1110,6 +1111,49 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1121,14 +1165,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1166,6 +1210,36 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.75",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.75",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
|
||||
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
@@ -1253,6 +1327,11 @@
|
||||
"luxon": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1264,9 +1343,105 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1664,6 +1839,14 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -1730,6 +1913,33 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"lightweight-charts": "^5.0.8",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||
import AppShell from './layout/AppShell';
|
||||
import ChartPanel from './features/chart/ChartPanel';
|
||||
@@ -11,6 +12,14 @@ import Button from './ui/Button';
|
||||
import TopNav from './layout/TopNav';
|
||||
import AuthStatus from './layout/AuthStatus';
|
||||
import LoginScreen from './layout/LoginScreen';
|
||||
import { useDlobStats } from './features/market/useDlobStats';
|
||||
import { useDlobL2 } from './features/market/useDlobL2';
|
||||
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
||||
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
||||
import DlobDashboard from './features/market/DlobDashboard';
|
||||
import BotObserverPanel from './features/bot/BotObserverPanel';
|
||||
import BotObserverSummaryCard from './features/bot/BotObserverSummaryCard';
|
||||
import { useBotObserver } from './features/bot/useBotObserver';
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const v = (import.meta as any).env?.[name];
|
||||
@@ -26,7 +35,8 @@ function envString(name: string, fallback: string): string {
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1000) return `$${v.toFixed(0)}`;
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
@@ -36,6 +46,111 @@ function formatQty(v: number | null | undefined, decimals: number): string {
|
||||
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
|
||||
function formatCompact(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
|
||||
if (abs >= 1) return v.toFixed(2);
|
||||
return v.toPrecision(4);
|
||||
}
|
||||
|
||||
function formatOrderbookUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (abs >= 100_000) return `${(v / 1000).toFixed(1)}K`;
|
||||
if (abs >= 10_000) return `${(v / 1000).toFixed(2)}K`;
|
||||
if (abs >= 1000) return `${(v / 1000).toFixed(2)}K`;
|
||||
if (abs >= 100) return v.toFixed(0);
|
||||
if (abs >= 1) return v.toFixed(2);
|
||||
return v.toPrecision(4);
|
||||
}
|
||||
|
||||
function formatClockTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const ts = Date.parse(iso);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
return new Date(ts).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOrderbookGroupingLabel(symbol: string, mode: string, referencePrice: number | null): string {
|
||||
const normalizedMode = String(mode || 'drift').trim().toLowerCase();
|
||||
if (normalizedMode === 'raw') return 'Raw';
|
||||
if (normalizedMode !== 'drift') return normalizedMode;
|
||||
const px = referencePrice == null || !Number.isFinite(referencePrice) ? 0 : Math.abs(referencePrice);
|
||||
let step = 0.000001;
|
||||
if (px >= 1000) step = 0.1;
|
||||
else if (px >= 100) step = 0.01;
|
||||
else if (px >= 10) step = 0.001;
|
||||
else if (px >= 1) step = 0.0001;
|
||||
else if (px >= 0.1) step = 0.00001;
|
||||
return `Drift (${step.toFixed(step >= 0.1 ? 1 : step >= 0.01 ? 2 : step >= 0.001 ? 3 : step >= 0.0001 ? 4 : 6).replace(/0+$/, '').replace(/\.$/, '')})`;
|
||||
}
|
||||
|
||||
function clamp01(scale: number): number {
|
||||
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
||||
}
|
||||
|
||||
function orderbookRowBarStyle(totalScale: number): CSSProperties {
|
||||
return {
|
||||
['--ob-bar-scale' as any]: clamp01(totalScale),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
const ORDERBOOK_LEVELS_STACKED = 12;
|
||||
const ORDERBOOK_LEVELS_LADDER = 20;
|
||||
type OrderbookLayoutMode = 'stacked' | 'ladder';
|
||||
type OrderbookBarScaleMode = 'per-side' | 'shared-log';
|
||||
type VisualTheme = 'day' | 'night';
|
||||
|
||||
function normalizeVisualTheme(value: unknown): VisualTheme {
|
||||
return value === 'day' ? 'day' : 'night';
|
||||
}
|
||||
|
||||
function OrderbookLayoutIcon({ mode }: { mode: OrderbookLayoutMode }) {
|
||||
if (mode === 'ladder') {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M1.5 4.5h4v2h-4zm9 0h4v2h-4zM1.5 7.5h4v2h-4zm9 0h4v2h-4zM1.5 10.5h4v2h-4zm9 0h4v2h-4zM6.75 3.5h2.5v9h-2.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2 3.5h12v2H2zm0 3.5h12v2H2zm0 3.5h12v2H2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderbookScaleIcon({ mode }: { mode: OrderbookBarScaleMode }) {
|
||||
if (mode === 'shared-log') {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2 12.5h2v-2h2v-2h2v-2h2v-2h2v8H2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2 11.5h3v-6H2zm4.5 2h3v-10h-3zm4.5-4h3v-2h-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function liquidityStyle(bid: number, ask: number): CSSProperties {
|
||||
const max = Math.max(1e-9, bid, ask);
|
||||
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
|
||||
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
|
||||
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
|
||||
}
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
@@ -45,8 +160,24 @@ type WhoamiResponse = {
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<string | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [storedTheme, setStoredTheme] = useLocalStorageState<VisualTheme>('trade.theme.v1', 'night');
|
||||
const devAuthUser = useMemo(() => envString('VITE_DEV_AUTH_USER', '').trim(), []);
|
||||
const theme = normalizeVisualTheme(storedTheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
return () => {
|
||||
delete document.documentElement.dataset.theme;
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (devAuthUser) {
|
||||
setUser(devAuthUser);
|
||||
setAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setAuthLoading(true);
|
||||
fetch('/whoami', { cache: 'no-store' })
|
||||
@@ -71,7 +202,7 @@ export default function App() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [devAuthUser]);
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -95,21 +226,39 @@ export default function App() {
|
||||
return <LoginScreen onLoggedIn={setUser} />;
|
||||
}
|
||||
|
||||
return <TradeApp user={user} onLogout={() => void logout()} />;
|
||||
return (
|
||||
<TradeApp
|
||||
user={user}
|
||||
theme={theme}
|
||||
onToggleTheme={() => setStoredTheme(theme === 'day' ? 'night' : 'day')}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
function TradeApp({
|
||||
user,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onLogout,
|
||||
}: {
|
||||
user: string;
|
||||
theme: VisualTheme;
|
||||
onToggleTheme: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
||||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||||
const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
||||
const [tf, setTf] = useLocalStorageState('trade.tf.v2', envString('VITE_TF', '1s'));
|
||||
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
'dlob' | 'bot' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
>('trade.bottomTab', 'positions');
|
||||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||||
@@ -118,8 +267,29 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
);
|
||||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||
const [orderbookGrouping, setOrderbookGrouping] = useLocalStorageState('trade.orderbookGrouping.v1', 'drift');
|
||||
const [orderbookLayout, setOrderbookLayout] = useLocalStorageState<OrderbookLayoutMode>(
|
||||
'trade.orderbookLayout.v1',
|
||||
'ladder'
|
||||
);
|
||||
const [orderbookBarScaleMode, setOrderbookBarScaleMode] = useLocalStorageState<OrderbookBarScaleMode>(
|
||||
'trade.orderbookBarScale.v1',
|
||||
'per-side'
|
||||
);
|
||||
const [chartResetView, setChartResetView] = useState<(() => void) | null>(null);
|
||||
const orderbookLevels = orderbookLayout === 'ladder' ? ORDERBOOK_LEVELS_LADDER : ORDERBOOK_LEVELS_STACKED;
|
||||
|
||||
const { candles, indicators, loading, error, refresh } = useChartData({
|
||||
useEffect(() => {
|
||||
if (symbol === 'BONK-PERP') {
|
||||
setSymbol('1MBONK-PERP');
|
||||
return;
|
||||
}
|
||||
if (!markets.includes(symbol)) {
|
||||
setSymbol('SOL-PERP');
|
||||
}
|
||||
}, [markets, setSymbol, symbol]);
|
||||
|
||||
const { candles, indicators, meta, loading, error, refresh, hasMoreHistory, requestHistoryBefore } = useChartData({
|
||||
symbol,
|
||||
source: source.trim() ? source : undefined,
|
||||
tf,
|
||||
@@ -127,43 +297,165 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
pollMs,
|
||||
});
|
||||
|
||||
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
|
||||
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, {
|
||||
levels: orderbookLevels,
|
||||
grouping: orderbookGrouping,
|
||||
});
|
||||
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
|
||||
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol);
|
||||
const botObserver = useBotObserver(symbol);
|
||||
|
||||
const latest = candles.length ? candles[candles.length - 1] : null;
|
||||
const first = candles.length ? candles[0] : null;
|
||||
const changePct =
|
||||
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
|
||||
const liveLastPrice = latest?.close ?? dlob?.markPrice ?? dlob?.mid ?? null;
|
||||
const liveOraclePrice = latest?.oracle ?? dlob?.oraclePrice ?? null;
|
||||
|
||||
const orderbook = useMemo(() => {
|
||||
if (!latest) return { asks: [], bids: [], mid: null as number | null };
|
||||
if (dlobL2) {
|
||||
return {
|
||||
asks: dlobL2.asks,
|
||||
bids: dlobL2.bids,
|
||||
mid: dlobL2.mid as number | null,
|
||||
bestBid: dlobL2.bestBid,
|
||||
bestAsk: dlobL2.bestAsk,
|
||||
updatedAt: dlobL2.updatedAt,
|
||||
};
|
||||
}
|
||||
if (!latest) {
|
||||
return {
|
||||
asks: [],
|
||||
bids: [],
|
||||
mid: null as number | null,
|
||||
bestBid: null as number | null,
|
||||
bestAsk: null as number | null,
|
||||
updatedAt: null as string | null,
|
||||
};
|
||||
}
|
||||
const mid = latest.close;
|
||||
const step = Math.max(mid * 0.00018, 0.0001);
|
||||
const levels = 14;
|
||||
const levels = orderbookLevels;
|
||||
|
||||
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
||||
price: mid + (i + 1) * step,
|
||||
size: 0.1 + ((i * 7) % 15) * 0.1,
|
||||
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
|
||||
}));
|
||||
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
||||
price: mid - (i + 1) * step,
|
||||
size: 0.1 + ((i * 5) % 15) * 0.1,
|
||||
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
|
||||
}));
|
||||
|
||||
let askTotal = 0;
|
||||
let askTotalBase = 0;
|
||||
let askTotalUsd = 0;
|
||||
const asks = asksRaw
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => {
|
||||
askTotal += r.size;
|
||||
return { ...r, total: askTotal };
|
||||
const sizeUsd = r.sizeBase * r.price;
|
||||
askTotalBase += r.sizeBase;
|
||||
askTotalUsd += sizeUsd;
|
||||
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
|
||||
});
|
||||
|
||||
let bidTotal = 0;
|
||||
let bidTotalBase = 0;
|
||||
let bidTotalUsd = 0;
|
||||
const bids = bidsRaw.map((r) => {
|
||||
bidTotal += r.size;
|
||||
return { ...r, total: bidTotal };
|
||||
const sizeUsd = r.sizeBase * r.price;
|
||||
bidTotalBase += r.sizeBase;
|
||||
bidTotalUsd += sizeUsd;
|
||||
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
|
||||
});
|
||||
|
||||
return { asks, bids, mid };
|
||||
}, [latest]);
|
||||
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null, updatedAt: null };
|
||||
}, [dlobL2, latest, orderbookLevels]);
|
||||
|
||||
const maxAskSize = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.asks]);
|
||||
|
||||
const maxBidSize = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.bids]);
|
||||
|
||||
const liquidity = useMemo(() => {
|
||||
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
|
||||
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
|
||||
const bestBid = orderbook.bestBid;
|
||||
const bestAsk = orderbook.bestAsk;
|
||||
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
|
||||
const spreadPct =
|
||||
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
|
||||
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
|
||||
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
|
||||
|
||||
const orderbookVersion = orderbook.updatedAt ?? String(latest?.time ?? '');
|
||||
const orderbookGroupingLabel = useMemo(
|
||||
() => resolveOrderbookGroupingLabel(symbol, orderbookGrouping, orderbook.mid),
|
||||
[orderbook.mid, orderbookGrouping, symbol]
|
||||
);
|
||||
const previousOrderbookMid = useRef<number | null>(null);
|
||||
const [orderbookMidTone, setOrderbookMidTone] = useState<'flat' | 'up' | 'down'>('flat');
|
||||
|
||||
useEffect(() => {
|
||||
const nextMid = orderbook.mid;
|
||||
const prevMid = previousOrderbookMid.current;
|
||||
|
||||
if (nextMid == null || !Number.isFinite(nextMid)) {
|
||||
previousOrderbookMid.current = null;
|
||||
setOrderbookMidTone('flat');
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevMid != null && Number.isFinite(prevMid) && nextMid !== prevMid) {
|
||||
setOrderbookMidTone(nextMid > prevMid ? 'up' : 'down');
|
||||
}
|
||||
|
||||
previousOrderbookMid.current = nextMid;
|
||||
}, [orderbook.mid]);
|
||||
|
||||
const orderbookLadderRows = useMemo(() => {
|
||||
const asksNearMid = orderbook.asks.slice().reverse();
|
||||
const bidsNearMid = orderbook.bids;
|
||||
const rowCount = Math.max(asksNearMid.length, bidsNearMid.length);
|
||||
return Array.from({ length: rowCount }, (_, index) => ({
|
||||
ask: asksNearMid[index] ?? null,
|
||||
bid: bidsNearMid[index] ?? null,
|
||||
}));
|
||||
}, [orderbook.asks, orderbook.bids]);
|
||||
|
||||
const orderbookLadderViewRows = useMemo(() => {
|
||||
let maxAsk = 0;
|
||||
let maxBid = 0;
|
||||
|
||||
for (const row of orderbookLadderRows) {
|
||||
maxAsk = Math.max(maxAsk, (row.ask as any)?.sizeUsd || 0);
|
||||
maxBid = Math.max(maxBid, (row.bid as any)?.sizeUsd || 0);
|
||||
}
|
||||
|
||||
const maxShared = Math.max(maxAsk, maxBid);
|
||||
const logDen = Math.log1p(maxShared);
|
||||
|
||||
const scaleValue = (value: number, side: 'bid' | 'ask'): number => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
if (orderbookBarScaleMode === 'shared-log') {
|
||||
return logDen > 0 ? Math.log1p(value) / logDen : 0;
|
||||
}
|
||||
const denom = side === 'bid' ? maxBid : maxAsk;
|
||||
return denom > 0 ? value / denom : 0;
|
||||
};
|
||||
|
||||
return orderbookLadderRows.map((row) => ({
|
||||
...row,
|
||||
bidScale: scaleValue((row.bid as any)?.sizeUsd || 0, 'bid'),
|
||||
askScale: scaleValue((row.ask as any)?.sizeUsd || 0, 'ask'),
|
||||
}));
|
||||
}, [orderbookBarScaleMode, orderbookLadderRows]);
|
||||
|
||||
const trades = useMemo(() => {
|
||||
const slice = candles.slice(-24).reverse();
|
||||
@@ -180,13 +472,31 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
|
||||
const effectiveTradePrice = useMemo(() => {
|
||||
if (tradeOrderType === 'limit') return tradePrice;
|
||||
return latest?.close ?? tradePrice;
|
||||
}, [latest?.close, tradeOrderType, tradePrice]);
|
||||
return liveLastPrice ?? tradePrice;
|
||||
}, [liveLastPrice, tradeOrderType, tradePrice]);
|
||||
|
||||
const orderValueUsd = useMemo(() => {
|
||||
if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null;
|
||||
if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null;
|
||||
const v = effectiveTradePrice * tradeSize;
|
||||
return Number.isFinite(v) && v > 0 ? v : null;
|
||||
}, [effectiveTradePrice, tradeSize]);
|
||||
|
||||
const dynamicSlippage = useMemo(() => {
|
||||
if (orderValueUsd == null) return null;
|
||||
const side = tradeSide === 'short' ? 'sell' : 'buy';
|
||||
const rows = slippageRows.filter((r) => r.side === side).slice();
|
||||
rows.sort((a, b) => a.sizeUsd - b.sizeUsd);
|
||||
if (!rows.length) return null;
|
||||
const biggest = rows[rows.length - 1];
|
||||
const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest;
|
||||
return match;
|
||||
}, [orderValueUsd, slippageRows, tradeSide]);
|
||||
|
||||
const topItems = useMemo(
|
||||
() => [
|
||||
{ key: 'BTC', label: 'BTC', changePct: 1.28, active: false },
|
||||
{ key: 'SOL', label: 'SOL', changePct: 1.89, active: false },
|
||||
{ key: 'SOL', label: 'SOL', changePct: 1.89, active: true },
|
||||
],
|
||||
[]
|
||||
);
|
||||
@@ -196,7 +506,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
{
|
||||
key: 'last',
|
||||
label: 'Last',
|
||||
value: formatUsd(latest?.close),
|
||||
value: formatUsd(liveLastPrice),
|
||||
sub:
|
||||
changePct == null ? (
|
||||
'—'
|
||||
@@ -207,20 +517,50 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
||||
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
|
||||
{ key: 'oi', label: 'Open Interest', value: '—' },
|
||||
{ key: 'vol', label: '24h Volume', value: '—' },
|
||||
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
|
||||
{ key: 'oracle', label: 'Oracle', value: formatUsd(liveOraclePrice) },
|
||||
{ key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
|
||||
{ key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
|
||||
{
|
||||
key: 'spread',
|
||||
label: 'Spread',
|
||||
value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`,
|
||||
sub: formatUsd(dlob?.spreadAbs ?? null),
|
||||
},
|
||||
{
|
||||
key: 'dlob',
|
||||
label: 'DLOB',
|
||||
value: dlobConnected ? 'live' : '—',
|
||||
sub: dlobError ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
|
||||
},
|
||||
{
|
||||
key: 'l2',
|
||||
label: 'L2',
|
||||
value: dlobL2Connected ? 'live' : '—',
|
||||
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : dlobL2?.updatedAt || '—',
|
||||
},
|
||||
];
|
||||
}, [latest?.close, latest?.oracle, changePct]);
|
||||
}, [changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error, liveLastPrice, liveOraclePrice]);
|
||||
|
||||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||||
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||||
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||||
top={<TickerBar items={topItems} />}
|
||||
header={
|
||||
<TopNav
|
||||
active="trade"
|
||||
rightSlot={
|
||||
<AuthStatus
|
||||
user={user}
|
||||
theme={theme}
|
||||
onResetView={chartResetView}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
main={
|
||||
<div className="tradeMain">
|
||||
<Card
|
||||
@@ -280,16 +620,49 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
<ChartPanel
|
||||
candles={candles}
|
||||
indicators={indicators}
|
||||
theme={theme}
|
||||
tickerItems={topItems}
|
||||
timeframe={tf}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
onTimeframeChange={setTf}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||
onResetViewReady={(handler) => setChartResetView(() => handler)}
|
||||
seriesLabel={seriesLabel}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
onRequestHistoryBefore={requestHistoryBefore}
|
||||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||||
/>
|
||||
|
||||
<Card className="bottomCard">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
id: 'dlob',
|
||||
label: 'DLOB',
|
||||
content: (
|
||||
<DlobDashboard
|
||||
market={symbol}
|
||||
stats={dlob}
|
||||
statsConnected={dlobConnected}
|
||||
statsError={dlobError}
|
||||
depthBands={depthBands}
|
||||
depthBandsConnected={depthBandsConnected}
|
||||
depthBandsError={depthBandsError}
|
||||
slippageRows={slippageRows}
|
||||
slippageConnected={slippageConnected}
|
||||
slippageError={slippageError}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bot',
|
||||
label: 'Bot',
|
||||
content: <BotObserverPanel marketName={symbol} observer={botObserver} />,
|
||||
},
|
||||
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
||||
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
||||
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
||||
@@ -311,48 +684,173 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</div>
|
||||
}
|
||||
sidebar={
|
||||
<Card
|
||||
className="orderbookCard"
|
||||
title={
|
||||
<div className="sideHead">
|
||||
<div className="sideHead__title">Orderbook</div>
|
||||
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card className="orderbookCard">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
id: 'orderbook',
|
||||
label: 'Orderbook',
|
||||
content: (
|
||||
<div className="orderbook">
|
||||
<div className="orderbook__header">
|
||||
<span>Price</span>
|
||||
<span className="orderbook__num">Size</span>
|
||||
<span className="orderbook__num">Total</span>
|
||||
</div>
|
||||
<div className="orderbook__rows">
|
||||
{orderbook.asks.map((r) => (
|
||||
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
<div className="orderbook">
|
||||
<div className="orderbook__toolbar">
|
||||
<label className="marketSelect orderbook__grouping">
|
||||
<span className="marketSelect__label">Grouping</span>
|
||||
<select
|
||||
className="marketSelect__input orderbook__groupingInput"
|
||||
value={orderbookGrouping}
|
||||
onChange={(e) => setOrderbookGrouping(e.target.value)}
|
||||
>
|
||||
<option value="drift">{orderbookGroupingLabel}</option>
|
||||
<option value="raw">Raw</option>
|
||||
<option value="0.001">0.001</option>
|
||||
<option value="0.005">0.005</option>
|
||||
<option value="0.01">0.01</option>
|
||||
<option value="0.05">0.05</option>
|
||||
<option value="0.1">0.1</option>
|
||||
</select>
|
||||
</label>
|
||||
{orderbookLayout === 'ladder' ? (
|
||||
<div className="orderbookToolbarSwitch" role="group" aria-label="Orderbook bar scale">
|
||||
<button
|
||||
type="button"
|
||||
className={`orderbookToolbarSwitch__btn${orderbookBarScaleMode === 'per-side' ? ' is-active' : ''}`}
|
||||
onClick={() => setOrderbookBarScaleMode('per-side')}
|
||||
title="Per-side scale"
|
||||
aria-pressed={orderbookBarScaleMode === 'per-side'}
|
||||
>
|
||||
<OrderbookScaleIcon mode="per-side" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`orderbookToolbarSwitch__btn${orderbookBarScaleMode === 'shared-log' ? ' is-active' : ''}`}
|
||||
onClick={() => setOrderbookBarScaleMode('shared-log')}
|
||||
title="Shared log scale"
|
||||
aria-pressed={orderbookBarScaleMode === 'shared-log'}
|
||||
>
|
||||
<OrderbookScaleIcon mode="shared-log" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{orderbookLayout === 'ladder' ? (
|
||||
<>
|
||||
<div className={`orderbookMid orderbookMid--${orderbookMidTone} orderbookMid--ladder`}>
|
||||
<span className="orderbookMid__label">mid</span>
|
||||
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
|
||||
</div>
|
||||
<div className="orderbook__header orderbook__header--ladder">
|
||||
<span className="orderbookLadderHead__side">Size (USD)</span>
|
||||
<span className="orderbookLadderHead__price">Price</span>
|
||||
<span className="orderbookLadderHead__side orderbookLadderHead__side--right">Size (USD)</span>
|
||||
</div>
|
||||
<div className="orderbook__rows orderbook__rows--ladder">
|
||||
{orderbookLadderViewRows.map((row, index) => (
|
||||
<div key={`pair-${index}-${orderbookVersion}`} className="orderbookPair">
|
||||
<span
|
||||
className="orderbookPair__num orderbookPair__num--bid"
|
||||
style={orderbookRowBarStyle(row.bidScale)}
|
||||
>
|
||||
{row.bid ? formatOrderbookUsd((row.bid as any).sizeUsd) : '—'}
|
||||
</span>
|
||||
<span className="orderbookPair__price orderbookPair__price--bid">
|
||||
{row.bid ? formatQty(row.bid.price, 3) : '—'}
|
||||
</span>
|
||||
<span className="orderbookPair__price orderbookPair__price--ask">
|
||||
{row.ask ? formatQty(row.ask.price, 3) : '—'}
|
||||
</span>
|
||||
<span
|
||||
className="orderbookPair__num orderbookPair__num--ask"
|
||||
style={orderbookRowBarStyle(row.askScale)}
|
||||
>
|
||||
{row.ask ? formatOrderbookUsd((row.ask as any).sizeUsd) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="orderbook__header">
|
||||
<span>Price</span>
|
||||
<span className="orderbook__num">Size (USD)</span>
|
||||
<span className="orderbook__num">Cum. (USD)</span>
|
||||
</div>
|
||||
<div className="orderbook__rows">
|
||||
<div className={`orderbookMid orderbookMid--${orderbookMidTone}`}>
|
||||
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
|
||||
<span className="orderbookMid__label">mid</span>
|
||||
</div>
|
||||
{orderbook.asks.map((r, index) => (
|
||||
<div
|
||||
key={`a-${index}-${r.price}-${orderbookVersion}`}
|
||||
className="orderbookRow orderbookRow--ask"
|
||||
style={orderbookRowBarStyle(maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).sizeUsd)}</span>
|
||||
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).totalUsd)}</span>
|
||||
</div>
|
||||
))}
|
||||
{orderbook.bids.map((r, index) => (
|
||||
<div
|
||||
key={`b-${index}-${r.price}-${orderbookVersion}`}
|
||||
className="orderbookRow orderbookRow--bid"
|
||||
style={orderbookRowBarStyle(maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).sizeUsd)}</span>
|
||||
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).totalUsd)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="orderbookMeta">
|
||||
<div className="orderbookMeta__row">
|
||||
<span className="muted">Updated</span>
|
||||
<span className="orderbookMeta__val">{formatClockTime(orderbook.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="orderbookMeta__row">
|
||||
<span className="muted">Spread</span>
|
||||
<span className="orderbookMeta__val">
|
||||
{liquidity.spreadAbs == null || liquidity.spreadPct == null
|
||||
? '—'
|
||||
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="orderbookMid">
|
||||
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span>
|
||||
<span className="orderbookMid__label">mid</span>
|
||||
</div>
|
||||
{orderbook.bids.map((r) => (
|
||||
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid">
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
<div className="orderbookMeta__row">
|
||||
<span className="muted">{`Liquidity (L1–L${orderbookLevels})`}</span>
|
||||
<span className="orderbookMeta__val">
|
||||
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
|
||||
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
|
||||
<div className="liquidityBar__bid" />
|
||||
<div className="liquidityBar__ask" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="orderbookLayoutSwitch" role="group" aria-label="Orderbook layout">
|
||||
<button
|
||||
type="button"
|
||||
className={`orderbookLayoutSwitch__btn${orderbookLayout === 'stacked' ? ' is-active' : ''}`}
|
||||
onClick={() => setOrderbookLayout('stacked')}
|
||||
title="Stacked book"
|
||||
aria-pressed={orderbookLayout === 'stacked'}
|
||||
>
|
||||
<OrderbookLayoutIcon mode="stacked" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`orderbookLayoutSwitch__btn${orderbookLayout === 'ladder' ? ' is-active' : ''}`}
|
||||
onClick={() => setOrderbookLayout('ladder')}
|
||||
title="Ladder book"
|
||||
aria-pressed={orderbookLayout === 'ladder'}
|
||||
>
|
||||
<OrderbookLayoutIcon mode="ladder" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -452,7 +950,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
<input
|
||||
className="formField__input"
|
||||
value={tradeOrderType === 'market' ? '' : String(tradePrice)}
|
||||
placeholder={tradeOrderType === 'market' ? formatQty(latest?.close ?? null, 3) : '0'}
|
||||
placeholder={tradeOrderType === 'market' ? formatQty(liveLastPrice ?? null, 3) : '0'}
|
||||
disabled={tradeOrderType !== 'limit'}
|
||||
onChange={(e) => setTradePrice(Number(e.target.value))}
|
||||
inputMode="decimal"
|
||||
@@ -480,7 +978,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</div>
|
||||
<div className="tradeMeta__row">
|
||||
<span className="tradeMeta__label">Slippage (Dynamic)</span>
|
||||
<span className="tradeMeta__value">—</span>
|
||||
<span className="tradeMeta__value">
|
||||
{slippageError ? (
|
||||
<span className="neg">{slippageError}</span>
|
||||
) : dynamicSlippage?.impactBps == null ? (
|
||||
slippageConnected ? (
|
||||
'—'
|
||||
) : (
|
||||
'offline'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
|
||||
<span className="muted">
|
||||
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
|
||||
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
|
||||
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
|
||||
: ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tradeMeta__row">
|
||||
<span className="tradeMeta__label">Margin Required</span>
|
||||
@@ -491,6 +1009,10 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
<span className="tradeMeta__value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tradeBotSummary">
|
||||
<BotObserverSummaryCard marketName={symbol} observer={botObserver} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
|
||||
11
apps/visualizer/src/diagram-main.tsx
Normal file
11
apps/visualizer/src/diagram-main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import './diagram.css';
|
||||
import { TradeSystemFlowPage } from './diagram/TradeSystemFlowPage';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<TradeSystemFlowPage />
|
||||
</React.StrictMode>
|
||||
);
|
||||
943
apps/visualizer/src/diagram.css
Normal file
943
apps/visualizer/src/diagram.css
Normal file
@@ -0,0 +1,943 @@
|
||||
:root {
|
||||
--rf-bg: #efe7db;
|
||||
--rf-panel: rgba(255, 252, 246, 0.94);
|
||||
--rf-card: #fffdf9;
|
||||
--rf-ink: #1b1714;
|
||||
--rf-muted: #625b52;
|
||||
--rf-line: rgba(99, 91, 82, 0.18);
|
||||
--rf-shadow: 0 24px 54px rgba(27, 23, 20, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
color: var(--rf-ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(31, 111, 139, 0.14), transparent 26%),
|
||||
radial-gradient(circle at top right, rgba(176, 79, 53, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #f8f3eb 0%, var(--rf-bg) 100%);
|
||||
}
|
||||
|
||||
.rfPage {
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rfToolbar,
|
||||
.rfCanvas,
|
||||
.rfInspector {
|
||||
background: var(--rf-panel);
|
||||
border: 1px solid rgba(217, 204, 187, 0.92);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--rf-shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.rfToolbar {
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(220px, auto) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rfToolbar__eyebrow,
|
||||
.rfInspector__eyebrow,
|
||||
.rfCard__subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfToolbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rfToolbar__mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
background:
|
||||
linear-gradient(135deg, #1f6f8b 0%, #1f6f8b 46%, #b04f35 46%, #b04f35 100%);
|
||||
box-shadow: 0 0 0 4px rgba(31, 111, 139, 0.1);
|
||||
}
|
||||
|
||||
.rfToolbar__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rfToolbar__title {
|
||||
margin-top: 2px;
|
||||
font-size: clamp(1.1rem, 1.3vw, 1.45rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
font-family: 'IBM Plex Serif', Georgia, serif;
|
||||
}
|
||||
|
||||
.rfToolbar__summary,
|
||||
.rfInspector p,
|
||||
.rfCard__host,
|
||||
.rfCard__bullets,
|
||||
.rfSection li {
|
||||
color: var(--rf-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rfInspector__metricGrid {
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
.rfToolbar__summary {
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rfFilters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rfHistoryWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rfFilter {
|
||||
border: 1px solid rgba(99, 91, 82, 0.15);
|
||||
border-radius: 999px;
|
||||
background: #fffaf2;
|
||||
color: var(--rf-ink);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
transition: 140ms ease;
|
||||
}
|
||||
|
||||
.rfFilter:hover,
|
||||
.rfFilter.isActive {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.rfFilter.isMeta {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rfFilter:disabled {
|
||||
opacity: 0.44;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rfHistoryPanel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 24;
|
||||
width: 340px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.16);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 252, 246, 0.98);
|
||||
box-shadow: 0 24px 48px rgba(27, 23, 20, 0.18);
|
||||
}
|
||||
|
||||
.rfHistoryPanel__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rfHistoryPanel__eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfHistoryPanel__title {
|
||||
margin-top: 3px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rfHistoryPanel__close,
|
||||
.rfHistoryItem__restore {
|
||||
border: 1px solid rgba(99, 91, 82, 0.14);
|
||||
border-radius: 999px;
|
||||
background: #fffaf2;
|
||||
color: var(--rf-ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 8px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rfHistoryPanel__close:hover,
|
||||
.rfHistoryItem__restore:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.rfHistoryItem__restore:disabled {
|
||||
opacity: 0.46;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rfHistoryPanel__note {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfHistoryPanel__note.isError {
|
||||
color: #8b3d24;
|
||||
}
|
||||
|
||||
.rfHistoryPanel__list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rfHistoryItem {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.rfHistoryItem.isCurrent {
|
||||
border-color: rgba(31, 111, 139, 0.25);
|
||||
box-shadow: inset 0 0 0 1px rgba(31, 111, 139, 0.12);
|
||||
}
|
||||
|
||||
.rfHistoryItem__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.rfHistoryItem__action {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rfHistoryItem__time,
|
||||
.rfHistoryItem__sub {
|
||||
font-size: 12px;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfStatusRail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rfStatusRail__summary {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfStatusRail__list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rfStatusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: rgba(255, 250, 242, 0.92);
|
||||
color: var(--rf-ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: 140ms ease;
|
||||
}
|
||||
|
||||
.rfStatusPill:hover,
|
||||
.rfStatusPill.isSelected {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.rfStatusPill__name {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rfStatusPill__state {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rfStatusPill.is-ok .rfStatusPill__state {
|
||||
color: #185f39;
|
||||
}
|
||||
|
||||
.rfStatusPill.is-warn .rfStatusPill__state {
|
||||
color: #8b3d24;
|
||||
}
|
||||
|
||||
.rfStatusPill.is-info .rfStatusPill__state {
|
||||
color: #425466;
|
||||
}
|
||||
|
||||
.rfLayout {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) 410px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rfCanvas {
|
||||
height: calc(100vh - 126px);
|
||||
min-height: 760px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rfInspector {
|
||||
padding: 22px;
|
||||
max-height: calc(100vh - 126px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rfInspector h2 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.05;
|
||||
font-family: 'IBM Plex Serif', Georgia, serif;
|
||||
}
|
||||
|
||||
.rfInspector__host {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rfSection {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid rgba(99, 91, 82, 0.12);
|
||||
}
|
||||
|
||||
.rfSection h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.rfSection ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.rfSection li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.rfRouteList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rfRouteItem {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: rgba(255, 252, 246, 0.84);
|
||||
}
|
||||
|
||||
.rfRouteItem.isEmpty {
|
||||
color: var(--rf-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rfRouteItem__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rfRouteItem__meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--rf-ink);
|
||||
}
|
||||
|
||||
.rfRouteItem__desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfRouteBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-in {
|
||||
background: rgba(75, 85, 99, 0.12);
|
||||
color: #3f4854;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-out {
|
||||
background: rgba(20, 114, 109, 0.14);
|
||||
color: #126863;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-path-read {
|
||||
background: rgba(31, 111, 139, 0.12);
|
||||
color: #1f6f8b;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-path-live {
|
||||
background: rgba(176, 79, 53, 0.12);
|
||||
color: #a04a33;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-path-write {
|
||||
background: rgba(122, 98, 19, 0.14);
|
||||
color: #7a6213;
|
||||
}
|
||||
|
||||
.rfRouteBadge.is-path-control {
|
||||
background: rgba(75, 85, 99, 0.12);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.rfLegend {
|
||||
background: rgba(255, 252, 246, 0.9);
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 12px 28px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.rfLegend__title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfLegend__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--rf-ink);
|
||||
}
|
||||
|
||||
.rfLegend__row span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
.rfCard {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(99, 91, 82, 0.14);
|
||||
border-left: 6px solid var(--lane-color);
|
||||
border-radius: 18px;
|
||||
background: var(--rf-card);
|
||||
box-shadow: 0 16px 32px rgba(27, 23, 20, 0.08);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rfCard.isWorkflow {
|
||||
border-color: rgba(50, 130, 80, 0.72);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(88, 175, 112, 0.22),
|
||||
0 18px 34px rgba(32, 92, 52, 0.12);
|
||||
}
|
||||
|
||||
.rfCard.isWorkflow .rfCard__subtitle {
|
||||
color: #2d6b3f;
|
||||
}
|
||||
|
||||
.rfCard.isSelected {
|
||||
box-shadow: 0 18px 38px rgba(27, 23, 20, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.rfCard.isDragging {
|
||||
border-color: rgba(176, 48, 35, 0.78);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(176, 48, 35, 0.3),
|
||||
0 20px 42px rgba(176, 48, 35, 0.16);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.rfCard__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rfDragHandle {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rfCard__title {
|
||||
margin-top: 4px;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 800;
|
||||
color: var(--rf-ink);
|
||||
}
|
||||
|
||||
.rfCard__host {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rfCard__runtime {
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: linear-gradient(180deg, rgba(251, 247, 239, 0.96), rgba(246, 239, 227, 0.82));
|
||||
}
|
||||
|
||||
.rfCard__runtimeLabel {
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfCard__metricGrid,
|
||||
.rfInspector__metricGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rfCard__metricGrid {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rfMetricChip {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 253, 249, 0.92);
|
||||
border: 1px solid rgba(99, 91, 82, 0.1);
|
||||
}
|
||||
|
||||
.rfMetricChip span {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfMetricChip strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: var(--rf-ink);
|
||||
}
|
||||
|
||||
.rfFactGrid,
|
||||
.rfPanelGrid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rfFactGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rfFact {
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: rgba(255, 252, 246, 0.84);
|
||||
}
|
||||
|
||||
.rfFact span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfFact strong {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--rf-ink);
|
||||
}
|
||||
|
||||
.rfPanelCard {
|
||||
padding: 12px 13px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: rgba(255, 252, 246, 0.84);
|
||||
}
|
||||
|
||||
.rfPanelCard__title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
}
|
||||
|
||||
.rfPanelCard p,
|
||||
.rfPanelCard li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rfPanelCard ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.rfPanelCard li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rfCard__bullets {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rfCard__bullets li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.rfStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rfStatus.isOk {
|
||||
background: rgba(30, 107, 67, 0.11);
|
||||
color: #185f39;
|
||||
}
|
||||
|
||||
.rfStatus.isWarn {
|
||||
background: rgba(176, 79, 53, 0.11);
|
||||
color: #8b3d24;
|
||||
}
|
||||
|
||||
.rfHandle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--lane-color);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.rfHandle.isRouteHandle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 253, 249, 0.98);
|
||||
box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.rfZone {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(110, 101, 91, 0.14);
|
||||
padding: 12px 14px;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rfZone__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-height: 64px;
|
||||
width: min(100%, 320px);
|
||||
padding: 2px 2px 10px;
|
||||
}
|
||||
|
||||
.rfZone__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 251, 244, 0.96);
|
||||
box-shadow: 0 6px 16px rgba(27, 23, 20, 0.06);
|
||||
font-size: 0.96rem;
|
||||
font-weight: 900;
|
||||
color: var(--rf-ink);
|
||||
font-family: 'IBM Plex Serif', Georgia, serif;
|
||||
}
|
||||
|
||||
.rfZone__subtitle {
|
||||
margin-top: 6px;
|
||||
max-width: 250px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--rf-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.rfZone.is-host {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 253, 249, 0.92), rgba(244, 236, 225, 0.66));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.rfZone.is-namespace {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.78), rgba(236, 228, 214, 0.52));
|
||||
border-style: dashed;
|
||||
border-color: rgba(98, 91, 82, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.rfZone.is-subzone {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 254, 250, 0.72), rgba(248, 242, 233, 0.46));
|
||||
border-color: rgba(98, 91, 82, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.rfZone.isDragging {
|
||||
border-color: rgba(176, 48, 35, 0.72);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(176, 48, 35, 0.22),
|
||||
0 0 0 3px rgba(176, 48, 35, 0.14);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.rfZone.isDragging .rfZone__title {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(176, 48, 35, 0.18),
|
||||
0 8px 18px rgba(176, 48, 35, 0.16);
|
||||
}
|
||||
|
||||
.react-flow__renderer {
|
||||
background:
|
||||
linear-gradient(rgba(225, 214, 198, 0.22) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(225, 214, 198, 0.22) 1px, transparent 1px),
|
||||
linear-gradient(180deg, #fffdf8 0%, #faf4ea 100%);
|
||||
background-size: 20px 20px, 20px 20px, 100% 100%;
|
||||
}
|
||||
|
||||
.react-flow__nodes {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.react-flow__edges,
|
||||
.react-flow__edgelabel-renderer {
|
||||
z-index: 30 !important;
|
||||
}
|
||||
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.react-flow__controls,
|
||||
.react-flow__minimap {
|
||||
border: 1px solid rgba(99, 91, 82, 0.14);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background: rgba(255, 252, 246, 0.92);
|
||||
color: var(--rf-ink);
|
||||
border-bottom-color: rgba(99, 91, 82, 0.12);
|
||||
}
|
||||
|
||||
.react-flow__edge-text {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rfResizeHandle {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
border-radius: 999px !important;
|
||||
border: 2px solid rgba(255, 253, 249, 0.96) !important;
|
||||
background: rgba(31, 111, 139, 0.72) !important;
|
||||
box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.rfResizeLine {
|
||||
border-color: rgba(31, 111, 139, 0.28) !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.rfCard:hover .rfResizeHandle,
|
||||
.rfCard:hover .rfResizeLine,
|
||||
.rfCard.isSelected .rfResizeHandle,
|
||||
.rfCard.isSelected .rfResizeLine,
|
||||
.rfCard.isDragging .rfResizeHandle,
|
||||
.rfCard.isDragging .rfResizeLine,
|
||||
.rfZone:hover .rfResizeHandle,
|
||||
.rfZone:hover .rfResizeLine,
|
||||
.rfZone.isSelected .rfResizeHandle,
|
||||
.rfZone.isSelected .rfResizeLine,
|
||||
.rfZone.isDragging .rfResizeHandle,
|
||||
.rfZone.isDragging .rfResizeLine {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.rfCard:hover .rfResizeHandle,
|
||||
.rfCard.isSelected .rfResizeHandle,
|
||||
.rfCard.isDragging .rfResizeHandle,
|
||||
.rfZone:hover .rfResizeHandle,
|
||||
.rfZone.isSelected .rfResizeHandle,
|
||||
.rfZone.isDragging .rfResizeHandle {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.rfCard:hover .rfResizeLine,
|
||||
.rfCard.isSelected .rfResizeLine,
|
||||
.rfCard.isDragging .rfResizeLine {
|
||||
border-color: rgba(31, 111, 139, 0.42) !important;
|
||||
}
|
||||
|
||||
.rfZone:hover .rfResizeLine,
|
||||
.rfZone.isSelected .rfResizeLine,
|
||||
.rfZone.isDragging .rfResizeLine {
|
||||
border-color: rgba(176, 48, 35, 0.26) !important;
|
||||
}
|
||||
|
||||
.rfZone .rfResizeHandle {
|
||||
background: rgba(176, 48, 35, 0.72) !important;
|
||||
}
|
||||
|
||||
.rfPage.isUltraWide .rfLayout {
|
||||
grid-template-columns: minmax(0, 1.45fr) 430px;
|
||||
}
|
||||
|
||||
.rfPage.isUltraWide .rfCanvas {
|
||||
height: calc(100vh - 118px);
|
||||
min-height: 820px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background: rgba(27, 23, 20, 0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.rfPage {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.rfToolbar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rfStatusRail__list {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rfLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rfCanvas {
|
||||
height: 72vh;
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.rfCard__metricGrid,
|
||||
.rfInspector__metricGrid,
|
||||
.rfFactGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
2327
apps/visualizer/src/diagram/TradeSystemFlowPage.tsx
Normal file
2327
apps/visualizer/src/diagram/TradeSystemFlowPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
282
apps/visualizer/src/features/bot/BotObserverPanel.tsx
Normal file
282
apps/visualizer/src/features/bot/BotObserverPanel.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import type { BotEventRow, BotObserverData, BotObserverRuntimeState } from './useBotObserver';
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatBps(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(2)} bps`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function formatAge(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const ts = Date.parse(iso);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function formatClock(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const ts = Date.parse(iso);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
return new Date(ts).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function numericClass(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v) || v === 0) return '';
|
||||
return v > 0 ? 'pos' : 'neg';
|
||||
}
|
||||
|
||||
function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' {
|
||||
if (value === true) return 'ok';
|
||||
if (value === false) return 'bad';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function eventSummary(event: BotEventRow): string {
|
||||
const payload = event.payload || {};
|
||||
if (event.type === 'decision' || event.type === 'skip') {
|
||||
const decision = (payload as any).decision || {};
|
||||
const side = typeof decision.side === 'string' ? decision.side.toUpperCase() : '—';
|
||||
const confidence = typeof decision.confidence === 'number' ? `${Math.round(decision.confidence * 100)}%` : '—';
|
||||
const target = typeof decision.target_notional_usd === 'number' ? formatUsd(decision.target_notional_usd) : '—';
|
||||
const skipReason = typeof decision.skip_reason === 'string' && decision.skip_reason ? ` • ${decision.skip_reason}` : '';
|
||||
return `${side} • ${confidence} • ${target}${skipReason}`;
|
||||
}
|
||||
if (event.type === 'status') {
|
||||
const status = (payload as any).status || {};
|
||||
const bot = (payload as any).bot || {};
|
||||
const mode = typeof bot.mode === 'string' ? bot.mode : '—';
|
||||
const active = status.active === true ? 'active' : 'inactive';
|
||||
const note = typeof status.note === 'string' && status.note ? ` • ${status.note}` : '';
|
||||
return `${mode} • ${active}${note}`;
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
return typeof (payload as any).error === 'string' ? (payload as any).error : 'error';
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function kpiValueClass(side: string | null | undefined): string {
|
||||
if (side === 'long') return 'pos';
|
||||
if (side === 'short') return 'neg';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderFeatureRow(label: string, value: string, valueClass = '') {
|
||||
return (
|
||||
<div className="botFeatureRow" key={label}>
|
||||
<span className="botFeatureRow__label">{label}</span>
|
||||
<span className={['botFeatureRow__value', valueClass].filter(Boolean).join(' ')}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BotObserverPanel({
|
||||
marketName,
|
||||
observer,
|
||||
}: {
|
||||
marketName: string;
|
||||
observer: BotObserverData;
|
||||
}) {
|
||||
const { bot, resolvedBy, state, events, loading, error, warning, lastUpdatedAt } = observer;
|
||||
const runtimeState: BotObserverRuntimeState | null = state?.state || null;
|
||||
const lastDecision = runtimeState?.last_decision || null;
|
||||
const lastFeatures = runtimeState?.last_features || null;
|
||||
const lastGates = runtimeState?.last_gates || null;
|
||||
const lastSnapshot = runtimeState?.last_snapshot || null;
|
||||
const counters = runtimeState?.counters || null;
|
||||
|
||||
if (loading && !bot) {
|
||||
return <div className="placeholder">Loading bot observer…</div>;
|
||||
}
|
||||
|
||||
if (error && !bot) {
|
||||
return <div className="uiError">{error}</div>;
|
||||
}
|
||||
|
||||
if (!bot) {
|
||||
if (warning === 'bot_control_plane_schema_missing') {
|
||||
return (
|
||||
<div className="botDash botDash--empty">
|
||||
<div className="botDash__emptyTitle">Bot control plane is not deployed here.</div>
|
||||
<div className="muted">This Hasura environment does not expose `bot_config`, `bot_state`, or `bot_events` yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="botDash botDash--empty">
|
||||
<div className="botDash__emptyTitle">No bot configured for this market.</div>
|
||||
<div className="muted">Set `VITE_BOT_ID` / `VITE_BOT_NAME` or create a bot for {marketName}.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="botDash dlobDash">
|
||||
<div className="dlobDash__head">
|
||||
<div className="dlobDash__title">Bot Observer</div>
|
||||
<div className="dlobDash__meta">
|
||||
<span className="dlobDash__market">{bot.name}</span>
|
||||
<span className="muted">{bot.market_name}</span>
|
||||
<span className="muted">{resolvedBy ? `resolved ${resolvedBy}` : 'manual'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="uiError">{error}</div> : null}
|
||||
|
||||
{bot.market_name !== marketName ? (
|
||||
<div className="botDash__note muted">Current chart market is {marketName}, panel shows bot for {bot.market_name}.</div>
|
||||
) : null}
|
||||
|
||||
<div className="dlobDash__statuses">
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">mode</span>
|
||||
<span className="dlobStatus__value">{bot.mode}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">status</span>
|
||||
<span className="dlobStatus__value">{runtimeState?.status || '—'}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">heartbeat</span>
|
||||
<span className="dlobStatus__value">{formatAge(state?.last_heartbeat_at)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">updated</span>
|
||||
<span className="dlobStatus__value">{lastUpdatedAt ? formatClock(lastUpdatedAt) : '—'}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">kill switch</span>
|
||||
<span className={['dlobStatus__value', bot.kill_switch ? 'neg' : 'pos'].join(' ')}>
|
||||
{bot.kill_switch ? 'enabled' : 'off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__grid">
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Decision</div>
|
||||
<div className={['dlobKpi__value', kpiValueClass(lastDecision?.side || null)].join(' ')}>
|
||||
{lastDecision?.side || '—'}
|
||||
</div>
|
||||
<div className="dlobKpi__sub muted">{lastDecision?.skip_reason || 'current signal'}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Confidence</div>
|
||||
<div className="dlobKpi__value">{formatPct(lastDecision?.confidence ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Target</div>
|
||||
<div className="dlobKpi__value">{formatUsd(lastDecision?.target_notional_usd ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">{lastDecision?.horizon_s != null ? `${lastDecision.horizon_s}s horizon` : '—'}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Long Score</div>
|
||||
<div className={['dlobKpi__value', numericClass(lastDecision?.long_score ?? null)].join(' ')}>
|
||||
{lastDecision?.long_score == null ? '—' : lastDecision.long_score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Short Score</div>
|
||||
<div className={['dlobKpi__value', numericClass(lastDecision?.short_score ?? null)].join(' ')}>
|
||||
{lastDecision?.short_score == null ? '—' : lastDecision.short_score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Loops</div>
|
||||
<div className="dlobKpi__value">{counters?.loops ?? '—'}</div>
|
||||
<div className="dlobKpi__sub muted">
|
||||
{counters ? `${counters.decisions ?? 0} decisions / ${counters.skips ?? 0} skips` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="botDash__layout">
|
||||
<div className="botPanel">
|
||||
<div className="botPanel__head">
|
||||
<div className="botPanel__title">Gates</div>
|
||||
<div className="muted">{lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms age` : '—'}</div>
|
||||
</div>
|
||||
<div className="botGateGrid">
|
||||
{[
|
||||
['fresh', lastGates?.fresh],
|
||||
['spread_ok', lastGates?.spread_ok],
|
||||
['slippage_ok', lastGates?.slippage_ok],
|
||||
['depth_ok', lastGates?.depth_ok],
|
||||
['has_candles', lastGates?.has_candles],
|
||||
['all', lastGates?.all],
|
||||
].map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`botGate botGate--${gateTone(value as boolean | null | undefined)}`}
|
||||
>
|
||||
<span className="botGate__label">{label}</span>
|
||||
<span className="botGate__value">{value == null ? '—' : value ? 'pass' : 'fail'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="botPanel__head">
|
||||
<div className="botPanel__title">Feature Snapshot</div>
|
||||
<div className="muted">{lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms query` : '—'}</div>
|
||||
</div>
|
||||
<div className="botFeatureList">
|
||||
{renderFeatureRow('spread_bps', formatBps(lastFeatures?.spread_bps ?? null))}
|
||||
{renderFeatureRow('mark_vs_oracle', formatBps(lastFeatures?.mark_vs_oracle_bps ?? null), numericClass(lastFeatures?.mark_vs_oracle_bps ?? null))}
|
||||
{renderFeatureRow('mom_3s', formatBps(lastFeatures?.mom_3s ?? null), numericClass(lastFeatures?.mom_3s ?? null))}
|
||||
{renderFeatureRow('mom_10s', formatBps(lastFeatures?.mom_10s ?? null), numericClass(lastFeatures?.mom_10s ?? null))}
|
||||
{renderFeatureRow('mom_30s', formatBps(lastFeatures?.mom_30s ?? null), numericClass(lastFeatures?.mom_30s ?? null))}
|
||||
{renderFeatureRow('vol_30s', formatBps(lastFeatures?.vol_30s ?? null))}
|
||||
{renderFeatureRow('depth_bid_usd', formatUsd(lastFeatures?.depth_bid_usd ?? null), 'pos')}
|
||||
{renderFeatureRow('depth_ask_usd', formatUsd(lastFeatures?.depth_ask_usd ?? null), 'neg')}
|
||||
{renderFeatureRow('depth_imbalance', formatPct(lastFeatures?.depth_imbalance ?? null), numericClass(lastFeatures?.depth_imbalance ?? null))}
|
||||
{renderFeatureRow('buy_slippage', formatBps(lastFeatures?.buy_slippage_bps ?? null))}
|
||||
{renderFeatureRow('sell_slippage', formatBps(lastFeatures?.sell_slippage_bps ?? null))}
|
||||
{renderFeatureRow('last_error', state?.last_error || runtimeState?.error || '—', state?.last_error || runtimeState?.error ? 'neg' : '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="botPanel">
|
||||
<div className="botPanel__head">
|
||||
<div className="botPanel__title">Event Feed</div>
|
||||
<div className="muted">{events.length ? `${events.length} rows` : '—'}</div>
|
||||
</div>
|
||||
{events.length ? (
|
||||
<div className="botEventList">
|
||||
{events.map((event) => (
|
||||
<div className={`botEvent botEvent--${event.type}`} key={event.id}>
|
||||
<div className="botEvent__head">
|
||||
<span className="botEvent__type">{event.type}</span>
|
||||
<span className="muted">{formatClock(event.ts)}</span>
|
||||
</div>
|
||||
<div className="botEvent__summary">{eventSummary(event)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="botPanel__empty muted">No bot events yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx
Normal file
185
apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { BotObserverData, BotObserverRuntimeState } from './useBotObserver';
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatAge(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const ts = Date.parse(iso);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' {
|
||||
if (value === true) return 'ok';
|
||||
if (value === false) return 'bad';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function stateTone(value: string | null | undefined): string {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'long') return 'pos';
|
||||
if (normalized === 'short' || normalized === 'panic') return 'neg';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveMachineState(runtimeState: BotObserverRuntimeState | null, desiredMode: string, killSwitch: boolean): string {
|
||||
if (killSwitch) return 'panic';
|
||||
const status = String(runtimeState?.status || runtimeState?.mode || desiredMode || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (['off', 'observe', 'paper', 'trade', 'panic'].includes(status)) return status;
|
||||
return 'off';
|
||||
}
|
||||
|
||||
export default function BotObserverSummaryCard({
|
||||
marketName,
|
||||
observer,
|
||||
}: {
|
||||
marketName: string;
|
||||
observer: BotObserverData;
|
||||
}) {
|
||||
const { bot, state, loading, error, warning } = observer;
|
||||
const runtimeState: BotObserverRuntimeState | null = state?.state || null;
|
||||
const machineState = resolveMachineState(runtimeState, bot?.mode || 'off', Boolean(bot?.kill_switch));
|
||||
const lastDecision = runtimeState?.last_decision || null;
|
||||
const lastSnapshot = runtimeState?.last_snapshot || null;
|
||||
const lastGates = runtimeState?.last_gates || null;
|
||||
const counters = runtimeState?.counters || null;
|
||||
|
||||
if (loading && !bot) {
|
||||
return <div className="botSummaryCard__empty muted">Loading bot…</div>;
|
||||
}
|
||||
|
||||
if (error && !bot) {
|
||||
return <div className="uiError botSummaryCard__error">{error}</div>;
|
||||
}
|
||||
|
||||
if (!bot) {
|
||||
if (warning === 'bot_control_plane_schema_missing') {
|
||||
return (
|
||||
<div className="botSummaryCard__empty">
|
||||
<div className="botSummaryCard__emptyTitle">Bot control plane missing</div>
|
||||
<div className="muted">Current Hasura environment does not publish bot tables yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="botSummaryCard__empty">
|
||||
<div className="botSummaryCard__emptyTitle">No bot configured</div>
|
||||
<div className="muted">No observer found for {marketName}.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="botSummaryCard">
|
||||
<div className="botSummaryCard__head">
|
||||
<div>
|
||||
<div className="botSummaryCard__title">Bot State Machine</div>
|
||||
<div className="botSummaryCard__subtitle muted">{bot.name}</div>
|
||||
</div>
|
||||
<div className={['botSummaryCard__side', stateTone(lastDecision?.side)].join(' ')}>
|
||||
{(lastDecision?.side || 'flat').toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="uiError botSummaryCard__error">{error}</div> : null}
|
||||
|
||||
{bot.market_name !== marketName ? (
|
||||
<div className="botSummaryCard__note muted">Chart: {marketName} • Bot: {bot.market_name}</div>
|
||||
) : null}
|
||||
|
||||
<div className="botStateRail">
|
||||
{['off', 'observe', 'paper', 'trade', 'panic'].map((stage) => (
|
||||
<div
|
||||
key={stage}
|
||||
className={[
|
||||
'botStateRail__node',
|
||||
machineState === stage ? 'botStateRail__node--active' : '',
|
||||
stage === 'panic' ? 'botStateRail__node--panic' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{stage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="botSummaryStats">
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Desired</span>
|
||||
<span className="botSummaryStats__value">{bot.mode}</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Runtime</span>
|
||||
<span className="botSummaryStats__value">{runtimeState?.status || runtimeState?.mode || '—'}</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Heartbeat</span>
|
||||
<span className="botSummaryStats__value">{formatAge(state?.last_heartbeat_at)}</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Data Age</span>
|
||||
<span className="botSummaryStats__value">
|
||||
{lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Query</span>
|
||||
<span className="botSummaryStats__value">
|
||||
{lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Confidence</span>
|
||||
<span className="botSummaryStats__value">{formatPct(lastDecision?.confidence ?? null)}</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Target</span>
|
||||
<span className="botSummaryStats__value">{formatUsd(lastDecision?.target_notional_usd ?? null)}</span>
|
||||
</div>
|
||||
<div className="botSummaryStats__row">
|
||||
<span className="botSummaryStats__label">Loops</span>
|
||||
<span className="botSummaryStats__value">{counters?.loops ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="botGatePills">
|
||||
{[
|
||||
['fresh', lastGates?.fresh],
|
||||
['spread', lastGates?.spread_ok],
|
||||
['slippage', lastGates?.slippage_ok],
|
||||
['depth', lastGates?.depth_ok],
|
||||
].map(([label, value]) => (
|
||||
<span key={label} className={`botGatePill botGatePill--${gateTone(value as boolean | null | undefined)}`}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lastDecision?.skip_reason ? (
|
||||
<div className="botSummaryCard__footer muted">skip: {lastDecision.skip_reason}</div>
|
||||
) : runtimeState?.inactive_reason ? (
|
||||
<div className="botSummaryCard__footer muted">{runtimeState.inactive_reason}</div>
|
||||
) : state?.last_error ? (
|
||||
<div className="botSummaryCard__footer neg">{state.last_error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
apps/visualizer/src/features/bot/useBotObserver.ts
Normal file
290
apps/visualizer/src/features/bot/useBotObserver.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useInterval } from '../../app/hooks/useInterval';
|
||||
|
||||
export type BotConfigSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
market_name: string;
|
||||
market_type: string;
|
||||
mode: string;
|
||||
kill_switch: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type BotObserverRuntimeState = {
|
||||
service?: string;
|
||||
version?: string;
|
||||
appVersion?: string;
|
||||
observe_only?: boolean;
|
||||
market_name?: string;
|
||||
market_type?: string;
|
||||
mode?: string;
|
||||
kill_switch?: boolean;
|
||||
status?: string;
|
||||
strategy_type?: string;
|
||||
loop_ms?: number;
|
||||
last_snapshot?: {
|
||||
query_latency_ms?: number | null;
|
||||
data_age_ms?: number | null;
|
||||
candles_count?: number | null;
|
||||
stats_age_ms?: number | null;
|
||||
depth_age_ms?: number | null;
|
||||
buy_slippage_age_ms?: number | null;
|
||||
sell_slippage_age_ms?: number | null;
|
||||
} | null;
|
||||
last_features?: {
|
||||
mark_price?: number | null;
|
||||
oracle_price?: number | null;
|
||||
mid_price?: number | null;
|
||||
spread_bps?: number | null;
|
||||
stats_imbalance?: number | null;
|
||||
depth_bid_usd?: number | null;
|
||||
depth_ask_usd?: number | null;
|
||||
depth_imbalance?: number | null;
|
||||
buy_slippage_bps?: number | null;
|
||||
sell_slippage_bps?: number | null;
|
||||
mark_vs_oracle_bps?: number | null;
|
||||
mom_3s?: number | null;
|
||||
mom_10s?: number | null;
|
||||
mom_30s?: number | null;
|
||||
vol_30s?: number | null;
|
||||
} | null;
|
||||
last_gates?: {
|
||||
fresh?: boolean;
|
||||
spread_ok?: boolean;
|
||||
slippage_ok?: boolean;
|
||||
depth_ok?: boolean;
|
||||
has_candles?: boolean;
|
||||
all?: boolean;
|
||||
} | null;
|
||||
last_decision?: {
|
||||
side?: string;
|
||||
confidence?: number | null;
|
||||
long_score?: number | null;
|
||||
short_score?: number | null;
|
||||
target_notional_usd?: number | null;
|
||||
horizon_s?: number | null;
|
||||
skip_reason?: string | null;
|
||||
} | null;
|
||||
counters?: {
|
||||
loops?: number;
|
||||
decisions?: number;
|
||||
skips?: number;
|
||||
errors?: number;
|
||||
} | null;
|
||||
inactive_reason?: string;
|
||||
error?: string;
|
||||
trade_requested_but_observer_only?: boolean;
|
||||
};
|
||||
|
||||
export type BotStateRow = {
|
||||
bot_id: string;
|
||||
last_heartbeat_at: string | null;
|
||||
last_action_at: string | null;
|
||||
last_error: string | null;
|
||||
state: BotObserverRuntimeState | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type BotEventRow = {
|
||||
id: number;
|
||||
ts: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type BotObserverData = {
|
||||
bot: BotConfigSummary | null;
|
||||
resolvedBy: string | null;
|
||||
state: BotStateRow | null;
|
||||
events: BotEventRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
warning: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
};
|
||||
|
||||
function envString(name: string): string | undefined {
|
||||
const value = (import.meta as any).env?.[name];
|
||||
if (value == null) return undefined;
|
||||
const text = String(value).trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function getApiBase(): string {
|
||||
return envString('VITE_API_URL') || '/api';
|
||||
}
|
||||
|
||||
function buildApiUrl(pathname: string): string {
|
||||
const base = new URL(getApiBase(), window.location.origin);
|
||||
const basePath = base.pathname && base.pathname !== '/' ? base.pathname.replace(/\/$/, '') : '';
|
||||
const [pathOnly, searchOnly = ''] = String(pathname || '').split('?');
|
||||
base.pathname = `${basePath}${pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`}`;
|
||||
base.search = searchOnly ? `?${searchOnly}` : '';
|
||||
base.hash = '';
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
async function apiRequest<T>(pathname: string): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(pathname), {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`Invalid API JSON for ${pathname}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.error || `API HTTP ${res.status}`);
|
||||
return json as T;
|
||||
}
|
||||
|
||||
function preferredBotId(): string | undefined {
|
||||
return envString('VITE_BOT_ID');
|
||||
}
|
||||
|
||||
function preferredBotName(): string | undefined {
|
||||
return envString('VITE_BOT_NAME');
|
||||
}
|
||||
|
||||
function resolveBot(bots: BotConfigSummary[], marketName: string): { bot: BotConfigSummary | null; by: string | null } {
|
||||
const configuredId = preferredBotId();
|
||||
if (configuredId) {
|
||||
const match = bots.find((entry) => entry.id === configuredId);
|
||||
if (match) return { bot: match, by: 'env:id' };
|
||||
}
|
||||
|
||||
const configuredName = preferredBotName();
|
||||
if (configuredName) {
|
||||
const match = bots.find((entry) => entry.name === configuredName);
|
||||
if (match) return { bot: match, by: 'env:name' };
|
||||
}
|
||||
|
||||
const match = bots.find((entry) => String(entry.market_name || '').trim().toUpperCase() === marketName.trim().toUpperCase());
|
||||
if (match) return { bot: match, by: 'market' };
|
||||
|
||||
return { bot: null, by: null };
|
||||
}
|
||||
|
||||
export function useBotObserver(marketName: string): BotObserverData {
|
||||
const mountedRef = useRef(true);
|
||||
const resolveInFlightRef = useRef(false);
|
||||
const detailsInFlightRef = useRef(false);
|
||||
const [bot, setBot] = useState<BotConfigSummary | null>(null);
|
||||
const [resolvedBy, setResolvedBy] = useState<string | null>(null);
|
||||
const [state, setState] = useState<BotStateRow | null>(null);
|
||||
const [events, setEvents] = useState<BotEventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function refreshBotResolution() {
|
||||
if (resolveInFlightRef.current) return;
|
||||
resolveInFlightRef.current = true;
|
||||
try {
|
||||
const json = await apiRequest<{ ok?: boolean; bots?: BotConfigSummary[]; error?: string; warning?: string }>(
|
||||
'/v1/bots?limit=100'
|
||||
);
|
||||
const bots = Array.isArray(json?.bots) ? json.bots : [];
|
||||
const resolved = resolveBot(bots, marketName);
|
||||
if (!mountedRef.current) return;
|
||||
setBot((current) => {
|
||||
if (current?.id === resolved.bot?.id) return current;
|
||||
return resolved.bot;
|
||||
});
|
||||
setResolvedBy(resolved.by);
|
||||
if (!resolved.bot) {
|
||||
setState(null);
|
||||
setEvents([]);
|
||||
setLoading(false);
|
||||
}
|
||||
setWarning(typeof json?.warning === 'string' && json.warning ? json.warning : null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(String((err as Error)?.message || err));
|
||||
setLoading(false);
|
||||
} finally {
|
||||
resolveInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBotDetails(botId: string) {
|
||||
if (detailsInFlightRef.current) return;
|
||||
detailsInFlightRef.current = true;
|
||||
try {
|
||||
const [stateJson, eventsJson] = await Promise.all([
|
||||
apiRequest<{ ok?: boolean; state?: BotStateRow | null; error?: string; warning?: string }>(
|
||||
`/v1/bots/${botId}/state`
|
||||
),
|
||||
apiRequest<{ ok?: boolean; events?: BotEventRow[]; error?: string; warning?: string }>(
|
||||
`/v1/bots/${botId}/events?limit=24`
|
||||
),
|
||||
]);
|
||||
if (!mountedRef.current) return;
|
||||
setState(stateJson?.state || null);
|
||||
setEvents(Array.isArray(eventsJson?.events) ? eventsJson.events : []);
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
setWarning(
|
||||
(typeof stateJson?.warning === 'string' && stateJson.warning) ||
|
||||
(typeof eventsJson?.warning === 'string' && eventsJson.warning) ||
|
||||
null
|
||||
);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(String((err as Error)?.message || err));
|
||||
setLoading(false);
|
||||
} finally {
|
||||
detailsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setBot(null);
|
||||
setResolvedBy(null);
|
||||
setState(null);
|
||||
setEvents([]);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setLastUpdatedAt(null);
|
||||
void refreshBotResolution();
|
||||
}, [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bot?.id) return;
|
||||
void refreshBotDetails(bot.id);
|
||||
}, [bot?.id]);
|
||||
|
||||
useInterval(() => {
|
||||
void refreshBotResolution();
|
||||
}, 5000);
|
||||
|
||||
useInterval(() => {
|
||||
if (!bot?.id) return;
|
||||
void refreshBotDetails(bot.id);
|
||||
}, bot?.id ? 1000 : null);
|
||||
|
||||
return {
|
||||
bot,
|
||||
resolvedBy,
|
||||
state,
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
warning,
|
||||
lastUpdatedAt,
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,21 @@ export function IconEye(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLayers(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Layers'} {...props}>
|
||||
<path
|
||||
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
|
||||
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||
|
||||
206
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
206
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { OverlayLayer } from './ChartPanel.types';
|
||||
import { IconEye, IconLock, IconTrash } from './ChartIcons';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
layers: OverlayLayer[];
|
||||
onRequestClose: () => void;
|
||||
|
||||
onToggleLayerVisible: (layerId: string) => void;
|
||||
onToggleLayerLocked: (layerId: string) => void;
|
||||
onSetLayerOpacity: (layerId: string, opacity: number) => void;
|
||||
|
||||
fibPresent: boolean;
|
||||
fibSelected: boolean;
|
||||
fibVisible: boolean;
|
||||
fibLocked: boolean;
|
||||
fibOpacity: number;
|
||||
onSelectFib: () => void;
|
||||
onToggleFibVisible: () => void;
|
||||
onToggleFibLocked: () => void;
|
||||
onSetFibOpacity: (opacity: number) => void;
|
||||
onDeleteFib: () => void;
|
||||
};
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
function opacityToPct(opacity: number): number {
|
||||
return Math.round(clamp01(opacity) * 100);
|
||||
}
|
||||
|
||||
function pctToOpacity(pct: number): number {
|
||||
if (!Number.isFinite(pct)) return 1;
|
||||
return clamp01(pct / 100);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
title,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OpacitySlider({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
const pct = opacityToPct(value);
|
||||
return (
|
||||
<div className="layersOpacity">
|
||||
<input
|
||||
className="layersOpacity__range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={pct}
|
||||
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="layersOpacity__pct">{pct}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChartLayersPanel({
|
||||
open,
|
||||
layers,
|
||||
onRequestClose,
|
||||
onToggleLayerVisible,
|
||||
onToggleLayerLocked,
|
||||
onSetLayerOpacity,
|
||||
fibPresent,
|
||||
fibSelected,
|
||||
fibVisible,
|
||||
fibLocked,
|
||||
fibOpacity,
|
||||
onSelectFib,
|
||||
onToggleFibVisible,
|
||||
onToggleFibLocked,
|
||||
onSetFibOpacity,
|
||||
onDeleteFib,
|
||||
}: Props) {
|
||||
const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
|
||||
onClick={open ? onRequestClose : undefined}
|
||||
/>
|
||||
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
|
||||
<div className="chartLayersPanel__head">
|
||||
<div className="chartLayersPanel__title">Layers</div>
|
||||
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chartLayersTable">
|
||||
<div className="chartLayersRow chartLayersRow--head">
|
||||
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
|
||||
<IconEye />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
|
||||
<IconLock />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">Name</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||
</div>
|
||||
|
||||
{layers.map((layer) => (
|
||||
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--layer">
|
||||
{layer.name}
|
||||
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{drawingsLayer && fibPresent ? (
|
||||
<div
|
||||
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={onSelectFib}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--object">Fib Retracement</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions">
|
||||
<IconButton title="Delete fib" onClick={onDeleteFib}>
|
||||
<IconTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,102 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||
import type { TickerItem } from '../tickerbar/TickerBar';
|
||||
import Card from '../../ui/Card';
|
||||
import ChartLayersPanel from './ChartLayersPanel';
|
||||
import ChartSideToolbar from './ChartSideToolbar';
|
||||
import ChartToolbar from './ChartToolbar';
|
||||
import TradingChart from './TradingChart';
|
||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||
import type { IChartApi } from 'lightweight-charts';
|
||||
import { LineStyle, type IChartApi, type ISeriesApi, type UTCTimestamp } from 'lightweight-charts';
|
||||
import type { OverlayLayer } from './ChartPanel.types';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
|
||||
tickerItems: TickerItem[];
|
||||
theme: 'day' | 'night';
|
||||
timeframe: string;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
showBuild: boolean;
|
||||
onToggleBuild: () => void;
|
||||
onResetViewReady?: ((handler: (() => void) | null) => void) | undefined;
|
||||
seriesLabel: string;
|
||||
hasMoreHistory?: boolean;
|
||||
onRequestHistoryBefore?: (beforeTime: number) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type FibDragMode = 'move' | 'edit-b';
|
||||
|
||||
type FibDrag = {
|
||||
pointerId: number;
|
||||
mode: FibDragMode;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
start: FibAnchor;
|
||||
origin: FibRetracement;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
function isEditableTarget(t: EventTarget | null): boolean {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
return t.isContentEditable;
|
||||
}
|
||||
|
||||
export default function ChartPanel({
|
||||
candles,
|
||||
indicators,
|
||||
dlobQuotes,
|
||||
tickerItems,
|
||||
theme,
|
||||
timeframe,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
showBuild,
|
||||
onToggleBuild,
|
||||
onResetViewReady,
|
||||
seriesLabel,
|
||||
hasMoreHistory = false,
|
||||
onRequestHistoryBefore,
|
||||
}: Props) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||
]);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [fibVisible, setFibVisible] = useState(true);
|
||||
const [fibLocked, setFibLocked] = useState(false);
|
||||
const [fibOpacity, setFibOpacity] = useState(1);
|
||||
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
|
||||
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||
|
||||
const chartApiRef = useRef<IChartApi | null>(null);
|
||||
const candleSeriesRef = useRef<ISeriesApi<'Candlestick', UTCTimestamp> | null>(null);
|
||||
const activeToolRef = useRef(activeTool);
|
||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const spaceDownRef = useRef<boolean>(false);
|
||||
const dragRef = useRef<FibDrag | null>(null);
|
||||
const selectPointerRef = useRef<number | null>(null);
|
||||
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
|
||||
const fibRef = useRef<FibRetracement | null>(fib);
|
||||
const resetChartViewRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
@@ -67,16 +128,70 @@ export default function ChartPanel({
|
||||
fibStartRef.current = fibStart;
|
||||
}, [fibStart]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedOverlayIdRef.current = selectedOverlayId;
|
||||
}, [selectedOverlayId]);
|
||||
|
||||
useEffect(() => {
|
||||
fibRef.current = fib;
|
||||
}, [fib]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
if (isEditableTarget(e.target)) return;
|
||||
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault();
|
||||
resetChartViewRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === 'Space') {
|
||||
spaceDownRef.current = true;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (dragRef.current) {
|
||||
dragRef.current = null;
|
||||
pendingDragRef.current = null;
|
||||
selectPointerRef.current = null;
|
||||
setFibDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToolRef.current === 'fib-retracement') {
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOverlayIdRef.current) setSelectedOverlayId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedOverlayIdRef.current === 'fib') {
|
||||
clearFib();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') {
|
||||
spaceDownRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,16 +213,144 @@ export default function ChartPanel({
|
||||
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
||||
}
|
||||
|
||||
function resetChartView() {
|
||||
const chart = chartApiRef.current;
|
||||
if (!chart) return;
|
||||
|
||||
chart.timeScale().resetTimeScale();
|
||||
candleSeriesRef.current?.priceScale().setAutoScale(true);
|
||||
setPriceAutoScale(true);
|
||||
}
|
||||
|
||||
resetChartViewRef.current = resetChartView;
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
|
||||
const quotesVisible = Boolean(quotesLayer?.visible);
|
||||
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
|
||||
|
||||
const priceLines = useMemo(() => {
|
||||
if (!quotesVisible) return [];
|
||||
return [
|
||||
{
|
||||
id: 'dlob-bid',
|
||||
title: 'DLOB Bid',
|
||||
price: dlobQuotes?.bid ?? null,
|
||||
color: `rgba(34,197,94,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
{
|
||||
id: 'dlob-mid',
|
||||
title: 'DLOB Mid',
|
||||
price: dlobQuotes?.mid ?? null,
|
||||
color: `rgba(230,233,239,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
},
|
||||
{
|
||||
id: 'dlob-ask',
|
||||
title: 'DLOB Ask',
|
||||
price: dlobQuotes?.ask ?? null,
|
||||
color: `rgba(239,68,68,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
];
|
||||
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible, theme]);
|
||||
|
||||
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||
}
|
||||
|
||||
function clearFib() {
|
||||
setFib(null);
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
dragRef.current = null;
|
||||
pendingDragRef.current = null;
|
||||
selectPointerRef.current = null;
|
||||
setSelectedOverlayId(null);
|
||||
}
|
||||
|
||||
function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement {
|
||||
if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer };
|
||||
const deltaLogical = pointer.logical - drag.start.logical;
|
||||
const deltaPrice = pointer.price - drag.start.price;
|
||||
return {
|
||||
a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice },
|
||||
b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice },
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
|
||||
const drag = dragRef.current;
|
||||
const pendingDrag = pendingDragRef.current;
|
||||
if (drag && pendingDrag) {
|
||||
if (!drag.moved) {
|
||||
const dx = pendingDrag.clientX - drag.startClientX;
|
||||
const dy = pendingDrag.clientY - drag.startClientY;
|
||||
if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold
|
||||
}
|
||||
if (drag.moved) {
|
||||
setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = pendingMoveRef.current;
|
||||
if (!pointer) return;
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
|
||||
const start2 = fibStartRef.current;
|
||||
if (!start2) return;
|
||||
setFibDraft({ a: start2, b: pointer });
|
||||
});
|
||||
}
|
||||
|
||||
const drawingsLayer =
|
||||
layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 };
|
||||
const fibEffectiveVisible = fibVisible && drawingsLayer.visible;
|
||||
const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity;
|
||||
const fibEffectiveLocked = fibLocked || drawingsLayer.locked;
|
||||
const fibSelected = selectedOverlayId === 'fib';
|
||||
const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOverlayId !== 'fib') return;
|
||||
if (!fib) {
|
||||
setSelectedOverlayId(null);
|
||||
return;
|
||||
}
|
||||
if (!fibEffectiveVisible) setSelectedOverlayId(null);
|
||||
}, [fib, fibEffectiveVisible, selectedOverlayId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onResetViewReady) return;
|
||||
onResetViewReady(() => resetChartViewRef.current());
|
||||
return () => onResetViewReady(null);
|
||||
}, [onResetViewReady]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||
<Card className={cardClassName}>
|
||||
<div className="chartCard__toolbar">
|
||||
<ChartToolbar
|
||||
tickerItems={tickerItems}
|
||||
timeframe={timeframe}
|
||||
onTimeframeChange={onTimeframeChange}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={onToggleIndicators}
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={onToggleBuild}
|
||||
priceAutoScale={priceAutoScale}
|
||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||
@@ -118,56 +361,147 @@ export default function ChartPanel({
|
||||
timeframe={timeframe}
|
||||
activeTool={activeTool}
|
||||
hasFib={fib != null || fibDraft != null}
|
||||
isLayersOpen={layersOpen}
|
||||
onToolChange={setActiveTool}
|
||||
onToggleLayers={() => setLayersOpen((v) => !v)}
|
||||
onZoomIn={() => zoomTime(0.8)}
|
||||
onZoomOut={() => zoomTime(1.25)}
|
||||
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
||||
onClearFib={() => {
|
||||
setFib(null);
|
||||
setFibStart(null);
|
||||
setFibDraft(null);
|
||||
}}
|
||||
onResetView={resetChartView}
|
||||
onClearFib={clearFib}
|
||||
/>
|
||||
<div className="chartCard__chart">
|
||||
<TradingChart
|
||||
candles={candles}
|
||||
theme={theme}
|
||||
oracle={indicators.oracle}
|
||||
sma20={indicators.sma20}
|
||||
ema20={indicators.ema20}
|
||||
bb20={indicators.bb20}
|
||||
showIndicators={showIndicators}
|
||||
fib={fibDraft ?? fib}
|
||||
onReady={({ chart }) => {
|
||||
showBuild={showBuild}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
priceLines={priceLines}
|
||||
fib={fibRenderable}
|
||||
fibOpacity={fibEffectiveOpacity}
|
||||
fibSelected={fibSelected}
|
||||
priceAutoScale={priceAutoScale}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
onRequestHistoryBefore={onRequestHistoryBefore}
|
||||
onReady={({ chart, candles: candleSeries }) => {
|
||||
chartApiRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
}}
|
||||
onChartClick={(p) => {
|
||||
if (activeTool !== 'fib-retracement') return;
|
||||
if (!fibStartRef.current) {
|
||||
fibStartRef.current = p;
|
||||
setFibStart(p);
|
||||
setFibDraft({ a: p, b: p });
|
||||
if (activeTool === 'fib-retracement') {
|
||||
if (!fibStartRef.current) {
|
||||
fibStartRef.current = p;
|
||||
setFibStart(p);
|
||||
setFibDraft({ a: p, b: p });
|
||||
return;
|
||||
}
|
||||
setFib({ a: fibStartRef.current, b: p });
|
||||
setFibStart(null);
|
||||
fibStartRef.current = null;
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
return;
|
||||
}
|
||||
setFib({ a: fibStartRef.current, b: p });
|
||||
setFibStart(null);
|
||||
fibStartRef.current = null;
|
||||
setFibDraft(null);
|
||||
setActiveTool('cursor');
|
||||
|
||||
if (p.target === 'chart') setSelectedOverlayId(null);
|
||||
}}
|
||||
onChartCrosshairMove={(p) => {
|
||||
if (activeToolRef.current !== 'fib-retracement') return;
|
||||
const start = fibStartRef.current;
|
||||
if (!start) return;
|
||||
pendingMoveRef.current = p;
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const move = pendingMoveRef.current;
|
||||
const start2 = fibStartRef.current;
|
||||
if (!move || !start2) return;
|
||||
setFibDraft({ a: start2, b: move });
|
||||
});
|
||||
scheduleFrame();
|
||||
}}
|
||||
onPointerEvent={({ type, logical, price, target, event }) => {
|
||||
const pointer: FibAnchor = { logical, price };
|
||||
|
||||
if (type === 'pointerdown') {
|
||||
if (event.button !== 0) return;
|
||||
if (spaceDownRef.current) return;
|
||||
if (activeToolRef.current !== 'cursor') return;
|
||||
if (target !== 'fib') return;
|
||||
if (!fibRef.current) return;
|
||||
if (!fibEffectiveVisible) return;
|
||||
|
||||
if (selectedOverlayIdRef.current !== 'fib') {
|
||||
setSelectedOverlayId('fib');
|
||||
selectPointerRef.current = event.pointerId;
|
||||
return { consume: true, capturePointer: true };
|
||||
}
|
||||
|
||||
if (fibEffectiveLocked) {
|
||||
selectPointerRef.current = event.pointerId;
|
||||
return { consume: true, capturePointer: true };
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
mode: event.ctrlKey ? 'edit-b' : 'move',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
start: pointer,
|
||||
origin: fibRef.current,
|
||||
moved: false,
|
||||
};
|
||||
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||
setFibDraft(fibRef.current);
|
||||
return { consume: true, capturePointer: true };
|
||||
}
|
||||
|
||||
const drag = dragRef.current;
|
||||
if (drag && drag.pointerId === event.pointerId) {
|
||||
if (type === 'pointermove') {
|
||||
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||
scheduleFrame();
|
||||
return { consume: true };
|
||||
}
|
||||
if (type === 'pointerup' || type === 'pointercancel') {
|
||||
if (drag.moved) setFib(computeFibFromDrag(drag, pointer));
|
||||
dragRef.current = null;
|
||||
pendingDragRef.current = null;
|
||||
setFibDraft(null);
|
||||
return { consume: true };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) {
|
||||
if (type === 'pointermove') return { consume: true };
|
||||
if (type === 'pointerup' || type === 'pointercancel') {
|
||||
selectPointerRef.current = null;
|
||||
return { consume: true };
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChartLayersPanel
|
||||
open={layersOpen}
|
||||
layers={layers}
|
||||
onRequestClose={() => setLayersOpen(false)}
|
||||
onToggleLayerVisible={(layerId) => {
|
||||
const layer = layers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
updateLayer(layerId, { visible: !layer.visible });
|
||||
}}
|
||||
onToggleLayerLocked={(layerId) => {
|
||||
const layer = layers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
updateLayer(layerId, { locked: !layer.locked });
|
||||
}}
|
||||
onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })}
|
||||
fibPresent={fib != null}
|
||||
fibSelected={fibSelected}
|
||||
fibVisible={fibVisible}
|
||||
fibLocked={fibLocked}
|
||||
fibOpacity={fibOpacity}
|
||||
onSelectFib={() => setSelectedOverlayId('fib')}
|
||||
onToggleFibVisible={() => setFibVisible((v) => !v)}
|
||||
onToggleFibLocked={() => setFibLocked((v) => !v)}
|
||||
onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))}
|
||||
onDeleteFib={clearFib}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type OverlayLayer = {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
opacity: number; // 0..1
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
IconBrush,
|
||||
IconCrosshair,
|
||||
IconCursor,
|
||||
IconEye,
|
||||
IconFib,
|
||||
IconLayers,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRuler,
|
||||
@@ -24,7 +24,9 @@ type Props = {
|
||||
timeframe: string;
|
||||
activeTool: ActiveTool;
|
||||
hasFib: boolean;
|
||||
isLayersOpen: boolean;
|
||||
onToolChange: (tool: ActiveTool) => void;
|
||||
onToggleLayers: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
@@ -35,7 +37,9 @@ export default function ChartSideToolbar({
|
||||
timeframe,
|
||||
activeTool,
|
||||
hasFib,
|
||||
isLayersOpen,
|
||||
onToolChange,
|
||||
onToggleLayers,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetView,
|
||||
@@ -195,9 +199,15 @@ export default function ChartSideToolbar({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
|
||||
<button
|
||||
type="button"
|
||||
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||
title="Layers"
|
||||
aria-label="Layers"
|
||||
onClick={onToggleLayers}
|
||||
>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconEye />
|
||||
<IconLayers />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import Button from '../../ui/Button';
|
||||
import TickerBar, { type TickerItem } from '../tickerbar/TickerBar';
|
||||
|
||||
type Props = {
|
||||
tickerItems: TickerItem[];
|
||||
timeframe: string;
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
showBuild: boolean;
|
||||
onToggleBuild: () => void;
|
||||
priceAutoScale: boolean;
|
||||
onTogglePriceAutoScale: () => void;
|
||||
seriesLabel: string;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
|
||||
export default function ChartToolbar({
|
||||
tickerItems,
|
||||
timeframe,
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
showBuild,
|
||||
onToggleBuild,
|
||||
priceAutoScale,
|
||||
onTogglePriceAutoScale,
|
||||
seriesLabel,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
@@ -24,6 +35,7 @@ export default function ChartToolbar({
|
||||
return (
|
||||
<div className="chartToolbar">
|
||||
<div className="chartToolbar__group">
|
||||
<TickerBar items={tickerItems} className="tickerBar--toolbar" />
|
||||
{timeframes.map((tf) => (
|
||||
<Button
|
||||
key={tf}
|
||||
@@ -41,6 +53,12 @@ export default function ChartToolbar({
|
||||
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||
Indicators
|
||||
</Button>
|
||||
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
|
||||
Build
|
||||
</Button>
|
||||
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
||||
Auto Scale
|
||||
</Button>
|
||||
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||
</Button>
|
||||
|
||||
@@ -54,6 +54,8 @@ type State = {
|
||||
fib: FibRetracement | null;
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
selected: boolean;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
@@ -64,8 +66,10 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { fib, series, chart } = this._getState();
|
||||
const { fib, series, chart, selected, opacity } = this._getState();
|
||||
if (!fib || !series || !chart) return;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
if (clampedOpacity <= 0) return;
|
||||
|
||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||
@@ -78,6 +82,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
const delta = p1 - p0;
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
context.save();
|
||||
context.globalAlpha *= clampedOpacity;
|
||||
try {
|
||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||
@@ -132,7 +139,7 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||
const by = Math.round(y1 * verticalPixelRatio);
|
||||
|
||||
context.strokeStyle = 'rgba(226,232,240,0.55)';
|
||||
context.strokeStyle = selected ? 'rgba(250,204,21,0.65)' : 'rgba(226,232,240,0.55)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||
context.beginPath();
|
||||
@@ -141,14 +148,23 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
|
||||
context.fillStyle = 'rgba(147,197,253,0.95)';
|
||||
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||
context.fillStyle = selected ? 'rgba(250,204,21,0.95)' : 'rgba(147,197,253,0.95)';
|
||||
if (selected) {
|
||||
context.strokeStyle = 'rgba(15,23,42,0.85)';
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
}
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
}
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -170,11 +186,19 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _fib: FibRetracement | null = null;
|
||||
private _selected = false;
|
||||
private _opacity = 1;
|
||||
private readonly _paneView: FibPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
|
||||
this._paneView = new FibPaneView(() => ({
|
||||
fib: this._fib,
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
selected: this._selected,
|
||||
opacity: this._opacity,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
@@ -196,4 +220,14 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
this._fib = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setSelected(next: boolean) {
|
||||
this._selected = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setOpacity(next: number) {
|
||||
this._opacity = Number.isFinite(next) ? next : 1;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
147
apps/visualizer/src/features/chart/KeyboardCursorPrimitive.ts
Normal file
147
apps/visualizer/src/features/chart/KeyboardCursorPrimitive.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
IPrimitivePaneRenderer,
|
||||
IPrimitivePaneView,
|
||||
ISeriesApi,
|
||||
ISeriesPrimitive,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export type KeyboardCursorPoint = {
|
||||
price: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type KeyboardCursorState = {
|
||||
time: number;
|
||||
price: number;
|
||||
points: KeyboardCursorPoint[];
|
||||
};
|
||||
|
||||
type State = {
|
||||
cursor: KeyboardCursorState | null;
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
lineColor: string;
|
||||
};
|
||||
|
||||
class KeyboardCursorPaneRenderer implements IPrimitivePaneRenderer {
|
||||
private readonly _getState: () => State;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._getState = getState;
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { cursor, series, chart, lineColor } = this._getState();
|
||||
if (!cursor || !series || !chart) return;
|
||||
|
||||
const x = chart.timeScale().timeToCoordinate(cursor.time as any);
|
||||
const y = series.priceToCoordinate(cursor.price);
|
||||
if (x == null || y == null) return;
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
context.save();
|
||||
try {
|
||||
const xPx = Math.round(x * horizontalPixelRatio);
|
||||
const yPx = Math.round(y * verticalPixelRatio);
|
||||
|
||||
context.strokeStyle = lineColor;
|
||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||
context.setLineDash([Math.max(3, Math.round(6 * horizontalPixelRatio)), Math.max(3, Math.round(6 * horizontalPixelRatio))]);
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(xPx, 0);
|
||||
context.lineTo(xPx, bitmapSize.height);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(0, yPx);
|
||||
context.lineTo(bitmapSize.width, yPx);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
|
||||
const outerR = Math.max(3, Math.round(4 * horizontalPixelRatio));
|
||||
const innerR = Math.max(2, Math.round(2.5 * horizontalPixelRatio));
|
||||
|
||||
for (const point of cursor.points) {
|
||||
if (!Number.isFinite(point.price)) continue;
|
||||
const pointY = series.priceToCoordinate(point.price);
|
||||
if (pointY == null) continue;
|
||||
const pointYPx = Math.round(pointY * verticalPixelRatio);
|
||||
|
||||
context.fillStyle = lineColor;
|
||||
context.beginPath();
|
||||
context.arc(xPx, pointYPx, outerR, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.fillStyle = point.color;
|
||||
context.beginPath();
|
||||
context.arc(xPx, pointYPx, innerR, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class KeyboardCursorPaneView implements IPrimitivePaneView {
|
||||
private readonly _renderer: KeyboardCursorPaneRenderer;
|
||||
|
||||
constructor(getState: () => State) {
|
||||
this._renderer = new KeyboardCursorPaneRenderer(getState);
|
||||
}
|
||||
|
||||
zOrder() {
|
||||
return 'top';
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return this._renderer;
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyboardCursorPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _cursor: KeyboardCursorState | null = null;
|
||||
private _lineColor = 'rgba(96,165,250,0.92)';
|
||||
private readonly _paneView: KeyboardCursorPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new KeyboardCursorPaneView(() => ({
|
||||
cursor: this._cursor,
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
lineColor: this._lineColor,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>) {
|
||||
this._param = param;
|
||||
this._series = param.series as ISeriesApi<'Candlestick', Time>;
|
||||
}
|
||||
|
||||
detached() {
|
||||
this._param = null;
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return this._paneViews;
|
||||
}
|
||||
|
||||
setCursor(next: KeyboardCursorState | null) {
|
||||
this._cursor = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setLineColor(next: string) {
|
||||
this._lineColor = String(next || '').trim() || 'rgba(96,165,250,0.92)';
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||
import { fetchChart } from '../../lib/api';
|
||||
import { useInterval } from '../../app/hooks/useInterval';
|
||||
import {
|
||||
applyLatestRowToCandles,
|
||||
buildChartIndicators,
|
||||
fetchChart,
|
||||
getChartLiveTable,
|
||||
mergeCandles,
|
||||
type Candle,
|
||||
type ChartIndicators,
|
||||
} from '../../lib/api';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
type Params = {
|
||||
symbol: string;
|
||||
@@ -14,44 +21,197 @@ type Params = {
|
||||
type Result = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
meta: { tf: string; bucketSeconds: number } | null;
|
||||
dataKey: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasMoreHistory: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
requestHistoryBefore: (beforeTime: number) => Promise<void>;
|
||||
};
|
||||
|
||||
type HasuraLatestRow = {
|
||||
updated_at?: string | null;
|
||||
oracle_price?: unknown;
|
||||
mark_price?: unknown;
|
||||
mid_price?: unknown;
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = Record<string, HasuraLatestRow[] | undefined>;
|
||||
|
||||
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
||||
const [candles, setCandles] = useState<Candle[]>([]);
|
||||
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
||||
const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
|
||||
const [dataKey, setDataKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
const [hasMoreHistory, setHasMoreHistory] = useState(true);
|
||||
|
||||
const fetchOnce = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchChart({ symbol, source, tf, limit });
|
||||
setCandles(res.candles);
|
||||
setIndicators(res.indicators);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [symbol, source, tf, limit]);
|
||||
const candlesRef = useRef<Candle[]>([]);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const loadingOlderRef = useRef(false);
|
||||
const requestedBeforeRef = useRef<number | null>(null);
|
||||
|
||||
const normalizedSource = useMemo(() => {
|
||||
const raw = String(source || '').trim();
|
||||
return raw || undefined;
|
||||
}, [source]);
|
||||
const queryKey = useMemo(() => JSON.stringify([symbol, normalizedSource ?? '', tf, limit]), [symbol, normalizedSource, tf, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOnce();
|
||||
}, [fetchOnce]);
|
||||
candlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
useInterval(() => void fetchOnce(), pollMs);
|
||||
const replaceState = useCallback(
|
||||
(nextCandles: Candle[], nextMeta: { tf: string; bucketSeconds: number } | null, nextDataKey: string) => {
|
||||
candlesRef.current = nextCandles;
|
||||
setCandles(nextCandles);
|
||||
setIndicators(buildChartIndicators(nextCandles));
|
||||
setMeta(nextMeta);
|
||||
setDataKey(nextDataKey);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchInitial = useCallback(async () => {
|
||||
controllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
controllerRef.current = controller;
|
||||
requestedBeforeRef.current = null;
|
||||
loadingOlderRef.current = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setHasMoreHistory(true);
|
||||
|
||||
try {
|
||||
const res = await fetchChart({
|
||||
symbol,
|
||||
source: normalizedSource,
|
||||
tf,
|
||||
limit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
replaceState(res.candles, res.meta, queryKey);
|
||||
setHasMoreHistory(res.candles.length > 0);
|
||||
} catch (e: any) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, normalizedSource, queryKey, replaceState, symbol, tf]);
|
||||
|
||||
const requestHistoryBefore = useCallback(
|
||||
async (beforeTime: number) => {
|
||||
const currentMeta = meta;
|
||||
if (!currentMeta) return;
|
||||
if (loadingOlderRef.current || !hasMoreHistory) return;
|
||||
if (!Number.isFinite(beforeTime)) return;
|
||||
|
||||
const cursor = Math.floor(beforeTime);
|
||||
if (requestedBeforeRef.current === cursor) return;
|
||||
requestedBeforeRef.current = cursor;
|
||||
loadingOlderRef.current = true;
|
||||
|
||||
try {
|
||||
const res = await fetchChart({
|
||||
symbol,
|
||||
source: normalizedSource,
|
||||
tf,
|
||||
limit,
|
||||
beforeTime: cursor,
|
||||
});
|
||||
const older = res.candles;
|
||||
if (!older.length) {
|
||||
setHasMoreHistory(false);
|
||||
return;
|
||||
}
|
||||
const merged = mergeCandles(older, candlesRef.current);
|
||||
replaceState(merged, currentMeta, queryKey);
|
||||
} catch (e: any) {
|
||||
requestedBeforeRef.current = null;
|
||||
setError(String(e?.message || e));
|
||||
} finally {
|
||||
loadingOlderRef.current = false;
|
||||
}
|
||||
},
|
||||
[hasMoreHistory, limit, meta, normalizedSource, queryKey, replaceState, symbol, tf]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchInitial();
|
||||
}, [fetchInitial]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => controllerRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol || !meta) return;
|
||||
|
||||
const table = getChartLiveTable(symbol);
|
||||
const sourceClause = normalizedSource ? 'source: {_eq: $source}' : '';
|
||||
const query = `
|
||||
subscription ChartLive($market: String!${normalizedSource ? ', $source: String!' : ''}) {
|
||||
${table}(
|
||||
where: {
|
||||
market_name: {_eq: $market}
|
||||
is_indicative: {_eq: false}
|
||||
${sourceClause}
|
||||
}
|
||||
limit: 1
|
||||
) {
|
||||
updated_at
|
||||
oracle_price
|
||||
mark_price
|
||||
mid_price
|
||||
source
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: {
|
||||
market: symbol,
|
||||
...(normalizedSource ? { source: normalizedSource } : {}),
|
||||
},
|
||||
pollMs,
|
||||
onError: (nextError) => setError(nextError),
|
||||
onData: (data) => {
|
||||
const row = data?.[table]?.[0];
|
||||
if (!row) return;
|
||||
const nextCandles = applyLatestRowToCandles(candlesRef.current, row, meta.bucketSeconds);
|
||||
if (nextCandles === candlesRef.current) return;
|
||||
replaceState(nextCandles, meta, queryKey);
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [meta, normalizedSource, pollMs, queryKey, replaceState, symbol]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
|
||||
[candles, indicators, loading, error, fetchOnce]
|
||||
() => ({
|
||||
candles,
|
||||
indicators,
|
||||
meta,
|
||||
dataKey,
|
||||
loading,
|
||||
error,
|
||||
hasMoreHistory,
|
||||
refresh: fetchInitial,
|
||||
requestHistoryBefore,
|
||||
}),
|
||||
[candles, dataKey, error, fetchInitial, hasMoreHistory, indicators, loading, meta, requestHistoryBefore]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DlobStats } from './useDlobStats';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||
import DlobSlippageChart from './DlobSlippageChart';
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
|
||||
function formatBps(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(1)} bps`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function statusLabel(connected: boolean, error: string | null): ReactNode {
|
||||
if (error) return <span className="neg">{error}</span>;
|
||||
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
|
||||
}
|
||||
|
||||
export default function DlobDashboard({
|
||||
market,
|
||||
stats,
|
||||
statsConnected,
|
||||
statsError,
|
||||
depthBands,
|
||||
depthBandsConnected,
|
||||
depthBandsError,
|
||||
slippageRows,
|
||||
slippageConnected,
|
||||
slippageError,
|
||||
}: {
|
||||
market: string;
|
||||
stats: DlobStats | null;
|
||||
statsConnected: boolean;
|
||||
statsError: string | null;
|
||||
depthBands: DlobDepthBandRow[];
|
||||
depthBandsConnected: boolean;
|
||||
depthBandsError: string | null;
|
||||
slippageRows: DlobSlippageRow[];
|
||||
slippageConnected: boolean;
|
||||
slippageError: string | null;
|
||||
}) {
|
||||
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||
|
||||
return (
|
||||
<div className="dlobDash">
|
||||
<div className="dlobDash__head">
|
||||
<div className="dlobDash__title">DLOB</div>
|
||||
<div className="dlobDash__meta">
|
||||
<span className="dlobDash__market">{market}</span>
|
||||
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__statuses">
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">stats</span>
|
||||
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">depth bands</span>
|
||||
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">slippage</span>
|
||||
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__grid">
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Bid</div>
|
||||
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Ask</div>
|
||||
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Mid</div>
|
||||
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Spread</div>
|
||||
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Depth (bid/ask)</div>
|
||||
<div className="dlobKpi__value">
|
||||
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
|
||||
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Imbalance</div>
|
||||
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">[-1..1]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__panes">
|
||||
<div className="dlobDash__pane">
|
||||
<DlobDepthBandsPanel rows={depthBands} />
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__pane">
|
||||
<div className="dlobSlippage">
|
||||
<div className="dlobSlippage__head">
|
||||
<div className="dlobSlippage__title">Slippage (impact bps)</div>
|
||||
<div className="dlobSlippage__meta muted">by size (USD)</div>
|
||||
</div>
|
||||
{slippageRows.length ? (
|
||||
<div className="dlobSlippage__chartWrap">
|
||||
<DlobSlippageChart rows={slippageRows} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
|
||||
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
|
||||
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
|
||||
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
|
||||
}
|
||||
|
||||
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
|
||||
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
|
||||
|
||||
const maxUsd = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of sorted) {
|
||||
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
|
||||
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
|
||||
}
|
||||
return max;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div className="dlobDepth">
|
||||
<div className="dlobDepth__head">
|
||||
<div className="dlobDepth__title">Depth (bands)</div>
|
||||
<div className="dlobDepth__meta">±bps around mid</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDepth__table">
|
||||
<div className="dlobDepthRow dlobDepthRow--head">
|
||||
<span>Band</span>
|
||||
<span className="dlobDepthRow__num">Ask USD</span>
|
||||
<span className="dlobDepthRow__num">Bid USD</span>
|
||||
<span className="dlobDepthRow__num">Bid %</span>
|
||||
</div>
|
||||
|
||||
{sorted.length ? (
|
||||
sorted.map((r) => (
|
||||
<div
|
||||
key={r.bandBps}
|
||||
className="dlobDepthRow"
|
||||
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
|
||||
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
|
||||
>
|
||||
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
|
||||
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
|
||||
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
|
||||
<span className="dlobDepthRow__num muted">
|
||||
{r.imbalance == null || !Number.isFinite(r.imbalance)
|
||||
? '—'
|
||||
: formatPct(((r.imbalance + 1) / 2) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
|
||||
type Point = { x: number; y: number | null };
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart | null>(null);
|
||||
|
||||
const { buy, sell, maxImpact } = useMemo(() => {
|
||||
const buy: Point[] = [];
|
||||
const sell: Point[] = [];
|
||||
let maxImpact = 0;
|
||||
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
|
||||
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
|
||||
if (y != null) maxImpact = Math.max(maxImpact, y);
|
||||
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
|
||||
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
|
||||
}
|
||||
|
||||
buy.sort((a, b) => a.x - b.x);
|
||||
sell.sort((a, b) => a.x - b.x);
|
||||
|
||||
return { buy, sell, maxImpact };
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
|
||||
chartRef.current = new Chart(canvasRef.current, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Buy',
|
||||
data: [],
|
||||
borderColor: 'rgba(34,197,94,0.9)',
|
||||
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: 'Sell',
|
||||
data: [],
|
||||
borderColor: 'rgba(239,68,68,0.9)',
|
||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
parsing: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e6e9ef' } },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
beginAtZero: true,
|
||||
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
|
||||
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [maxImpact]);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0]!.data = buy as any;
|
||||
chart.data.datasets[1]!.data = sell as any;
|
||||
chart.update('none');
|
||||
}, [buy, sell]);
|
||||
|
||||
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||
}
|
||||
|
||||
244
apps/visualizer/src/features/market/dlobDerivedMetrics.ts
Normal file
244
apps/visualizer/src/features/market/dlobDerivedMetrics.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { DlobL2, OrderbookRow } from './useDlobL2';
|
||||
|
||||
export const DEFAULT_DLOB_DEPTH_BANDS_BPS = [5, 10, 20, 50, 100, 200] as const;
|
||||
export const DEFAULT_DLOB_SLIPPAGE_SIZES_USD = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 50000] as const;
|
||||
|
||||
export type ComputedDlobDepthBandRow = {
|
||||
marketName: string;
|
||||
bandBps: number;
|
||||
midPrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
bidUsd: number | null;
|
||||
askUsd: number | null;
|
||||
bidBase: number | null;
|
||||
askBase: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ComputedDlobSlippageRow = {
|
||||
marketName: string;
|
||||
side: 'buy' | 'sell';
|
||||
sizeUsd: number;
|
||||
midPrice: number | null;
|
||||
vwapPrice: number | null;
|
||||
worstPrice: number | null;
|
||||
filledUsd: number | null;
|
||||
filledBase: number | null;
|
||||
impactBps: number | null;
|
||||
levelsConsumed: number | null;
|
||||
fillPct: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type OrderbookSide = 'buy' | 'sell';
|
||||
|
||||
function levelUsd(level: OrderbookRow): number {
|
||||
if (Number.isFinite(level.sizeUsd)) return level.sizeUsd;
|
||||
if (Number.isFinite(level.sizeBase) && Number.isFinite(level.price)) return level.sizeBase * level.price;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function canonicalBids(l2: DlobL2): OrderbookRow[] {
|
||||
return (Array.isArray(l2.bids) ? l2.bids : [])
|
||||
.slice()
|
||||
.sort((a, b) => b.price - a.price);
|
||||
}
|
||||
|
||||
function canonicalAsks(l2: DlobL2): OrderbookRow[] {
|
||||
return (Array.isArray(l2.asks) ? l2.asks.slice().reverse() : [])
|
||||
.slice()
|
||||
.sort((a, b) => a.price - b.price);
|
||||
}
|
||||
|
||||
function resolveBestBid(l2: DlobL2, bids: OrderbookRow[]): number | null {
|
||||
if (l2.bestBid != null && Number.isFinite(l2.bestBid)) return l2.bestBid;
|
||||
const price = bids[0]?.price;
|
||||
return Number.isFinite(price) ? price : null;
|
||||
}
|
||||
|
||||
function resolveBestAsk(l2: DlobL2, asks: OrderbookRow[]): number | null {
|
||||
if (l2.bestAsk != null && Number.isFinite(l2.bestAsk)) return l2.bestAsk;
|
||||
const price = asks[0]?.price;
|
||||
return Number.isFinite(price) ? price : null;
|
||||
}
|
||||
|
||||
function resolveMidPrice(l2: DlobL2, bestBid: number | null, bestAsk: number | null): number | null {
|
||||
if (l2.mid != null && Number.isFinite(l2.mid)) return l2.mid;
|
||||
if (bestBid != null && bestAsk != null) return (bestBid + bestAsk) / 2;
|
||||
return bestBid ?? bestAsk ?? null;
|
||||
}
|
||||
|
||||
function computeBandDepth(
|
||||
bids: OrderbookRow[],
|
||||
asks: OrderbookRow[],
|
||||
midPrice: number | null,
|
||||
bandBps: number
|
||||
): Pick<ComputedDlobDepthBandRow, 'bidUsd' | 'askUsd' | 'bidBase' | 'askBase' | 'imbalance'> {
|
||||
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) {
|
||||
return {
|
||||
bidUsd: null,
|
||||
askUsd: null,
|
||||
bidBase: null,
|
||||
askBase: null,
|
||||
imbalance: null,
|
||||
};
|
||||
}
|
||||
|
||||
const minBidPrice = midPrice * (1 - bandBps / 10_000);
|
||||
const maxAskPrice = midPrice * (1 + bandBps / 10_000);
|
||||
|
||||
let bidBase = 0;
|
||||
let askBase = 0;
|
||||
let bidUsd = 0;
|
||||
let askUsd = 0;
|
||||
|
||||
for (const level of bids) {
|
||||
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
|
||||
if (level.price < minBidPrice) break;
|
||||
bidBase += level.sizeBase;
|
||||
bidUsd += levelUsd(level);
|
||||
}
|
||||
|
||||
for (const level of asks) {
|
||||
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
|
||||
if (level.price > maxAskPrice) break;
|
||||
askBase += level.sizeBase;
|
||||
askUsd += levelUsd(level);
|
||||
}
|
||||
|
||||
const denom = bidUsd + askUsd;
|
||||
return {
|
||||
bidUsd,
|
||||
askUsd,
|
||||
bidBase,
|
||||
askBase,
|
||||
imbalance: denom > 0 ? (bidUsd - askUsd) / denom : null,
|
||||
};
|
||||
}
|
||||
|
||||
function simulateFill(levels: OrderbookRow[], sizeUsd: number) {
|
||||
let remainingUsd = sizeUsd;
|
||||
let filledUsd = 0;
|
||||
let filledBase = 0;
|
||||
let worstPrice: number | null = null;
|
||||
let levelsConsumed = 0;
|
||||
|
||||
for (const level of levels) {
|
||||
const availableUsd = levelUsd(level);
|
||||
if (!(availableUsd > 0) || !Number.isFinite(level.price) || !(level.price > 0)) continue;
|
||||
if (remainingUsd <= 0) break;
|
||||
|
||||
levelsConsumed += 1;
|
||||
worstPrice = level.price;
|
||||
|
||||
const takeUsd = Math.min(remainingUsd, availableUsd);
|
||||
filledUsd += takeUsd;
|
||||
filledBase += takeUsd / level.price;
|
||||
remainingUsd -= takeUsd;
|
||||
}
|
||||
|
||||
return {
|
||||
filledUsd,
|
||||
filledBase,
|
||||
worstPrice,
|
||||
levelsConsumed,
|
||||
vwapPrice: filledBase > 0 ? filledUsd / filledBase : null,
|
||||
fillPct: sizeUsd > 0 ? (filledUsd / sizeUsd) * 100 : null,
|
||||
};
|
||||
}
|
||||
|
||||
function impactBps(side: OrderbookSide, midPrice: number | null, vwapPrice: number | null): number | null {
|
||||
if (midPrice == null || vwapPrice == null) return null;
|
||||
if (!(midPrice > 0) || !Number.isFinite(vwapPrice)) return null;
|
||||
if (side === 'buy') return ((vwapPrice / midPrice) - 1) * 10_000;
|
||||
return (1 - vwapPrice / midPrice) * 10_000;
|
||||
}
|
||||
|
||||
export function computeDepthBandsFromDlobL2(
|
||||
l2: DlobL2 | null | undefined,
|
||||
bandList: readonly number[] = DEFAULT_DLOB_DEPTH_BANDS_BPS
|
||||
): ComputedDlobDepthBandRow[] {
|
||||
if (!l2?.marketName) return [];
|
||||
|
||||
const bids = canonicalBids(l2);
|
||||
const asks = canonicalAsks(l2);
|
||||
const bestBid = resolveBestBid(l2, bids);
|
||||
const bestAsk = resolveBestAsk(l2, asks);
|
||||
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
|
||||
const updatedAt = l2.updatedAt ?? null;
|
||||
|
||||
return bandList
|
||||
.map((bandBps) => Number(bandBps))
|
||||
.filter((bandBps, idx, arr) => Number.isFinite(bandBps) && bandBps > 0 && arr.indexOf(bandBps) === idx)
|
||||
.sort((a, b) => a - b)
|
||||
.map((bandBps) => ({
|
||||
marketName: l2.marketName,
|
||||
bandBps,
|
||||
midPrice,
|
||||
bestBid,
|
||||
bestAsk,
|
||||
updatedAt,
|
||||
...computeBandDepth(bids, asks, midPrice, bandBps),
|
||||
}));
|
||||
}
|
||||
|
||||
export function computeSlippageFromDlobL2(
|
||||
l2: DlobL2 | null | undefined,
|
||||
sizeList: readonly number[] = DEFAULT_DLOB_SLIPPAGE_SIZES_USD
|
||||
): ComputedDlobSlippageRow[] {
|
||||
if (!l2?.marketName) return [];
|
||||
|
||||
const bids = canonicalBids(l2);
|
||||
const asks = canonicalAsks(l2);
|
||||
const bestBid = resolveBestBid(l2, bids);
|
||||
const bestAsk = resolveBestAsk(l2, asks);
|
||||
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
|
||||
const updatedAt = l2.updatedAt ?? null;
|
||||
|
||||
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) return [];
|
||||
|
||||
const sizes = sizeList
|
||||
.map((sizeUsd) => Number(sizeUsd))
|
||||
.filter((sizeUsd, idx, arr) => Number.isFinite(sizeUsd) && sizeUsd > 0 && arr.indexOf(sizeUsd) === idx)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const rows: ComputedDlobSlippageRow[] = [];
|
||||
|
||||
for (const sizeUsd of sizes) {
|
||||
const buyFill = simulateFill(asks, sizeUsd);
|
||||
rows.push({
|
||||
marketName: l2.marketName,
|
||||
side: 'buy',
|
||||
sizeUsd,
|
||||
midPrice,
|
||||
vwapPrice: buyFill.vwapPrice,
|
||||
worstPrice: buyFill.worstPrice,
|
||||
filledUsd: buyFill.filledUsd,
|
||||
filledBase: buyFill.filledBase,
|
||||
impactBps: impactBps('buy', midPrice, buyFill.vwapPrice),
|
||||
levelsConsumed: buyFill.levelsConsumed,
|
||||
fillPct: buyFill.fillPct,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const sellFill = simulateFill(bids, sizeUsd);
|
||||
rows.push({
|
||||
marketName: l2.marketName,
|
||||
side: 'sell',
|
||||
sizeUsd,
|
||||
midPrice,
|
||||
vwapPrice: sellFill.vwapPrice,
|
||||
worstPrice: sellFill.worstPrice,
|
||||
filledUsd: sellFill.filledUsd,
|
||||
filledBase: sellFill.filledBase,
|
||||
impactBps: impactBps('sell', midPrice, sellFill.vwapPrice),
|
||||
levelsConsumed: sellFill.levelsConsumed,
|
||||
fillPct: sellFill.fillPct,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
32
apps/visualizer/src/features/market/dlobSource.ts
Normal file
32
apps/visualizer/src/features/market/dlobSource.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const HOT_MARKETS = new Set(['SOL-PERP', 'JTO-PERP', 'ADA-PERP']);
|
||||
|
||||
export type OrderbookTable = 'dlob_hot_snapshot_latest' | 'dlob_all_derived_latest';
|
||||
export type LatestDlobTable = 'dlob_hot_derived_latest' | 'dlob_all_derived_latest';
|
||||
|
||||
export function normalizeMarketName(marketName: string): string {
|
||||
return String(marketName || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function isHotMarket(marketName: string): boolean {
|
||||
return HOT_MARKETS.has(normalizeMarketName(marketName));
|
||||
}
|
||||
|
||||
export function getOrderbookTables(marketName: string): OrderbookTable[] {
|
||||
return isHotMarket(marketName) ? ['dlob_hot_snapshot_latest', 'dlob_all_derived_latest'] : ['dlob_all_derived_latest'];
|
||||
}
|
||||
|
||||
export function getOrderbookTable(marketName: string): OrderbookTable {
|
||||
return getOrderbookTables(marketName)[0];
|
||||
}
|
||||
|
||||
export function getLatestDlobTables(marketName: string): LatestDlobTable[] {
|
||||
return isHotMarket(marketName) ? ['dlob_hot_derived_latest', 'dlob_all_derived_latest'] : ['dlob_all_derived_latest'];
|
||||
}
|
||||
|
||||
export function getLatestDlobTable(marketName: string): LatestDlobTable {
|
||||
return getLatestDlobTables(marketName)[0];
|
||||
}
|
||||
|
||||
export function getChartSeriesTable(marketName: string): 'dlob_hot_derived_ts' | 'dlob_all_derived_ts' {
|
||||
return isHotMarket(marketName) ? 'dlob_hot_derived_ts' : 'dlob_all_derived_ts';
|
||||
}
|
||||
19
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
19
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useMemo } from 'react';
|
||||
import { computeDepthBandsFromDlobL2, type ComputedDlobDepthBandRow } from './dlobDerivedMetrics';
|
||||
import { useDlobL2 } from './useDlobL2';
|
||||
|
||||
const COMPUTED_DLOB_LEVELS = 512;
|
||||
|
||||
export type DlobDepthBandRow = ComputedDlobDepthBandRow;
|
||||
|
||||
export function useDlobDepthBands(
|
||||
marketName: string
|
||||
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
|
||||
const { l2, connected, error } = useDlobL2(marketName, {
|
||||
levels: COMPUTED_DLOB_LEVELS,
|
||||
grouping: 'raw',
|
||||
});
|
||||
|
||||
const rows = useMemo(() => computeDepthBandsFromDlobL2(l2), [l2]);
|
||||
return { rows, connected, error };
|
||||
}
|
||||
304
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
304
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
import { getOrderbookTable, isHotMarket } from './dlobSource';
|
||||
|
||||
const ORDERBOOK_POLL_MS = 400;
|
||||
|
||||
export type OrderbookRow = {
|
||||
price: number;
|
||||
sizeBase: number;
|
||||
sizeUsd: number;
|
||||
totalBase: number;
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
export type DlobL2 = {
|
||||
marketName: string;
|
||||
bids: OrderbookRow[];
|
||||
asks: OrderbookRow[];
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type ParsedLevel = {
|
||||
price: number;
|
||||
sizeBase: number;
|
||||
sizeUsd: number;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeJson(v: unknown): unknown {
|
||||
if (typeof v !== 'string') return v;
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
const PRICE_PRECISION = 1_000_000;
|
||||
const BASE_PRECISION = 1_000_000_000;
|
||||
|
||||
function toScaledNum(v: unknown, precision: number): number | null {
|
||||
const n = toNum(v);
|
||||
if (n == null) return null;
|
||||
return n / precision;
|
||||
}
|
||||
|
||||
function parseNormalizedLevels(raw: unknown): ParsedLevel[] {
|
||||
const v = normalizeJson(raw);
|
||||
if (!Array.isArray(v)) return [];
|
||||
|
||||
const out: ParsedLevel[] = [];
|
||||
for (const item of v) {
|
||||
const price = toNum((item as any)?.price);
|
||||
const sizeBase = toNum((item as any)?.sizeBase ?? (item as any)?.size);
|
||||
if (price == null || sizeBase == null) continue;
|
||||
if (!Number.isFinite(price) || !Number.isFinite(sizeBase)) continue;
|
||||
out.push({ price, sizeBase, sizeUsd: sizeBase * price });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseRawHotLevels(raw: unknown): ParsedLevel[] {
|
||||
const v = normalizeJson(raw);
|
||||
if (!Array.isArray(v)) return [];
|
||||
|
||||
const out: ParsedLevel[] = [];
|
||||
for (const item of v) {
|
||||
const price = toScaledNum((item as any)?.price, PRICE_PRECISION);
|
||||
const sizeBase = toScaledNum((item as any)?.size, BASE_PRECISION);
|
||||
if (price == null || sizeBase == null) continue;
|
||||
if (!Number.isFinite(price) || !Number.isFinite(sizeBase)) continue;
|
||||
out.push({ price, sizeBase, sizeUsd: sizeBase * price });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function limitHotWindow(levels: ParsedLevel[], visibleLevels: number): ParsedLevel[] {
|
||||
const cap = Math.max(visibleLevels * 4, 24);
|
||||
return levels.slice(0, cap);
|
||||
}
|
||||
|
||||
function withTotals(levels: ParsedLevel[]): OrderbookRow[] {
|
||||
let totalBase = 0;
|
||||
let totalUsd = 0;
|
||||
|
||||
return levels.map((l) => {
|
||||
totalBase += l.sizeBase;
|
||||
totalUsd += l.sizeUsd;
|
||||
|
||||
return {
|
||||
price: l.price,
|
||||
sizeBase: l.sizeBase,
|
||||
sizeUsd: l.sizeUsd,
|
||||
totalBase,
|
||||
totalUsd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type HasuraDlobL2Row = {
|
||||
market_name: string;
|
||||
bids_norm?: unknown;
|
||||
asks_norm?: unknown;
|
||||
best_bid_price?: unknown;
|
||||
best_ask_price?: unknown;
|
||||
mid_price?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type HasuraHotSnapshotRow = {
|
||||
market_name: string;
|
||||
bids?: unknown;
|
||||
asks?: unknown;
|
||||
best_bid_price_raw?: unknown;
|
||||
best_ask_price_raw?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = Record<string, Array<HasuraDlobL2Row | HasuraHotSnapshotRow> | undefined>;
|
||||
|
||||
export function useDlobL2(
|
||||
marketName: string,
|
||||
opts?: { levels?: number; grouping?: string }
|
||||
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
|
||||
const [l2, setL2] = useState<DlobL2 | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
|
||||
const grouping = useMemo(() => String(opts?.grouping || 'drift').trim().toLowerCase(), [opts?.grouping]);
|
||||
|
||||
function resolveDriftGroupingStep(referencePrice: number | null): number {
|
||||
const px = referencePrice == null || !Number.isFinite(referencePrice) ? 0 : Math.abs(referencePrice);
|
||||
if (px >= 1000) return 0.1;
|
||||
if (px >= 100) return 0.01;
|
||||
if (px >= 10) return 0.001;
|
||||
if (px >= 1) return 0.0001;
|
||||
if (px >= 0.1) return 0.00001;
|
||||
return 0.000001;
|
||||
}
|
||||
|
||||
function resolveGroupingStep(mode: string, referencePrice: number | null): number | null {
|
||||
if (!mode || mode === 'raw' || mode === 'none' || mode === 'off') return null;
|
||||
if (mode === 'drift') return resolveDriftGroupingStep(referencePrice);
|
||||
const parsed = Number(mode);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function groupLevelsByStep(
|
||||
side: 'bid' | 'ask',
|
||||
input: ParsedLevel[],
|
||||
step: number | null
|
||||
): ParsedLevel[] {
|
||||
if (step == null || !Number.isFinite(step) || step <= 0) return input;
|
||||
const stepMicros = Math.max(1, Math.round(step * PRICE_PRECISION));
|
||||
const grouped = new Map<number, { sizeBase: number; sizeUsd: number }>();
|
||||
|
||||
for (const level of input) {
|
||||
const priceMicros = Math.round(level.price * PRICE_PRECISION);
|
||||
const bucketMicros =
|
||||
side === 'bid'
|
||||
? Math.floor(priceMicros / stepMicros) * stepMicros
|
||||
: Math.ceil(priceMicros / stepMicros) * stepMicros;
|
||||
const existing = grouped.get(bucketMicros) || { sizeBase: 0, sizeUsd: 0 };
|
||||
existing.sizeBase += level.sizeBase;
|
||||
existing.sizeUsd += level.sizeUsd;
|
||||
grouped.set(bucketMicros, existing);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([bucketMicros, totals]) => ({
|
||||
price: bucketMicros / PRICE_PRECISION,
|
||||
sizeBase: totals.sizeBase,
|
||||
sizeUsd: totals.sizeUsd,
|
||||
}))
|
||||
.sort((a, b) => (side === 'bid' ? b.price - a.price : a.price - b.price));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setL2(null);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const table = getOrderbookTable(normalizedMarket);
|
||||
const hotMarket = isHotMarket(normalizedMarket);
|
||||
|
||||
const query = `
|
||||
subscription DlobL2($market: String!) {
|
||||
${table}(
|
||||
where: {
|
||||
market_name: {_eq: $market}
|
||||
is_indicative: {_eq: false}
|
||||
${hotMarket ? 'snapshot_kind: {_eq: "orderbook_l2"}' : ''}
|
||||
}
|
||||
limit: 1
|
||||
) {
|
||||
market_name
|
||||
${hotMarket ? 'bids asks best_bid_price_raw best_ask_price_raw' : 'bids_norm asks_norm best_bid_price best_ask_price mid_price'}
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
pollMs: ORDERBOOK_POLL_MS,
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const row = data?.[table]?.[0] as HasuraDlobL2Row | HasuraHotSnapshotRow | undefined;
|
||||
if (!row?.market_name) {
|
||||
setL2(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const bestBidRaw = hotMarket
|
||||
? toScaledNum((row as HasuraHotSnapshotRow).best_bid_price_raw, PRICE_PRECISION)
|
||||
: toNum((row as HasuraDlobL2Row).best_bid_price);
|
||||
const bestAskRaw = hotMarket
|
||||
? toScaledNum((row as HasuraHotSnapshotRow).best_ask_price_raw, PRICE_PRECISION)
|
||||
: toNum((row as HasuraDlobL2Row).best_ask_price);
|
||||
const referencePrice =
|
||||
bestBidRaw != null && bestAskRaw != null ? (bestBidRaw + bestAskRaw) / 2 : bestBidRaw ?? bestAskRaw ?? null;
|
||||
const groupingStep = resolveGroupingStep(grouping, referencePrice);
|
||||
|
||||
const bidsSource = hotMarket
|
||||
? limitHotWindow(parseRawHotLevels((row as HasuraHotSnapshotRow).bids), levels)
|
||||
: parseNormalizedLevels((row as HasuraDlobL2Row).bids_norm);
|
||||
const asksSource = hotMarket
|
||||
? limitHotWindow(parseRawHotLevels((row as HasuraHotSnapshotRow).asks), levels)
|
||||
: parseNormalizedLevels((row as HasuraDlobL2Row).asks_norm);
|
||||
|
||||
const bidsSorted = groupLevelsByStep(
|
||||
'bid',
|
||||
bidsSource,
|
||||
groupingStep
|
||||
)
|
||||
.slice()
|
||||
.slice(0, levels)
|
||||
.map((l) => ({ price: l.price, sizeBase: l.sizeBase, sizeUsd: l.sizeUsd }));
|
||||
|
||||
const asksSorted = groupLevelsByStep(
|
||||
'ask',
|
||||
asksSource,
|
||||
groupingStep
|
||||
)
|
||||
.slice()
|
||||
.slice(0, levels)
|
||||
.map((l) => ({ price: l.price, sizeBase: l.sizeBase, sizeUsd: l.sizeUsd }));
|
||||
|
||||
// We compute totals from best -> worse.
|
||||
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
|
||||
const bids = withTotals(bidsSorted);
|
||||
const asks = withTotals(asksSorted).slice().reverse();
|
||||
|
||||
const bestBid = bestBidRaw ?? (bidsSorted.length ? bidsSorted[0].price : null);
|
||||
const bestAsk = bestAskRaw ?? (asksSorted.length ? asksSorted[0].price : null);
|
||||
const mid = hotMarket
|
||||
? bestBid != null && bestAsk != null
|
||||
? (bestBid + bestAsk) / 2
|
||||
: null
|
||||
: toNum((row as HasuraDlobL2Row).mid_price) ?? (bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null);
|
||||
|
||||
setL2({
|
||||
marketName: row.market_name,
|
||||
bids,
|
||||
asks,
|
||||
bestBid,
|
||||
bestAsk,
|
||||
mid,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [grouping, normalizedMarket, levels]);
|
||||
|
||||
return { l2, connected, error };
|
||||
}
|
||||
17
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
17
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { computeSlippageFromDlobL2, type ComputedDlobSlippageRow } from './dlobDerivedMetrics';
|
||||
import { useDlobL2 } from './useDlobL2';
|
||||
|
||||
const COMPUTED_DLOB_LEVELS = 512;
|
||||
|
||||
export type DlobSlippageRow = ComputedDlobSlippageRow;
|
||||
|
||||
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||
const { l2, connected, error } = useDlobL2(marketName, {
|
||||
levels: COMPUTED_DLOB_LEVELS,
|
||||
grouping: 'raw',
|
||||
});
|
||||
|
||||
const rows = useMemo(() => computeSlippageFromDlobL2(l2), [l2]);
|
||||
return { rows, connected, error };
|
||||
}
|
||||
134
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
134
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
import { getLatestDlobTable } from './dlobSource';
|
||||
|
||||
const ORDERBOOK_STATS_POLL_MS = 400;
|
||||
|
||||
export type DlobStats = {
|
||||
marketName: string;
|
||||
markPrice: number | null;
|
||||
oraclePrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
spreadAbs: number | null;
|
||||
spreadBps: number | null;
|
||||
depthBidBase: number | null;
|
||||
depthAskBase: number | null;
|
||||
depthBidUsd: number | null;
|
||||
depthAskUsd: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type HasuraDlobStatsRow = {
|
||||
market_name: string;
|
||||
mark_price?: unknown;
|
||||
oracle_price?: unknown;
|
||||
best_bid_price?: unknown;
|
||||
best_ask_price?: unknown;
|
||||
mid_price?: unknown;
|
||||
spread_quote?: unknown;
|
||||
spread_bps?: unknown;
|
||||
depth_bid_base?: unknown;
|
||||
depth_ask_base?: unknown;
|
||||
depth_bid_quote?: unknown;
|
||||
depth_ask_quote?: unknown;
|
||||
imbalance?: unknown;
|
||||
updated_at?: string | null;
|
||||
is_indicative?: boolean | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = Record<string, HasuraDlobStatsRow[] | undefined>;
|
||||
|
||||
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
|
||||
const [stats, setStats] = useState<DlobStats | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setStats(null);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const table = getLatestDlobTable(normalizedMarket);
|
||||
|
||||
const query = `
|
||||
subscription DlobStats($market: String!) {
|
||||
${table}(
|
||||
where: {market_name: {_eq: $market}, is_indicative: {_eq: false}}
|
||||
limit: 1
|
||||
) {
|
||||
market_name
|
||||
mark_price
|
||||
oracle_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
mid_price
|
||||
spread_quote
|
||||
spread_bps
|
||||
depth_bid_base
|
||||
depth_ask_base
|
||||
depth_bid_quote
|
||||
depth_ask_quote
|
||||
imbalance
|
||||
updated_at
|
||||
is_indicative
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
pollMs: ORDERBOOK_STATS_POLL_MS,
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const row = data?.[table]?.[0];
|
||||
if (!row?.market_name) {
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
setStats({
|
||||
marketName: row.market_name,
|
||||
markPrice: toNum(row.mark_price),
|
||||
oraclePrice: toNum(row.oracle_price),
|
||||
bestBid: toNum(row.best_bid_price),
|
||||
bestAsk: toNum(row.best_ask_price),
|
||||
mid: toNum(row.mid_price),
|
||||
spreadAbs: toNum(row.spread_quote),
|
||||
spreadBps: toNum(row.spread_bps),
|
||||
depthBidBase: toNum(row.depth_bid_base),
|
||||
depthAskBase: toNum(row.depth_ask_base),
|
||||
depthBidUsd: toNum(row.depth_bid_quote),
|
||||
depthAskUsd: toNum(row.depth_ask_quote),
|
||||
imbalance: toNum(row.imbalance),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { stats, connected, error };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type TickerItem = {
|
||||
|
||||
type Props = {
|
||||
items: TickerItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function formatPct(v: number): string {
|
||||
@@ -16,9 +17,9 @@ function formatPct(v: number): string {
|
||||
return `${s}${v.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export default function TickerBar({ items }: Props) {
|
||||
export default function TickerBar({ items, className }: Props) {
|
||||
return (
|
||||
<div className="tickerBar">
|
||||
<div className={['tickerBar', className].filter(Boolean).join(' ')}>
|
||||
{items.map((t) => (
|
||||
<div key={t.key} className={['ticker', t.active ? 'ticker--active' : ''].filter(Boolean).join(' ')}>
|
||||
<span className="ticker__label">{t.label}</span>
|
||||
@@ -28,4 +29,3 @@ export default function TickerBar({ items }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
header?: ReactNode;
|
||||
@@ -9,10 +10,57 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AppShell({ header, top, main, sidebar, rightbar }: Props) {
|
||||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||
const topRef = useRef<HTMLDivElement | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const [topHeight, setTopHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const observers: ResizeObserver[] = [];
|
||||
|
||||
const observeHeight = (
|
||||
element: HTMLDivElement | null,
|
||||
setHeight: (height: number) => void
|
||||
) => {
|
||||
if (!element) {
|
||||
setHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setHeight(Math.ceil(element.getBoundingClientRect().height));
|
||||
update();
|
||||
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(element);
|
||||
observers.push(observer);
|
||||
};
|
||||
|
||||
observeHeight(headerRef.current, setHeaderHeight);
|
||||
observeHeight(topRef.current, setTopHeight);
|
||||
|
||||
return () => {
|
||||
for (const observer of observers) observer.disconnect();
|
||||
};
|
||||
}, [header, top]);
|
||||
|
||||
const shellStyle = {
|
||||
['--shell-header-height' as any]: `${headerHeight}px`,
|
||||
['--shell-top-height' as any]: `${topHeight}px`,
|
||||
['--shell-top-offset' as any]: `${headerHeight + topHeight}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
{header ? <div className="shellHeader">{header}</div> : null}
|
||||
{top ? <div className="shellTop">{top}</div> : null}
|
||||
<div className="shell" style={shellStyle}>
|
||||
{header ? (
|
||||
<div className="shellHeader" ref={headerRef}>
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
{top ? (
|
||||
<div className="shellTop" ref={topRef}>
|
||||
{top}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="shellBody">
|
||||
<div className="shellMain">{main}</div>
|
||||
{sidebar ? <div className="shellSidebar">{sidebar}</div> : null}
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
type Props = {
|
||||
user: string;
|
||||
theme: 'day' | 'night';
|
||||
onResetView?: (() => void) | null;
|
||||
onToggleTheme: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export default function AuthStatus({ user, onLogout }: Props) {
|
||||
export default function AuthStatus({ user, theme, onResetView, onToggleTheme, onLogout }: Props) {
|
||||
const formatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const [nowLabel, setNowLabel] = useState(() => formatter.format(new Date()));
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setNowLabel(formatter.format(new Date()));
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [formatter]);
|
||||
|
||||
return (
|
||||
<div className="authStatus">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
title="Reset View (Alt+R)"
|
||||
aria-keyshortcuts="Alt+R"
|
||||
onClick={onResetView ?? undefined}
|
||||
disabled={!onResetView}
|
||||
>
|
||||
Reset View
|
||||
</Button>
|
||||
<button
|
||||
className={`themeSwitch themeSwitch--${theme}`}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={theme === 'night'}
|
||||
aria-label={theme === 'day' ? 'Switch to Night theme' : 'Switch to Day theme'}
|
||||
title={theme === 'day' ? 'Switch to Night theme' : 'Switch to Day theme'}
|
||||
onClick={onToggleTheme}
|
||||
>
|
||||
<span className="themeSwitch__label themeSwitch__label--day">Day</span>
|
||||
<span className="themeSwitch__label themeSwitch__label--night">Night</span>
|
||||
<span className="themeSwitch__thumb" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="authStatus__clock" aria-label="Bieżący czas">
|
||||
{nowLabel}
|
||||
</div>
|
||||
<div className="authStatus__user" aria-label="Zalogowany użytkownik">
|
||||
<div className="authStatus__userLabel">Zalogowany</div>
|
||||
<div className="authStatus__userName">{user}</div>
|
||||
|
||||
@@ -11,6 +11,21 @@ type LoginResponse = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
};
|
||||
|
||||
async function readSessionUser(): Promise<string | null> {
|
||||
const res = await fetch('/whoami', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as WhoamiResponse | null;
|
||||
const user = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export default function LoginScreen({ onLoggedIn }: Props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -26,17 +41,18 @@ export default function LoginScreen({ onLoggedIn }: Props) {
|
||||
const res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as LoginResponse | null;
|
||||
const ok = Boolean(res.ok && json?.ok);
|
||||
if (!ok) throw new Error(json?.error || 'invalid_credentials');
|
||||
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||
const u = await readSessionUser();
|
||||
if (!u) throw new Error('bad_response');
|
||||
setPassword('');
|
||||
onLoggedIn(u);
|
||||
} catch {
|
||||
setError('Nieprawidłowy login lub hasło.');
|
||||
setError('Logowanie nie utworzyło sesji. Sprawdź dane albo konfigurację dev proxy.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -87,4 +103,3 @@ export default function LoginScreen({ onLoggedIn }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,18 @@ type Props = {
|
||||
active?: NavId;
|
||||
onSelect?: (id: NavId) => void;
|
||||
rightSlot?: ReactNode;
|
||||
rightEndSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEndSlot }: Props) {
|
||||
export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props) {
|
||||
return (
|
||||
<header className="topNav">
|
||||
<div className="topNav__left">
|
||||
<div className="topNav__brand" aria-label="Drift">
|
||||
<div className="topNav__brand" aria-label="Trade">
|
||||
<div className="topNav__brandMark" aria-hidden="true" />
|
||||
<div className="topNav__brandName">Drift</div>
|
||||
<div className="topNav__brandText">
|
||||
<div className="topNav__brandName">Trade</div>
|
||||
<div className="topNav__brandMeta">by mpabi</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="topNav__menu" aria-label="Primary">
|
||||
{navItems.map((it) => (
|
||||
@@ -56,7 +58,6 @@ export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEnd
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rightEndSlot}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { getChartSeriesTable, getLatestDlobTable } from '../features/market/dlobSource';
|
||||
import { bollingerBands, ema, macd, rsi, sma } from './indicators';
|
||||
|
||||
export type Candle = {
|
||||
time: number; // unix seconds
|
||||
open: number;
|
||||
@@ -6,6 +9,9 @@ export type Candle = {
|
||||
close: number;
|
||||
volume?: number;
|
||||
oracle?: number | null;
|
||||
flow?: { up: number; down: number; flat: number };
|
||||
flowRows?: number[];
|
||||
flowMoves?: number[];
|
||||
};
|
||||
|
||||
export type SeriesPoint = {
|
||||
@@ -22,21 +28,289 @@ export type ChartIndicators = {
|
||||
macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] };
|
||||
};
|
||||
|
||||
export type ChartResponse = {
|
||||
ok: boolean;
|
||||
symbol?: string;
|
||||
source?: string | null;
|
||||
tf?: string;
|
||||
bucketSeconds?: number;
|
||||
candles?: Candle[];
|
||||
indicators?: ChartIndicators;
|
||||
error?: string;
|
||||
type GraphqlError = { message: string };
|
||||
type HasuraDerivedTsRow = {
|
||||
event_ts: string;
|
||||
oracle_price?: number | string | null;
|
||||
mark_price?: number | string | null;
|
||||
mid_price?: number | string | null;
|
||||
};
|
||||
type HasuraLatestDlobRow = {
|
||||
updated_at?: string | null;
|
||||
oracle_price?: number | string | null;
|
||||
mark_price?: number | string | null;
|
||||
mid_price?: number | string | null;
|
||||
};
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const v = (import.meta as any).env?.VITE_API_URL;
|
||||
if (v) return String(v);
|
||||
return '/api';
|
||||
function readEnv(name: string): string | undefined {
|
||||
const viteValue = (import.meta as any).env?.[name];
|
||||
if (viteValue != null && String(viteValue).trim()) return String(viteValue);
|
||||
const nodeValue = (globalThis as any)?.process?.env?.[name];
|
||||
if (nodeValue != null && String(nodeValue).trim()) return String(nodeValue);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getHasuraUrl(): string {
|
||||
const v = readEnv('VITE_HASURA_URL');
|
||||
return v ? String(v) : '/graphql';
|
||||
}
|
||||
|
||||
function getHasuraAuthHeaders(): Record<string, string> | undefined {
|
||||
const token = readEnv('VITE_HASURA_AUTH_TOKEN');
|
||||
if (token) return { authorization: `Bearer ${String(token)}` };
|
||||
const secret = readEnv('VITE_HASURA_ADMIN_SECRET');
|
||||
if (secret) return { 'x-hasura-admin-secret': String(secret) };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimeframeToSeconds(tf: string): number {
|
||||
const raw = String(tf || '').trim();
|
||||
const match = raw.match(/^(\d+)([smhdSMHD])$/);
|
||||
if (!match) return 60;
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
if (!Number.isFinite(value) || value <= 0) return 60;
|
||||
if (unit === 's') return value;
|
||||
if (unit === 'm') return value * 60;
|
||||
if (unit === 'h') return value * 3600;
|
||||
if (unit === 'd') return value * 86400;
|
||||
return 60;
|
||||
}
|
||||
|
||||
function toSeries(times: number[], values: Array<number | null>) {
|
||||
return times.map((time, i) => ({ time, value: values[i] ?? null }));
|
||||
}
|
||||
|
||||
function buildIndicators(candles: Candle[]): ChartIndicators {
|
||||
const times = candles.map((c) => c.time);
|
||||
const closes = candles.map((c) => c.close);
|
||||
const oracleValues = candles.map((c) => (c.oracle == null ? null : c.oracle));
|
||||
const sma20 = sma(closes, 20);
|
||||
const ema20 = ema(closes, 20);
|
||||
const bb20 = bollingerBands(closes, 20, 2);
|
||||
const rsi14 = rsi(closes, 14);
|
||||
const macdOut = macd(closes, 12, 26, 9);
|
||||
|
||||
return {
|
||||
oracle: toSeries(times, oracleValues),
|
||||
sma20: toSeries(times, sma20),
|
||||
ema20: toSeries(times, ema20),
|
||||
bb20: {
|
||||
upper: toSeries(times, bb20.upper),
|
||||
lower: toSeries(times, bb20.lower),
|
||||
mid: toSeries(times, bb20.mid),
|
||||
},
|
||||
rsi14: toSeries(times, rsi14),
|
||||
macd: {
|
||||
macd: toSeries(times, macdOut.macd),
|
||||
signal: toSeries(times, macdOut.signal),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function aggregateRowsToCandles(
|
||||
rows: HasuraDerivedTsRow[],
|
||||
bucketSeconds: number,
|
||||
options?: { fillGaps?: boolean; limit?: number }
|
||||
): Candle[] {
|
||||
const buckets = new Map<number, Candle>();
|
||||
const sortedRows = rows.slice().sort((a, b) => Date.parse(a.event_ts) - Date.parse(b.event_ts));
|
||||
|
||||
for (const row of sortedRows) {
|
||||
const tsSec = Math.floor(Date.parse(row.event_ts) / 1000);
|
||||
if (!Number.isFinite(tsSec)) continue;
|
||||
const bucket = tsSec - (tsSec % bucketSeconds);
|
||||
const oracle = toNum(row.oracle_price);
|
||||
const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle;
|
||||
if (close == null) continue;
|
||||
|
||||
const existing = buckets.get(bucket);
|
||||
if (!existing) {
|
||||
buckets.set(bucket, {
|
||||
time: bucket,
|
||||
open: close,
|
||||
high: close,
|
||||
low: close,
|
||||
close,
|
||||
volume: 1,
|
||||
oracle,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.high = Math.max(existing.high, close);
|
||||
existing.low = Math.min(existing.low, close);
|
||||
existing.close = close;
|
||||
existing.volume = (existing.volume || 0) + 1;
|
||||
existing.oracle = oracle ?? existing.oracle ?? null;
|
||||
}
|
||||
|
||||
const sorted = Array.from(buckets.values()).sort((a, b) => a.time - b.time);
|
||||
if (!sorted.length) return [];
|
||||
|
||||
if (!options?.fillGaps) {
|
||||
return options?.limit ? sorted.slice(-options.limit) : sorted;
|
||||
}
|
||||
|
||||
const tail = options?.limit ? sorted.slice(-options.limit) : sorted;
|
||||
const candles: Candle[] = [];
|
||||
const firstBucket = tail[0]!.time;
|
||||
const lastBucket = tail[tail.length - 1]!.time;
|
||||
const byTime = new Map(tail.map((c) => [c.time, c]));
|
||||
let prev = tail[0]!;
|
||||
|
||||
for (let bucket = firstBucket; bucket <= lastBucket; bucket += bucketSeconds) {
|
||||
const current = byTime.get(bucket);
|
||||
if (current) {
|
||||
candles.push(current);
|
||||
prev = current;
|
||||
continue;
|
||||
}
|
||||
candles.push({
|
||||
time: bucket,
|
||||
open: prev.close,
|
||||
high: prev.close,
|
||||
low: prev.close,
|
||||
close: prev.close,
|
||||
volume: 0,
|
||||
oracle: prev.oracle ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
export function mergeCandles(prev: Candle[], next: Candle[]): Candle[] {
|
||||
const map = new Map<number, Candle>();
|
||||
for (const candle of prev) map.set(candle.time, candle);
|
||||
for (const candle of next) map.set(candle.time, candle);
|
||||
return Array.from(map.values()).sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function applyLatestRowToCandles(
|
||||
candles: Candle[],
|
||||
row: HasuraLatestDlobRow,
|
||||
bucketSeconds: number
|
||||
): Candle[] {
|
||||
const tsSec = Math.floor(Date.parse(String(row.updated_at || '')) / 1000);
|
||||
if (!Number.isFinite(tsSec)) return candles;
|
||||
const bucket = tsSec - (tsSec % bucketSeconds);
|
||||
const oracle = toNum(row.oracle_price);
|
||||
const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle;
|
||||
if (close == null) return candles;
|
||||
|
||||
const next = candles.slice();
|
||||
const last = next[next.length - 1];
|
||||
if (!last || bucket > last.time) {
|
||||
next.push({
|
||||
time: bucket,
|
||||
open: last?.close ?? close,
|
||||
high: close,
|
||||
low: close,
|
||||
close,
|
||||
volume: 1,
|
||||
oracle,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (bucket < last.time) return candles;
|
||||
|
||||
last.high = Math.max(last.high, close);
|
||||
last.low = Math.min(last.low, close);
|
||||
last.close = close;
|
||||
last.volume = (last.volume || 0) + 1;
|
||||
last.oracle = oracle ?? last.oracle ?? null;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildChartIndicators(candles: Candle[]): ChartIndicators {
|
||||
return buildIndicators(candles);
|
||||
}
|
||||
|
||||
export function getChartLiveTable(marketName: string) {
|
||||
return getLatestDlobTable(marketName);
|
||||
}
|
||||
|
||||
async function fetchChartFromHasura(params: {
|
||||
symbol: string;
|
||||
source?: string;
|
||||
tf: string;
|
||||
limit: number;
|
||||
beforeTime?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||
const bucketSeconds = parseTimeframeToSeconds(params.tf);
|
||||
const table = getChartSeriesTable(params.symbol);
|
||||
const maxPoints = Math.min(200_000, Math.max(5_000, params.limit * Math.max(1, bucketSeconds) * 3));
|
||||
const sourceFilter = params.source?.trim();
|
||||
const beforeTime = Number.isFinite(params.beforeTime) ? Number(params.beforeTime) : null;
|
||||
const beforeIso = beforeTime == null ? null : new Date(beforeTime * 1000).toISOString();
|
||||
const query = `
|
||||
query ChartSeries($symbol: String!, $limit: Int!${sourceFilter ? ', $source: String!' : ''}${beforeIso ? ', $before: timestamptz!' : ''}) {
|
||||
${table}(
|
||||
limit: $limit
|
||||
order_by: {event_ts: desc}
|
||||
where: {
|
||||
market_name: {_eq: $symbol}
|
||||
is_indicative: {_eq: false}
|
||||
${sourceFilter ? 'source: {_eq: $source}' : ''}
|
||||
${beforeIso ? 'event_ts: {_lt: $before}' : ''}
|
||||
}
|
||||
) {
|
||||
event_ts
|
||||
oracle_price
|
||||
mark_price
|
||||
mid_price
|
||||
}
|
||||
}
|
||||
`;
|
||||
const res = await fetch(getHasuraUrl(), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
signal: params.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(getHasuraAuthHeaders() || {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
symbol: params.symbol,
|
||||
limit: maxPoints,
|
||||
...(sourceFilter ? { source: sourceFilter } : {}),
|
||||
...(beforeIso ? { before: beforeIso } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
|
||||
|
||||
const json = JSON.parse(text) as { data?: Record<string, HasuraDerivedTsRow[]>; errors?: GraphqlError[] };
|
||||
if (Array.isArray(json.errors) && json.errors.length) {
|
||||
throw new Error(json.errors.map((e) => e.message).join(' | '));
|
||||
}
|
||||
const rows = json.data?.[table] || [];
|
||||
const candles = aggregateRowsToCandles(rows, bucketSeconds, { fillGaps: beforeIso == null, limit: params.limit });
|
||||
|
||||
return {
|
||||
candles,
|
||||
indicators: buildIndicators(candles),
|
||||
meta: { tf: params.tf, bucketSeconds },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchChart(params: {
|
||||
@@ -44,33 +318,8 @@ export async function fetchChart(params: {
|
||||
source?: string;
|
||||
tf: string;
|
||||
limit: number;
|
||||
beforeTime?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||
const base = getApiBaseUrl();
|
||||
const u = new URL(base, window.location.origin);
|
||||
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/chart' : '/v1/chart';
|
||||
u.searchParams.set('symbol', params.symbol);
|
||||
u.searchParams.set('tf', params.tf);
|
||||
u.searchParams.set('limit', String(params.limit));
|
||||
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
||||
|
||||
const res = await fetch(u.toString());
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||
const json = JSON.parse(text) as ChartResponse;
|
||||
if (!json.ok) throw new Error(json.error || 'API: error');
|
||||
return {
|
||||
candles: (json.candles || []).map((c) => ({
|
||||
...c,
|
||||
time: Number(c.time),
|
||||
open: Number(c.open),
|
||||
high: Number(c.high),
|
||||
low: Number(c.low),
|
||||
close: Number(c.close),
|
||||
volume: c.volume == null ? undefined : Number(c.volume),
|
||||
oracle: c.oracle == null ? null : Number(c.oracle),
|
||||
})),
|
||||
indicators: json.indicators || {},
|
||||
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||
};
|
||||
return await fetchChartFromHasura(params);
|
||||
}
|
||||
|
||||
|
||||
294
apps/visualizer/src/lib/graphqlWs.ts
Normal file
294
apps/visualizer/src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
type HeadersMap = Record<string, string>;
|
||||
type GraphqlError = { message: string };
|
||||
|
||||
type SubscribeParams<T> = {
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
onData: (data: T) => void;
|
||||
onError?: (err: string) => void;
|
||||
onStatus?: (s: { connected: boolean }) => void;
|
||||
pollMs?: number;
|
||||
};
|
||||
|
||||
function envString(name: string): string | undefined {
|
||||
const viteValue = (import.meta as any).env?.[name];
|
||||
const viteString = viteValue == null ? '' : String(viteValue).trim();
|
||||
if (viteString) return viteString;
|
||||
const nodeValue = (globalThis as any)?.process?.env?.[name];
|
||||
const nodeString = nodeValue == null ? '' : String(nodeValue).trim();
|
||||
return nodeString || undefined;
|
||||
}
|
||||
|
||||
function envBool(name: string): boolean {
|
||||
const v = envString(name);
|
||||
if (!v) return false;
|
||||
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
|
||||
}
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = envString(name);
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveGraphqlHttpUrl(): string {
|
||||
return envString('VITE_HASURA_URL') || '/graphql';
|
||||
}
|
||||
|
||||
function resolveGraphqlWsUrl(): string {
|
||||
const explicit = envString('VITE_HASURA_WS_URL');
|
||||
if (explicit) {
|
||||
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||
return `${proto}//${host}${path}`;
|
||||
}
|
||||
|
||||
const httpUrl = resolveGraphqlHttpUrl();
|
||||
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
||||
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
|
||||
return `${proto}//${host}${path}`;
|
||||
}
|
||||
|
||||
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
||||
if (token) return { authorization: `Bearer ${token}` };
|
||||
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type WsMessage =
|
||||
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||
| { type: 'connection_error'; payload?: any }
|
||||
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
|
||||
| { type: 'error'; id: string; payload?: any };
|
||||
|
||||
export type SubscriptionHandle = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
async function fetchGraphqlOnce<T>(
|
||||
httpUrl: string,
|
||||
headers: HeadersMap | undefined,
|
||||
query: string,
|
||||
variables: Record<string, unknown> | undefined
|
||||
): Promise<T> {
|
||||
const operation = query.replace(/^\s*subscription\b/, 'query');
|
||||
const res = await fetch(httpUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(headers || {}),
|
||||
},
|
||||
body: JSON.stringify({ query: operation, variables: variables ?? {} }),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
|
||||
|
||||
let json: { data?: T; errors?: GraphqlError[] };
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Hasura invalid JSON: ${text}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(json.errors) && json.errors.length) {
|
||||
throw new Error(json.errors.map((e) => e.message).join(' | '));
|
||||
}
|
||||
if (json.data == null) throw new Error('Hasura: empty response');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function subscribeGraphqlWs<T>({
|
||||
query,
|
||||
variables,
|
||||
onData,
|
||||
onError,
|
||||
onStatus,
|
||||
pollMs: pollMsOverride,
|
||||
}: SubscribeParams<T>): SubscriptionHandle {
|
||||
const httpUrl = resolveGraphqlHttpUrl();
|
||||
const wsUrl = resolveGraphqlWsUrl();
|
||||
const headers = resolveAuthHeaders();
|
||||
const pollOnly = envBool('VITE_GRAPHQL_POLL_ONLY');
|
||||
const pollMs = Math.max(50, pollMsOverride ?? envInt('VITE_GRAPHQL_POLL_MS', 1000));
|
||||
let ws: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let started = false;
|
||||
let polling = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
const subId = '1';
|
||||
|
||||
const emitError = (e: unknown) => {
|
||||
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
|
||||
onError?.(msg);
|
||||
};
|
||||
|
||||
const setConnected = (connected: boolean) => onStatus?.({ connected });
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPollTimer = () => {
|
||||
if (pollTimer != null) {
|
||||
window.clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeSocket = () => {
|
||||
if (!ws) return;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ws = null;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!ws || started) return;
|
||||
started = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: subId,
|
||||
type: 'start',
|
||||
payload: { query, variables: variables ?? {} },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const data = await fetchGraphqlOnce<T>(httpUrl, headers, query, variables);
|
||||
if (closed) return;
|
||||
setConnected(true);
|
||||
onData(data);
|
||||
} catch (e) {
|
||||
if (closed) return;
|
||||
setConnected(false);
|
||||
emitError(e);
|
||||
} finally {
|
||||
if (!closed && polling) {
|
||||
pollTimer = window.setTimeout(() => void pollOnce(), pollMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (closed || polling) return;
|
||||
polling = true;
|
||||
clearReconnectTimer();
|
||||
closeSocket();
|
||||
started = false;
|
||||
void pollOnce();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
if (polling) return;
|
||||
started = false;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
const payload = headers ? { headers } : {};
|
||||
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: WsMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data));
|
||||
} catch (e) {
|
||||
emitError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'connection_ack') {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'connection_error') {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||
|
||||
if (msg.type === 'error') {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'data') {
|
||||
const errors = msg.payload?.errors;
|
||||
if (Array.isArray(errors) && errors.length) {
|
||||
emitError(errors.map((e) => e.message).join(' | '));
|
||||
return;
|
||||
}
|
||||
if (msg.payload?.data != null) onData(msg.payload.data as T);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false);
|
||||
startPolling();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
if (closed) return;
|
||||
startPolling();
|
||||
};
|
||||
};
|
||||
|
||||
if (pollOnly) {
|
||||
startPolling();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
closed = true;
|
||||
polling = false;
|
||||
setConnected(false);
|
||||
clearReconnectTimer();
|
||||
clearPollTimer();
|
||||
if (!ws) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
closeSocket();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
|
||||
}
|
||||
|
||||
function getHasuraUrl(): string {
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
|
||||
}
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
@@ -45,6 +45,7 @@ export async function hasuraRequest<T>(query: string, variables: Record<string,
|
||||
|
||||
const res = await fetch(getHasuraUrl(), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
@@ -67,7 +68,9 @@ export async function fetchLatestTicks(symbol: string, limit: number, source?: s
|
||||
u.searchParams.set('limit', String(limit));
|
||||
if (source && source.trim()) u.searchParams.set('source', source.trim());
|
||||
|
||||
const res = await fetch(u.toString());
|
||||
const res = await fetch(u.toString(), {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||
const json = JSON.parse(text) as { ok?: boolean; ticks?: any[]; error?: string };
|
||||
|
||||
366
apps/visualizer/src/lib/runtime.ts
Normal file
366
apps/visualizer/src/lib/runtime.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
export type DiagramRuntimePublisher = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'ok' | 'warn';
|
||||
health: {
|
||||
httpOk: boolean;
|
||||
gauge: number | null;
|
||||
};
|
||||
metrics: {
|
||||
transport: string;
|
||||
updateIntervalMs: number;
|
||||
perpMarkets: number;
|
||||
spotMarkets: number;
|
||||
totalMarkets: number;
|
||||
latestSlot: number | null;
|
||||
};
|
||||
endpoints: {
|
||||
health: string;
|
||||
metrics: string;
|
||||
};
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type DiagramRuntimeResponse = {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
fetchedAt?: string;
|
||||
nodes?: {
|
||||
publisher?: DiagramRuntimePublisher;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DiagramLayoutView = 'standard' | 'ultra';
|
||||
|
||||
export type DiagramLayoutState = Record<
|
||||
string,
|
||||
{
|
||||
position?: { x: number; y: number };
|
||||
size?: { width: number; height: number };
|
||||
parentId?: string | null;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DiagramLayoutResponse = {
|
||||
ok: boolean;
|
||||
view: DiagramLayoutView;
|
||||
updatedAt?: string;
|
||||
layout?: DiagramLayoutState;
|
||||
currentRevisionId?: number | null;
|
||||
revisionId?: number | null;
|
||||
restoredFromRevisionId?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DiagramLayoutRevision = {
|
||||
id: number;
|
||||
view: DiagramLayoutView;
|
||||
updatedAt: string;
|
||||
action: string;
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
export type DiagramLayoutHistoryResponse = {
|
||||
ok: boolean;
|
||||
view: DiagramLayoutView;
|
||||
currentRevisionId?: number | null;
|
||||
revisions?: DiagramLayoutRevision[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DiagramLayoutStoredRevision = DiagramLayoutRevision & {
|
||||
layout: DiagramLayoutState;
|
||||
};
|
||||
|
||||
type DiagramLayoutStoredView = {
|
||||
layout: DiagramLayoutState;
|
||||
updatedAt?: string;
|
||||
currentRevisionId: number | null;
|
||||
nextRevisionId: number;
|
||||
revisions: DiagramLayoutStoredRevision[];
|
||||
};
|
||||
|
||||
type DiagramLayoutStore = {
|
||||
views: Partial<Record<DiagramLayoutView, DiagramLayoutStoredView>>;
|
||||
};
|
||||
|
||||
type DiagramDebugEvent = {
|
||||
ts: string;
|
||||
view: DiagramLayoutView;
|
||||
event: string;
|
||||
nodeId?: string | null;
|
||||
viewport?: { x?: number; y?: number; zoom?: number };
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const layoutStoreKey = 'trade-system-flow-layout-store:v1';
|
||||
const debugStoreKey = 'trade-system-flow-debug-log:v1';
|
||||
const maxRevisionCount = 32;
|
||||
const maxDebugEventCount = 200;
|
||||
|
||||
function canUseStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function getIsoNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function cloneLayoutState(layout: DiagramLayoutState | undefined): DiagramLayoutState {
|
||||
return JSON.parse(JSON.stringify(layout || {})) as DiagramLayoutState;
|
||||
}
|
||||
|
||||
function emptyStoredView(): DiagramLayoutStoredView {
|
||||
return {
|
||||
layout: {},
|
||||
updatedAt: undefined,
|
||||
currentRevisionId: null,
|
||||
nextRevisionId: 1,
|
||||
revisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeStoredView(value: unknown): DiagramLayoutStoredView {
|
||||
const raw = value && typeof value === 'object' ? (value as Partial<DiagramLayoutStoredView>) : {};
|
||||
const revisions = Array.isArray(raw.revisions)
|
||||
? raw.revisions
|
||||
.filter((entry): entry is DiagramLayoutStoredRevision => {
|
||||
return Boolean(
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
Number.isInteger((entry as DiagramLayoutStoredRevision).id) &&
|
||||
typeof (entry as DiagramLayoutStoredRevision).updatedAt === 'string'
|
||||
);
|
||||
})
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
view: entry.view,
|
||||
updatedAt: entry.updatedAt,
|
||||
action: entry.action,
|
||||
entryCount: Number.isFinite(entry.entryCount) ? Math.max(0, Math.trunc(entry.entryCount)) : 0,
|
||||
layout: cloneLayoutState(entry.layout),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const nextRevisionId = Number.isInteger(raw.nextRevisionId)
|
||||
? Math.max(1, Number(raw.nextRevisionId))
|
||||
: revisions.reduce((maxId, entry) => Math.max(maxId, entry.id), 0) + 1;
|
||||
|
||||
return {
|
||||
layout: cloneLayoutState(raw.layout),
|
||||
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined,
|
||||
currentRevisionId: Number.isInteger(raw.currentRevisionId) ? Number(raw.currentRevisionId) : null,
|
||||
nextRevisionId,
|
||||
revisions,
|
||||
};
|
||||
}
|
||||
|
||||
function readLayoutStore(): DiagramLayoutStore {
|
||||
if (!canUseStorage()) return { views: {} };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(layoutStoreKey);
|
||||
if (!raw) return { views: {} };
|
||||
const parsed = JSON.parse(raw) as Partial<DiagramLayoutStore>;
|
||||
const views = parsed?.views && typeof parsed.views === 'object' ? parsed.views : {};
|
||||
return {
|
||||
views: {
|
||||
standard: sanitizeStoredView(views.standard),
|
||||
ultra: sanitizeStoredView(views.ultra),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { views: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeLayoutStore(store: DiagramLayoutStore) {
|
||||
if (!canUseStorage()) return;
|
||||
window.localStorage.setItem(layoutStoreKey, JSON.stringify(store));
|
||||
}
|
||||
|
||||
function readStoredView(view: DiagramLayoutView): DiagramLayoutStoredView {
|
||||
const store = readLayoutStore();
|
||||
return sanitizeStoredView(store.views[view]);
|
||||
}
|
||||
|
||||
function writeStoredView(view: DiagramLayoutView, next: DiagramLayoutStoredView) {
|
||||
const store = readLayoutStore();
|
||||
store.views = {
|
||||
...store.views,
|
||||
[view]: sanitizeStoredView(next),
|
||||
};
|
||||
writeLayoutStore(store);
|
||||
}
|
||||
|
||||
function appendRevision(
|
||||
view: DiagramLayoutView,
|
||||
storedView: DiagramLayoutStoredView,
|
||||
layout: DiagramLayoutState,
|
||||
action: string,
|
||||
updatedAt: string
|
||||
) {
|
||||
const revisionId = Math.max(1, storedView.nextRevisionId || 1);
|
||||
const layoutCopy = cloneLayoutState(layout);
|
||||
const revision: DiagramLayoutStoredRevision = {
|
||||
id: revisionId,
|
||||
view,
|
||||
updatedAt,
|
||||
action: action || 'manual-save',
|
||||
entryCount: Object.keys(layoutCopy).length,
|
||||
layout: layoutCopy,
|
||||
};
|
||||
|
||||
const revisions = [revision, ...storedView.revisions].slice(0, maxRevisionCount);
|
||||
return {
|
||||
revisionId,
|
||||
next: {
|
||||
layout: layoutCopy,
|
||||
updatedAt,
|
||||
currentRevisionId: revisionId,
|
||||
nextRevisionId: revisionId + 1,
|
||||
revisions,
|
||||
} satisfies DiagramLayoutStoredView,
|
||||
};
|
||||
}
|
||||
|
||||
function toHistoryRevisions(revisions: DiagramLayoutStoredRevision[], limit: number) {
|
||||
return revisions.slice(0, Math.max(1, limit)).map((entry) => ({
|
||||
id: entry.id,
|
||||
view: entry.view,
|
||||
updatedAt: entry.updatedAt,
|
||||
action: entry.action,
|
||||
entryCount: entry.entryCount,
|
||||
}));
|
||||
}
|
||||
|
||||
function readDebugEvents(): DiagramDebugEvent[] {
|
||||
if (!canUseStorage()) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(debugStoreKey);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as DiagramDebugEvent[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeDebugEvents(events: DiagramDebugEvent[]) {
|
||||
if (!canUseStorage()) return;
|
||||
window.localStorage.setItem(debugStoreKey, JSON.stringify(events.slice(-maxDebugEventCount)));
|
||||
}
|
||||
|
||||
export async function fetchDiagramRuntime(): Promise<DiagramRuntimeResponse> {
|
||||
return {
|
||||
ok: true,
|
||||
version: 'frontend-local',
|
||||
fetchedAt: getIsoNow(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDiagramLayout(view: DiagramLayoutView): Promise<DiagramLayoutResponse> {
|
||||
const storedView = readStoredView(view);
|
||||
return {
|
||||
ok: true,
|
||||
view,
|
||||
updatedAt: storedView.updatedAt,
|
||||
layout: cloneLayoutState(storedView.layout),
|
||||
currentRevisionId: storedView.currentRevisionId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveDiagramLayout(
|
||||
view: DiagramLayoutView,
|
||||
layout: DiagramLayoutState,
|
||||
action?: string
|
||||
): Promise<DiagramLayoutResponse> {
|
||||
const storedView = readStoredView(view);
|
||||
const updatedAt = getIsoNow();
|
||||
const { revisionId, next } = appendRevision(view, storedView, layout, action || 'manual-save', updatedAt);
|
||||
writeStoredView(view, next);
|
||||
return {
|
||||
ok: true,
|
||||
view,
|
||||
updatedAt,
|
||||
layout: cloneLayoutState(next.layout),
|
||||
currentRevisionId: revisionId,
|
||||
revisionId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDiagramLayout(view: DiagramLayoutView): Promise<DiagramLayoutResponse> {
|
||||
const storedView = readStoredView(view);
|
||||
const updatedAt = getIsoNow();
|
||||
const { revisionId, next } = appendRevision(view, storedView, {}, 'reset', updatedAt);
|
||||
writeStoredView(view, next);
|
||||
return {
|
||||
ok: true,
|
||||
view,
|
||||
updatedAt,
|
||||
layout: {},
|
||||
currentRevisionId: revisionId,
|
||||
revisionId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDiagramLayoutHistory(
|
||||
view: DiagramLayoutView,
|
||||
limit = 12
|
||||
): Promise<DiagramLayoutHistoryResponse> {
|
||||
const storedView = readStoredView(view);
|
||||
return {
|
||||
ok: true,
|
||||
view,
|
||||
currentRevisionId: storedView.currentRevisionId,
|
||||
revisions: toHistoryRevisions(storedView.revisions, limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreDiagramLayoutRevision(
|
||||
view: DiagramLayoutView,
|
||||
revisionId: number
|
||||
): Promise<DiagramLayoutResponse> {
|
||||
const storedView = readStoredView(view);
|
||||
const revision = storedView.revisions.find((entry) => entry.id === revisionId);
|
||||
if (!revision) throw new Error(`Local layout revision ${revisionId} not found`);
|
||||
|
||||
const updatedAt = getIsoNow();
|
||||
const { revisionId: nextRevisionId, next } = appendRevision(
|
||||
view,
|
||||
storedView,
|
||||
revision.layout,
|
||||
'restore',
|
||||
updatedAt
|
||||
);
|
||||
writeStoredView(view, next);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
view,
|
||||
updatedAt,
|
||||
layout: cloneLayoutState(revision.layout),
|
||||
currentRevisionId: nextRevisionId,
|
||||
revisionId: nextRevisionId,
|
||||
restoredFromRevisionId: revision.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postDiagramDebugEvent(body: {
|
||||
view: DiagramLayoutView;
|
||||
event: string;
|
||||
nodeId?: string | null;
|
||||
viewport?: { x?: number; y?: number; zoom?: number };
|
||||
payload?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const events = readDebugEvents();
|
||||
events.push({
|
||||
ts: getIsoNow(),
|
||||
view: body.view,
|
||||
event: body.event,
|
||||
nodeId: body.nodeId,
|
||||
viewport: body.viewport,
|
||||
payload: body.payload,
|
||||
});
|
||||
writeDebugEvents(events);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(DIR, '../..');
|
||||
|
||||
function readApiReadToken(): string | undefined {
|
||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||
type BasicAuth = { username: string; password: string };
|
||||
|
||||
function stripTrailingSlashes(p: string): string {
|
||||
const out = p.replace(/\/+$/, '');
|
||||
return out || '/';
|
||||
}
|
||||
|
||||
function readApiReadToken(env: Record<string, string | undefined>): string | undefined {
|
||||
if (env.API_READ_TOKEN) return env.API_READ_TOKEN;
|
||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||
if (!fs.existsSync(p)) return undefined;
|
||||
try {
|
||||
@@ -20,24 +27,213 @@ function readApiReadToken(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const apiReadToken = readApiReadToken();
|
||||
function readApiAdminSecret(env: Record<string, string | undefined>): string | undefined {
|
||||
if (Object.prototype.hasOwnProperty.call(env, 'API_PROXY_ADMIN_SECRET')) {
|
||||
const raw = String(env.API_PROXY_ADMIN_SECRET || '').trim();
|
||||
return raw || undefined;
|
||||
}
|
||||
const p = path.join(ROOT, 'tokens', 'api.json');
|
||||
if (!fs.existsSync(p)) return undefined;
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const json = JSON.parse(raw) as { adminSecret?: string };
|
||||
return json?.adminSecret ? String(json.adminSecret) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
|
||||
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return undefined;
|
||||
const idx = raw.indexOf(':');
|
||||
if (idx <= 0) return undefined;
|
||||
const username = raw.slice(0, idx).trim();
|
||||
const password = raw.slice(idx + 1);
|
||||
if (!username || !password) return undefined;
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
function readProxyBasicAuth(env: Record<string, string | undefined>): BasicAuth | undefined {
|
||||
const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const fileRaw = String(env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
||||
if (!fileRaw) return undefined;
|
||||
|
||||
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||
if (!fs.existsSync(p)) return undefined;
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const json = JSON.parse(raw) as { username?: string; password?: string };
|
||||
const username = typeof json?.username === 'string' ? json.username.trim() : '';
|
||||
const password = typeof json?.password === 'string' ? json.password : '';
|
||||
if (!username || !password) return undefined;
|
||||
return { username, password };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(v: string): URL | undefined {
|
||||
try {
|
||||
return new URL(v);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||
try {
|
||||
const u = new URL(apiTarget);
|
||||
const p = stripTrailingSlashes(u.pathname || '/');
|
||||
if (!p.endsWith('/api')) return undefined;
|
||||
const basePath = p.slice(0, -'/api'.length) || '/';
|
||||
u.pathname = basePath;
|
||||
u.search = '';
|
||||
u.hash = '';
|
||||
const out = u.toString();
|
||||
return out.endsWith('/') ? out.slice(0, -1) : out;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyProxyBasicAuth(proxyReq: any) {
|
||||
if (!proxyBasicAuth) return false;
|
||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||
proxyReq.setHeader('Authorization', `Basic ${b64}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||
const v = proxyRes?.headers?.['set-cookie'];
|
||||
if (!v) return;
|
||||
const rewrite = (cookie: string) => {
|
||||
let out = cookie.replace(/;\s*secure\b/gi, '');
|
||||
out = out.replace(/;\s*domain=[^;]+/gi, '');
|
||||
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
|
||||
return out;
|
||||
};
|
||||
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||
}
|
||||
|
||||
function readDevProxyAuthUser(env: Record<string, string | undefined>): string | undefined {
|
||||
const raw = String(env.DEV_PROXY_AUTH_USER || env.VITE_DEV_AUTH_USER || '').trim();
|
||||
return raw || undefined;
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = { ...process.env, ...loadEnv(mode, DIR, '') } as Record<string, string | undefined>;
|
||||
const apiReadToken = readApiReadToken(env);
|
||||
const apiAdminSecret = readApiAdminSecret(env);
|
||||
const proxyBasicAuth = readProxyBasicAuth(env);
|
||||
const devProxyAuthUser = readDevProxyAuthUser(env);
|
||||
const apiProxyTargetRaw = String(env.API_PROXY_TARGET || '').trim();
|
||||
const apiProxyTarget = apiProxyTargetRaw || undefined;
|
||||
const apiProxyTargetUrl = apiProxyTarget ? parseUrl(apiProxyTarget) : undefined;
|
||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||
const apiProxyStripPrefixRaw = String(env.API_PROXY_STRIP_PREFIX || (apiProxyTargetEndsWithApi ? '/api' : '')).trim();
|
||||
const apiProxyStripPrefix = apiProxyStripPrefixRaw ? stripTrailingSlashes(apiProxyStripPrefixRaw) : '';
|
||||
const uiProxyTarget =
|
||||
env.FRONTEND_PROXY_TARGET ||
|
||||
env.UI_PROXY_TARGET ||
|
||||
env.AUTH_PROXY_TARGET ||
|
||||
inferUiProxyTarget(apiProxyTarget) ||
|
||||
(apiProxyTargetUrl && apiProxyTargetPath === '/'
|
||||
? stripTrailingSlashes(apiProxyTargetUrl.toString())
|
||||
: undefined);
|
||||
const graphqlProxyTarget = env.GRAPHQL_PROXY_TARGET || env.HASURA_PROXY_TARGET || uiProxyTarget;
|
||||
const graphqlProxyPathRaw = String(env.GRAPHQL_PROXY_PATH || '').trim();
|
||||
const graphqlProxyPath = graphqlProxyPathRaw ? stripTrailingSlashes(graphqlProxyPathRaw) : '';
|
||||
const portRaw = env.VITE_DEV_PORT || env.DEV_PORT || '5173';
|
||||
const port = Number.parseInt(String(portRaw), 10);
|
||||
|
||||
function applyProxyBasicAuth(proxyReq: any) {
|
||||
if (!proxyBasicAuth) return false;
|
||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||
proxyReq.setHeader('Authorization', `Basic ${b64}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyDevProxyAuth(proxyReq: any) {
|
||||
if (!devProxyAuthUser) return false;
|
||||
proxyReq.setHeader('x-trade-user', devProxyAuthUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
const proxy: Record<string, any> = {
|
||||
};
|
||||
|
||||
if (apiProxyTarget) {
|
||||
proxy['/api'] = {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (p: string) => {
|
||||
if (!apiProxyStripPrefix) return p;
|
||||
const rewritten = p.replace(new RegExp(`^${apiProxyStripPrefix}`), '');
|
||||
return rewritten || '/';
|
||||
},
|
||||
configure: (p: any) => {
|
||||
p.on('proxyReq', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
applyDevProxyAuth(proxyReq);
|
||||
if (apiAdminSecret) {
|
||||
proxyReq.setHeader('x-admin-secret', apiAdminSecret);
|
||||
return;
|
||||
}
|
||||
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (graphqlProxyTarget) {
|
||||
for (const prefix of ['/graphql', '/graphql-ws']) {
|
||||
proxy[prefix] = {
|
||||
target: graphqlProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||
ws: true,
|
||||
rewrite: (p: string) => (graphqlProxyPath ? p.replace(new RegExp(`^${prefix}`), graphqlProxyPath) : p),
|
||||
configure: (p: any) => {
|
||||
p.on('proxyReq', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
applyDevProxyAuth(proxyReq);
|
||||
});
|
||||
p.on('proxyReqWs', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
applyDevProxyAuth(proxyReq);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (uiProxyTarget) {
|
||||
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||
proxy[prefix] = {
|
||||
target: uiProxyTarget,
|
||||
changeOrigin: true,
|
||||
configure: (p: any) => {
|
||||
p.on('proxyReq', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
applyDevProxyAuth(proxyReq);
|
||||
});
|
||||
p.on('proxyRes', (proxyRes: any) => {
|
||||
rewriteSetCookieForLocalDevHttp(proxyRes);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: Number.isInteger(port) && port > 0 ? port : 5173,
|
||||
strictPort: true,
|
||||
proxy,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import tls from 'node:tls';
|
||||
|
||||
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
||||
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
||||
@@ -14,8 +16,9 @@ const STARTED_AT = new Date().toISOString();
|
||||
|
||||
const STATIC_DIR = process.env.STATIC_DIR || '/srv';
|
||||
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
|
||||
const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json';
|
||||
const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787';
|
||||
const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080';
|
||||
const HASURA_GRAPHQL_PATH = process.env.HASURA_GRAPHQL_PATH || '/v1/graphql';
|
||||
const GRAPHQL_CORS_ORIGIN = process.env.GRAPHQL_CORS_ORIGIN || process.env.CORS_ORIGIN || '*';
|
||||
const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -63,13 +66,6 @@ function loadBasicAuth() {
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
function loadApiReadToken() {
|
||||
const j = readJson(API_READ_TOKEN_FILE);
|
||||
const token = (j?.token || '').toString();
|
||||
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
function send(res, status, headers, body) {
|
||||
res.statusCode = status;
|
||||
for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v);
|
||||
@@ -363,28 +359,28 @@ function readBody(req, limitBytes = 1024 * 16) {
|
||||
});
|
||||
}
|
||||
|
||||
function proxyApi(req, res, apiReadToken) {
|
||||
const upstreamBase = new URL(API_UPSTREAM);
|
||||
function withCors(res) {
|
||||
res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN);
|
||||
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
||||
res.setHeader(
|
||||
'access-control-allow-headers',
|
||||
'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id'
|
||||
);
|
||||
}
|
||||
|
||||
function proxyGraphqlHttp(req, res) {
|
||||
const upstreamBase = new URL(HASURA_UPSTREAM);
|
||||
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
|
||||
const prefix = '/api';
|
||||
const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null;
|
||||
if (strippedPath == null) {
|
||||
send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = new URL(upstreamBase.toString());
|
||||
target.pathname = strippedPath || '/';
|
||||
target.pathname = HASURA_GRAPHQL_PATH;
|
||||
target.search = inUrl.search;
|
||||
|
||||
const isHttps = target.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const headers = stripHopByHopHeaders(req.headers);
|
||||
delete headers.authorization; // basic auth from client must not leak upstream
|
||||
headers.host = target.host;
|
||||
headers.authorization = `Bearer ${apiReadToken}`;
|
||||
|
||||
const upstreamReq = lib.request(
|
||||
{
|
||||
@@ -397,6 +393,7 @@ function proxyApi(req, res, apiReadToken) {
|
||||
},
|
||||
(upstreamRes) => {
|
||||
const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
|
||||
withCors(res);
|
||||
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
|
||||
upstreamRes.pipe(res);
|
||||
}
|
||||
@@ -404,6 +401,7 @@ function proxyApi(req, res, apiReadToken) {
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
withCors(res);
|
||||
send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`);
|
||||
} else {
|
||||
res.destroy();
|
||||
@@ -413,6 +411,70 @@ function proxyApi(req, res, apiReadToken) {
|
||||
req.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
function isGraphqlPath(pathname) {
|
||||
return pathname === '/graphql' || pathname === '/graphql-ws';
|
||||
}
|
||||
|
||||
function proxyGraphqlWs(req, socket, head) {
|
||||
const upstreamBase = new URL(HASURA_UPSTREAM);
|
||||
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
|
||||
const target = new URL(upstreamBase.toString());
|
||||
target.pathname = HASURA_GRAPHQL_PATH;
|
||||
target.search = inUrl.search;
|
||||
|
||||
const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
|
||||
const host = target.hostname;
|
||||
|
||||
const connect =
|
||||
target.protocol === 'https:'
|
||||
? () => tls.connect({ host, port, servername: host })
|
||||
: () => net.connect({ host, port });
|
||||
|
||||
const upstream = connect();
|
||||
upstream.setNoDelay(true);
|
||||
socket.setNoDelay(true);
|
||||
|
||||
// For WebSocket upgrades we must forward `connection`/`upgrade` and related headers.
|
||||
const headers = { ...req.headers };
|
||||
delete headers['content-length'];
|
||||
delete headers['content-type'];
|
||||
headers.host = target.host;
|
||||
|
||||
const lines = [];
|
||||
lines.push(`GET ${target.pathname + target.search} HTTP/1.1`);
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (v == null) continue;
|
||||
if (Array.isArray(v)) {
|
||||
for (const vv of v) lines.push(`${k}: ${vv}`);
|
||||
} else {
|
||||
lines.push(`${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
lines.push('', '');
|
||||
upstream.write(lines.join('\r\n'));
|
||||
|
||||
if (head?.length) upstream.write(head);
|
||||
|
||||
upstream.on('error', () => {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
socket.on('error', () => {
|
||||
try {
|
||||
upstream.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
upstream.pipe(socket);
|
||||
socket.pipe(upstream);
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
||||
send(
|
||||
@@ -425,6 +487,25 @@ async function handler(req, res) {
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
if (isGraphqlPath(url.pathname)) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
withCors(res);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
|
||||
const user = resolveAuthenticatedUser(req);
|
||||
if (!user) {
|
||||
withCors(res);
|
||||
unauthorized(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
withCors(res);
|
||||
proxyGraphqlHttp(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/whoami') {
|
||||
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
||||
return;
|
||||
@@ -500,26 +581,6 @@ async function handler(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) {
|
||||
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
|
||||
const user = resolveAuthenticatedUser(req);
|
||||
if (!user) {
|
||||
unauthorized(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = loadApiReadToken();
|
||||
} catch (e) {
|
||||
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
|
||||
return;
|
||||
}
|
||||
proxyApi(req, res, token);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(req, res);
|
||||
}
|
||||
|
||||
@@ -532,6 +593,30 @@ const server = http.createServer((req, res) => {
|
||||
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
|
||||
});
|
||||
});
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
if (!isGraphqlPath(url.pathname)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
|
||||
const user = resolveAuthenticatedUser(req);
|
||||
if (!user) {
|
||||
try {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
proxyGraphqlWs(req, socket, head);
|
||||
} catch {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
server.listen(PORT, () => {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -539,10 +624,9 @@ server.listen(PORT, () => {
|
||||
service: 'trade-frontend',
|
||||
port: PORT,
|
||||
staticDir: STATIC_DIR,
|
||||
apiUpstream: API_UPSTREAM,
|
||||
hasuraUpstream: HASURA_UPSTREAM,
|
||||
basicAuthFile: BASIC_AUTH_FILE,
|
||||
basicAuthMode: BASIC_AUTH_MODE,
|
||||
apiReadTokenFile: API_READ_TOKEN_FILE,
|
||||
authUserHeader: AUTH_USER_HEADER,
|
||||
authMode: AUTH_MODE,
|
||||
htpasswdFile: HTPASSWD_FILE,
|
||||
|
||||
Reference in New Issue
Block a user