feat: gate admin ui with optional access key
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later"
|
||||||
],
|
],
|
||||||
|
"management_access_key": "",
|
||||||
"non_stream_status_code": 502,
|
"non_stream_status_code": 502,
|
||||||
"stream_action": "strict_502",
|
"stream_action": "strict_502",
|
||||||
"log_match": true,
|
"log_match": true,
|
||||||
|
|||||||
+226
-2
@@ -26,6 +26,7 @@ const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`;
|
|||||||
const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`;
|
const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`;
|
||||||
const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
|
const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
|
||||||
const STATUS_REASONING_COUNT_LIMIT = 24;
|
const STATUS_REASONING_COUNT_LIMIT = 24;
|
||||||
|
const MANAGEMENT_ACCESS_COOKIE = "codex_retry_gateway_access";
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
profile_name: "default",
|
profile_name: "default",
|
||||||
@@ -47,6 +48,7 @@ const DEFAULT_CONFIG = {
|
|||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||||
],
|
],
|
||||||
|
management_access_key: "",
|
||||||
upstream_fetch_retry_attempts: 5,
|
upstream_fetch_retry_attempts: 5,
|
||||||
upstream_fetch_retry_backoff_ms: 350,
|
upstream_fetch_retry_backoff_ms: 350,
|
||||||
non_stream_status_code: 502,
|
non_stream_status_code: 502,
|
||||||
@@ -216,6 +218,105 @@ function firstNonEmptyString(...values) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timingSafeEquals(left, right) {
|
||||||
|
const leftText = Buffer.from(`${left || ""}`);
|
||||||
|
const rightText = Buffer.from(`${right || ""}`);
|
||||||
|
if (leftText.length !== rightText.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mismatch = 0;
|
||||||
|
for (let i = 0; i < leftText.length; i += 1) {
|
||||||
|
mismatch |= leftText[i] ^ rightText[i];
|
||||||
|
}
|
||||||
|
return mismatch === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieHeader(cookieHeader) {
|
||||||
|
const cookies = {};
|
||||||
|
for (const part of `${cookieHeader || ""}`.split(";")) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separatorIndex = trimmed.indexOf("=");
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = trimmed.slice(0, separatorIndex).trim();
|
||||||
|
const value = trimmed.slice(separatorIndex + 1).trim();
|
||||||
|
cookies[key] = value;
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManagementAccessKey(value) {
|
||||||
|
return `${value || ""}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentManagementAccessKey(config) {
|
||||||
|
return normalizeManagementAccessKey(config?.management_access_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function managementAccessEnabled(config) {
|
||||||
|
return Boolean(currentManagementAccessKey(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestManagementAccessKey(req, requestUrl) {
|
||||||
|
const headerKey = firstNonEmptyString(
|
||||||
|
req.headers["x-codex-retry-gateway-key"],
|
||||||
|
req.headers["x-codex-retry-gateway-access-key"],
|
||||||
|
req.headers.authorization?.startsWith?.("Bearer ")
|
||||||
|
? req.headers.authorization.slice("Bearer ".length)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (headerKey) {
|
||||||
|
return headerKey;
|
||||||
|
}
|
||||||
|
const queryKey = firstNonEmptyString(
|
||||||
|
requestUrl?.searchParams?.get("key"),
|
||||||
|
requestUrl?.searchParams?.get("access_key"),
|
||||||
|
);
|
||||||
|
if (queryKey) {
|
||||||
|
return queryKey;
|
||||||
|
}
|
||||||
|
const cookies = parseCookieHeader(req.headers.cookie || "");
|
||||||
|
return firstNonEmptyString(cookies[MANAGEMENT_ACCESS_COOKIE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasManagementAccess(req, requestUrl, config) {
|
||||||
|
const expected = currentManagementAccessKey(config);
|
||||||
|
if (!expected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const provided = requestManagementAccessKey(req, requestUrl);
|
||||||
|
return Boolean(provided) && timingSafeEquals(provided, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearManagementAccessCookieHeaders() {
|
||||||
|
return [
|
||||||
|
`${MANAGEMENT_ACCESS_COOKIE}=; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManagementAccessCookieHeaders(config, accessKey) {
|
||||||
|
const expected = currentManagementAccessKey(config);
|
||||||
|
if (!expected || !accessKey || !timingSafeEquals(accessKey, expected)) {
|
||||||
|
return clearManagementAccessCookieHeaders();
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`${MANAGEMENT_ACCESS_COOKIE}=${accessKey}; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function managementUnauthorizedPayload() {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: "management access key required",
|
||||||
|
code: "management_access_key_required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function extractStringByPointers(payload, pointers) {
|
function extractStringByPointers(payload, pointers) {
|
||||||
for (const pointer of pointers) {
|
for (const pointer of pointers) {
|
||||||
const raw = jsonPointerGet(payload, pointer);
|
const raw = jsonPointerGet(payload, pointer);
|
||||||
@@ -1043,6 +1144,9 @@ function buildConfigFromProfileEnv(profileName, env) {
|
|||||||
env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages,
|
env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages,
|
||||||
DEFAULT_CONFIG.retryable_error_messages,
|
DEFAULT_CONFIG.retryable_error_messages,
|
||||||
),
|
),
|
||||||
|
management_access_key: normalizeManagementAccessKey(
|
||||||
|
env.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY || DEFAULT_CONFIG.management_access_key,
|
||||||
|
),
|
||||||
upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
|
upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
|
||||||
? normalizePositiveInteger(
|
? normalizePositiveInteger(
|
||||||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS,
|
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS,
|
||||||
@@ -1194,6 +1298,11 @@ async function buildProfileEnvText(payload) {
|
|||||||
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
|
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const managementAccessKey = normalizeManagementAccessKey(payload.management_access_key);
|
||||||
|
if (managementAccessKey) {
|
||||||
|
envPairs.push(["CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY", managementAccessKey]);
|
||||||
|
}
|
||||||
|
|
||||||
const modelRemap = `${payload.model_remap || ""}`.trim();
|
const modelRemap = `${payload.model_remap || ""}`.trim();
|
||||||
if (modelRemap) {
|
if (modelRemap) {
|
||||||
envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]);
|
envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]);
|
||||||
@@ -1486,6 +1595,7 @@ async function loadConfig(configPath) {
|
|||||||
config.retryable_error_messages,
|
config.retryable_error_messages,
|
||||||
DEFAULT_CONFIG.retryable_error_messages,
|
DEFAULT_CONFIG.retryable_error_messages,
|
||||||
);
|
);
|
||||||
|
config.management_access_key = normalizeManagementAccessKey(config.management_access_key);
|
||||||
config.upstream_fetch_retry_attempts = normalizePositiveInteger(
|
config.upstream_fetch_retry_attempts = normalizePositiveInteger(
|
||||||
config.upstream_fetch_retry_attempts,
|
config.upstream_fetch_retry_attempts,
|
||||||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||||||
@@ -1543,6 +1653,7 @@ function normalizeAuthMode(value) {
|
|||||||
|
|
||||||
function sanitizeConfigForStatus(config) {
|
function sanitizeConfigForStatus(config) {
|
||||||
const {
|
const {
|
||||||
|
management_access_key,
|
||||||
upstream_auth_file,
|
upstream_auth_file,
|
||||||
upstream_auth_json_path,
|
upstream_auth_json_path,
|
||||||
upstream_auth_env,
|
upstream_auth_env,
|
||||||
@@ -1553,6 +1664,7 @@ function sanitizeConfigForStatus(config) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
management_access_key_configured: Boolean(management_access_key),
|
||||||
upstream_auth_env: upstream_auth_env || null,
|
upstream_auth_env: upstream_auth_env || null,
|
||||||
upstream_auth_file: upstream_auth_file ? "[configured]" : "",
|
upstream_auth_file: upstream_auth_file ? "[configured]" : "",
|
||||||
upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "",
|
upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "",
|
||||||
@@ -2077,6 +2189,72 @@ async function serveManagementUi(req, res, requestPathname) {
|
|||||||
return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
|
return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function serveManagementUiWithOptionalCookie(req, res, requestPathname, cookieHeaders) {
|
||||||
|
const uiPrefix = `${UI_PATH}/`;
|
||||||
|
if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) {
|
||||||
|
try {
|
||||||
|
const body = await readFile(path.join(UI_STATIC_ROOT, "index.html"));
|
||||||
|
respondBuffer(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
...(cookieHeaders?.length ? { "set-cookie": cookieHeaders } : {}),
|
||||||
|
},
|
||||||
|
req?.headers?.["accept-encoding"] || "",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!requestPathname.startsWith(uiPrefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return serveManagementUi(req, res, requestPathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManagementAccessPage(requestPathname, errorMessage = "") {
|
||||||
|
const escapedAction = `${requestPathname || UI_PATH}`.replace(/&/g, "&").replace(/"/g, """);
|
||||||
|
const escapedError = `${errorMessage || ""}`
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Codex Retry Gateway</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b1020; color: #eef2ff; }
|
||||||
|
main { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||||
|
form { width: min(420px, 100%); background: #121933; border: 1px solid #26304f; padding: 24px; border-radius: 8px; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 20px; }
|
||||||
|
p { margin: 0 0 16px; color: #aab4d6; line-height: 1.5; }
|
||||||
|
label { display: block; margin-bottom: 8px; font-size: 14px; color: #cbd5f5; }
|
||||||
|
input { width: 100%; box-sizing: border-box; padding: 12px; border-radius: 6px; border: 1px solid #33406a; background: #0f1630; color: #eef2ff; }
|
||||||
|
button { margin-top: 16px; width: 100%; padding: 12px; border: 0; border-radius: 6px; background: #7c9cff; color: #09101f; font-weight: 600; cursor: pointer; }
|
||||||
|
.error { min-height: 20px; margin-top: 12px; color: #fca5a5; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<form method="get" action="${escapedAction}">
|
||||||
|
<h1>Codex Retry Gateway</h1>
|
||||||
|
<p>此页面已启用访问 key。输入后会在当前浏览器保存后台访问状态。</p>
|
||||||
|
<label for="key">Access key</label>
|
||||||
|
<input id="key" name="key" type="password" autofocus autocomplete="current-password">
|
||||||
|
<button type="submit">进入后台</button>
|
||||||
|
<div class="error">${escapedError}</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildEditableConfig(currentConfig, payload) {
|
function buildEditableConfig(currentConfig, payload) {
|
||||||
const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals);
|
const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals);
|
||||||
const nextRetryableStatusCodes = normalizeIntegerList(
|
const nextRetryableStatusCodes = normalizeIntegerList(
|
||||||
@@ -2140,6 +2318,10 @@ function buildEditableConfig(currentConfig, payload) {
|
|||||||
reasoning_equals: nextReasoning,
|
reasoning_equals: nextReasoning,
|
||||||
retryable_status_codes: nextRetryableStatusCodes,
|
retryable_status_codes: nextRetryableStatusCodes,
|
||||||
retryable_error_messages: nextRetryableErrorMessages,
|
retryable_error_messages: nextRetryableErrorMessages,
|
||||||
|
management_access_key:
|
||||||
|
payload.management_access_key === undefined
|
||||||
|
? currentManagementAccessKey(currentConfig)
|
||||||
|
: normalizeManagementAccessKey(payload.management_access_key),
|
||||||
endpoints: nextEndpoints,
|
endpoints: nextEndpoints,
|
||||||
upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts,
|
upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts,
|
||||||
upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs,
|
upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs,
|
||||||
@@ -2150,9 +2332,43 @@ function buildEditableConfig(currentConfig, payload) {
|
|||||||
|
|
||||||
async function handleManagementRequest(runtime, req, res, requestUrl) {
|
async function handleManagementRequest(runtime, req, res, requestUrl) {
|
||||||
const pathname = normalizePath(requestUrl.pathname);
|
const pathname = normalizePath(requestUrl.pathname);
|
||||||
|
const accessEnabled = managementAccessEnabled(runtime.config);
|
||||||
|
const accessGranted = hasManagementAccess(req, requestUrl, runtime.config);
|
||||||
|
|
||||||
if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) {
|
if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) {
|
||||||
if (!(await serveManagementUi(req, res, requestUrl.pathname))) {
|
if (accessEnabled && !accessGranted) {
|
||||||
|
if (
|
||||||
|
pathname === UI_PATH ||
|
||||||
|
pathname === `${UI_PATH}/` ||
|
||||||
|
pathname === `${UI_PATH}/index.html`
|
||||||
|
) {
|
||||||
|
const body = Buffer.from(
|
||||||
|
renderManagementAccessPage(pathname, requestUrl.searchParams.get("key") ? "access key 不正确" : ""),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return respondBuffer(
|
||||||
|
res,
|
||||||
|
401,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
"set-cookie": clearManagementAccessCookieHeaders(),
|
||||||
|
},
|
||||||
|
req?.headers?.["accept-encoding"] || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
jsonResponse(req, res, 401, managementUnauthorizedPayload(), {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
"set-cookie": clearManagementAccessCookieHeaders(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeaders = accessEnabled
|
||||||
|
? buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl))
|
||||||
|
: [];
|
||||||
|
if (!(await serveManagementUiWithOptionalCookie(req, res, requestUrl.pathname, cookieHeaders))) {
|
||||||
jsonResponse(req, res, 503, {
|
jsonResponse(req, res, 503, {
|
||||||
error: {
|
error: {
|
||||||
message: "UI assets were not built. Run: npm run build:ui",
|
message: "UI assets were not built. Run: npm run build:ui",
|
||||||
@@ -2163,6 +2379,14 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessEnabled && !accessGranted && pathname.startsWith(`${ADMIN_BASE_PATH}/`)) {
|
||||||
|
jsonResponse(req, res, 401, managementUnauthorizedPayload(), {
|
||||||
|
"cache-control": "no-store",
|
||||||
|
"set-cookie": clearManagementAccessCookieHeaders(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === STATUS_API_PATH && req.method === "GET") {
|
if (pathname === STATUS_API_PATH && req.method === "GET") {
|
||||||
const state = await readRuntimeState(runtime);
|
const state = await readRuntimeState(runtime);
|
||||||
jsonResponse(req, res, 200, {
|
jsonResponse(req, res, 200, {
|
||||||
@@ -2179,7 +2403,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
|
|||||||
profiles_dir: runtime.paths.profilesDir,
|
profiles_dir: runtime.paths.profilesDir,
|
||||||
},
|
},
|
||||||
metrics: buildMetricsSnapshot(runtime.monitor),
|
metrics: buildMetricsSnapshot(runtime.monitor),
|
||||||
});
|
}, accessEnabled && accessGranted ? { "set-cookie": buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl)) } : {});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function assert(condition, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminHeaders = {
|
||||||
|
"x-codex-retry-gateway-key": "test-admin-key",
|
||||||
|
};
|
||||||
|
|
||||||
async function getFreePort() {
|
async function getFreePort() {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.listen(0, "127.0.0.1");
|
server.listen(0, "127.0.0.1");
|
||||||
@@ -388,6 +392,7 @@ async function run() {
|
|||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||||||
],
|
],
|
||||||
|
management_access_key: "test-admin-key",
|
||||||
upstream_fetch_retry_attempts: 5,
|
upstream_fetch_retry_attempts: 5,
|
||||||
upstream_fetch_retry_backoff_ms: 25,
|
upstream_fetch_retry_backoff_ms: 25,
|
||||||
non_stream_status_code: 502,
|
non_stream_status_code: 502,
|
||||||
@@ -411,6 +416,33 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui`);
|
||||||
|
assert(lockedUiResponse.status === 401, `未带 key 的 UI 不应可访问: ${lockedUiResponse.status}`);
|
||||||
|
const lockedUiText = await lockedUiResponse.text();
|
||||||
|
assert(lockedUiText.includes("Access key"), "未带 key 的 UI 未返回 access key 页面");
|
||||||
|
|
||||||
|
const lockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
||||||
|
assert(lockedStatusResponse.status === 401, `未带 key 的 status API 不应可访问: ${lockedStatusResponse.status}`);
|
||||||
|
|
||||||
|
const unlockedUiResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/ui?key=test-admin-key`, {
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
assert(unlockedUiResponse.status === 200, `带 key 的 UI 访问失败: ${unlockedUiResponse.status}`);
|
||||||
|
assert(
|
||||||
|
(unlockedUiResponse.headers.get("set-cookie") || "").includes("codex_retry_gateway_access=test-admin-key"),
|
||||||
|
"带 key 的 UI 未设置 access cookie",
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlockedStatusResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, {
|
||||||
|
headers: { "x-codex-retry-gateway-key": "test-admin-key" },
|
||||||
|
});
|
||||||
|
assert(unlockedStatusResponse.status === 200, `带 key 的 status API 访问失败: ${unlockedStatusResponse.status}`);
|
||||||
|
const unlockedStatusPayload = await unlockedStatusResponse.json();
|
||||||
|
assert(
|
||||||
|
unlockedStatusPayload?.config?.management_access_key_configured === true,
|
||||||
|
"status API 未暴露 management_access_key_configured",
|
||||||
|
);
|
||||||
|
|
||||||
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
const modelsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/v1/models`);
|
||||||
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
assert(modelsResponse.status === 200, `/v1/models 透传状态异常: ${modelsResponse.status}`);
|
||||||
assert(
|
assert(
|
||||||
@@ -455,7 +487,7 @@ async function run() {
|
|||||||
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
|
assert(recoveredResponse.status === 200, `首次 fetch failed 后未自动恢复: ${recoveredResponse.status}`);
|
||||||
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
|
assert(recoveredBody?.retry_attempt === 2, "首次 fetch failed 后未命中第二次上游请求");
|
||||||
|
|
||||||
const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsPayload = await requestsResponse.json();
|
const requestsPayload = await requestsResponse.json();
|
||||||
const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200);
|
const recoveredEntry = requestsPayload?.entries?.find((entry) => entry.path === "/responses" && entry.status_code === 200);
|
||||||
assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`);
|
assert(requestsResponse.status === 200, `请求历史 API 状态异常: ${requestsResponse.status}`);
|
||||||
@@ -475,7 +507,7 @@ async function run() {
|
|||||||
assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id");
|
assert(threadTrackedBody?.id === "resp_test", "thread non-stream 返回体缺少 response id");
|
||||||
assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id");
|
assert(threadTrackedBody?.thread_id === "thread_nonstream", "thread non-stream 返回体缺少 thread_id");
|
||||||
|
|
||||||
const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`);
|
const threadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_nonstream")}`, { headers: adminHeaders });
|
||||||
const threadRequestsPayload = await threadRequestsResponse.json();
|
const threadRequestsPayload = await threadRequestsResponse.json();
|
||||||
const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream");
|
const threadEntry = (threadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_nonstream");
|
||||||
assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id");
|
assert(threadEntry?.response_id === "resp_test", "non-stream 请求记录未保留 response_id");
|
||||||
@@ -515,7 +547,7 @@ async function run() {
|
|||||||
assert(differentPathResponse.status === 200, `不同路径发送失败: ${differentPathResponse.status}`);
|
assert(differentPathResponse.status === 200, `不同路径发送失败: ${differentPathResponse.status}`);
|
||||||
await differentPathResponse.json();
|
await differentPathResponse.json();
|
||||||
|
|
||||||
const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`);
|
const requestIdRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=40`, { headers: adminHeaders });
|
||||||
const requestIdRequestsPayload = await requestIdRequestsResponse.json();
|
const requestIdRequestsPayload = await requestIdRequestsResponse.json();
|
||||||
const sameRequestEntries = (requestIdRequestsPayload?.entries || []).filter(
|
const sameRequestEntries = (requestIdRequestsPayload?.entries || []).filter(
|
||||||
(entry) => entry.path === "/responses" && entry.request_body_bytes === Buffer.byteLength(sameRequestPayload),
|
(entry) => entry.path === "/responses" && entry.request_body_bytes === Buffer.byteLength(sameRequestPayload),
|
||||||
@@ -543,6 +575,7 @@ async function run() {
|
|||||||
|
|
||||||
const requestIdQueryResponse = await fetch(
|
const requestIdQueryResponse = await fetch(
|
||||||
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(sameRequestEntryA.request_id)}`,
|
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(sameRequestEntryA.request_id)}`,
|
||||||
|
{ headers: adminHeaders },
|
||||||
);
|
);
|
||||||
const requestIdQueryPayload = await requestIdQueryResponse.json();
|
const requestIdQueryPayload = await requestIdQueryResponse.json();
|
||||||
assert(requestIdQueryResponse.status === 200, `request_id 搜索失败: ${requestIdQueryResponse.status}`);
|
assert(requestIdQueryResponse.status === 200, `request_id 搜索失败: ${requestIdQueryResponse.status}`);
|
||||||
@@ -595,7 +628,7 @@ async function run() {
|
|||||||
"capacity 抖动恢复后的返回体异常",
|
"capacity 抖动恢复后的返回体异常",
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsAfterCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsAfterCapacityRecovery = await requestsAfterCapacityRecoveryResponse.json();
|
const requestsAfterCapacityRecovery = await requestsAfterCapacityRecoveryResponse.json();
|
||||||
const capacityRecoveredEntry = requestsAfterCapacityRecovery?.entries?.find(
|
const capacityRecoveredEntry = requestsAfterCapacityRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
||||||
@@ -615,7 +648,7 @@ async function run() {
|
|||||||
"200+capacity 抖动恢复后的返回体异常",
|
"200+capacity 抖动恢复后的返回体异常",
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`);
|
const requestsAfterCapacityStatus200RecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders });
|
||||||
const requestsAfterCapacityStatus200Recovery = await requestsAfterCapacityStatus200RecoveryResponse.json();
|
const requestsAfterCapacityStatus200Recovery = await requestsAfterCapacityStatus200RecoveryResponse.json();
|
||||||
const capacityStatus200RecoveredEntry = requestsAfterCapacityStatus200Recovery?.entries?.find(
|
const capacityStatus200RecoveredEntry = requestsAfterCapacityStatus200Recovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.upstream_attempt_count >= 3,
|
||||||
@@ -660,7 +693,7 @@ async function run() {
|
|||||||
assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`);
|
assert(streamCapacityRecoveredResponse.status === 200, `stream capacity 抖动后未自动恢复: ${streamCapacityRecoveredResponse.status}`);
|
||||||
assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容");
|
assert(streamCapacityRecoveredText.includes("hello"), "stream capacity 恢复后未拿到正常 SSE 内容");
|
||||||
|
|
||||||
const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const requestsAfterStreamCapacityRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json();
|
const requestsAfterStreamCapacityRecovery = await requestsAfterStreamCapacityRecoveryResponse.json();
|
||||||
const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find(
|
const streamCapacityRecoveredEntry = requestsAfterStreamCapacityRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
||||||
@@ -682,7 +715,7 @@ async function run() {
|
|||||||
assert(streamCapacityResponseFailedRecoveredResponse.status === 200, `stream response.failed capacity 抖动后未自动恢复: ${streamCapacityResponseFailedRecoveredResponse.status}`);
|
assert(streamCapacityResponseFailedRecoveredResponse.status === 200, `stream response.failed capacity 抖动后未自动恢复: ${streamCapacityResponseFailedRecoveredResponse.status}`);
|
||||||
assert(streamCapacityResponseFailedRecoveredText.includes("hello"), "stream response.failed capacity 恢复后未拿到正常 SSE 内容");
|
assert(streamCapacityResponseFailedRecoveredText.includes("hello"), "stream response.failed capacity 恢复后未拿到正常 SSE 内容");
|
||||||
|
|
||||||
const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`);
|
const requestsAfterStreamCapacityResponseFailedRecoveryResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=30`, { headers: adminHeaders });
|
||||||
const requestsAfterStreamCapacityResponseFailedRecovery = await requestsAfterStreamCapacityResponseFailedRecoveryResponse.json();
|
const requestsAfterStreamCapacityResponseFailedRecovery = await requestsAfterStreamCapacityResponseFailedRecoveryResponse.json();
|
||||||
const streamCapacityResponseFailedRecoveredEntry = requestsAfterStreamCapacityResponseFailedRecovery?.entries?.find(
|
const streamCapacityResponseFailedRecoveredEntry = requestsAfterStreamCapacityResponseFailedRecovery?.entries?.find(
|
||||||
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
(entry) => entry.path === "/responses" && entry.status_code === 200 && entry.response_stream && entry.upstream_attempt_count >= 3,
|
||||||
@@ -694,7 +727,7 @@ async function run() {
|
|||||||
{ stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" },
|
{ stream: true, test_reasoning_tokens: 128, thread_id: "thread_stream_ok" },
|
||||||
);
|
);
|
||||||
assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`);
|
assert(streamThreadResponse.status === 200, `stream thread 请求失败: ${streamThreadResponse.status}`);
|
||||||
const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`);
|
const streamThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_stream_ok")}`, { headers: adminHeaders });
|
||||||
const streamThreadRequestsPayload = await streamThreadRequestsResponse.json();
|
const streamThreadRequestsPayload = await streamThreadRequestsResponse.json();
|
||||||
const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok");
|
const streamThreadEntry = (streamThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_stream_ok");
|
||||||
assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id");
|
assert(streamThreadEntry?.response_id === "resp_stream", "stream 请求记录未保留 response_id");
|
||||||
@@ -717,7 +750,7 @@ async function run() {
|
|||||||
});
|
});
|
||||||
assert(metadataThreadResponse.status === 200, `metadata thread 请求失败: ${metadataThreadResponse.status}`);
|
assert(metadataThreadResponse.status === 200, `metadata thread 请求失败: ${metadataThreadResponse.status}`);
|
||||||
await metadataThreadResponse.json();
|
await metadataThreadResponse.json();
|
||||||
const metadataThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_client_metadata")}`);
|
const metadataThreadRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent("thread_client_metadata")}`, { headers: adminHeaders });
|
||||||
const metadataThreadRequestsPayload = await metadataThreadRequestsResponse.json();
|
const metadataThreadRequestsPayload = await metadataThreadRequestsResponse.json();
|
||||||
const metadataThreadEntry = (metadataThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_client_metadata");
|
const metadataThreadEntry = (metadataThreadRequestsPayload?.entries || []).find((entry) => entry.thread_id === "thread_client_metadata");
|
||||||
assert(metadataThreadEntry?.thread_id === "thread_client_metadata", "client_metadata.thread_id 未写入请求记录");
|
assert(metadataThreadEntry?.thread_id === "thread_client_metadata", "client_metadata.thread_id 未写入请求记录");
|
||||||
@@ -770,7 +803,7 @@ async function run() {
|
|||||||
body: JSON.stringify({ stream: true, test_reasoning_tokens: 128, test_stream_chunk_delay_ms: 180 }),
|
body: JSON.stringify({ stream: true, test_reasoning_tokens: 128, test_stream_chunk_delay_ms: 180 }),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||||
const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`);
|
const midRequestsResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?limit=20`, { headers: adminHeaders });
|
||||||
const midRequestsPayload = await midRequestsResponse.json();
|
const midRequestsPayload = await midRequestsResponse.json();
|
||||||
const inFlightStreamEntry = midRequestsPayload?.entries?.find(
|
const inFlightStreamEntry = midRequestsPayload?.entries?.find(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
@@ -800,7 +833,7 @@ async function run() {
|
|||||||
);
|
);
|
||||||
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
|
assert(terminatedStream.status === 502, `/responses 上游半路断流未返回 502: ${terminatedStream.status}`);
|
||||||
|
|
||||||
const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
const metricsBeforeRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
||||||
const metricsBeforeRestart = await metricsBeforeRestartResponse.json();
|
const metricsBeforeRestart = await metricsBeforeRestartResponse.json();
|
||||||
assert(metricsBeforeRestartResponse.status === 200, `status API 状态异常: ${metricsBeforeRestartResponse.status}`);
|
assert(metricsBeforeRestartResponse.status === 200, `status API 状态异常: ${metricsBeforeRestartResponse.status}`);
|
||||||
assert(metricsBeforeRestart?.metrics?.reasoning_516_count >= 1, "重启前 reasoning_516_count 未累计");
|
assert(metricsBeforeRestart?.metrics?.reasoning_516_count >= 1, "重启前 reasoning_516_count 未累计");
|
||||||
@@ -812,7 +845,7 @@ async function run() {
|
|||||||
gateway = startGateway(configPath, logPath);
|
gateway = startGateway(configPath, logPath);
|
||||||
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
await waitForHealth(`http://127.0.0.1:${gatewayPort}${config.health_path}`);
|
||||||
|
|
||||||
const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`);
|
const metricsAfterRestartResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/status`, { headers: adminHeaders });
|
||||||
const metricsAfterRestart = await metricsAfterRestartResponse.json();
|
const metricsAfterRestart = await metricsAfterRestartResponse.json();
|
||||||
assert(metricsAfterRestartResponse.status === 200, `重启后 status API 状态异常: ${metricsAfterRestartResponse.status}`);
|
assert(metricsAfterRestartResponse.status === 200, `重启后 status API 状态异常: ${metricsAfterRestartResponse.status}`);
|
||||||
assert(metricsAfterRestart?.metrics?.reasoning_516_count >= metricsBeforeRestart?.metrics?.reasoning_516_count, "重启后 reasoning_516_count 未保留");
|
assert(metricsAfterRestart?.metrics?.reasoning_516_count >= metricsBeforeRestart?.metrics?.reasoning_516_count, "重启后 reasoning_516_count 未保留");
|
||||||
|
|||||||
+21
-1
@@ -7,6 +7,8 @@ type GatewayConfig = {
|
|||||||
profile_name?: string;
|
profile_name?: string;
|
||||||
listen_host?: string;
|
listen_host?: string;
|
||||||
listen_port?: number;
|
listen_port?: number;
|
||||||
|
management_access_key?: string;
|
||||||
|
management_access_key_configured?: boolean;
|
||||||
upstream_base_url?: string;
|
upstream_base_url?: string;
|
||||||
upstream_auth_mode?: string;
|
upstream_auth_mode?: string;
|
||||||
upstream_auth_env?: string | null;
|
upstream_auth_env?: string | null;
|
||||||
@@ -227,6 +229,8 @@ const api = {
|
|||||||
restore: "/__codex_retry_gateway/api/restore",
|
restore: "/__codex_retry_gateway/api/restore",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACCESS_KEY_STORAGE_KEY = "codex_retry_gateway_access_key";
|
||||||
|
|
||||||
const REQUEST_PAGE_SIZE = 20;
|
const REQUEST_PAGE_SIZE = 20;
|
||||||
const LOG_PAGE_SIZE = 200;
|
const LOG_PAGE_SIZE = 200;
|
||||||
|
|
||||||
@@ -403,7 +407,12 @@ function splitLines(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, { cache: "no-store", ...options });
|
const headers = new Headers(options?.headers || {});
|
||||||
|
const accessKey = window.localStorage.getItem(ACCESS_KEY_STORAGE_KEY)?.trim();
|
||||||
|
if (accessKey && !headers.has("x-codex-retry-gateway-key")) {
|
||||||
|
headers.set("x-codex-retry-gateway-key", accessKey);
|
||||||
|
}
|
||||||
|
const response = await fetch(url, { cache: "no-store", ...options, headers });
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload?.error?.message || "请求失败");
|
throw new Error(payload?.error?.message || "请求失败");
|
||||||
@@ -499,6 +508,17 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const accessKey = url.searchParams.get("key")?.trim();
|
||||||
|
if (!accessKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(ACCESS_KEY_STORAGE_KEY, accessKey);
|
||||||
|
url.searchParams.delete("key");
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [page, setPage] = useState<PageKey>(() => {
|
const [page, setPage] = useState<PageKey>(() => {
|
||||||
const hash = window.location.hash.replace("#", "") as PageKey;
|
const hash = window.location.hash.replace("#", "") as PageKey;
|
||||||
return pageCopy[hash] ? hash : "overview";
|
return pageCopy[hash] ? hash : "overview";
|
||||||
|
|||||||
Reference in New Issue
Block a user