stock/static/app.js
2025-08-15 12:19:07 +02:00

254 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app.js
// ── utils ──────────────────────────────────────────────────────────────────────
const fmtNum = (x, d = 2) =>
x === null || x === undefined || Number.isNaN(x) ? "" : Number(x).toFixed(d);
// Potencjalne adresy backendu (API). Pierwszy to aktualny origin UI.
const apiCandidates = [
window.location.origin,
"http://127.0.0.1:8000",
"http://localhost:8000",
"http://172.27.20.120:8000", // z logów serwera
];
let API_BASE = null;
let warnedMixed = false;
function withTimeout(ms = 6000) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort("timeout"), ms);
return { signal: ctrl.signal, done: () => clearTimeout(id) };
}
function makeUrl(path) {
return `${API_BASE}${path}${path.includes("?") ? "&" : "?"}_ts=${Date.now()}`;
}
function setBadge(id, text, ok = true) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.className = ok ? "badge ok" : "badge err";
}
function setBadgeTitle(id, title) {
const el = document.getElementById(id);
if (el) el.title = title || "";
}
function warnMixedContent(base) {
if (!warnedMixed && location.protocol === "https:" && base?.startsWith("http://")) {
warnedMixed = true;
console.warn(
"[api] UI działa przez HTTPS, a API przez HTTP — przeglądarka może blokować żądania (mixed content)."
);
alert(
"UI działa przez HTTPS, a API przez HTTP. Uruchom UI przez HTTP albo włącz HTTPS dla API — inaczej przeglądarka zablokuje żądania."
);
}
}
// ── autodetekcja backendu ─────────────────────────────────────────────────────
async function pickBackend() {
for (const base of apiCandidates) {
try {
const t = withTimeout(2500);
const r = await fetch(`${base}/api/status?_ts=${Date.now()}`, {
cache: "no-store",
signal: t.signal,
});
t.done();
if (r.ok) {
API_BASE = base;
console.debug("[api] using", API_BASE);
warnMixedContent(API_BASE);
setBadgeTitle("loopState", `API: ${API_BASE}`);
return;
}
console.debug("[api] probe", base, "->", r.status);
} catch (e) {
// ignorujemy i próbujemy kolejny kandydat
console.debug("[api] probe fail", base, e?.message || e);
}
}
throw new Error("Nie znaleziono działającego backendu (API_BASE). Upewnij się, że server.py działa na porcie 8000.");
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiGet(path) {
const url = makeUrl(path);
const t0 = performance.now();
const t = withTimeout(6000);
try {
const r = await fetch(url, { cache: "no-store", signal: t.signal });
const t1 = performance.now();
console.debug("[api] GET", url, r.status, (t1 - t0).toFixed(1) + "ms");
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
} finally {
t.done();
}
}
async function apiPost(path, body) {
const url = makeUrl(path);
const t0 = performance.now();
const t = withTimeout(6000);
try {
const r = await fetch(url, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: t.signal,
});
const t1 = performance.now();
console.debug("[api] POST", url, r.status, (t1 - t0).toFixed(1) + "ms");
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
} finally {
t.done();
}
}
// ── refreshers ────────────────────────────────────────────────────────────────
async function refreshStatus() {
try {
const s = await apiGet("/api/status");
setBadge("loopState", s.running ? "RUNNING" : "STOPPED", s.running);
setBadgeTitle("loopState", `API: ${API_BASE} | last_action=${s.last_action || ""}`);
const roundEl = document.getElementById("roundNo");
if (roundEl) roundEl.textContent = s.round ?? "";
const cashEl = document.getElementById("cash");
if (cashEl) cashEl.textContent = fmtNum(s.cash, 2);
// now-playing
const stageEl = document.getElementById("stage");
if (stageEl) stageEl.textContent = (s.stage || "idle").toUpperCase();
const tickerEl = document.getElementById("ticker");
if (tickerEl) tickerEl.textContent = s.current_ticker || "";
const idx = s.current_index ?? 0;
const total = s.tickers_total ?? 0;
const progressTextEl = document.getElementById("progressText");
if (progressTextEl) progressTextEl.textContent = `${Math.min(idx + 1, total)} / ${total}`;
const prog = document.getElementById("progress");
if (prog) {
prog.max = total || 1;
prog.value = Math.min(idx + 1, total) || 0;
}
const lastActionEl = document.getElementById("lastAction");
if (lastActionEl) lastActionEl.textContent = s.last_action || "";
} catch (e) {
console.error("status error:", e);
setBadge("loopState", "ERR", false);
setBadgeTitle("loopState", `API: ${API_BASE || "—"} | ${e?.message || e}`);
}
}
async function refreshPositions() {
try {
const { positions } = await apiGet("/api/positions");
const tbody = document.querySelector("#positions tbody");
if (!tbody) return;
tbody.innerHTML = "";
for (const p of positions) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.ticker}</td>
<td>${p.side}</td>
<td class="num">${fmtNum(p.size, 0)}</td>
<td class="num">${fmtNum(p.entry_price)}</td>
<td class="num">${fmtNum(p.last_price)}</td>
<td class="num">${fmtNum(p.pnl)}</td>
`;
tbody.appendChild(tr);
}
} catch (e) {
console.error("positions error:", e);
}
}
async function refreshTrades() {
try {
const { trades } = await apiGet("/api/trades");
const tbody = document.querySelector("#trades tbody");
if (!tbody) return;
tbody.innerHTML = "";
trades.slice(-50).reverse().forEach((t) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${t.time ?? ""}</td>
<td>${t.ticker ?? ""}</td>
<td>${t.action ?? ""}</td>
<td class="num">${fmtNum(t.price)}</td>
<td class="num">${fmtNum(t.size, 0)}</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
console.error("trades error:", e);
}
}
async function refreshAll() {
await Promise.all([refreshStatus(), refreshPositions(), refreshTrades()]);
}
// ── auto refresh ──────────────────────────────────────────────────────────────
let timer = null;
let currentInterval = 2000; // domyślnie 2s (zgodne z Twoim selectem)
function startAutoRefresh() {
if (timer) return;
timer = setInterval(refreshAll, currentInterval);
console.debug("[ui] auto refresh started", currentInterval, "ms");
}
function stopAutoRefresh() {
if (!timer) return;
clearInterval(timer);
timer = null;
console.debug("[ui] auto refresh stopped");
}
// ── bootstrap ────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", async () => {
// Przyciski
document.getElementById("btnStart")?.addEventListener("click", async () => {
try { await apiPost("/api/start"); await refreshStatus(); } catch (e) { console.error(e); }
});
document.getElementById("btnStop")?.addEventListener("click", async () => {
try { await apiPost("/api/stop"); await refreshStatus(); } catch (e) { console.error(e); }
});
document.getElementById("btnTick")?.addEventListener("click", async () => {
try { await apiPost("/api/run-once"); await refreshAll(); } catch (e) { console.error(e); }
});
const sel = document.getElementById("refreshMs");
sel?.addEventListener("change", () => {
currentInterval = parseInt(sel.value, 10);
if (timer) { stopAutoRefresh(); startAutoRefresh(); }
});
document.getElementById("autoOn")?.addEventListener("click", startAutoRefresh);
document.getElementById("autoOff")?.addEventListener("click", stopAutoRefresh);
// Autodetekcja backendu przed pierwszym odświeżeniem
try {
await pickBackend();
await refreshAll();
startAutoRefresh();
} catch (e) {
console.error(e);
setBadge("loopState", "NO API", false);
setBadgeTitle("loopState", e?.message || String(e));
alert("UI nie może połączyć się z backendem (port 8000). Uruchom server.py lub zaktualizuj API_BASE w app.js.");
}
});