feat(v2): add Hasura /graphql WS proxy and live DLOB stats
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|
||||||
# Fallback (optional): query Hasura directly (not recommended in browser).
|
# Hasura GraphQL endpoint (supports subscriptions via WS).
|
||||||
VITE_HASURA_URL=http://localhost:8080/v1/graphql
|
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||||
# Optional (only if you intentionally query Hasura directly from the browser):
|
VITE_HASURA_URL=/graphql
|
||||||
|
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||||
|
# VITE_HASURA_WS_URL=wss://YOUR_HOST/graphql
|
||||||
|
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||||
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||||
# VITE_HASURA_ADMIN_SECRET=devsecret
|
|
||||||
VITE_SYMBOL=PUMP-PERP
|
VITE_SYMBOL=PUMP-PERP
|
||||||
# Optional: filter by source (leave empty for all)
|
# Optional: filter by source (leave empty for all)
|
||||||
# VITE_SOURCE=drift_oracle
|
# VITE_SOURCE=drift_oracle
|
||||||
|
|||||||
1
apps/visualizer/__start
Normal file
1
apps/visualizer/__start
Normal file
@@ -0,0 +1 @@
|
|||||||
|
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl npm run dev
|
||||||
@@ -11,6 +11,7 @@ import Button from './ui/Button';
|
|||||||
import TopNav from './layout/TopNav';
|
import TopNav from './layout/TopNav';
|
||||||
import AuthStatus from './layout/AuthStatus';
|
import AuthStatus from './layout/AuthStatus';
|
||||||
import LoginScreen from './layout/LoginScreen';
|
import LoginScreen from './layout/LoginScreen';
|
||||||
|
import { useDlobStats } from './features/market/useDlobStats';
|
||||||
|
|
||||||
function envNumber(name: string, fallback: number): number {
|
function envNumber(name: string, fallback: number): number {
|
||||||
const v = (import.meta as any).env?.[name];
|
const v = (import.meta as any).env?.[name];
|
||||||
@@ -99,7 +100,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BONK-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', 'PUMP-PERP'));
|
||||||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||||||
@@ -128,6 +129,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
pollMs,
|
pollMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
|
||||||
|
|
||||||
const latest = candles.length ? candles[candles.length - 1] : null;
|
const latest = candles.length ? candles[candles.length - 1] : null;
|
||||||
const first = candles.length ? candles[0] : null;
|
const first = candles.length ? candles[0] : null;
|
||||||
const changePct =
|
const changePct =
|
||||||
@@ -209,12 +212,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
||||||
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
|
{ key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
|
||||||
{ key: 'oi', label: 'Open Interest', value: '—' },
|
{ key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
|
||||||
{ key: 'vol', label: '24h Volume', value: '—' },
|
{
|
||||||
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
|
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 || '—',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}, [latest?.close, latest?.oracle, changePct]);
|
}, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError]);
|
||||||
|
|
||||||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||||||
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CandlestickSeries,
|
CandlestickSeries,
|
||||||
ColorType,
|
ColorType,
|
||||||
CrosshairMode,
|
CrosshairMode,
|
||||||
HistogramSeries,
|
HistogramSeries,
|
||||||
|
type IPrimitivePaneRenderer,
|
||||||
|
type IPrimitivePaneView,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
|
type ISeriesPrimitive,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
LineSeries,
|
LineSeries,
|
||||||
|
type SeriesAttachedParameter,
|
||||||
|
type Time,
|
||||||
createChart,
|
createChart,
|
||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
type CandlestickData,
|
type CandlestickData,
|
||||||
@@ -49,6 +54,13 @@ type Props = {
|
|||||||
type LinePoint = LineData | WhitespaceData;
|
type LinePoint = LineData | WhitespaceData;
|
||||||
type BuildSample = { t: number; v: number };
|
type BuildSample = { t: number; v: number };
|
||||||
|
|
||||||
|
const BUILD_UP_COLOR = '#22c55e';
|
||||||
|
const BUILD_DOWN_COLOR = '#ef4444';
|
||||||
|
const BUILD_FLAT_COLOR = '#60a5fa';
|
||||||
|
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
|
||||||
|
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
|
||||||
|
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
|
||||||
|
|
||||||
function toTime(t: number): UTCTimestamp {
|
function toTime(t: number): UTCTimestamp {
|
||||||
return t as UTCTimestamp;
|
return t as UTCTimestamp;
|
||||||
}
|
}
|
||||||
@@ -110,6 +122,258 @@ function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
|||||||
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function colorForDelta(delta: number): string {
|
||||||
|
if (delta > 0) return BUILD_UP_COLOR;
|
||||||
|
if (delta < 0) return BUILD_DOWN_COLOR;
|
||||||
|
return BUILD_FLAT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceColorForDelta(delta: number): string {
|
||||||
|
if (delta > 0) return BUILD_UP_SLICE;
|
||||||
|
if (delta < 0) return BUILD_DOWN_SLICE;
|
||||||
|
return BUILD_FLAT_SLICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SliceDir = -1 | 0 | 1;
|
||||||
|
|
||||||
|
function dirForDelta(delta: number): SliceDir {
|
||||||
|
if (delta > 0) return 1;
|
||||||
|
if (delta < 0) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
|
||||||
|
const eps = 1e-3;
|
||||||
|
const startT = candle.time + eps;
|
||||||
|
const endT = candle.time + bs - eps;
|
||||||
|
if (!(endT > startT)) return [];
|
||||||
|
|
||||||
|
const points: BuildSample[] = [{ t: startT, v: 0 }];
|
||||||
|
let lastT = startT;
|
||||||
|
for (const p of samples || []) {
|
||||||
|
let t = p.t;
|
||||||
|
if (t <= lastT + eps) t = lastT + eps;
|
||||||
|
if (t >= endT) break;
|
||||||
|
points.push({ t, v: p.v });
|
||||||
|
lastT = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDelta = candle.close - candle.open;
|
||||||
|
if (endT > lastT + eps) {
|
||||||
|
points.push({ t: endT, v: finalDelta });
|
||||||
|
} else if (points.length) {
|
||||||
|
points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
|
||||||
|
out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
|
||||||
|
|
||||||
|
let lastLineIdx = out.length - 1;
|
||||||
|
let lastVal = points[0]!.v;
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const v = points[i]!.v;
|
||||||
|
const prev = out[lastLineIdx] as LineData;
|
||||||
|
out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
|
||||||
|
out.push({ time: toTime(points[i]!.t), value: v } as LineData);
|
||||||
|
lastLineIdx = out.length - 1;
|
||||||
|
lastVal = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildSlicesState = {
|
||||||
|
enabled: boolean;
|
||||||
|
candles: Candle[];
|
||||||
|
bucketSeconds: number;
|
||||||
|
samples: Map<number, BuildSample[]>;
|
||||||
|
series: ISeriesApi<'Histogram', Time> | null;
|
||||||
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||||
|
private readonly _getState: () => BuildSlicesState;
|
||||||
|
|
||||||
|
constructor(getState: () => BuildSlicesState) {
|
||||||
|
this._getState = getState;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(target: any) {
|
||||||
|
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!candles.length || !series || !chart) return;
|
||||||
|
|
||||||
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||||
|
const rows = Math.min(60, Math.max(12, Math.floor(bs)));
|
||||||
|
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
|
||||||
|
|
||||||
|
const yBase = series.priceToCoordinate(0);
|
||||||
|
if (yBase == null) return;
|
||||||
|
|
||||||
|
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
|
||||||
|
|
||||||
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||||
|
const yBottomPx = Math.round(yBase * verticalPixelRatio);
|
||||||
|
const lastIdx = xs.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < candles.length; i += 1) {
|
||||||
|
const x = xs[i];
|
||||||
|
if (x == null) continue;
|
||||||
|
if (!Number.isFinite(x)) continue;
|
||||||
|
|
||||||
|
const c = candles[i]!;
|
||||||
|
const list = samples.get(c.time);
|
||||||
|
const start = c.time;
|
||||||
|
const end = start + bs;
|
||||||
|
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
|
||||||
|
|
||||||
|
let spacing = 0;
|
||||||
|
const prevX = i > 0 ? xs[i - 1] : null;
|
||||||
|
const nextX = i < lastIdx ? xs[i + 1] : null;
|
||||||
|
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
|
||||||
|
if (nextX != null && Number.isFinite(nextX)) {
|
||||||
|
const s2 = nextX - x;
|
||||||
|
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
|
||||||
|
}
|
||||||
|
if (!(spacing > 0)) spacing = 6;
|
||||||
|
|
||||||
|
const barWidthCss = Math.max(1, spacing * 0.9);
|
||||||
|
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
|
||||||
|
const xCenterPx = Math.round(x * horizontalPixelRatio);
|
||||||
|
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
|
||||||
|
|
||||||
|
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
|
||||||
|
if (!(volumeValue > 0)) continue;
|
||||||
|
const yTop = series.priceToCoordinate(volumeValue);
|
||||||
|
if (yTop == null) continue;
|
||||||
|
const yTopPx = Math.round(yTop * verticalPixelRatio);
|
||||||
|
if (!(yBottomPx > yTopPx)) continue;
|
||||||
|
const barHeightPx = yBottomPx - yTopPx;
|
||||||
|
|
||||||
|
const rowDirs: Array<SliceDir | null> = Array.from({ length: rows }, () => null);
|
||||||
|
const overallDir = dirForDelta(c.close - c.open);
|
||||||
|
|
||||||
|
const points: BuildSample[] = [{ t: start, v: 0 }];
|
||||||
|
if (list?.length) {
|
||||||
|
for (const p of list) {
|
||||||
|
if (!Number.isFinite(p.t) || !Number.isFinite(p.v)) continue;
|
||||||
|
const t = Math.min(end, Math.max(start, p.t));
|
||||||
|
points.push({ t, v: p.v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endV = c.close - c.open;
|
||||||
|
const lastPt = points[points.length - 1]!;
|
||||||
|
if (progressT > lastPt.t + 1e-3) {
|
||||||
|
points.push({ t: progressT, v: endV });
|
||||||
|
} else {
|
||||||
|
lastPt.v = endV;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 1; j < points.length; j += 1) {
|
||||||
|
const a = points[j - 1]!;
|
||||||
|
const b = points[j]!;
|
||||||
|
const dir = dirForDelta(b.v - a.v);
|
||||||
|
|
||||||
|
const from = Math.max(0, Math.min(rows - 1, Math.floor(((a.t - start) / bs) * rows)));
|
||||||
|
const to = Math.max(0, Math.min(rows - 1, Math.floor(((b.t - start) / bs) * rows)));
|
||||||
|
|
||||||
|
for (let r = from; r <= to; r += 1) rowDirs[r] = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
if (rowDirs[r] == null) rowDirs[r] = overallDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
|
||||||
|
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
|
||||||
|
const w = x1 - x0;
|
||||||
|
if (!(w > 0)) continue;
|
||||||
|
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
const dir = rowDirs[r];
|
||||||
|
if (dir == null) continue;
|
||||||
|
|
||||||
|
const rowTop = yBottomPx - Math.round(((r + 1) * barHeightPx) / rows);
|
||||||
|
const rowBottom = yBottomPx - Math.round((r * barHeightPx) / rows);
|
||||||
|
const h = rowBottom - rowTop;
|
||||||
|
if (!(h > 0)) continue;
|
||||||
|
|
||||||
|
context.fillStyle = sliceColorForDelta(dir);
|
||||||
|
context.fillRect(x0, rowTop, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuildSlicesPaneView implements IPrimitivePaneView {
|
||||||
|
private readonly _renderer: BuildSlicesPaneRenderer;
|
||||||
|
|
||||||
|
constructor(getState: () => BuildSlicesState) {
|
||||||
|
this._renderer = new BuildSlicesPaneRenderer(getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
zOrder() {
|
||||||
|
return 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer() {
|
||||||
|
return this._renderer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
|
||||||
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||||
|
private _series: ISeriesApi<'Histogram', Time> | null = null;
|
||||||
|
private _enabled = true;
|
||||||
|
private _candles: Candle[] = [];
|
||||||
|
private _bucketSeconds = 0;
|
||||||
|
private _samples: Map<number, BuildSample[]> = new Map();
|
||||||
|
private readonly _paneView: BuildSlicesPaneView;
|
||||||
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._paneView = new BuildSlicesPaneView(() => ({
|
||||||
|
enabled: this._enabled,
|
||||||
|
candles: this._candles,
|
||||||
|
bucketSeconds: this._bucketSeconds,
|
||||||
|
samples: this._samples,
|
||||||
|
series: this._series,
|
||||||
|
chart: this._param?.chart ?? null,
|
||||||
|
}));
|
||||||
|
this._paneViews = [this._paneView];
|
||||||
|
}
|
||||||
|
|
||||||
|
attached(param: SeriesAttachedParameter<Time>) {
|
||||||
|
this._param = param;
|
||||||
|
this._series = param.series as ISeriesApi<'Histogram', Time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
detached() {
|
||||||
|
this._param = null;
|
||||||
|
this._series = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
paneViews() {
|
||||||
|
return this._paneViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(next: boolean) {
|
||||||
|
this._enabled = Boolean(next);
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
|
||||||
|
this._candles = Array.isArray(next.candles) ? next.candles : [];
|
||||||
|
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
|
||||||
|
this._samples = next.samples;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TradingChart({
|
export default function TradingChart({
|
||||||
candles,
|
candles,
|
||||||
oracle,
|
oracle,
|
||||||
@@ -136,18 +400,22 @@ export default function TradingChart({
|
|||||||
const fibOpacityRef = useRef<number>(fibOpacity);
|
const fibOpacityRef = useRef<number>(fibOpacity);
|
||||||
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
|
const showBuildRef = useRef<boolean>(showBuild);
|
||||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||||
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
||||||
const capturedOverlayPointerRef = useRef<number | null>(null);
|
const capturedOverlayPointerRef = useRef<number | null>(null);
|
||||||
|
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
|
||||||
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
||||||
const buildKeyRef = useRef<string | null>(null);
|
const buildKeyRef = useRef<string | null>(null);
|
||||||
const lastBuildCandleStartRef = useRef<number | null>(null);
|
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||||
|
const hoverCandleTimeRef = useRef<number | null>(null);
|
||||||
|
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
|
||||||
const seriesRef = useRef<{
|
const seriesRef = useRef<{
|
||||||
candles?: ISeriesApi<'Candlestick'>;
|
candles?: ISeriesApi<'Candlestick'>;
|
||||||
volume?: ISeriesApi<'Histogram'>;
|
volume?: ISeriesApi<'Histogram'>;
|
||||||
build?: ISeriesApi<'Line'>;
|
buildHover?: ISeriesApi<'Line'>;
|
||||||
oracle?: ISeriesApi<'Line'>;
|
oracle?: ISeriesApi<'Line'>;
|
||||||
sma20?: ISeriesApi<'Line'>;
|
sma20?: ISeriesApi<'Line'>;
|
||||||
ema20?: ISeriesApi<'Line'>;
|
ema20?: ISeriesApi<'Line'>;
|
||||||
@@ -198,6 +466,14 @@ export default function TradingChart({
|
|||||||
priceAutoScaleRef.current = priceAutoScale;
|
priceAutoScaleRef.current = priceAutoScale;
|
||||||
}, [priceAutoScale]);
|
}, [priceAutoScale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showBuildRef.current = showBuild;
|
||||||
|
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
|
||||||
|
hoverCandleTimeRef.current = null;
|
||||||
|
setHoverCandleTime(null);
|
||||||
|
}
|
||||||
|
}, [showBuild, hoverCandleTime]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
if (chartRef.current) return;
|
if (chartRef.current) return;
|
||||||
@@ -249,8 +525,13 @@ export default function TradingChart({
|
|||||||
scaleMargins: { top: 0.88, bottom: 0 },
|
scaleMargins: { top: 0.88, bottom: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildSeries = chart.addSeries(LineSeries, {
|
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
||||||
color: '#60a5fa',
|
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
||||||
|
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
||||||
|
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
|
||||||
|
|
||||||
|
const buildHoverSeries = chart.addSeries(LineSeries, {
|
||||||
|
color: BUILD_FLAT_COLOR,
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
priceFormat,
|
priceFormat,
|
||||||
priceScaleId: 'build',
|
priceScaleId: 'build',
|
||||||
@@ -258,7 +539,7 @@ export default function TradingChart({
|
|||||||
priceLineVisible: false,
|
priceLineVisible: false,
|
||||||
crosshairMarkerVisible: false,
|
crosshairMarkerVisible: false,
|
||||||
});
|
});
|
||||||
buildSeries.priceScale().applyOptions({
|
buildHoverSeries.priceScale().applyOptions({
|
||||||
scaleMargins: { top: 0.72, bottom: 0.12 },
|
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||||
visible: false,
|
visible: false,
|
||||||
borderVisible: false,
|
borderVisible: false,
|
||||||
@@ -285,7 +566,7 @@ export default function TradingChart({
|
|||||||
seriesRef.current = {
|
seriesRef.current = {
|
||||||
candles: candleSeries,
|
candles: candleSeries,
|
||||||
volume: volumeSeries,
|
volume: volumeSeries,
|
||||||
build: buildSeries,
|
buildHover: buildHoverSeries,
|
||||||
oracle: oracleSeries,
|
oracle: oracleSeries,
|
||||||
sma20: smaSeries,
|
sma20: smaSeries,
|
||||||
ema20: emaSeries,
|
ema20: emaSeries,
|
||||||
@@ -335,7 +616,23 @@ export default function TradingChart({
|
|||||||
chart.subscribeClick(onClick);
|
chart.subscribeClick(onClick);
|
||||||
|
|
||||||
const onCrosshairMove = (param: any) => {
|
const onCrosshairMove = (param: any) => {
|
||||||
if (!param?.point) return;
|
if (!param?.point) {
|
||||||
|
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
|
||||||
|
hoverCandleTimeRef.current = null;
|
||||||
|
setHoverCandleTime(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBuildRef.current) {
|
||||||
|
const t = typeof param?.time === 'number' ? Number(param.time) : null;
|
||||||
|
const next = t != null && Number.isFinite(t) ? t : null;
|
||||||
|
if (hoverCandleTimeRef.current !== next) {
|
||||||
|
hoverCandleTimeRef.current = next;
|
||||||
|
setHoverCandleTime(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||||
if (logical == null) return;
|
if (logical == null) return;
|
||||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||||
@@ -580,6 +877,10 @@ export default function TradingChart({
|
|||||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||||
fibPrimitiveRef.current = null;
|
fibPrimitiveRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (buildSlicesPrimitiveRef.current) {
|
||||||
|
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
|
||||||
|
buildSlicesPrimitiveRef.current = null;
|
||||||
|
}
|
||||||
chart.remove();
|
chart.remove();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
seriesRef.current = {};
|
seriesRef.current = {};
|
||||||
@@ -588,7 +889,7 @@ export default function TradingChart({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = seriesRef.current;
|
const s = seriesRef.current;
|
||||||
if (!s.candles || !s.volume || !s.build) return;
|
if (!s.candles || !s.volume || !s.buildHover) return;
|
||||||
s.candles.setData(candleData);
|
s.candles.setData(candleData);
|
||||||
s.volume.setData(volumeData);
|
s.volume.setData(volumeData);
|
||||||
s.oracle?.setData(oracleData);
|
s.oracle?.setData(oracleData);
|
||||||
@@ -598,34 +899,18 @@ export default function TradingChart({
|
|||||||
s.bbLower?.setData(bbLower);
|
s.bbLower?.setData(bbLower);
|
||||||
s.bbMid?.setData(bbMid);
|
s.bbMid?.setData(bbMid);
|
||||||
|
|
||||||
s.build.applyOptions({ visible: showBuild });
|
|
||||||
if (!showBuild) {
|
|
||||||
buildSamplesRef.current.clear();
|
|
||||||
buildKeyRef.current = seriesKey;
|
|
||||||
lastBuildCandleStartRef.current = null;
|
|
||||||
s.build.setData([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buildKeyRef.current !== seriesKey) {
|
|
||||||
buildSamplesRef.current.clear();
|
|
||||||
buildKeyRef.current = seriesKey;
|
|
||||||
lastBuildCandleStartRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showBuild) {
|
|
||||||
s.sma20?.applyOptions({ visible: showIndicators });
|
|
||||||
s.ema20?.applyOptions({ visible: showIndicators });
|
|
||||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
|
||||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
|
||||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||||
const eps = 1e-3;
|
const eps = 1e-3;
|
||||||
const maxPointsPerCandle = 600;
|
const maxPointsPerCandle = 600;
|
||||||
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
||||||
const map = buildSamplesRef.current;
|
const map = buildSamplesRef.current;
|
||||||
|
|
||||||
|
if (buildKeyRef.current !== seriesKey) {
|
||||||
|
map.clear();
|
||||||
|
buildKeyRef.current = seriesKey;
|
||||||
|
lastBuildCandleStartRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
const visibleStarts = new Set(candles.map((c) => c.time));
|
const visibleStarts = new Set(candles.map((c) => c.time));
|
||||||
for (const start of Array.from(map.keys())) {
|
for (const start of Array.from(map.keys())) {
|
||||||
if (!visibleStarts.has(start)) map.delete(start);
|
if (!visibleStarts.has(start)) map.delete(start);
|
||||||
@@ -671,63 +956,27 @@ export default function TradingChart({
|
|||||||
lastBuildCandleStartRef.current = start;
|
lastBuildCandleStartRef.current = start;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildRaw: LinePoint[] = [];
|
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
||||||
for (const c of candles) {
|
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
||||||
const list = map.get(c.time);
|
buildPrimitive?.setEnabled(!showBuild);
|
||||||
if (!list?.length) continue;
|
|
||||||
|
|
||||||
const startT = c.time + eps;
|
if (showBuild) {
|
||||||
const endT = c.time + bs - eps;
|
const hoverTime = hoverCandleTime;
|
||||||
if (!(endT > startT)) continue;
|
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
|
||||||
|
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
|
||||||
|
|
||||||
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
|
if (hoverData.length) {
|
||||||
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
|
s.buildHover.applyOptions({ visible: true });
|
||||||
|
s.buildHover.setData(hoverData);
|
||||||
let lastT = startT;
|
} else {
|
||||||
for (const p of list) {
|
s.buildHover.applyOptions({ visible: false });
|
||||||
let t = p.t;
|
s.buildHover.setData([]);
|
||||||
if (t <= lastT + eps) t = lastT + eps;
|
|
||||||
if (t >= endT) break;
|
|
||||||
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
|
|
||||||
lastT = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalDelta = c.close - c.open;
|
|
||||||
if (endT > lastT + eps) {
|
|
||||||
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
|
|
||||||
} else if (buildRaw.length) {
|
|
||||||
const prev = buildRaw[buildRaw.length - 1];
|
|
||||||
if ('value' in prev) {
|
|
||||||
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.buildHover.applyOptions({ visible: false });
|
||||||
|
s.buildHover.setData([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildColored: LinePoint[] = [];
|
|
||||||
let lastLineIdx: number | null = null;
|
|
||||||
let lastLineVal: number | null = null;
|
|
||||||
for (const p of buildRaw) {
|
|
||||||
if (!('value' in p)) {
|
|
||||||
buildColored.push(p);
|
|
||||||
lastLineIdx = null;
|
|
||||||
lastLineVal = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastLineIdx != null && lastLineVal != null) {
|
|
||||||
const delta = p.value - lastLineVal;
|
|
||||||
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
|
|
||||||
const prev = buildColored[lastLineIdx] as LineData;
|
|
||||||
buildColored[lastLineIdx] = { ...prev, color };
|
|
||||||
}
|
|
||||||
|
|
||||||
buildColored.push({ time: p.time, value: p.value } as LineData);
|
|
||||||
lastLineIdx = buildColored.length - 1;
|
|
||||||
lastLineVal = p.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
s.build.setData(buildColored);
|
|
||||||
|
|
||||||
s.sma20?.applyOptions({ visible: showIndicators });
|
s.sma20?.applyOptions({ visible: showIndicators });
|
||||||
s.ema20?.applyOptions({ visible: showIndicators });
|
s.ema20?.applyOptions({ visible: showIndicators });
|
||||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||||
@@ -747,13 +996,14 @@ export default function TradingChart({
|
|||||||
candles,
|
candles,
|
||||||
bucketSeconds,
|
bucketSeconds,
|
||||||
seriesKey,
|
seriesKey,
|
||||||
|
hoverCandleTime,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = seriesRef.current;
|
const s = seriesRef.current;
|
||||||
if (!s.candles) return;
|
if (!s.candles) return;
|
||||||
s.candles.applyOptions({ priceFormat });
|
s.candles.applyOptions({ priceFormat });
|
||||||
s.build?.applyOptions({ priceFormat });
|
s.buildHover?.applyOptions({ priceFormat });
|
||||||
s.oracle?.applyOptions({ priceFormat });
|
s.oracle?.applyOptions({ priceFormat });
|
||||||
s.sma20?.applyOptions({ priceFormat });
|
s.sma20?.applyOptions({ priceFormat });
|
||||||
s.ema20?.applyOptions({ priceFormat });
|
s.ema20?.applyOptions({ priceFormat });
|
||||||
|
|||||||
104
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
104
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobStats = {
|
||||||
|
marketName: string;
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
mid: number | null;
|
||||||
|
spreadAbs: number | null;
|
||||||
|
spreadBps: number | null;
|
||||||
|
depthBidUsd: number | null;
|
||||||
|
depthAskUsd: 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;
|
||||||
|
best_bid_price?: string | null;
|
||||||
|
best_ask_price?: string | null;
|
||||||
|
mid_price?: string | null;
|
||||||
|
spread_abs?: string | null;
|
||||||
|
spread_bps?: string | null;
|
||||||
|
depth_bid_usd?: string | null;
|
||||||
|
depth_ask_usd?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_stats_latest: HasuraDlobStatsRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 query = `
|
||||||
|
subscription DlobStats($market: String!) {
|
||||||
|
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||||
|
market_name
|
||||||
|
best_bid_price
|
||||||
|
best_ask_price
|
||||||
|
mid_price
|
||||||
|
spread_abs
|
||||||
|
spread_bps
|
||||||
|
depth_bid_usd
|
||||||
|
depth_ask_usd
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const row = data?.dlob_stats_latest?.[0];
|
||||||
|
if (!row?.market_name) return;
|
||||||
|
setStats({
|
||||||
|
marketName: row.market_name,
|
||||||
|
bestBid: toNum(row.best_bid_price),
|
||||||
|
bestAsk: toNum(row.best_ask_price),
|
||||||
|
mid: toNum(row.mid_price),
|
||||||
|
spreadAbs: toNum(row.spread_abs),
|
||||||
|
spreadBps: toNum(row.spread_bps),
|
||||||
|
depthBidUsd: toNum(row.depth_bid_usd),
|
||||||
|
depthAskUsd: toNum(row.depth_ask_usd),
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { stats, connected, error };
|
||||||
|
}
|
||||||
|
|
||||||
173
apps/visualizer/src/lib/graphqlWs.ts
Normal file
173
apps/visualizer/src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
type HeadersMap = Record<string, string>;
|
||||||
|
|
||||||
|
type SubscribeParams<T> = {
|
||||||
|
query: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
onData: (data: T) => void;
|
||||||
|
onError?: (err: string) => void;
|
||||||
|
onStatus?: (s: { connected: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envString(name: string): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.[name];
|
||||||
|
const s = v == null ? '' : String(v).trim();
|
||||||
|
return s ? s : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlHttpUrl(): string {
|
||||||
|
return envString('VITE_HASURA_URL') || '/graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlWsUrl(): string {
|
||||||
|
const explicit = envString('VITE_HASURA_WS_URL');
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
|
||||||
|
const wsUrl = resolveGraphqlWsUrl();
|
||||||
|
const headers = resolveAuthHeaders();
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let closed = false;
|
||||||
|
let started = false;
|
||||||
|
let reconnectTimer: 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 start = () => {
|
||||||
|
if (!ws || started) return;
|
||||||
|
started = true;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: subId,
|
||||||
|
type: 'start',
|
||||||
|
payload: { query, variables: variables ?? {} },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
started = false;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
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') {
|
||||||
|
emitError(msg.payload || 'connection_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
emitError(msg.payload || 'subscription_error');
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
setConnected(false);
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (!ws) return;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,8 @@ const uiProxyTarget =
|
|||||||
inferUiProxyTarget(apiProxyTarget) ||
|
inferUiProxyTarget(apiProxyTarget) ||
|
||||||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
||||||
|
|
||||||
|
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
|
||||||
|
|
||||||
function applyProxyBasicAuth(proxyReq: any) {
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
if (!proxyBasicAuth) return false;
|
if (!proxyBasicAuth) return false;
|
||||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
@@ -131,6 +133,24 @@ const proxy: Record<string, any> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (graphqlProxyTarget) {
|
||||||
|
for (const prefix of ['/graphql', '/graphql-ws']) {
|
||||||
|
proxy[prefix] = {
|
||||||
|
target: graphqlProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyReqWs', (proxyReq: any) => {
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (uiProxyTarget) {
|
if (uiProxyTarget) {
|
||||||
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
proxy[prefix] = {
|
proxy[prefix] = {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
|
|||||||
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
|
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
|
||||||
|
|
||||||
- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS.
|
- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS.
|
||||||
|
- UI ma też GraphQL (Hasura) pod `/graphql` (HTTP + WS subscriptions) – w dev proxy’ujemy `/graphql` i `/graphql-ws` do VPS, żeby subscriptions działały na `http://localhost:5173`.
|
||||||
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
||||||
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
||||||
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
||||||
@@ -85,7 +86,7 @@ Przykład:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/visualizer
|
cd apps/visualizer
|
||||||
API_PROXY_TARGET=https://trade.mpabi.pl \
|
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl \
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
||||||
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
||||||
@@ -16,6 +18,9 @@ const STATIC_DIR = process.env.STATIC_DIR || '/srv';
|
|||||||
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
|
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_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 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')
|
const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -413,6 +418,122 @@ function proxyApi(req, res, apiReadToken) {
|
|||||||
req.pipe(upstreamReq);
|
req.pipe(upstreamReq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 target = new URL(upstreamBase.toString());
|
||||||
|
target.pathname = HASURA_GRAPHQL_PATH;
|
||||||
|
target.search = inUrl.search;
|
||||||
|
|
||||||
|
const isHttps = target.protocol === 'https:';
|
||||||
|
const lib = isHttps ? https : http;
|
||||||
|
|
||||||
|
const headers = stripHopByHopHeaders(req.headers);
|
||||||
|
headers.host = target.host;
|
||||||
|
|
||||||
|
const upstreamReq = lib.request(
|
||||||
|
{
|
||||||
|
protocol: target.protocol,
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port || (isHttps ? 443 : 80),
|
||||||
|
method: req.method,
|
||||||
|
path: target.pathname + target.search,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(upstreamRes) => {
|
||||||
|
const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
|
||||||
|
withCors(res);
|
||||||
|
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
|
||||||
|
upstreamRes.pipe(res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
async function handler(req, res) {
|
||||||
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
||||||
send(
|
send(
|
||||||
@@ -425,6 +546,25 @@ async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
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') {
|
if (req.method === 'GET' && url.pathname === '/whoami') {
|
||||||
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
||||||
return;
|
return;
|
||||||
@@ -532,6 +672,30 @@ const server = http.createServer((req, res) => {
|
|||||||
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -540,6 +704,7 @@ server.listen(PORT, () => {
|
|||||||
port: PORT,
|
port: PORT,
|
||||||
staticDir: STATIC_DIR,
|
staticDir: STATIC_DIR,
|
||||||
apiUpstream: API_UPSTREAM,
|
apiUpstream: API_UPSTREAM,
|
||||||
|
hasuraUpstream: HASURA_UPSTREAM,
|
||||||
basicAuthFile: BASIC_AUTH_FILE,
|
basicAuthFile: BASIC_AUTH_FILE,
|
||||||
basicAuthMode: BASIC_AUTH_MODE,
|
basicAuthMode: BASIC_AUTH_MODE,
|
||||||
apiReadTokenFile: API_READ_TOKEN_FILE,
|
apiReadTokenFile: API_READ_TOKEN_FILE,
|
||||||
|
|||||||
Reference in New Issue
Block a user