fix(web): read sidebar trading mode from the bot status API (#80)
The Live Trading control stored its mode in localStorage, so the shell could claim live money while the bot was in dry-run. Show the Tradingbot status when TRADING_BOT_URL is set, and an unknown state when it is not. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
1741cdeea6
commit
de2a64f070
@ -217,6 +217,15 @@ EVOO_COCKPIT_TOKEN=
|
|||||||
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
|
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
|
||||||
EVOO_COCKPIT_CORS_ORIGIN=
|
EVOO_COCKPIT_CORS_ORIGIN=
|
||||||
|
|
||||||
|
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
|
||||||
|
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
|
||||||
|
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
|
||||||
|
# TRADING_BOT_URL=http://127.0.0.1:8000
|
||||||
|
# Optional build-time override. When set, the web app polls
|
||||||
|
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
|
||||||
|
# bot must allow this web origin. Leave unset to use the API proxy above.
|
||||||
|
# VITE_TRADING_BOT_URL=
|
||||||
|
|
||||||
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
|
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
|
||||||
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
||||||
# VITE_DEFAULT_UI_LOCALE=en
|
# VITE_DEFAULT_UI_LOCALE=en
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
EncryptedSecretStore,
|
EncryptedSecretStore,
|
||||||
ExpoPushProvider,
|
ExpoPushProvider,
|
||||||
GraphileJobPublisher,
|
GraphileJobPublisher,
|
||||||
|
GraphileJobWorkerHost,
|
||||||
InMemoryJobQueue,
|
InMemoryJobQueue,
|
||||||
InMemoryRealtimeFanout,
|
InMemoryRealtimeFanout,
|
||||||
InstalledConnectorProvider,
|
InstalledConnectorProvider,
|
||||||
@ -55,7 +56,6 @@ import {
|
|||||||
pipedreamConfigFromEnv,
|
pipedreamConfigFromEnv,
|
||||||
piSessionsRoot,
|
piSessionsRoot,
|
||||||
pushTokenPath,
|
pushTokenPath,
|
||||||
GraphileJobWorkerHost,
|
|
||||||
reconcileCloudAgents,
|
reconcileCloudAgents,
|
||||||
reconcileComputerUpdates,
|
reconcileComputerUpdates,
|
||||||
removePiUserSessions,
|
removePiUserSessions,
|
||||||
@ -107,6 +107,7 @@ import {
|
|||||||
settleWithTimeout,
|
settleWithTimeout,
|
||||||
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
||||||
} from "./team-chat-startup.js";
|
} from "./team-chat-startup.js";
|
||||||
|
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
||||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||||
|
|
||||||
@ -508,6 +509,13 @@ export async function createApp(
|
|||||||
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
mountTradingBotStatus(app, {
|
||||||
|
baseUrl: env.tradingBotUrl,
|
||||||
|
authorize: async (request) => {
|
||||||
|
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
|
||||||
|
return Boolean(session?.user);
|
||||||
|
},
|
||||||
|
});
|
||||||
if (localEmailEmulator && env.nodeEnv === "development") {
|
if (localEmailEmulator && env.nodeEnv === "development") {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/dev/emails",
|
"/api/dev/emails",
|
||||||
|
|||||||
@ -35,6 +35,16 @@ describe("loadEnv", () => {
|
|||||||
expect(env.wakeupDriver).toBe("memory");
|
expect(env.wakeupDriver).toBe("memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads a trading bot origin and refuses credentials", () => {
|
||||||
|
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
|
||||||
|
).toBe("http://127.0.0.1:8000");
|
||||||
|
expect(() =>
|
||||||
|
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
|
||||||
|
).toThrow(/^Invalid TRADING_BOT_URL$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("loads an optional integrations catalog mirror", () => {
|
it("loads an optional integrations catalog mirror", () => {
|
||||||
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
resolveEncryptionKey,
|
resolveEncryptionKey,
|
||||||
resolveScreenProxySecret,
|
resolveScreenProxySecret,
|
||||||
resolveSupervisorToken,
|
resolveSupervisorToken,
|
||||||
|
resolveTradingBotOrigin,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
|
|
||||||
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
||||||
@ -91,6 +92,8 @@ export interface AppEnv {
|
|||||||
updaterToken: string | undefined;
|
updaterToken: string | undefined;
|
||||||
/** Current application image tag; used for compose manual-upgrade command selection. */
|
/** Current application image tag; used for compose manual-upgrade command selection. */
|
||||||
imageTag: string | undefined;
|
imageTag: string | undefined;
|
||||||
|
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
|
||||||
|
tradingBotUrl: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||||
@ -177,6 +180,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
updaterUrl,
|
updaterUrl,
|
||||||
updaterToken,
|
updaterToken,
|
||||||
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
||||||
|
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
apps/api/src/trading-bot-proxy.test.ts
Normal file
96
apps/api/src/trading-bot-proxy.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
||||||
|
|
||||||
|
function proxyApp(options: {
|
||||||
|
baseUrl?: string;
|
||||||
|
authorize?: (request: Request) => Promise<boolean>;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}) {
|
||||||
|
const app = new Hono();
|
||||||
|
mountTradingBotStatus(app, {
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
authorize: options.authorize ?? (async () => true),
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("trading bot status proxy", () => {
|
||||||
|
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ configured: false });
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a session before it proxies a configured origin", async () => {
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const response = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
authorize: async () => false,
|
||||||
|
fetchImpl,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the mapped status and does not forward browser credentials", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const response = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH, {
|
||||||
|
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
configured: true,
|
||||||
|
reachable: true,
|
||||||
|
status: {
|
||||||
|
mode: "live",
|
||||||
|
kind: "live",
|
||||||
|
adapterCount: 2,
|
||||||
|
lastError: "adapter timeout",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("http://bot.test:8000/bot/api/status");
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
expect(headers.get("cookie")).toBeNull();
|
||||||
|
expect(headers.get("authorization")).toBeNull();
|
||||||
|
expect(init.redirect).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
|
||||||
|
const unmapped = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const unknown = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl: unmapped,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
|
||||||
|
|
||||||
|
const down = vi.fn(async () => new Response("nope", { status: 500 }));
|
||||||
|
const offline = await proxyApp({
|
||||||
|
baseUrl: "http://bot.test:8000",
|
||||||
|
fetchImpl: down,
|
||||||
|
}).request(TRADING_BOT_PROXY_PATH);
|
||||||
|
expect(offline.status).toBe(502);
|
||||||
|
expect(await offline.json()).toEqual({ configured: true, reachable: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
79
apps/api/src/trading-bot-proxy.ts
Normal file
79
apps/api/src/trading-bot-proxy.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
mapTradingBotStatus,
|
||||||
|
readBoundedJsonResponse,
|
||||||
|
TRADING_BOT_PROXY_PATH,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
TRADING_BOT_STATUS_TIMEOUT_MS,
|
||||||
|
tradingBotStatusUrl,
|
||||||
|
} from "@rakazo/core";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
|
||||||
|
export function mountTradingBotStatus(
|
||||||
|
app: Hono,
|
||||||
|
options: {
|
||||||
|
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
|
||||||
|
baseUrl: string | undefined;
|
||||||
|
authorize: (request: Request) => Promise<boolean>;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
|
||||||
|
c.header("cache-control", "no-store");
|
||||||
|
if (!options.baseUrl) return c.json({ configured: false });
|
||||||
|
const allowed = await options.authorize(c.req.raw).catch(() => false);
|
||||||
|
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "error",
|
||||||
|
cache: "no-store",
|
||||||
|
signal: timeout.signal,
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
cancelBody(response);
|
||||||
|
return c.json({ configured: true, reachable: false }, 502);
|
||||||
|
}
|
||||||
|
const body = await readBoundedJsonResponse<unknown>(
|
||||||
|
response,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
timeout.signal,
|
||||||
|
);
|
||||||
|
const status = mapTradingBotStatus(body);
|
||||||
|
return c.json({ configured: true, reachable: true, status: status ?? null });
|
||||||
|
} catch {
|
||||||
|
return c.json({ configured: true, reachable: false }, 502);
|
||||||
|
} finally {
|
||||||
|
timeout.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkAbort(
|
||||||
|
parent: AbortSignal,
|
||||||
|
timeoutMs: number,
|
||||||
|
): { signal: AbortSignal; cancel: () => void } {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const onParent = () => controller.abort();
|
||||||
|
if (parent.aborted) controller.abort();
|
||||||
|
else parent.addEventListener("abort", onParent);
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
cancel: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
parent.removeEventListener("abort", onParent);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBody(response: Response): void {
|
||||||
|
try {
|
||||||
|
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort. The caller still reports the bot as unreachable.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
|||||||
// - textbox "Search" (search input)
|
// - textbox "Search" (search input)
|
||||||
// - button "Heute" (today)
|
// - button "Heute" (today)
|
||||||
// - text "Unterhaltungen" (conversations section header)
|
// - text "Unterhaltungen" (conversations section header)
|
||||||
// - button "Live Trading"
|
// - status "Not configured" (trading bot URL unset)
|
||||||
// - NO "Integration(en)" button or text
|
// - NO "Integration(en)" button or text
|
||||||
|
|
||||||
// Search: it's a textbox, not a button
|
// Search: it's a textbox, not a button
|
||||||
@ -38,9 +38,12 @@ test("sidebar has expected navigation items and no integrations", async ({ page
|
|||||||
const conversationsText = sidebar.getByText("Unterhaltungen");
|
const conversationsText = sidebar.getByText("Unterhaltungen");
|
||||||
await expect(conversationsText).toBeVisible({ timeout: 5000 });
|
await expect(conversationsText).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// "Live Trading"
|
// Trading mode comes from the bot API. With no URL configured the sidebar
|
||||||
const liveTradingBtn = sidebar.getByRole("button", { name: "Live Trading" });
|
// must not claim Live Trading from a local preference.
|
||||||
await expect(liveTradingBtn).toBeVisible({ timeout: 5000 });
|
const tradingStatus = sidebar.getByTestId("trading-mode-status");
|
||||||
|
await expect(tradingStatus).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(tradingStatus).toContainText("Not configured");
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0);
|
||||||
|
|
||||||
// No "Integration(en)" button
|
// No "Integration(en)" button
|
||||||
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
|
const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i });
|
||||||
|
|||||||
55
apps/web/src/components/TradingModeIndicator.test.tsx
Normal file
55
apps/web/src/components/TradingModeIndicator.test.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@lingui/react/macro", () => ({
|
||||||
|
Trans: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TradingModeIndicator } from "./TradingModeIndicator";
|
||||||
|
|
||||||
|
it("renders the API mode and ignores a stored live preference", async () => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
window.localStorage.setItem("evoo_trading_mode", "live");
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<TradingModeIndicator
|
||||||
|
snapshot={{
|
||||||
|
state: "ready",
|
||||||
|
status: { mode: "paper", kind: "dry_run", adapterCount: 3, lastError: null },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const status = container.querySelector("[data-testid='trading-mode-status']");
|
||||||
|
expect(status?.getAttribute("data-trading-state")).toBe("dry_run");
|
||||||
|
expect(status?.textContent).toContain("Dry run");
|
||||||
|
expect(status?.textContent).toContain("3 adapters");
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
expect(status?.textContent).not.toContain("Live Trading");
|
||||||
|
|
||||||
|
await act(async () => root.render(<TradingModeIndicator snapshot={{ state: "offline" }} />));
|
||||||
|
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
|
||||||
|
"Offline",
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
root.render(<TradingModeIndicator snapshot={{ state: "unconfigured" }} />),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
|
||||||
|
"Not configured",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
window.localStorage.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
95
apps/web/src/components/TradingModeIndicator.tsx
Normal file
95
apps/web/src/components/TradingModeIndicator.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { cn } from "@rakazo/ui-web";
|
||||||
|
import { Activity } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
presentTradingStatus,
|
||||||
|
type TradingBotUiState,
|
||||||
|
type TradingModeLabelId,
|
||||||
|
type TradingModeSubtitle,
|
||||||
|
} from "../lib/trading-bot-status";
|
||||||
|
|
||||||
|
export function TradingModeIndicator({
|
||||||
|
snapshot,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
snapshot: TradingBotUiState;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const presentation = presentTradingStatus(snapshot);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={presentation.label === "loading"}
|
||||||
|
data-testid="trading-mode-status"
|
||||||
|
data-trading-state={presentation.label}
|
||||||
|
className={cn("flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2", className)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg",
|
||||||
|
presentation.tone === "live"
|
||||||
|
? "bg-warning/10 text-warning"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
strokeWidth={1.8}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-icon-md", presentation.pulse && "animate-pulse")}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-ui-body font-medium text-foreground/90">
|
||||||
|
<TradingModeLabel label={presentation.label} />
|
||||||
|
</span>
|
||||||
|
{presentation.subtitle ? (
|
||||||
|
<span className="block truncate text-ui-body-sm text-muted-foreground/70">
|
||||||
|
<TradingModeSubtitleText subtitle={presentation.subtitle} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradingModeLabel({ label }: { label: TradingModeLabelId }): ReactNode {
|
||||||
|
switch (label) {
|
||||||
|
case "loading":
|
||||||
|
return <Trans>Trading</Trans>;
|
||||||
|
case "unconfigured":
|
||||||
|
return <Trans>Not configured</Trans>;
|
||||||
|
case "offline":
|
||||||
|
return <Trans>Offline</Trans>;
|
||||||
|
case "unknown":
|
||||||
|
return <Trans>Unknown</Trans>;
|
||||||
|
case "live":
|
||||||
|
return <Trans>Live Trading</Trans>;
|
||||||
|
case "paper":
|
||||||
|
return <Trans>Paper</Trans>;
|
||||||
|
case "dry_run":
|
||||||
|
return <Trans>Dry run</Trans>;
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = label;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradingModeSubtitleText({ subtitle }: { subtitle: TradingModeSubtitle }): ReactNode {
|
||||||
|
switch (subtitle.type) {
|
||||||
|
case "error":
|
||||||
|
return subtitle.text;
|
||||||
|
case "adapters":
|
||||||
|
return subtitle.count === 1 ? (
|
||||||
|
<Trans>1 adapter</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>{subtitle.count} adapters</Trans>
|
||||||
|
);
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = subtitle;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
apps/web/src/lib/trading-bot-status.test.ts
Normal file
178
apps/web/src/lib/trading-bot-status.test.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchTradingBotSnapshot,
|
||||||
|
pollTradingBotStatus,
|
||||||
|
presentTradingStatus,
|
||||||
|
resolveTradingStatusTarget,
|
||||||
|
} from "./trading-bot-status";
|
||||||
|
|
||||||
|
describe("resolveTradingStatusTarget", () => {
|
||||||
|
it("polls the bot directly only when the Vite URL is a bare origin", () => {
|
||||||
|
expect(resolveTradingStatusTarget(undefined)).toEqual({
|
||||||
|
kind: "proxy",
|
||||||
|
url: TRADING_BOT_PROXY_PATH,
|
||||||
|
});
|
||||||
|
expect(resolveTradingStatusTarget("")).toEqual({ kind: "proxy", url: TRADING_BOT_PROXY_PATH });
|
||||||
|
expect(resolveTradingStatusTarget(" http://100.64.0.1:8000/bot/ ")).toEqual({
|
||||||
|
kind: "direct",
|
||||||
|
url: "http://100.64.0.1:8000/bot/api/status",
|
||||||
|
});
|
||||||
|
expect(resolveTradingStatusTarget("file:///tmp/bot")).toEqual({
|
||||||
|
kind: "proxy",
|
||||||
|
url: TRADING_BOT_PROXY_PATH,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("presentTradingStatus", () => {
|
||||||
|
it("shows the API mode and never a stored live preference", () => {
|
||||||
|
expect(
|
||||||
|
presentTradingStatus({
|
||||||
|
state: "ready",
|
||||||
|
status: { mode: "live", kind: "live", adapterCount: 2, lastError: null },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
label: "live",
|
||||||
|
tone: "live",
|
||||||
|
pulse: true,
|
||||||
|
subtitle: { type: "adapters", count: 2 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
presentTradingStatus({
|
||||||
|
state: "ready",
|
||||||
|
status: { mode: "paper", kind: "dry_run", adapterCount: 2, lastError: "adapter timeout" },
|
||||||
|
}).subtitle,
|
||||||
|
).toEqual({ type: "error", text: "adapter timeout" });
|
||||||
|
expect(presentTradingStatus({ state: "offline" }).label).toBe("offline");
|
||||||
|
expect(presentTradingStatus({ state: "unknown" }).label).toBe("unknown");
|
||||||
|
expect(presentTradingStatus({ state: "unconfigured" }).label).toBe("unconfigured");
|
||||||
|
expect(presentTradingStatus({ state: "loading" }).label).toBe("loading");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchTradingBotSnapshot", () => {
|
||||||
|
it("maps a direct live body and reports an unmapped 200 as unknown", async () => {
|
||||||
|
const live = await fetchTradingBotSnapshot(
|
||||||
|
{ kind: "direct", url: "http://bot.test/bot/api/status" },
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => jsonResponse({ mode: "LIVE" }),
|
||||||
|
);
|
||||||
|
expect(live).toMatchObject({ state: "ready", status: { mode: "live", kind: "live" } });
|
||||||
|
|
||||||
|
const unknown = await fetchTradingBotSnapshot(
|
||||||
|
{ kind: "direct", url: "http://bot.test/bot/api/status" },
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => jsonResponse({ status: "ok" }),
|
||||||
|
);
|
||||||
|
expect(unknown).toEqual({ state: "unknown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a failed direct fetch as offline", async () => {
|
||||||
|
const offline = await fetchTradingBotSnapshot(
|
||||||
|
{ kind: "direct", url: "http://bot.test/bot/api/status" },
|
||||||
|
new AbortController().signal,
|
||||||
|
async () => {
|
||||||
|
throw new Error("network");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(offline).toEqual({ state: "offline" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops on an explicit unconfigured proxy response", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => jsonResponse({ configured: false }));
|
||||||
|
const snapshots: string[] = [];
|
||||||
|
await pollTradingBotStatus({
|
||||||
|
target: { kind: "proxy", url: TRADING_BOT_PROXY_PATH },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
fetchImpl,
|
||||||
|
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
|
||||||
|
sleep: async () => false,
|
||||||
|
});
|
||||||
|
expect(snapshots).toEqual(["unconfigured"]);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pollTradingBotStatus", () => {
|
||||||
|
it("follows a live read with offline on failure and backs off", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return jsonResponse({ badge: "LIVE" });
|
||||||
|
throw new Error("down");
|
||||||
|
});
|
||||||
|
const sleeps: number[] = [];
|
||||||
|
let releaseSleep: () => void = () => undefined;
|
||||||
|
const sleep = (ms: number, signal: AbortSignal) => {
|
||||||
|
sleeps.push(ms);
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => resolve(true);
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
releaseSleep = () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const snapshots: string[] = [];
|
||||||
|
const controller = new AbortController();
|
||||||
|
const done = pollTradingBotStatus({
|
||||||
|
target: { kind: "direct", url: "http://bot.test/bot/api/status" },
|
||||||
|
signal: controller.signal,
|
||||||
|
fetchImpl,
|
||||||
|
intervalMs: 20_000,
|
||||||
|
maxBackoffMs: 120_000,
|
||||||
|
sleep,
|
||||||
|
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(snapshots).toEqual(["ready"]));
|
||||||
|
expect(sleeps).toEqual([20_000]);
|
||||||
|
releaseSleep();
|
||||||
|
await vi.waitFor(() => expect(snapshots).toEqual(["ready", "offline"]));
|
||||||
|
expect(sleeps).toEqual([20_000, 20_000]);
|
||||||
|
releaseSleep();
|
||||||
|
await vi.waitFor(() => expect(sleeps).toEqual([20_000, 20_000, 40_000]));
|
||||||
|
controller.abort();
|
||||||
|
await done;
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish offline when the poll is aborted in flight", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const snapshots: string[] = [];
|
||||||
|
const fetchImpl: typeof fetch = (_input, init) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
const abort = () => {
|
||||||
|
const error = new Error("aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
if (init?.signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
init?.signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
});
|
||||||
|
const done = pollTradingBotStatus({
|
||||||
|
target: { kind: "direct", url: "http://bot.test/bot/api/status" },
|
||||||
|
signal: controller.signal,
|
||||||
|
fetchImpl,
|
||||||
|
onSnapshot: (snapshot) => snapshots.push(snapshot.state),
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
await done;
|
||||||
|
expect(snapshots).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
243
apps/web/src/lib/trading-bot-status.ts
Normal file
243
apps/web/src/lib/trading-bot-status.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import type { TradingBotSnapshot, TradingBotStatusView } from "@rakazo/core";
|
||||||
|
import {
|
||||||
|
interpretTradingBotProxyResponse,
|
||||||
|
mapTradingBotStatus,
|
||||||
|
parseTradingBotOrigin,
|
||||||
|
readBoundedJsonResponse,
|
||||||
|
TRADING_BOT_POLL_INTERVAL_MS,
|
||||||
|
TRADING_BOT_POLL_MAX_BACKOFF_MS,
|
||||||
|
TRADING_BOT_PROXY_PATH,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
TRADING_BOT_STATUS_TIMEOUT_MS,
|
||||||
|
tradingBotBackoffMs,
|
||||||
|
tradingBotStatusUrl,
|
||||||
|
} from "@rakazo/core";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type TradingStatusTarget =
|
||||||
|
| { kind: "direct"; url: string }
|
||||||
|
| { kind: "proxy"; url: typeof TRADING_BOT_PROXY_PATH };
|
||||||
|
|
||||||
|
export type TradingBotUiState = TradingBotSnapshot | { state: "loading" };
|
||||||
|
|
||||||
|
export type TradingModeLabelId =
|
||||||
|
| "loading"
|
||||||
|
| "unconfigured"
|
||||||
|
| "offline"
|
||||||
|
| "unknown"
|
||||||
|
| "live"
|
||||||
|
| "paper"
|
||||||
|
| "dry_run";
|
||||||
|
|
||||||
|
export type TradingModeSubtitle =
|
||||||
|
| { type: "adapters"; count: number }
|
||||||
|
| { type: "error"; text: string };
|
||||||
|
|
||||||
|
export type TradingModePresentation = {
|
||||||
|
label: TradingModeLabelId;
|
||||||
|
tone: "live" | "quiet";
|
||||||
|
pulse: boolean;
|
||||||
|
subtitle: TradingModeSubtitle | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchSnapshot = (
|
||||||
|
target: TradingStatusTarget,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
timeoutMs: number,
|
||||||
|
) => Promise<TradingBotSnapshot>;
|
||||||
|
|
||||||
|
/** Direct poll when the Vite URL is set. Otherwise the same-origin API proxy. */
|
||||||
|
export function resolveTradingStatusTarget(viteUrl: string | undefined): TradingStatusTarget {
|
||||||
|
const parsed = parseTradingBotOrigin(viteUrl);
|
||||||
|
if (parsed.kind === "origin") {
|
||||||
|
return { kind: "direct", url: tradingBotStatusUrl(parsed.origin) };
|
||||||
|
}
|
||||||
|
return { kind: "proxy", url: TRADING_BOT_PROXY_PATH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presentTradingStatus(snapshot: TradingBotUiState): TradingModePresentation {
|
||||||
|
switch (snapshot.state) {
|
||||||
|
case "loading":
|
||||||
|
return { label: "loading", tone: "quiet", pulse: false, subtitle: null };
|
||||||
|
case "unconfigured":
|
||||||
|
return { label: "unconfigured", tone: "quiet", pulse: false, subtitle: null };
|
||||||
|
case "offline":
|
||||||
|
return { label: "offline", tone: "quiet", pulse: false, subtitle: null };
|
||||||
|
case "unknown":
|
||||||
|
return { label: "unknown", tone: "quiet", pulse: false, subtitle: null };
|
||||||
|
case "ready":
|
||||||
|
return presentReady(snapshot.status);
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = snapshot;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTradingBotSnapshot(
|
||||||
|
target: TradingStatusTarget,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
timeoutMs = TRADING_BOT_STATUS_TIMEOUT_MS,
|
||||||
|
): Promise<TradingBotSnapshot> {
|
||||||
|
const timeout = linkAbort(signal, timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(target.url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "error",
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: target.kind === "direct" ? "omit" : "include",
|
||||||
|
signal: timeout.signal,
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (target.kind === "direct" && !response.ok) {
|
||||||
|
cancelBody(response);
|
||||||
|
return { state: "offline" };
|
||||||
|
}
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await readBoundedJsonResponse<unknown>(
|
||||||
|
response,
|
||||||
|
TRADING_BOT_STATUS_MAX_BYTES,
|
||||||
|
timeout.signal,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw error;
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
if (target.kind === "proxy") return interpretTradingBotProxyResponse(response.status, body);
|
||||||
|
if (body === null) return { state: "offline" };
|
||||||
|
const status = mapTradingBotStatus(body);
|
||||||
|
return status ? { state: "ready", status } : { state: "unknown" };
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw error;
|
||||||
|
return { state: "offline" };
|
||||||
|
} finally {
|
||||||
|
timeout.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollTradingBotStatus(options: {
|
||||||
|
target: TradingStatusTarget;
|
||||||
|
signal: AbortSignal;
|
||||||
|
onSnapshot: (snapshot: TradingBotSnapshot) => void;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
fetchSnapshot?: FetchSnapshot;
|
||||||
|
intervalMs?: number;
|
||||||
|
maxBackoffMs?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
sleep?: (ms: number, signal: AbortSignal) => Promise<boolean>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const fetchSnapshot = options.fetchSnapshot ?? fetchTradingBotSnapshot;
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const intervalMs = options.intervalMs ?? TRADING_BOT_POLL_INTERVAL_MS;
|
||||||
|
const maxBackoffMs = options.maxBackoffMs ?? TRADING_BOT_POLL_MAX_BACKOFF_MS;
|
||||||
|
const timeoutMs = options.timeoutMs ?? TRADING_BOT_STATUS_TIMEOUT_MS;
|
||||||
|
const sleep = options.sleep ?? delayAbortable;
|
||||||
|
let failures = 0;
|
||||||
|
while (!options.signal.aborted) {
|
||||||
|
let snapshot: TradingBotSnapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await fetchSnapshot(options.target, options.signal, fetchImpl, timeoutMs);
|
||||||
|
} catch {
|
||||||
|
if (options.signal.aborted) return;
|
||||||
|
snapshot = { state: "offline" };
|
||||||
|
}
|
||||||
|
if (options.signal.aborted) return;
|
||||||
|
options.onSnapshot(snapshot);
|
||||||
|
if (snapshot.state === "unconfigured") return;
|
||||||
|
if (snapshot.state === "offline") failures += 1;
|
||||||
|
else failures = 0;
|
||||||
|
const delay =
|
||||||
|
snapshot.state === "offline"
|
||||||
|
? tradingBotBackoffMs(intervalMs, failures, maxBackoffMs)
|
||||||
|
: intervalMs;
|
||||||
|
const aborted = await sleep(delay, options.signal);
|
||||||
|
if (aborted) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTradingBotStatus(): TradingBotUiState {
|
||||||
|
const [snapshot, setSnapshot] = useState<TradingBotUiState>({ state: "loading" });
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const target = resolveTradingStatusTarget(readViteTradingBotUrl());
|
||||||
|
void pollTradingBotStatus({
|
||||||
|
target,
|
||||||
|
signal: controller.signal,
|
||||||
|
onSnapshot: (next) => {
|
||||||
|
if (!controller.signal.aborted) setSnapshot(next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readViteTradingBotUrl(): string | undefined {
|
||||||
|
const value = import.meta.env.VITE_TRADING_BOT_URL;
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentReady(status: TradingBotStatusView): TradingModePresentation {
|
||||||
|
const subtitle: TradingModeSubtitle | null = status.lastError
|
||||||
|
? { type: "error", text: status.lastError }
|
||||||
|
: status.adapterCount === null
|
||||||
|
? null
|
||||||
|
: { type: "adapters", count: status.adapterCount };
|
||||||
|
switch (status.kind) {
|
||||||
|
case "live":
|
||||||
|
return { label: "live", tone: "live", pulse: true, subtitle };
|
||||||
|
case "paper":
|
||||||
|
return { label: "paper", tone: "quiet", pulse: false, subtitle };
|
||||||
|
case "dry_run":
|
||||||
|
return { label: "dry_run", tone: "quiet", pulse: false, subtitle };
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = status.kind;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkAbort(
|
||||||
|
parent: AbortSignal,
|
||||||
|
timeoutMs: number,
|
||||||
|
): { signal: AbortSignal; cancel: () => void } {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const onParent = () => controller.abort();
|
||||||
|
if (parent.aborted) controller.abort();
|
||||||
|
else parent.addEventListener("abort", onParent);
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
cancel: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
parent.removeEventListener("abort", onParent);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function delayAbortable(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||||
|
if (signal.aborted) return Promise.resolve(true);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(false);
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBody(response: Response): void {
|
||||||
|
try {
|
||||||
|
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Best-effort. The snapshot is already offline.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { AvatarStyle } from "@rakazo/contracts";
|
import type { AvatarStyle } from "@rakazo/contracts";
|
||||||
import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
|
import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web";
|
||||||
import { Activity, ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
@ -16,8 +16,10 @@ import { SuccessPop } from "../components/ai/primitives";
|
|||||||
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
|
import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
|
||||||
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
||||||
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
||||||
|
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
||||||
import { authClient } from "../lib/auth";
|
import { authClient } from "../lib/auth";
|
||||||
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
||||||
|
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
||||||
import {
|
import {
|
||||||
type AppearancePreference,
|
type AppearancePreference,
|
||||||
getUiAppearancePreference,
|
getUiAppearancePreference,
|
||||||
@ -33,59 +35,21 @@ export type SettingsGeneralProps = {
|
|||||||
messagingEnabled?: boolean;
|
messagingEnabled?: boolean;
|
||||||
onOpenMessaging?: () => void;
|
onOpenMessaging?: () => void;
|
||||||
isDeploymentOwner?: boolean;
|
isDeploymentOwner?: boolean;
|
||||||
tradingMode?: "live" | "paper";
|
tradingStatus?: TradingBotUiState;
|
||||||
onDiscoverLiveTrading?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LiveTradingBanner({
|
export function LiveTradingBanner({ snapshot }: { snapshot: TradingBotUiState }) {
|
||||||
tradingMode,
|
if (snapshot.state === "loading" || snapshot.state === "unconfigured") return null;
|
||||||
onDiscover,
|
const live = snapshot.state === "ready" && snapshot.status.mode === "live";
|
||||||
}: {
|
|
||||||
tradingMode: "live" | "paper";
|
|
||||||
onDiscover: () => void;
|
|
||||||
}) {
|
|
||||||
const isLive = tradingMode === "live";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`rounded-xl border px-4 py-4 ${
|
className={`rounded-xl border px-2 py-2 ${
|
||||||
isLive ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-card/40"
|
live ? "border-warning/30 bg-warning/5" : "border-border bg-card/40"
|
||||||
}`}
|
}`}
|
||||||
data-testid="live-trading-banner"
|
data-testid="live-trading-banner"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<TradingModeIndicator snapshot={snapshot} />
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`grid size-10 shrink-0 place-items-center rounded-lg ${
|
|
||||||
isLive
|
|
||||||
? "bg-amber-500/10 text-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.2)]"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Activity strokeWidth={1.8} className={`size-5 ${isLive ? "animate-pulse" : ""}`} />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h3 className="text-ui-body font-medium text-foreground">
|
|
||||||
<Trans>Live Trading</Trans>
|
|
||||||
</h3>
|
|
||||||
{isLive ? (
|
|
||||||
<p className="mt-0.5 text-ui-body-sm text-muted-foreground/70">
|
|
||||||
<Trans>Active</Trans>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isLive ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="shrink-0 rounded-full"
|
|
||||||
onClick={onDiscover}
|
|
||||||
>
|
|
||||||
<Trans>Discover now</Trans>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -98,8 +62,7 @@ export function GeneralSettingsPanels({
|
|||||||
messagingEnabled = false,
|
messagingEnabled = false,
|
||||||
onOpenMessaging,
|
onOpenMessaging,
|
||||||
isDeploymentOwner = false,
|
isDeploymentOwner = false,
|
||||||
tradingMode,
|
tradingStatus,
|
||||||
onDiscoverLiveTrading,
|
|
||||||
}: SettingsGeneralProps) {
|
}: SettingsGeneralProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
||||||
@ -135,9 +98,7 @@ export function GeneralSettingsPanels({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{tradingMode && onDiscoverLiveTrading ? (
|
{tradingStatus ? <LiveTradingBanner snapshot={tradingStatus} /> : null}
|
||||||
<LiveTradingBanner tradingMode={tradingMode} onDiscover={onDiscoverLiveTrading} />
|
|
||||||
) : null}
|
|
||||||
<section className="rounded-xl border border-border px-4 py-4">
|
<section className="rounded-xl border border-border px-4 py-4">
|
||||||
<h3 className="text-ui-body font-medium text-foreground">
|
<h3 className="text-ui-body font-medium text-foreground">
|
||||||
<Trans>Account</Trans>
|
<Trans>Account</Trans>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentType, useEffect, useRef, useState } from "react";
|
import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||||
|
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
||||||
import {
|
import {
|
||||||
ComputerSettingsPanel,
|
ComputerSettingsPanel,
|
||||||
GeneralSettingsPanels,
|
GeneralSettingsPanels,
|
||||||
@ -58,8 +59,7 @@ export function SettingsOverlay({
|
|||||||
onVoiceStatusMaybeChanged,
|
onVoiceStatusMaybeChanged,
|
||||||
activeBotId,
|
activeBotId,
|
||||||
onOpenMcp,
|
onOpenMcp,
|
||||||
tradingMode,
|
tradingStatus,
|
||||||
onDiscoverLiveTrading,
|
|
||||||
}: {
|
}: {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@ -77,8 +77,7 @@ export function SettingsOverlay({
|
|||||||
onVoiceStatusMaybeChanged?: () => void | Promise<void>;
|
onVoiceStatusMaybeChanged?: () => void | Promise<void>;
|
||||||
activeBotId?: string;
|
activeBotId?: string;
|
||||||
onOpenMcp?: () => void;
|
onOpenMcp?: () => void;
|
||||||
tradingMode?: "live" | "paper";
|
tradingStatus?: TradingBotUiState;
|
||||||
onDiscoverLiveTrading?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@ -231,8 +230,7 @@ export function SettingsOverlay({
|
|||||||
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
|
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
|
||||||
}
|
}
|
||||||
isDeploymentOwner={isDeploymentOwner}
|
isDeploymentOwner={isDeploymentOwner}
|
||||||
tradingMode={tradingMode}
|
tradingStatus={tradingStatus}
|
||||||
onDiscoverLiveTrading={onDiscoverLiveTrading}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{section === "integrations" ? (
|
{section === "integrations" ? (
|
||||||
|
|||||||
@ -45,7 +45,9 @@ import {
|
|||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isToolActivityBlock,
|
isToolActivityBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
|
matchVoiceByName,
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
|
parseVoiceChatCommand,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
reorderBotTo,
|
reorderBotTo,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
@ -57,8 +59,6 @@ import {
|
|||||||
serializeComposerPrompt,
|
serializeComposerPrompt,
|
||||||
speechFromBlocks,
|
speechFromBlocks,
|
||||||
truncateSlashDescription,
|
truncateSlashDescription,
|
||||||
matchVoiceByName,
|
|
||||||
parseVoiceChatCommand,
|
|
||||||
userVisibleMessages,
|
userVisibleMessages,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
@ -83,7 +83,6 @@ import {
|
|||||||
Wordmark,
|
Wordmark,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@ -152,6 +151,7 @@ import {
|
|||||||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||||||
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
||||||
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
||||||
|
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
||||||
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
||||||
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
|
import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay";
|
||||||
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
|
import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay";
|
||||||
@ -172,9 +172,9 @@ import {
|
|||||||
requestBrowserNotificationPermission,
|
requestBrowserNotificationPermission,
|
||||||
shouldNotifyBrowser,
|
shouldNotifyBrowser,
|
||||||
} from "../lib/browser-notifications";
|
} from "../lib/browser-notifications";
|
||||||
|
import { browserPreviewActivity } from "../lib/browser-preview-context";
|
||||||
import { loadComputerScreen } from "../lib/computer-screen";
|
import { loadComputerScreen } from "../lib/computer-screen";
|
||||||
import { desktopBridge } from "../lib/desktop";
|
import { desktopBridge } from "../lib/desktop";
|
||||||
import { browserPreviewActivity } from "../lib/browser-preview-context";
|
|
||||||
import { scheduleFocusPrompt } from "../lib/focus-prompt";
|
import { scheduleFocusPrompt } from "../lib/focus-prompt";
|
||||||
import {
|
import {
|
||||||
isWorkingRunStatus,
|
isWorkingRunStatus,
|
||||||
@ -216,6 +216,7 @@ import {
|
|||||||
threadRunError,
|
threadRunError,
|
||||||
userHoldsComputerControl,
|
userHoldsComputerControl,
|
||||||
} from "../lib/thread-events";
|
} from "../lib/thread-events";
|
||||||
|
import { useTradingBotStatus } from "../lib/trading-bot-status";
|
||||||
import {
|
import {
|
||||||
transcriptCanSnapAfterFrame,
|
transcriptCanSnapAfterFrame,
|
||||||
transcriptIsNearEnd,
|
transcriptIsNearEnd,
|
||||||
@ -366,23 +367,7 @@ export function ShellPage() {
|
|||||||
if (botId) return "team";
|
if (botId) return "team";
|
||||||
return "heute";
|
return "heute";
|
||||||
});
|
});
|
||||||
const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => {
|
const tradingStatus = useTradingBotStatus();
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
|
||||||
const saved = window.localStorage.getItem("evoo_trading_mode");
|
|
||||||
if (saved === "paper" || saved === "live") return saved;
|
|
||||||
}
|
|
||||||
return "live";
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleTradingMode = useCallback(() => {
|
|
||||||
setTradingMode((prev) => {
|
|
||||||
const next = prev === "live" ? "paper" : "live";
|
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
|
||||||
window.localStorage.setItem("evoo_trading_mode", next);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (botId) setActiveNavPillar("team");
|
if (botId) setActiveNavPillar("team");
|
||||||
@ -567,15 +552,6 @@ export function ShellPage() {
|
|||||||
const [mcpOpen, setMcpOpen] = useState(false);
|
const [mcpOpen, setMcpOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const discoverLiveTrading = useCallback(() => {
|
|
||||||
setTradingMode("live");
|
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
|
||||||
window.localStorage.setItem("evoo_trading_mode", "live");
|
|
||||||
}
|
|
||||||
setActiveNavPillar("heute");
|
|
||||||
navigate("/app");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
|
||||||
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
|
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
|
||||||
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
|
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
|
||||||
@ -1775,7 +1751,7 @@ export function ShellPage() {
|
|||||||
abort.abort();
|
abort.abort();
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [showSpaceSearch, Calendar, Activity, Settings, spaceQuery]);
|
}, [showSpaceSearch, Calendar, Settings, spaceQuery]);
|
||||||
|
|
||||||
async function jumpToSearchHit(hit: SearchHit) {
|
async function jumpToSearchHit(hit: SearchHit) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@ -3910,27 +3886,7 @@ export function ShellPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<TradingModeIndicator snapshot={tradingStatus} className="mx-3 mb-1 lg:mx-5 2xl:mx-7" />
|
||||||
type="button"
|
|
||||||
onClick={toggleTradingMode}
|
|
||||||
className="mx-3 lg:mx-5 2xl:mx-7 mb-1 flex items-center gap-3 rounded-xl px-2.5 py-2 hover:bg-sidebar-accent group"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`grid h-[30px] w-[30px] place-items-center rounded-lg transition-colors ${
|
|
||||||
tradingMode === "live"
|
|
||||||
? "bg-amber-500/10 text-amber-500 shadow-[0_0_12px_rgba(245,158,11,0.2)]"
|
|
||||||
: "bg-indigo-500/10 text-indigo-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Activity
|
|
||||||
strokeWidth={1.8}
|
|
||||||
className={`size-icon-md ${tradingMode === "live" ? "animate-pulse" : ""}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-ui-body font-medium text-foreground/90">
|
|
||||||
{tradingMode === "live" ? "Live Trading" : "Paper Sim"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
data-testid="user-menu-trigger"
|
data-testid="user-menu-trigger"
|
||||||
@ -5226,8 +5182,7 @@ export function ShellPage() {
|
|||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setMessagingSettingsOpen(true);
|
setMessagingSettingsOpen(true);
|
||||||
}}
|
}}
|
||||||
tradingMode={tradingMode}
|
tradingStatus={tradingStatus}
|
||||||
onDiscoverLiveTrading={discoverLiveTrading}
|
|
||||||
onAvatarStyleChange={async (avatarStyle) => {
|
onAvatarStyleChange={async (avatarStyle) => {
|
||||||
const nextMe = await rpc.preferences.update({ avatarStyle });
|
const nextMe = await rpc.preferences.update({ avatarStyle });
|
||||||
setBootstrapMe(nextMe);
|
setBootstrapMe(nextMe);
|
||||||
|
|||||||
2
apps/web/src/vite-env.d.ts
vendored
2
apps/web/src/vite-env.d.ts
vendored
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_DEFAULT_UI_LOCALE?: string;
|
readonly VITE_DEFAULT_UI_LOCALE?: string;
|
||||||
|
/** Optional Tradingbot origin. Empty uses the same-origin API proxy. */
|
||||||
|
readonly VITE_TRADING_BOT_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -45,5 +45,6 @@ export * from "./text-direction.js";
|
|||||||
export * from "./thread-message-updates.js";
|
export * from "./thread-message-updates.js";
|
||||||
export * from "./thread-subscription.js";
|
export * from "./thread-subscription.js";
|
||||||
export * from "./tool-activity.js";
|
export * from "./tool-activity.js";
|
||||||
|
export * from "./trading-bot-status.js";
|
||||||
export * from "./voice-chat-command.js";
|
export * from "./voice-chat-command.js";
|
||||||
export * from "./voice-labels.js";
|
export * from "./voice-labels.js";
|
||||||
|
|||||||
141
packages/core/src/trading-bot-status.test.ts
Normal file
141
packages/core/src/trading-bot-status.test.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
coerceTradingBotStatusView,
|
||||||
|
interpretTradingBotProxyResponse,
|
||||||
|
mapTradingBotStatus,
|
||||||
|
parseTradingBotOrigin,
|
||||||
|
resolveTradingBotOrigin,
|
||||||
|
sanitizeTradingBotError,
|
||||||
|
tradingBotBackoffMs,
|
||||||
|
tradingBotStatusUrl,
|
||||||
|
} from "./trading-bot-status.js";
|
||||||
|
|
||||||
|
describe("parseTradingBotOrigin", () => {
|
||||||
|
it("treats an empty value as unset and keeps the origin without a path", () => {
|
||||||
|
expect(parseTradingBotOrigin(undefined)).toEqual({ kind: "unset" });
|
||||||
|
expect(parseTradingBotOrigin(" ")).toEqual({ kind: "unset" });
|
||||||
|
expect(parseTradingBotOrigin(" http://127.0.0.1:8000/bot/ ")).toEqual({
|
||||||
|
kind: "origin",
|
||||||
|
origin: "http://127.0.0.1:8000",
|
||||||
|
});
|
||||||
|
expect(tradingBotStatusUrl("http://127.0.0.1:8000")).toBe(
|
||||||
|
"http://127.0.0.1:8000/bot/api/status",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects credentials and non-http schemes without echoing the value", () => {
|
||||||
|
expect(parseTradingBotOrigin("http://user:secret@127.0.0.1:8000").kind).toBe("invalid");
|
||||||
|
expect(parseTradingBotOrigin("file:///tmp/status").kind).toBe("invalid");
|
||||||
|
expect(parseTradingBotOrigin("not a url").kind).toBe("invalid");
|
||||||
|
expect(resolveTradingBotOrigin("")).toBeUndefined();
|
||||||
|
expect(() => resolveTradingBotOrigin("javascript:alert(1)")).toThrow(
|
||||||
|
/^Invalid TRADING_BOT_URL$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapTradingBotStatus", () => {
|
||||||
|
it("maps live, paper, and dry-run badges without defaulting to live", () => {
|
||||||
|
expect(mapTradingBotStatus({ mode: "LIVE" })).toMatchObject({ mode: "live", kind: "live" });
|
||||||
|
expect(mapTradingBotStatus({ badge: "DRY RUN" })).toMatchObject({
|
||||||
|
mode: "paper",
|
||||||
|
kind: "dry_run",
|
||||||
|
});
|
||||||
|
expect(mapTradingBotStatus({ mode: "Paper Sim" })).toMatchObject({
|
||||||
|
mode: "paper",
|
||||||
|
kind: "paper",
|
||||||
|
});
|
||||||
|
expect(mapTradingBotStatus({ dry_run: true })).toMatchObject({ kind: "dry_run" });
|
||||||
|
expect(mapTradingBotStatus({ live: true })).toMatchObject({ kind: "live" });
|
||||||
|
expect(mapTradingBotStatus({ status: "ok" })).toBeNull();
|
||||||
|
expect(mapTradingBotStatus({})).toBeNull();
|
||||||
|
expect(mapTradingBotStatus(null)).toBeNull();
|
||||||
|
expect(mapTradingBotStatus({ live: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a body that claims live and dry-run together", () => {
|
||||||
|
expect(mapTradingBotStatus({ mode: "live", dry_run: true })).toBeNull();
|
||||||
|
expect(mapTradingBotStatus({ live: true, paper: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a nested mode and safe subtitle fields", () => {
|
||||||
|
expect(
|
||||||
|
mapTradingBotStatus({
|
||||||
|
data: { mode: "DRY RUN", adapters: ["a", "b", "c"], last_error: "adapter timeout" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
mode: "paper",
|
||||||
|
kind: "dry_run",
|
||||||
|
adapterCount: 3,
|
||||||
|
lastError: "adapter timeout",
|
||||||
|
});
|
||||||
|
expect(mapTradingBotStatus({ mode: "paper", adapter_count: "4" })?.adapterCount).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops secret-like errors and still reports the mode", () => {
|
||||||
|
expect(
|
||||||
|
mapTradingBotStatus({ mode: "paper", last_error: "Bearer sk-test-secret" })?.lastError,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
mapTradingBotStatus({ mode: "live", error: "failed http://10.0.0.8/status" })?.lastError,
|
||||||
|
).toBeNull();
|
||||||
|
expect(sanitizeTradingBotError("ok")).toBeNull();
|
||||||
|
const long = "x".repeat(120);
|
||||||
|
expect(sanitizeTradingBotError(long)?.endsWith("…")).toBe(true);
|
||||||
|
expect(sanitizeTradingBotError(long)?.length).toBe(80);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("interpretTradingBotProxyResponse", () => {
|
||||||
|
it("keeps unconfigured, offline, unknown, and a live read distinct", () => {
|
||||||
|
expect(interpretTradingBotProxyResponse(200, { configured: false })).toEqual({
|
||||||
|
state: "unconfigured",
|
||||||
|
});
|
||||||
|
expect(interpretTradingBotProxyResponse(502, { configured: true, reachable: false })).toEqual({
|
||||||
|
state: "offline",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
interpretTradingBotProxyResponse(200, { configured: true, reachable: true, status: null }),
|
||||||
|
).toEqual({ state: "unknown" });
|
||||||
|
expect(interpretTradingBotProxyResponse(401, { error: "Unauthorized" })).toEqual({
|
||||||
|
state: "offline",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
interpretTradingBotProxyResponse(200, {
|
||||||
|
configured: true,
|
||||||
|
reachable: true,
|
||||||
|
status: { mode: "live", kind: "live", adapterCount: 1, lastError: null },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
state: "ready",
|
||||||
|
status: { mode: "live", kind: "live", adapterCount: 1, lastError: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips an unsafe error from an otherwise valid proxy status", () => {
|
||||||
|
const snapshot = interpretTradingBotProxyResponse(200, {
|
||||||
|
configured: true,
|
||||||
|
reachable: true,
|
||||||
|
status: {
|
||||||
|
mode: "paper",
|
||||||
|
kind: "dry_run",
|
||||||
|
adapterCount: null,
|
||||||
|
lastError: "token=super-secret",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(snapshot).toEqual({
|
||||||
|
state: "ready",
|
||||||
|
status: { mode: "paper", kind: "dry_run", adapterCount: null, lastError: null },
|
||||||
|
});
|
||||||
|
expect(coerceTradingBotStatusView({ mode: "live", kind: "paper" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tradingBotBackoffMs", () => {
|
||||||
|
it("doubles the interval and then caps it", () => {
|
||||||
|
expect(tradingBotBackoffMs(20_000, 1, 120_000)).toBe(20_000);
|
||||||
|
expect(tradingBotBackoffMs(20_000, 2, 120_000)).toBe(40_000);
|
||||||
|
expect(tradingBotBackoffMs(20_000, 3, 120_000)).toBe(80_000);
|
||||||
|
expect(tradingBotBackoffMs(20_000, 4, 120_000)).toBe(120_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
297
packages/core/src/trading-bot-status.ts
Normal file
297
packages/core/src/trading-bot-status.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
/** Shared Tradingbot status contract. The browser and the API proxy both use this. */
|
||||||
|
|
||||||
|
export const TRADING_BOT_STATUS_PATH = "/bot/api/status";
|
||||||
|
export const TRADING_BOT_PROXY_PATH = "/api/trading-bot/status";
|
||||||
|
export const TRADING_BOT_POLL_INTERVAL_MS = 20_000;
|
||||||
|
export const TRADING_BOT_POLL_MAX_BACKOFF_MS = 120_000;
|
||||||
|
export const TRADING_BOT_STATUS_TIMEOUT_MS = 5_000;
|
||||||
|
export const TRADING_BOT_STATUS_MAX_BYTES = 65_536;
|
||||||
|
|
||||||
|
const MAX_ORIGIN_LENGTH = 200;
|
||||||
|
const MAX_ERROR_LENGTH = 80;
|
||||||
|
const MAX_ADAPTER_COUNT = 10_000;
|
||||||
|
|
||||||
|
const LIVE_TOKENS = new Set(["live", "live_trading", "real", "production"]);
|
||||||
|
const PAPER_TOKENS = new Set(["paper", "paper_sim", "paper_trading", "simulation", "sim"]);
|
||||||
|
const DRY_RUN_TOKENS = new Set(["dry_run", "dryrun", "dry"]);
|
||||||
|
|
||||||
|
const MODE_STRING_KEYS = [
|
||||||
|
"mode",
|
||||||
|
"trading_mode",
|
||||||
|
"tradingMode",
|
||||||
|
"run_mode",
|
||||||
|
"runMode",
|
||||||
|
"execution_mode",
|
||||||
|
"executionMode",
|
||||||
|
"badge",
|
||||||
|
"status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NESTED_KEYS = ["data", "bot", "state", "result", "status"] as const;
|
||||||
|
|
||||||
|
const ADAPTER_COUNT_KEYS = [
|
||||||
|
"adapterCount",
|
||||||
|
"adapter_count",
|
||||||
|
"adaptersCount",
|
||||||
|
"adapters_count",
|
||||||
|
"n_adapters",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ERROR_KEYS = [
|
||||||
|
"lastError",
|
||||||
|
"last_error",
|
||||||
|
"last_error_message",
|
||||||
|
"errorMessage",
|
||||||
|
"error",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const EMPTY_ERRORS = new Set(["ok", "none", "null", "undefined", "n/a", "-"]);
|
||||||
|
|
||||||
|
const UNSAFE_ERROR = /bearer\s|api[_-]?key|password|secret|token|authorization|cookie|https?:\/\//i;
|
||||||
|
|
||||||
|
export type TradingBotKind = "live" | "paper" | "dry_run";
|
||||||
|
|
||||||
|
export type TradingBotStatusView = {
|
||||||
|
mode: "live" | "paper";
|
||||||
|
kind: TradingBotKind;
|
||||||
|
adapterCount: number | null;
|
||||||
|
lastError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TradingBotOrigin =
|
||||||
|
| { kind: "unset" }
|
||||||
|
| { kind: "invalid" }
|
||||||
|
| { kind: "origin"; origin: string };
|
||||||
|
|
||||||
|
export type TradingBotSnapshot =
|
||||||
|
| { state: "unconfigured" }
|
||||||
|
| { state: "offline" }
|
||||||
|
| { state: "unknown" }
|
||||||
|
| { state: "ready"; status: TradingBotStatusView };
|
||||||
|
|
||||||
|
/** Accept a bare http(s) origin. Credentials and other schemes are rejected. */
|
||||||
|
export function parseTradingBotOrigin(value: string | undefined): TradingBotOrigin {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
if (!trimmed) return { kind: "unset" };
|
||||||
|
if (trimmed.length > MAX_ORIGIN_LENGTH || /\s/.test(trimmed) || hasControlChar(trimmed)) {
|
||||||
|
return { kind: "invalid" };
|
||||||
|
}
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { kind: "invalid" };
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") return { kind: "invalid" };
|
||||||
|
if (url.username || url.password || !url.hostname) return { kind: "invalid" };
|
||||||
|
return { kind: "origin", origin: url.origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throw when the operator set a value that must not be fetched. Unset stays unset. */
|
||||||
|
export function resolveTradingBotOrigin(value: string | undefined): string | undefined {
|
||||||
|
const parsed = parseTradingBotOrigin(value);
|
||||||
|
if (parsed.kind === "invalid") throw new Error("Invalid TRADING_BOT_URL");
|
||||||
|
return parsed.kind === "origin" ? parsed.origin : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tradingBotStatusUrl(origin: string): string {
|
||||||
|
return `${origin}${TRADING_BOT_STATUS_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tradingBotBackoffMs(
|
||||||
|
intervalMs: number,
|
||||||
|
failureCount: number,
|
||||||
|
maxBackoffMs: number,
|
||||||
|
): number {
|
||||||
|
const exponent = Math.min(Math.max(failureCount - 1, 0), 6);
|
||||||
|
return Math.min(intervalMs * 2 ** exponent, maxBackoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a Tradingbot `/bot/api/status` body. Unknown or conflicting modes return null. */
|
||||||
|
export function mapTradingBotStatus(body: unknown): TradingBotStatusView | null {
|
||||||
|
const record = asRecord(body);
|
||||||
|
if (!record) return null;
|
||||||
|
const kind = resolveKind(readSignals(record));
|
||||||
|
if (!kind) return null;
|
||||||
|
return {
|
||||||
|
mode: kind === "live" ? "live" : "paper",
|
||||||
|
kind,
|
||||||
|
adapterCount: readAdapterCount(record),
|
||||||
|
lastError: readLastError(record),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept a proxy payload field. Drops unsafe error text instead of dropping the mode. */
|
||||||
|
export function coerceTradingBotStatusView(value: unknown): TradingBotStatusView | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return null;
|
||||||
|
const { mode, kind } = record;
|
||||||
|
if (mode !== "live" && mode !== "paper") return null;
|
||||||
|
if (kind !== "live" && kind !== "paper" && kind !== "dry_run") return null;
|
||||||
|
if ((mode === "live") !== (kind === "live")) return null;
|
||||||
|
if (record.adapterCount !== null && parseCount(record.adapterCount) === null) return null;
|
||||||
|
if (record.lastError !== null && typeof record.lastError !== "string") return null;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
kind,
|
||||||
|
adapterCount: record.adapterCount === null ? null : parseCount(record.adapterCount),
|
||||||
|
lastError: record.lastError === null ? null : sanitizeTradingBotError(record.lastError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpretTradingBotProxyResponse(
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
): TradingBotSnapshot {
|
||||||
|
const record = asRecord(body);
|
||||||
|
if (status === 200 && record?.configured === false) return { state: "unconfigured" };
|
||||||
|
if (record?.configured === true && record.reachable === false) return { state: "offline" };
|
||||||
|
if (status === 200 && record?.configured === true && record.reachable === true) {
|
||||||
|
if (record.status == null) return { state: "unknown" };
|
||||||
|
const view = coerceTradingBotStatusView(record.status);
|
||||||
|
return view ? { state: "ready", status: view } : { state: "unknown" };
|
||||||
|
}
|
||||||
|
return { state: "offline" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep a short operator-facing error. Drop secrets, URLs, and empty placeholders. */
|
||||||
|
export function sanitizeTradingBotError(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const cleaned = stripControlChars(value).replace(/\s+/g, " ").trim();
|
||||||
|
if (!cleaned || EMPTY_ERRORS.has(cleaned.toLowerCase())) return null;
|
||||||
|
if (UNSAFE_ERROR.test(cleaned)) return null;
|
||||||
|
if (cleaned.length <= MAX_ERROR_LENGTH) return cleaned;
|
||||||
|
return `${cleaned.slice(0, MAX_ERROR_LENGTH - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSignals(record: Record<string, unknown>): TradingBotKind[] {
|
||||||
|
const signals: TradingBotKind[] = [];
|
||||||
|
pushSignals(record, signals);
|
||||||
|
if (signals.length > 0) return signals;
|
||||||
|
for (const key of NESTED_KEYS) {
|
||||||
|
const nested = asRecord(record[key]);
|
||||||
|
if (nested) pushSignals(nested, signals);
|
||||||
|
}
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSignals(record: Record<string, unknown>, signals: TradingBotKind[]): void {
|
||||||
|
for (const key of MODE_STRING_KEYS) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string") continue;
|
||||||
|
const kind = classifyModeToken(value);
|
||||||
|
if (kind) signals.push(kind);
|
||||||
|
}
|
||||||
|
if (record.live === true || record.is_live === true || record.isLive === true) {
|
||||||
|
signals.push("live");
|
||||||
|
}
|
||||||
|
if (record.dry_run === true || record.dryRun === true) signals.push("dry_run");
|
||||||
|
if (
|
||||||
|
record.paper === true ||
|
||||||
|
record.is_paper === true ||
|
||||||
|
record.isPaper === true ||
|
||||||
|
record.paper_trading === true ||
|
||||||
|
record.paperTrading === true
|
||||||
|
) {
|
||||||
|
signals.push("paper");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKind(signals: TradingBotKind[]): TradingBotKind | null {
|
||||||
|
const unique = new Set(signals);
|
||||||
|
const live = unique.has("live");
|
||||||
|
const paper = unique.has("paper");
|
||||||
|
const dryRun = unique.has("dry_run");
|
||||||
|
if (live && (paper || dryRun)) return null;
|
||||||
|
if (dryRun) return "dry_run";
|
||||||
|
if (paper) return "paper";
|
||||||
|
if (live) return "live";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyModeToken(value: string): TradingBotKind | null {
|
||||||
|
const token = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s-]+/g, "_");
|
||||||
|
if (LIVE_TOKENS.has(token)) return "live";
|
||||||
|
if (DRY_RUN_TOKENS.has(token)) return "dry_run";
|
||||||
|
if (PAPER_TOKENS.has(token)) return "paper";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdapterCount(record: Record<string, unknown>): number | null {
|
||||||
|
const found = readOptional(record, adapterCountIn);
|
||||||
|
return found === undefined ? null : found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterCountIn(record: Record<string, unknown>): number | null | undefined {
|
||||||
|
for (const key of ADAPTER_COUNT_KEYS) {
|
||||||
|
if (!Object.hasOwn(record, key)) continue;
|
||||||
|
return parseCount(record[key]);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(record, "adapters") && Array.isArray(record.adapters)) {
|
||||||
|
return parseCount(record.adapters.length);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastError(record: Record<string, unknown>): string | null {
|
||||||
|
const found = readOptional(record, lastErrorIn);
|
||||||
|
return found === undefined ? null : found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastErrorIn(record: Record<string, unknown>): string | null | undefined {
|
||||||
|
for (const key of ERROR_KEYS) {
|
||||||
|
if (!Object.hasOwn(record, key)) continue;
|
||||||
|
return sanitizeTradingBotError(record[key]);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptional<T>(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
read: (row: Record<string, unknown>) => T | undefined,
|
||||||
|
): T | undefined {
|
||||||
|
const direct = read(record);
|
||||||
|
if (direct !== undefined) return direct;
|
||||||
|
for (const key of NESTED_KEYS) {
|
||||||
|
const nested = asRecord(record[key]);
|
||||||
|
if (!nested) continue;
|
||||||
|
const value = read(nested);
|
||||||
|
if (value !== undefined) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCount(value: unknown): number | null {
|
||||||
|
if (typeof value === "string" && !/^\d+$/.test(value.trim())) return null;
|
||||||
|
const numeric =
|
||||||
|
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
||||||
|
if (!Number.isInteger(numeric) || numeric < 0 || numeric > MAX_ADAPTER_COUNT) return null;
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasControlChar(value: string): boolean {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
if (code < 32 || code === 127) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripControlChars(value: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
const char = value[index];
|
||||||
|
if (char === undefined) continue;
|
||||||
|
out += code < 32 || code === 127 ? " " : char;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user