Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const crossOriginError = captureError(
assert.match(crossOriginError.message, /cross-origin request rejected/);
assert.equal(crossOriginError.status, 403);

assert.equal(
await __test.withTimeout(new Promise((resolve) => setTimeout(() => resolve("late"), 25)), 1, "fallback"),
"fallback",
);

function captureError(fn) {
try {
fn();
Expand Down
21 changes: 16 additions & 5 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -746,9 +746,11 @@ <h2 data-i18n="summary">切换摘要</h2>
summary: "当前配置状态",
copySummary: "复制状态",
loginToRun: "登录后切换",
loadingConfig: "读取配置中",
configureAccounts: "配置账号后切换",
runSwitch: "一键切换",
readonlyNote: "登录后才可执行切换。",
loadingConfigNote: "正在读取账号配置和当前状态。",
missingConfigNote: "账号配置未加载,暂时不能执行。",
readyNote: "点击后会触发 workflow,并同步目标平台服务。",
invalidStrategyNote: "当前账号没有可执行策略,暂时不能切换。",
Expand Down Expand Up @@ -797,9 +799,11 @@ <h2 data-i18n="summary">切换摘要</h2>
summary: "Current Config",
copySummary: "Copy state",
loginToRun: "Sign in to switch",
loadingConfig: "Loading config",
configureAccounts: "Configure accounts",
runSwitch: "Switch now",
readonlyNote: "Sign in to switch.",
loadingConfigNote: "Reading account config and current state.",
missingConfigNote: "Account config is not loaded, so switching is disabled.",
readyNote: "This dispatches the workflow and syncs the target platform service.",
invalidStrategyNote: "This account has no runnable strategy, so switching is disabled.",
Expand Down Expand Up @@ -1108,7 +1112,9 @@ <h2 data-i18n="summary">切换摘要</h2>
const currentProfile = currentStrategyForAccount(state.selected, account);
const currentMode = normalizeExecutionMode(currentEntry?.execution_mode, currentEntry?.dry_run_only);
const source = currentEntry?.source
|| (state.configSource === "private" ? t("accountConfigLoaded") : t("publicPreview"));
|| (state.configSource === "loading"
? t("loadingConfig")
: (state.configSource === "private" ? t("accountConfigLoaded") : t("publicPreview")));
return [
[t("repository"), repositories[state.selected]],
[t("selectedAccount"), account.label],
Expand Down Expand Up @@ -1242,16 +1248,19 @@ <h2 data-i18n="summary">切换摘要</h2>

const dispatch = el("dispatch-button");
const hasPrivateAccounts = state.configSource === "private";
const loadingConfig = state.configSource === "loading";
const hasValidStrategy = hasValidStrategySelection();
dispatch.disabled = !state.auth.allowed || !hasPrivateAccounts || !hasValidStrategy;
dispatch.disabled = !state.auth.allowed || loadingConfig || !hasPrivateAccounts || !hasValidStrategy;
dispatch.textContent = state.auth.allowed
? (hasPrivateAccounts ? t("runSwitch") : t("configureAccounts"))
? (loadingConfig ? t("loadingConfig") : (hasPrivateAccounts ? t("runSwitch") : t("configureAccounts")))
: t("loginToRun");
const note = el("action-note");
note.textContent = state.auth.allowed
? (hasPrivateAccounts ? (hasValidStrategy ? t("readyNote") : t("invalidStrategyNote")) : t("missingConfigNote"))
? (loadingConfig
? t("loadingConfigNote")
: (hasPrivateAccounts ? (hasValidStrategy ? t("readyNote") : t("invalidStrategyNote")) : t("missingConfigNote")))
: t("readonlyNote");
note.classList.toggle("warning", state.auth.allowed && (!hasPrivateAccounts || !hasValidStrategy));
note.classList.toggle("warning", state.auth.allowed && !loadingConfig && (!hasPrivateAccounts || !hasValidStrategy));
}

function render() {
Expand Down Expand Up @@ -1296,6 +1305,8 @@ <h2 data-i18n="summary">切换摘要</h2>

async function refreshConfig() {
if (!state.auth.available || !state.auth.allowed) return;
state.configSource = "loading";
render();
Comment on lines +1308 to +1309

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore render after config load failures

When /api/config fails, returns non-OK, or the session expires and returns no accountOptions, this new loading state is rendered before the request but the failure/no-config paths only set state.configSource = "default" without calling render(). The UI therefore stays stuck on “Loading config” with switching disabled until the page is reloaded; re-render after the fallback state is applied.

Useful? React with 👍 / 👎.

try {
const response = await fetch("/api/config", { cache: "no-store" });
if (!response.ok) throw new Error("no config");
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const ACCOUNT_OPTIONS_KEY = "account_options";
const STRATEGY_PROFILES_KEY = "strategy_profiles";
const AUDIT_LOG_KEY = "audit_log";
const AUDIT_LOG_LIMIT = 50;
const CURRENT_STRATEGIES_TIMEOUT_MS = 3500;

const SUPPORTED_PLATFORMS = ["longbridge", "ibkr", "schwab", "firstrade"];
const SUPPORTED_STRATEGY_DOMAINS = ["us_equity", "hk_equity"];
Expand Down Expand Up @@ -452,11 +453,10 @@ async function configPayload(request, env) {
if (!session?.allowed) return { accountOptions: null };
const accountConfig = await loadAccountOptionsConfig(env);
const strategyProfiles = await loadStrategyProfilesConfig(env);
const currentStrategies = await loadCurrentStrategies(accountConfig.options, env);
return {
accountOptions: accountConfig.options,
strategyProfiles,
currentStrategies,
currentStrategies: await loadCurrentStrategiesSafely(accountConfig.options, env),
};
}

Expand Down Expand Up @@ -503,6 +503,26 @@ async function loadCurrentStrategies(accountOptions, env) {
return currentStrategies;
}

async function loadCurrentStrategiesSafely(accountOptions, env) {
try {
return await withTimeout(
loadCurrentStrategies(accountOptions, env),
CURRENT_STRATEGIES_TIMEOUT_MS,
{},
);
} catch {
return {};
}
}

function withTimeout(promise, timeoutMs, fallback) {
let timeoutId;
const timeout = new Promise((resolve) => {
timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
}

async function resolveCurrentStrategyForAccount({ platform, option, optionsCount, repository, readVariable }) {
const serviceTargetsValue = await readVariable(repository, "repository", "", "CLOUD_RUN_SERVICE_TARGETS_JSON");
const serviceTarget = runtimeTargetFromServiceTargets(serviceTargetsValue, platform, option);
Expand Down Expand Up @@ -1488,4 +1508,5 @@ export const __test = {
requireSameOrigin,
responseHeaders,
supportedDomainsForAccount,
withTimeout,
};