Fix login hang, chat boot wiring, and username wipe on remount.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 20:21:31 +08:00
parent 5bcaf7fd2d
commit 42092423fb
10 changed files with 261 additions and 113 deletions

View file

@ -779,7 +779,7 @@
<script defer src="/admin/assets/vendor/marked.min.js"></script>
<script defer src="/admin/assets/vendor/purify.min.js"></script>
<script defer src="/admin/assets/admin-theme.js"></script>
<script type="module" src="/admin/assets/chat.js?v=20260812-chat-3" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load chat.js',h:'Confirm the gateway is up and /admin/assets/chat.js is reachable.'}:{t:'无法加载 chat.js',h:'请确认网关已启动且 /admin/assets/chat.js 可访问。'};var s=document.getElementById('chat-boot-splash');if(s){var a=s.querySelector('.chat-boot-splash__title span:last-child');var b=s.querySelector('.chat-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
<script type="module" src="/admin/assets/chat.js?v=20260812-chat-6" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load chat.js',h:'Confirm the gateway is up and /admin/assets/chat.js is reachable.'}:{t:'无法加载 chat.js',h:'请确认网关已启动且 /admin/assets/chat.js 可访问。'};var s=document.getElementById('chat-boot-splash');if(s){var a=s.querySelector('.chat-boot-splash__title span:last-child');var b=s.querySelector('.chat-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
<script
defer

View file

@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>oliver</title>
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-19" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-19" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-19" />
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-21" />
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-21" />
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-21" />
<style>
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
.admin-boot-splash {
@ -129,33 +129,33 @@
<script type="importmap">
{
"imports": {
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-19",
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-19",
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-19",
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-19",
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-19",
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-19",
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-19",
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-19",
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-19",
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-19",
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-19",
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-19",
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-19",
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-19",
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-19",
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-19",
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-19",
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-19",
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-19",
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-19",
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-19",
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-19",
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-19",
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-19"
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-21",
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-21",
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-21",
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-21",
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-21",
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-21",
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-21",
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-21",
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-21",
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-21",
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-21",
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-21",
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-21",
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-21",
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-21",
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-21",
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-21",
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-21",
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-21",
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-21",
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-21",
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-21",
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-21",
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-21"
}
}
</script>
<script type="module" src="/admin/assets/app.js?v=20260812-19" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
<script type="module" src="/admin/assets/app.js?v=20260812-21" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
</body>
</html>

View file

@ -3,4 +3,4 @@
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
*/
export const ADMIN_ASSET_V = "20260812-19";
export const ADMIN_ASSET_V = "20260812-21";

View file

@ -1048,6 +1048,21 @@ function withTimeout(promise, ms, label) {
});
}
function invokeChatBoot() {
const fn = state.boot;
if (typeof fn === "function") return fn();
return Promise.reject(new Error("chat_boot_uninitialized"));
}
let _chatReauthTimer = null;
function scheduleChatReauth() {
if (_chatReauthTimer != null) return;
_chatReauthTimer = setTimeout(() => {
_chatReauthTimer = null;
invokeChatBoot().catch(() => {});
}, 0);
}
async function apiGet(path) {
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
const headers = { accept: "application/json" };
@ -1057,8 +1072,11 @@ async function apiGet(path) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`GET ${path} ${res.status}`);
return await res.json();
@ -1088,8 +1106,11 @@ async function apiPost(path, body) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`POST ${path} ${res.status}`);
return data ?? {};
@ -1108,8 +1129,11 @@ async function apiPatch(path, body) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`);
return await res.json();
@ -1124,8 +1148,11 @@ async function apiDelete(path) {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => boot().catch(() => {}), 0);
return await new Promise(() => {});
setTimeout(() => scheduleChatReauth(), 0);
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
throw err;
}
if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`);
return await res.json();
@ -2898,8 +2925,22 @@ async function fileToPayloadEntry(file) {
async function renderLogin() {
applyI18nStatic();
const username = el("input", { class: "input", placeholder: t("auth.username") });
const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") });
if (!state.loginDraft) state.loginDraft = { username: "", password: "" };
const draft = state.loginDraft;
const username = el("input", {
class: "input",
placeholder: t("auth.username"),
autocomplete: "username",
spellcheck: "false",
});
username.value = draft.username || "";
const password = el("input", {
class: "input",
type: "password",
placeholder: t("auth.password"),
autocomplete: "current-password",
});
password.value = draft.password || "";
const status = el("div", { class: "muted", text: "" });
const btn = el("button", {
class: "btn btn--primary",
@ -2907,6 +2948,12 @@ async function renderLogin() {
text: t("auth.login"),
});
let busy = false;
const syncDraft = () => {
draft.username = String(username.value || "");
draft.password = String(password.value || "");
};
username.addEventListener("input", syncDraft);
password.addEventListener("input", syncDraft);
const setBusy = (on) => {
busy = !!on;
btn.disabled = busy;
@ -2914,6 +2961,7 @@ async function renderLogin() {
};
const doLogin = async () => {
if (busy) return;
syncDraft();
setBusy(true);
status.textContent = t("auth.loggingIn");
try {
@ -2934,6 +2982,8 @@ async function renderLogin() {
setBusy(false);
return;
}
draft.username = "";
draft.password = "";
localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token));
localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
state.authSession = resp.session || null;
@ -2946,10 +2996,25 @@ async function renderLogin() {
]),
);
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
await boot();
await invokeChatBoot();
} catch (err) {
status.textContent = String((err && err.message) || err || t("auth.invalid"));
setBusy(false);
if (err && err.authRequired) return;
mount(
el("div", { class: "chat-app--login" }, [
el("div", { class: "card u-modal-card-sm" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "muted", text: String((err && err.message) || err || t("auth.invalid")) }),
el("div", { class: "row" }, [
el("button", {
class: "btn btn--primary",
type: "button",
text: t("auth.login"),
onclick: () => invokeChatBoot().catch(() => {}),
}),
]),
]),
]),
);
}
};
const onEnterLogin = (ev) => {
@ -2963,7 +3028,7 @@ async function renderLogin() {
ev.preventDefault();
doLogin();
});
const card = el("div", { class: "card u-modal-card-sm" }, [
const card = el("div", { class: "card u-modal-card-sm", "data-chat-login": "1" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "chat-login-fields" }, [username, password]),
el("div", { class: "row chat-login-actions" }, [btn]),
@ -2971,7 +3036,9 @@ async function renderLogin() {
]);
setTimeout(() => {
try {
username.focus();
if (document.activeElement === username || document.activeElement === password) return;
if (draft.username && !draft.password) password.focus();
else username.focus();
} catch (_) {}
}, 0);
return el("div", { class: "chat-app--login" }, [card]);

View file

@ -27,6 +27,13 @@ async function boot() {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
state.authSession = null;
const existingLogin = document.querySelector("#app [data-chat-login='1']");
const ae = document.activeElement;
if (existingLogin && ae && existingLogin.contains(ae)) {
applyI18nStatic();
syncAuthUserLabel();
return;
}
try {
await withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout");
} catch (_) {}
@ -57,6 +64,8 @@ async function boot() {
syncAuthUserLabel();
}
state.boot = boot;
document.body.addEventListener("click", async (e) => {
const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]");
if (menuBtn) {

View file

@ -22,4 +22,6 @@ export const state = {
statusReasonPairs: [],
jobsBadgeEl: null,
jobsBtnLabelEl: null,
/** @type {null | (() => Promise<any>)} set by main.js to avoid circular imports */
boot: null,
};

View file

@ -144,22 +144,37 @@ function getStoredAuthToken() {
}
/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */
let _reauthTimer = null;
function scheduleReauthAfter401(requestUrl) {
const u = String(requestUrl || "");
if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return;
authStoreRemove(AUTH_TOKEN_KEY);
authStoreRemove(AUTH_SESSION_KEY);
state.authSession = null;
setTimeout(() => {
// Coalesce: many parallel 401s must not remount login repeatedly (wipes username while typing).
if (_reauthTimer != null) return;
_reauthTimer = setTimeout(() => {
_reauthTimer = null;
const fn = state.reauthHandler;
if (typeof fn === "function") fn();
}, 0);
}
function isAuthRequiredError(err) {
return !!(err && (err.authRequired === true || err.name === "AuthRequiredError"));
}
function _authRequiredError() {
const err = new Error("auth_required");
err.name = "AuthRequiredError";
err.authRequired = true;
return err;
}
function _haltAfter401() {
// 401 means "login required". We re-route to login; callers should not show a "request failed" popup.
// Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert.
return new Promise(() => {});
// 401 means "login required". Re-auth is already scheduled; reject so page boots
// (Promise.all / await) do not hang forever on a never-resolving promise.
return Promise.reject(_authRequiredError());
}
async function apiGet(path) {
@ -711,6 +726,7 @@ export {
getStoredAuthToken,
scheduleReauthAfter401,
_haltAfter401,
isAuthRequiredError,
apiGet,
apiGetOptional,
apiGetNoHang,

View file

@ -1,14 +1,36 @@
import { state, t, el, mount, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js";
/** Survive login remounts (reauth races) so typing is not wiped. */
const loginDraft = { username: "", password: "" };
async function renderLogin() {
applyI18nStatic();
const username = el("input", { class: "input", placeholder: t("auth.username"), value: "" });
const username = el("input", {
class: "input",
placeholder: t("auth.username"),
autocomplete: "username",
spellcheck: "false",
});
username.value = loginDraft.username;
const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") });
const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") });
const password = el("input", {
class: "input",
type: "password",
placeholder: t("auth.password"),
autocomplete: "current-password",
});
password.value = loginDraft.password;
const status = el("div", { class: "muted", text: "" });
const btn = el("button", { class: "btn btn--primary", type: "button", text: t("auth.login") });
let busy = false;
const syncDraft = () => {
loginDraft.username = String(username.value || "");
loginDraft.password = String(password.value || "");
};
username.addEventListener("input", syncDraft);
password.addEventListener("input", syncDraft);
const setBusy = (on) => {
busy = !!on;
btn.disabled = busy;
@ -17,6 +39,7 @@ async function renderLogin() {
const doLogin = async () => {
if (busy) return;
syncDraft();
setBusy(true);
status.textContent = t("auth.loggingIn");
try {
@ -32,6 +55,8 @@ async function renderLogin() {
setBusy(false);
return;
}
loginDraft.username = "";
loginDraft.password = "";
const newTok = String(resp.token || "").trim();
authStoreSet(AUTH_TOKEN_KEY, newTok);
authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
@ -46,8 +71,20 @@ async function renderLogin() {
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
await navigateAdmin();
} catch (err) {
status.textContent = String((err && err.message) || err || t("auth.invalid"));
setBusy(false);
mount(
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "muted", text: String((err && err.message) || err || t("auth.invalid")) }),
el("div", { class: "row" }, [
el("button", {
class: "btn btn--primary",
type: "button",
text: t("auth.login"),
onclick: () => navigateAdmin(),
}),
]),
]),
);
}
};
@ -62,13 +99,15 @@ async function renderLogin() {
ev.preventDefault();
doLogin();
});
// Focus after mount so users can type immediately.
// Focus after mount so users can type immediately (keep caret if already typing).
setTimeout(() => {
try {
username.focus();
if (document.activeElement === username || document.activeElement === password) return;
if (loginDraft.username && !loginDraft.password) password.focus();
else username.focus();
} catch (_) {}
}, 0);
return el("div", { class: "card" }, [
return el("div", { class: "card", "data-admin-login": "1" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "row" }, [username]),
userHint,

View file

@ -1,4 +1,4 @@
import { t, el, tdCell, apiGet, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js";
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js";
const PLUGINS_PAGE_SIZE = 15;
@ -100,14 +100,17 @@ async function renderPlugins() {
wecom_longconn_inbound_queue_maxsize: 200,
};
const [p, mcp, mcpBinding, toolPolicyRaw] = await Promise.all([
apiGet("/admin/api/plugins"),
apiGet("/admin/api/mcp/servers").catch(() => ({ servers: [] })),
apiGet("/admin/api/mcp/binding").catch(() => ({
available_specialists: ["generalist"],
servers: [],
mapping: {},
})),
apiGet("/admin/api/tool-policy").catch(() => null),
apiGetNoHang("/admin/api/plugins").then((r) => r || { plugins: [] }),
apiGetNoHang("/admin/api/mcp/servers").then((r) => r || { servers: [] }),
apiGetNoHang("/admin/api/mcp/binding").then(
(r) =>
r || {
available_specialists: ["generalist"],
servers: [],
mapping: {},
},
),
apiGetNoHang("/admin/api/tool-policy"),
]);
let toolPolicy = toolPolicyRaw && typeof toolPolicyRaw === "object" ? toolPolicyRaw : defaultToolPolicy;

View file

@ -1,6 +1,7 @@
import {
state, t, el, mount, setActive, applyI18nStatic, getRoute,
authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, getStoredAuthToken, apiPost,
isAuthRequiredError,
} from "./core.js";
import { hasPermission, canManageApiGrants, isAdministratorUsername } from "./pages/authz.js";
import { renderStack } from "./pages/stack.js";
@ -26,6 +27,12 @@ async function router() {
authStoreRemove(AUTH_SESSION_KEY);
state.authSession = null;
}
const existingLogin = document.querySelector("#content [data-admin-login='1']");
const ae = document.activeElement;
// Avoid wiping username/password while the user is typing on an already-shown login form.
if (existingLogin && ae && existingLogin.contains(ae)) {
return;
}
try {
await apiPost("/admin/api/auth/bootstrap", {});
} catch (_) {}
@ -57,6 +64,11 @@ async function router() {
}
const forbiddenCard = () =>
el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]);
const errorCard = (err) =>
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("common.error") }),
el("div", { class: "pre", text: String((err && err.message) || err || "") }),
]);
const mountPageLoading = (titleKey) => {
mount(
el("div", { class: "card" }, [
@ -71,55 +83,55 @@ async function router() {
requestAnimationFrame(() => resolve());
});
let view;
if (page === "stack") {
mountPageLoading("title.stack");
await yieldForPaint();
view = await renderStack();
}
else if (page === "scheduled-jobs") {
view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
}
else if (page === "users") {
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
} else if (page === "memory") view = await renderMemory();
else if (page === "models") view = await renderModels();
else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") {
view = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("common.notFound") }),
el("div", { class: "muted", text: t("common.pageRemoved") }),
]);
} else if (page === "audit") view = await renderAudit(route.params.get("session_id") || "");
else if (false && page === "session-monitor") {
view = isAdministratorUsername() ? await renderSessionMonitor() : el("div", { class: "card" }, [el("div", { class: "card__title", text: t("sessionMonitor.onlyAdministrator") })]);
} else if (false && page === "admin-audit") {
view = hasPermission("admin:user:write") ? await renderAdminAudit() : forbiddenCard();
} else if (page === "plugins") {
if (!hasPermission("admin:user:write")) {
view = forbiddenCard();
} else {
mountPageLoading("title.plugins");
try {
if (page === "stack") {
mountPageLoading("title.stack");
await yieldForPaint();
view = await renderPlugins();
}
} else if (page === "skills") {
if (!hasPermission("admin:read")) {
view = forbiddenCard();
} else {
mountPageLoading("title.skills");
await yieldForPaint();
view = await renderSkills();
}
} else if (page === "attachments") {
view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard();
} else if (page === "workspace-paths") {
view =
hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read")
? await renderWorkspacePaths()
: forbiddenCard();
} else if (page === "profile") {
view = await renderProfile();
} else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]);
mount(view);
view = await renderStack();
} else if (page === "scheduled-jobs") {
view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
} else if (page === "users") {
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
} else if (page === "memory") view = await renderMemory();
else if (page === "models") view = await renderModels();
else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") {
view = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("common.notFound") }),
el("div", { class: "muted", text: t("common.pageRemoved") }),
]);
} else if (page === "audit") view = await renderAudit(route.params.get("session_id") || "");
else if (page === "plugins") {
if (!hasPermission("admin:user:write")) {
view = forbiddenCard();
} else {
mountPageLoading("title.plugins");
await yieldForPaint();
view = await renderPlugins();
}
} else if (page === "skills") {
if (!hasPermission("admin:read")) {
view = forbiddenCard();
} else {
mountPageLoading("title.skills");
await yieldForPaint();
view = await renderSkills();
}
} else if (page === "attachments") {
view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard();
} else if (page === "workspace-paths") {
view =
hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read")
? await renderWorkspacePaths()
: forbiddenCard();
} else if (page === "profile") {
view = await renderProfile();
} else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]);
} catch (err) {
// Re-auth already scheduled; do not paint an error over the login redirect.
if (isAuthRequiredError(err)) return;
view = errorCard(err);
}
if (view) mount(view);
}