Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9ccc0b00e | |||
| 9420c89f52 | |||
| 545e1abfaa | |||
| 759173b5be | |||
| 194d596284 | |||
| 444f427420 | |||
| af267ad6c9 | |||
| f3c4a999c3 | |||
| 1c8a6900e8 | |||
| abaee44835 | |||
| f57366fad2 | |||
| b0c7806cb6 |
15
README.md
15
README.md
@@ -12,6 +12,21 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Dev z backendem na VPS (staging)
|
||||
|
||||
Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby nie bawić się w CORS i nie wkładać tokena do przeglądarki):
|
||||
|
||||
```bash
|
||||
cd apps/visualizer
|
||||
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite proxy’uje wtedy: `/api/*`, `/whoami`, `/auth/*`, `/logout` do VPS. Dodatkowo w dev usuwa `Secure` z `Set-Cookie`, żeby sesja działała na `http://localhost:5173`.
|
||||
|
||||
Jeśli staging jest dodatkowo chroniony basic auth (np. Traefik `basicAuth`), ustaw:
|
||||
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
|
||||
@@ -107,6 +107,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
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', false);
|
||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
@@ -119,7 +120,7 @@ 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 { candles, indicators, loading, error, refresh } = useChartData({
|
||||
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||
symbol,
|
||||
source: source.trim() ? source : undefined,
|
||||
tf,
|
||||
@@ -216,6 +217,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
}, [latest?.close, latest?.oracle, changePct]);
|
||||
|
||||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||||
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||||
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -281,9 +284,13 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
candles={candles}
|
||||
indicators={indicators}
|
||||
timeframe={tf}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
onTimeframeChange={setTf}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
/>
|
||||
|
||||
|
||||
@@ -13,9 +13,13 @@ type Props = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
timeframe: string;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
showBuild: boolean;
|
||||
onToggleBuild: () => void;
|
||||
seriesLabel: string;
|
||||
};
|
||||
|
||||
@@ -42,9 +46,13 @@ export default function ChartPanel({
|
||||
candles,
|
||||
indicators,
|
||||
timeframe,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
showBuild,
|
||||
onToggleBuild,
|
||||
seriesLabel,
|
||||
}: Props) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
@@ -234,6 +242,7 @@ export default function ChartPanel({
|
||||
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 });
|
||||
@@ -267,6 +276,8 @@ export default function ChartPanel({
|
||||
onTimeframeChange={onTimeframeChange}
|
||||
showIndicators={showIndicators}
|
||||
onToggleIndicators={onToggleIndicators}
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={onToggleBuild}
|
||||
priceAutoScale={priceAutoScale}
|
||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
@@ -295,6 +306,9 @@ export default function ChartPanel({
|
||||
ema20={indicators.ema20}
|
||||
bb20={indicators.bb20}
|
||||
showIndicators={showIndicators}
|
||||
showBuild={showBuild}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
fib={fibRenderable}
|
||||
fibOpacity={fibEffectiveOpacity}
|
||||
fibSelected={fibSelected}
|
||||
|
||||
@@ -5,6 +5,8 @@ type Props = {
|
||||
onTimeframeChange: (tf: string) => void;
|
||||
showIndicators: boolean;
|
||||
onToggleIndicators: () => void;
|
||||
showBuild: boolean;
|
||||
onToggleBuild: () => void;
|
||||
priceAutoScale: boolean;
|
||||
onTogglePriceAutoScale: () => void;
|
||||
seriesLabel: string;
|
||||
@@ -19,6 +21,8 @@ export default function ChartToolbar({
|
||||
onTimeframeChange,
|
||||
showIndicators,
|
||||
onToggleIndicators,
|
||||
showBuild,
|
||||
onToggleBuild,
|
||||
priceAutoScale,
|
||||
onTogglePriceAutoScale,
|
||||
seriesLabel,
|
||||
@@ -45,6 +49,9 @@ 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>
|
||||
|
||||
@@ -25,6 +25,9 @@ type Props = {
|
||||
ema20?: SeriesPoint[];
|
||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||
showIndicators: boolean;
|
||||
showBuild: boolean;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
fib?: FibRetracement | null;
|
||||
fibOpacity?: number;
|
||||
fibSelected?: boolean;
|
||||
@@ -44,11 +47,23 @@ type Props = {
|
||||
};
|
||||
|
||||
type LinePoint = LineData | WhitespaceData;
|
||||
type BuildSample = { t: number; v: number };
|
||||
|
||||
function toTime(t: number): UTCTimestamp {
|
||||
return t as UTCTimestamp;
|
||||
}
|
||||
|
||||
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
|
||||
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
|
||||
if (candles.length >= 2) {
|
||||
const last = candles[candles.length - 1]?.time;
|
||||
const prev = candles[candles.length - 2]?.time;
|
||||
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
|
||||
if (Number.isFinite(delta) && delta > 0) return delta;
|
||||
}
|
||||
return 60;
|
||||
}
|
||||
|
||||
function samplePriceFromCandles(candles: Candle[]): number | null {
|
||||
for (let i = candles.length - 1; i >= 0; i -= 1) {
|
||||
const close = candles[i]?.close;
|
||||
@@ -82,11 +97,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
|
||||
|
||||
function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||
return candles.map((c) => {
|
||||
const up = c.close >= c.open;
|
||||
return {
|
||||
time: toTime(c.time),
|
||||
value: c.volume ?? 0,
|
||||
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
|
||||
color: 'rgba(148,163,184,0.22)',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -103,6 +117,9 @@ export default function TradingChart({
|
||||
ema20,
|
||||
bb20,
|
||||
showIndicators,
|
||||
showBuild,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
fib,
|
||||
fibOpacity = 1,
|
||||
fibSelected = false,
|
||||
@@ -124,9 +141,13 @@ export default function TradingChart({
|
||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
||||
const capturedOverlayPointerRef = useRef<number | null>(null);
|
||||
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
||||
const buildKeyRef = useRef<string | null>(null);
|
||||
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||
const seriesRef = useRef<{
|
||||
candles?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
build?: ISeriesApi<'Line'>;
|
||||
oracle?: ISeriesApi<'Line'>;
|
||||
sma20?: ISeriesApi<'Line'>;
|
||||
ema20?: ISeriesApi<'Line'>;
|
||||
@@ -225,7 +246,22 @@ export default function TradingChart({
|
||||
color: 'rgba(255,255,255,0.15)',
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
scaleMargins: { top: 0.88, bottom: 0 },
|
||||
});
|
||||
|
||||
const buildSeries = chart.addSeries(LineSeries, {
|
||||
color: '#60a5fa',
|
||||
lineWidth: 2,
|
||||
priceFormat,
|
||||
priceScaleId: 'build',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
buildSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||
visible: false,
|
||||
borderVisible: false,
|
||||
});
|
||||
|
||||
const oracleSeries = chart.addSeries(LineSeries, {
|
||||
@@ -249,6 +285,7 @@ export default function TradingChart({
|
||||
seriesRef.current = {
|
||||
candles: candleSeries,
|
||||
volume: volumeSeries,
|
||||
build: buildSeries,
|
||||
oracle: oracleSeries,
|
||||
sma20: smaSeries,
|
||||
ema20: emaSeries,
|
||||
@@ -551,7 +588,7 @@ export default function TradingChart({
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles || !s.volume) return;
|
||||
if (!s.candles || !s.volume || !s.build) return;
|
||||
s.candles.setData(candleData);
|
||||
s.volume.setData(volumeData);
|
||||
s.oracle?.setData(oracleData);
|
||||
@@ -561,17 +598,162 @@ export default function TradingChart({
|
||||
s.bbLower?.setData(bbLower);
|
||||
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 eps = 1e-3;
|
||||
const maxPointsPerCandle = 600;
|
||||
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
||||
const map = buildSamplesRef.current;
|
||||
const visibleStarts = new Set(candles.map((c) => c.time));
|
||||
for (const start of Array.from(map.keys())) {
|
||||
if (!visibleStarts.has(start)) map.delete(start);
|
||||
}
|
||||
|
||||
const last = candles[candles.length - 1];
|
||||
if (last) {
|
||||
const prevStart = lastBuildCandleStartRef.current;
|
||||
if (prevStart != null && prevStart !== last.time) {
|
||||
const prevCandle = candles.find((c) => c.time === prevStart);
|
||||
if (prevCandle) {
|
||||
const endT = prevStart + bs - eps;
|
||||
const finalDelta = prevCandle.close - prevCandle.open;
|
||||
const list = map.get(prevStart) ?? [];
|
||||
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
|
||||
if (endT > lastT + eps) {
|
||||
list.push({ t: endT, v: finalDelta });
|
||||
} else if (list.length) {
|
||||
list[list.length - 1] = { t: lastT, v: finalDelta };
|
||||
}
|
||||
map.set(prevStart, list);
|
||||
}
|
||||
}
|
||||
|
||||
const start = last.time;
|
||||
const endT = start + bs - eps;
|
||||
const delta = last.close - last.open;
|
||||
const nowT = Date.now() / 1000;
|
||||
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
|
||||
const list = map.get(start) ?? [];
|
||||
if (list.length) {
|
||||
const lastPt = list[list.length - 1]!;
|
||||
if (tClamped - lastPt.t < minStep) {
|
||||
list[list.length - 1] = { t: tClamped, v: delta };
|
||||
} else {
|
||||
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
|
||||
if (t > lastPt.t) list.push({ t, v: delta });
|
||||
}
|
||||
} else {
|
||||
list.push({ t: tClamped, v: delta });
|
||||
}
|
||||
map.set(start, list);
|
||||
lastBuildCandleStartRef.current = start;
|
||||
}
|
||||
|
||||
const buildRaw: LinePoint[] = [];
|
||||
for (const c of candles) {
|
||||
const list = map.get(c.time);
|
||||
if (!list?.length) continue;
|
||||
|
||||
const startT = c.time + eps;
|
||||
const endT = c.time + bs - eps;
|
||||
if (!(endT > startT)) continue;
|
||||
|
||||
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
|
||||
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
|
||||
|
||||
let lastT = startT;
|
||||
for (const p of list) {
|
||||
let t = p.t;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.ema20?.applyOptions({ visible: showIndicators });
|
||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
||||
}, [
|
||||
candleData,
|
||||
volumeData,
|
||||
oracleData,
|
||||
smaData,
|
||||
emaData,
|
||||
bbUpper,
|
||||
bbLower,
|
||||
bbMid,
|
||||
showIndicators,
|
||||
showBuild,
|
||||
candles,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles) return;
|
||||
s.candles.applyOptions({ priceFormat });
|
||||
s.build?.applyOptions({ priceFormat });
|
||||
s.oracle?.applyOptions({ priceFormat });
|
||||
s.sma20?.applyOptions({ priceFormat });
|
||||
s.ema20?.applyOptions({ priceFormat });
|
||||
|
||||
@@ -14,6 +14,7 @@ type Params = {
|
||||
type Result = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
meta: { tf: string; bucketSeconds: number } | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -22,6 +23,7 @@ type Result = {
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
@@ -34,6 +36,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
||||
const res = await fetchChart({ symbol, source, tf, limit });
|
||||
setCandles(res.candles);
|
||||
setIndicators(res.indicators);
|
||||
setMeta(res.meta);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message || e));
|
||||
@@ -50,8 +53,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
||||
useInterval(() => void fetchOnce(), pollMs);
|
||||
|
||||
return useMemo(
|
||||
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
|
||||
[candles, indicators, loading, error, fetchOnce]
|
||||
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
|
||||
[candles, indicators, meta, loading, error, fetchOnce]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ import react from '@vitejs/plugin-react';
|
||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(DIR, '../..');
|
||||
|
||||
type BasicAuth = { username: string; password: string };
|
||||
|
||||
function stripTrailingSlashes(p: string): string {
|
||||
const out = p.replace(/\/+$/, '');
|
||||
return out || '/';
|
||||
}
|
||||
|
||||
function readApiReadToken(): string | undefined {
|
||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||
@@ -20,24 +27,132 @@ function readApiReadToken(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
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(): BasicAuth | undefined {
|
||||
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const fileRaw = String(process.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;
|
||||
}
|
||||
}
|
||||
|
||||
const apiReadToken = readApiReadToken();
|
||||
const proxyBasicAuth = readProxyBasicAuth();
|
||||
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787';
|
||||
|
||||
function parseUrl(v: string): URL | undefined {
|
||||
try {
|
||||
return new URL(v);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const uiProxyTarget =
|
||||
process.env.FRONTEND_PROXY_TARGET ||
|
||||
process.env.UI_PROXY_TARGET ||
|
||||
process.env.AUTH_PROXY_TARGET ||
|
||||
inferUiProxyTarget(apiProxyTarget) ||
|
||||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : 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));
|
||||
}
|
||||
|
||||
const proxy: Record<string, any> = {
|
||||
'/api': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||
configure: (p: any) => {
|
||||
p.on('proxyReq', (proxyReq: any) => {
|
||||
if (applyProxyBasicAuth(proxyReq)) return;
|
||||
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
p.on('proxyRes', (proxyRes: any) => {
|
||||
rewriteSetCookieForLocalDevHttp(proxyRes);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
proxy,
|
||||
},
|
||||
});
|
||||
|
||||
99
doc/workflow.md
Normal file
99
doc/workflow.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Workflow pracy w projekcie `trade` (dev → staging na VPS) + snapshot/rollback
|
||||
|
||||
Ten plik jest “source of truth” dla sposobu pracy nad zmianami w `trade`.
|
||||
Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do którego można wrócić.
|
||||
|
||||
## Dla agenta / po restarcie sesji
|
||||
|
||||
1) Przeczytaj ten plik: `doc/workflow.md`.
|
||||
2) Kontekst funkcjonalny: `README.md`, `info.md`.
|
||||
3) Kontekst stacka: `doc/workflow-api-ingest.md` oraz `devops/*/README.md`.
|
||||
4) Stan VPS/k3s + GitOps: `doc/migration.md` i log zmian: `doc/steps.md`.
|
||||
|
||||
## Zasady (must-have)
|
||||
|
||||
- **Nie edytujemy “na żywo” VPS** (żadnych ręcznych poprawek w kontenerach/plikach na serwerze) → tylko Git + CI + Argo.
|
||||
- **Sekrety nie trafiają do gita**: `tokens/*.json` są gitignored; w dokumentacji/logach redagujemy hasła/tokeny.
|
||||
- **Brak `latest`**: obrazy w deployu są przypięte do `sha-<shortsha>` albo digesta.
|
||||
- **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy.
|
||||
|
||||
## Domyślne środowisko pracy (ważne)
|
||||
|
||||
- **Frontend**: domyślnie pracujemy lokalnie (Vite) i łączymy się z backendem na VPS (staging) przez proxy. Deploy frontendu na VPS robimy tylko jeśli jest to wyraźnie powiedziane (“wdrażam na VPS”).
|
||||
- **Backend (trade-api + ingestor)**: zmiany backendu weryfikujemy/wdrażamy na VPS (staging), bo tam działa ingestor i tam są dane. Nie traktujemy lokalnego uruchomienia backendu jako domyślnego (tylko na wyraźną prośbę do debugowania).
|
||||
|
||||
## Standardowy flow zmian (polecany)
|
||||
|
||||
1) Zmiana w kodzie lokalnie.
|
||||
- Nie musisz odpalać lokalnego Dockera; na start rób szybkie walidacje (build/typecheck).
|
||||
2) Commit + push (najlepiej przez PR).
|
||||
3) CI:
|
||||
- build → push obrazów (tag `sha-*` lub digest),
|
||||
- aktualizacja `trade-deploy` (bump obrazu/rewizji).
|
||||
4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`.
|
||||
5) Test na VPS:
|
||||
- API: `/healthz`, `/v1/ticks`, `/v1/chart`
|
||||
- UI: `trade.mpabi.pl`
|
||||
- Ingest: logi `trade-ingestor` + napływ ticków do tabeli.
|
||||
|
||||
## Snapshoty i rollback (playbook)
|
||||
|
||||
### Rollback szybki (preferowany)
|
||||
|
||||
- Cofnij snapshot w repo deploy:
|
||||
- `git revert` commita, który podbił obrazy, **albo**
|
||||
- w Argo cofnij do poprzedniej rewizji (ten sam efekt).
|
||||
|
||||
Efekt: Argo wraca do poprzedniego “dobrego” zestawu obrazów i konfiguracji.
|
||||
|
||||
### Rollback bezpieczny dla “dużych” zmian (schema/ingest)
|
||||
|
||||
Jeśli zmiana dotyka danych/ingestu, rób ją jako nową wersję vN:
|
||||
- nowa tabela: `drift_ticks_vN`
|
||||
- nowa funkcja: `get_drift_candles_vN`
|
||||
- osobny deploy API/UI (inne porty/sufiksy), a ingest przełączany “cutover”.
|
||||
|
||||
W razie problemów: robisz “cut back” vN → v1 (stare dane zostają nietknięte).
|
||||
|
||||
## Lokalne uruchomienie (opcjonalne, do debugowania)
|
||||
|
||||
Dokładna instrukcja: `doc/workflow-api-ingest.md`.
|
||||
|
||||
Skrót:
|
||||
```bash
|
||||
npm install
|
||||
docker compose -f devops/db/docker-compose.yml up -d
|
||||
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
|
||||
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
|
||||
docker compose -f devops/app/docker-compose.yml up -d --build api
|
||||
npm run token:api -- --scopes write --out tokens/alg.json
|
||||
npm run token:api -- --scopes read --out tokens/read.json
|
||||
docker compose -f devops/app/docker-compose.yml up -d --build frontend
|
||||
docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
|
||||
```
|
||||
|
||||
### Frontend dev (Vite) z backendem na VPS (staging)
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
||||
|
||||
Przykład:
|
||||
|
||||
```bash
|
||||
cd apps/visualizer
|
||||
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
|
||||
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||
|
||||
## Definicja “done” dla zmiany
|
||||
|
||||
- Jest snapshot (commit w deploy) i można wrócić jednym ruchem.
|
||||
- Staging działa (API/ingest/UI) i ma podstawowe smoke-checki.
|
||||
- Sekrety nie zostały dodane do repo ani do logów/komentarzy.
|
||||
Reference in New Issue
Block a user