oclaw/interfaces/admin/static/js/core.js
oliver 5bcaf7fd2d Show login progress immediately and stop auth 401 hangs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:03:34 +08:00

748 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { state } from "./state.js";
import { t, tf, applyI18nStatic, toggleLang } from "./i18n/index.js";
const SESSION_MONITOR_ROLE_FILTER_KEY = "ops_session_monitor_role_filter";
const AUTH_TOKEN_KEY = "ops_admin_token";
const AUTH_SESSION_KEY = "ops_admin_session";
const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode";
/** 与 localStorage 不同:sessionStorage 按标签页隔离,避免「管理员页 + 普通用户控制台」互相覆盖 token。 */
function authStoreGet(key) {
try {
return sessionStorage.getItem(key);
} catch (_) {
return null;
}
}
function authStoreSet(key, value) {
try {
sessionStorage.setItem(key, value);
} catch (_) {}
}
function authStoreRemove(key) {
try {
sessionStorage.removeItem(key);
} catch (_) {}
}
/** 历史版本把 token 放在 localStorage,多标签会串号;启动时清掉以免误读。 */
try {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_SESSION_KEY);
} catch (_) {}
/**
* /chat 与 /admin 历史上使用不同 key;当用户在同一窗口从 /chat 跳到 /admin 时,
* 没有 opener 可用,需要从 chat 存储主动迁移一次以复用登录态。
*/
(function seedAdminAuthFromChatStorageIfNeeded() {
try {
if (authStoreGet(AUTH_TOKEN_KEY)) return;
const pairs = [
[sessionStorage, "ops_chat_token", "ops_chat_session"],
[localStorage, "ops_chat_token", "ops_chat_session"],
];
for (const [store, kTok, kSess] of pairs) {
let tok = null;
let sess = null;
try {
tok = store.getItem(kTok);
sess = store.getItem(kSess);
} catch (_) {
continue;
}
const t = String(tok || "").trim();
const s = String(sess || "").trim();
if (t && s) {
authStoreSet(AUTH_TOKEN_KEY, t);
authStoreSet(AUTH_SESSION_KEY, s);
break;
}
}
} catch (_) {}
})();
/** 从聊天页新开 /admin(审计、设置等)时,复用 opener 同一登录态(chat 或 admin token)。勿对 window.open 使用 noopener,否则读不到 opener。 */
(function seedAdminAuthFromOpenerIfNeeded() {
try {
if (authStoreGet(AUTH_TOKEN_KEY)) return;
const op = window.opener;
if (!op || op === window) return;
if (String(location.origin) !== String(op.location.origin)) return;
const pairs = [
["ops_admin_token", "ops_admin_session"],
["ops_chat_token", "ops_chat_session"],
];
for (const [kTok, kSess] of pairs) {
let tok = null;
let sess = null;
try {
tok = op.sessionStorage.getItem(kTok) || op.localStorage.getItem(kTok);
sess = op.sessionStorage.getItem(kSess) || op.localStorage.getItem(kSess);
} catch (_) {
continue;
}
const t = String(tok || "").trim();
const s = String(sess || "").trim();
if (t && s) {
authStoreSet(AUTH_TOKEN_KEY, t);
authStoreSet(AUTH_SESSION_KEY, s);
break;
}
}
} catch (_) {}
})();
/** 合并展示名与登录名(列表/团队页共用) */
function formatUserLabel(u) {
const un = String(u.username ?? "").trim();
const dn = String(u.display_name ?? "").trim();
if (dn && un && dn.toLowerCase() !== un.toLowerCase()) {
return `${dn} (${un})`;
}
return dn || un || "—";
}
function formatAuditActor(r) {
const un = String(r.actor_username ?? "").trim();
const dn = String(r.actor_display_name ?? "").trim();
const id = String(r.actor_user_id ?? "").trim();
if (dn && un && dn.toLowerCase() !== un.toLowerCase()) {
return `${dn} (${un})`;
}
if (dn || un) return dn || un;
return id ? id.slice(0, 8) + (id.length > 8 ? "…" : "") : "—";
}
function getRoute() {
const raw = (location.hash || "#/stack").replace(/^#\//, "");
const [pageRaw, queryRaw] = raw.split("?", 2);
let page = (pageRaw || "stack").trim();
if (page === "channels") page = "stack";
if (page === "tenants") page = "users";
const params = new URLSearchParams(queryRaw || "");
return { page, params };
}
/** 当控制台挂在子路径(如 /gw/admin)时,把 /admin/api/... 解析为 /gw/admin/api/... */
function resolveAdminApiUrl(path) {
const p = String(path || "");
if (!p.startsWith("/admin/")) return p;
const pathname = (location.pathname || "").replace(/\/+$/, "") || "/";
const marker = "/admin";
const pos = pathname.lastIndexOf(marker);
if (pos <= 0) return p;
return pathname.slice(0, pos) + p;
}
function resolveChatUrl() {
const pathname = (location.pathname || "").replace(/\/+$/, "") || "/";
const marker = "/admin";
if (pathname.endsWith(marker)) {
return `${pathname.slice(0, -marker.length) || ""}/chat`;
}
return "/chat";
}
function getStoredAuthToken() {
return String(authStoreGet(AUTH_TOKEN_KEY) || "").trim();
}
/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */
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(() => {
const fn = state.reauthHandler;
if (typeof fn === "function") fn();
}, 0);
}
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(() => {});
}
async function apiGet(path) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { "accept": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (res.status === 401) {
scheduleReauthAfter401(url);
return await _haltAfter401();
}
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
return await res.json();
}
async function apiGetOptional(path) {
try {
return await apiGet(path);
} catch (err) {
console.warn(`apiGetOptional failed path=${String(path || "")} err=${String(err)}`);
return null;
}
}
async function apiGetNoHang(path) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { "accept": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (res.status === 401) {
scheduleReauthAfter401(url);
// Returning null avoids Promise.all / await from hanging forever.
return null;
}
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
return await res.json();
}
async function apiPost(path, body) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { "content-type": "application/json", "accept": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = null;
}
if (res.status === 401) {
const isAuthEndpoint =
url.includes("/admin/api/auth/login") || url.includes("/admin/api/auth/bootstrap");
// Login/bootstrap must never hang on the never-resolving 401 sentinel.
if (isAuthEndpoint) {
return data && typeof data === "object" ? data : { ok: false, error: "unauthorized" };
}
scheduleReauthAfter401(url);
return await _haltAfter401();
}
if (!res.ok) {
const d = data && typeof data === "object" ? data : {};
const detail = (d).detail != null ? String((d).detail) : "";
const errKey = (d).error != null ? String((d).error) : "";
const msg = detail || errKey || `POST ${url} ${res.status}`;
throw new Error(msg);
}
return data ?? {};
}
/** Skills endpoints often return HTTP 200 with `{ ok: false, result: {...} }` on failure — treat as error for UX. */
function assertSkillMutationOk(r, fallbackMessage) {
if (!r || r.ok !== false) return;
const res = r.result && typeof r.result === "object" ? r.result : {};
const code = res.error_code != null ? String(res.error_code).trim() : "";
const detail = res.detail != null ? String(res.detail).trim() : "";
const msg = [code, detail].filter(Boolean).join(": ") || String(fallbackMessage || "skill operation failed");
throw new Error(msg);
}
async function apiRequest(method, path, body) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { "accept": "application/json" };
if (token) headers["authorization"] = `Bearer ${token}`;
if (body !== undefined && method !== "GET" && method !== "HEAD") {
headers["content-type"] = "application/json";
}
const res = await fetch(url, {
method,
headers,
body: body !== undefined && method !== "GET" && method !== "HEAD" ? JSON.stringify(body ?? {}) : undefined,
});
if (res.status === 401) {
scheduleReauthAfter401(url);
return await _haltAfter401();
}
if (!res.ok) {
let msg = `${method} ${url} ${res.status}`;
try {
const j = await res.json();
if (j && j.detail !== undefined) {
msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
}
} catch (_) {}
throw new Error(msg);
}
const ct = res.headers.get("content-type") || "";
if (!ct.includes("application/json")) return {};
return await res.json();
}
async function apiPostFormData(path, formData) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = {};
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "POST", headers, body: formData });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = null;
}
if (res.status === 401) {
scheduleReauthAfter401(url);
return await _haltAfter401();
}
if (!res.ok) {
const d = data && typeof data === "object" ? data : {};
const detail = d.detail != null ? String(d.detail) : "";
const errKey = d.error != null ? String(d.error) : "";
throw new Error(detail || errKey || `POST ${url} ${res.status}`);
}
return data ?? {};
}
async function apiDeleteJson(path) {
const url = resolveAdminApiUrl(path);
const token = getStoredAuthToken();
const headers = { accept: "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(url, { method: "DELETE", headers });
if (res.status === 401) {
scheduleReauthAfter401(url);
return await _haltAfter401();
}
if (!res.ok) {
let msg = `DELETE ${url} ${res.status}`;
try {
const j = await res.json();
if (j && j.detail !== undefined) msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
} catch (_) {}
throw new Error(msg);
}
try {
return await res.json();
} catch (_) {
return {};
}
}
function _activeCopyCell() {
return document.querySelector(".cell-copyable.cell-selected");
}
function _selectCopyCell(cell) {
const prev = _activeCopyCell();
if (prev && prev !== cell) prev.classList.remove("cell-selected");
if (cell) cell.classList.add("cell-selected");
}
function _shouldIgnoreCopyShortcut(ev) {
const t = ev && ev.target ? ev.target : null;
if (!t || !(t instanceof HTMLElement)) return false;
const tag = String(t.tagName || "").toLowerCase();
if (t.isContentEditable) return true;
return tag === "input" || tag === "textarea" || tag === "select";
}
function _installCellCopyKeyboardShortcut() {
if (window.__adminCellCopyShortcutInstalled) return;
window.__adminCellCopyShortcutInstalled = true;
document.addEventListener("keydown", async (ev) => {
if (_shouldIgnoreCopyShortcut(ev)) return;
if (!(ev.ctrlKey || ev.metaKey) || String(ev.key || "").toLowerCase() !== "c") return;
const active = _activeCopyCell();
if (!active) return;
try {
const txt = String(active.getAttribute("data-copy-text") || active.getAttribute("title") || active.textContent || "");
if (!txt.trim()) return;
ev.preventDefault();
await navigator.clipboard.writeText(txt);
active.classList.add("cell-copied");
setTimeout(() => active.classList.remove("cell-copied"), 2600);
} catch (_) {
// no-op
}
});
}
function attachCellCopyBehavior(cell, getCopyText) {
if (!cell || cell.dataset.copyBound === "1") return;
_installCellCopyKeyboardShortcut();
cell.dataset.copyBound = "1";
cell.classList.add("cell-copyable");
cell.addEventListener("click", () => {
_selectCopyCell(cell);
});
cell.addEventListener("dblclick", async () => {
try {
const txt = String(typeof getCopyText === "function" ? getCopyText() : (cell.textContent || ""));
if (!txt) return;
cell.setAttribute("data-copy-text", txt);
await navigator.clipboard.writeText(txt);
cell.classList.add("cell-copied");
setTimeout(() => cell.classList.remove("cell-copied"), 2600);
} catch (_) {
// keep tooltip fallback when clipboard API unavailable
}
});
}
function el(tag, attrs = {}, children = []) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null) continue;
if (k === "class") e.className = v;
else if (k === "text") e.textContent = v;
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
else if (k === "disabled" && v === false) {
e.removeAttribute("disabled");
} else e.setAttribute(k, v);
}
for (const c of children) e.appendChild(c);
if (String(tag || "").toLowerCase() === "td" && String(e.getAttribute("data-copy-disabled") || "") !== "1") {
const txt = String(e.textContent || "").trim();
if (txt) {
if (!e.getAttribute("title")) e.setAttribute("title", txt);
attachCellCopyBehavior(e, () => String(e.getAttribute("title") || e.textContent || ""));
}
}
return e;
}
function tdCell(value, maxLen = 120) {
const full = String(value ?? "");
const shown = formatSystemLocalDateTime(full);
const title = shown === full ? full : `${shown}\n${full}`;
const td = el("td", { text: shortText(shown, maxLen), title });
td.setAttribute("data-copy-text", shown);
attachCellCopyBehavior(td, () => shown);
return td;
}
function enableTableColumnResize(tableEl, columnIndexes = []) {
if (!tableEl) return;
const heads = Array.from(tableEl.querySelectorAll("thead th"));
if (!heads.length) return;
tableEl.classList.add("table--resizable");
heads.forEach((th) => th.querySelectorAll(".table-col-resizer").forEach((x) => x.remove()));
const enabled = new Set(Array.isArray(columnIndexes) ? columnIndexes : []);
heads.forEach((th, idx) => {
if (enabled.size && !enabled.has(idx)) return;
const handle = document.createElement("span");
handle.className = "table-col-resizer";
handle.addEventListener("mousedown", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const startX = ev.clientX;
const startW = th.getBoundingClientRect().width;
document.body.classList.add("col-resize-active");
const onMove = (mv) => {
const next = Math.max(72, Math.round(startW + (mv.clientX - startX)));
th.style.width = `${next}px`;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.classList.remove("col-resize-active");
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
th.appendChild(handle);
});
}
function setActive(page) {
document.querySelectorAll(".nav__item").forEach((a) => {
a.classList.toggle("nav__item--active", a.dataset.page === page);
});
document.getElementById("topTitle").textContent = ({
stack: t("title.stack"),
"scheduled-jobs": t("title.scheduledJobs"),
users: t("title.users"),
memory: t("title.memory"),
models: t("title.models"),
"api-grants": t("title.apiGrants"),
audit: t("title.audit"),
"session-monitor": t("title.sessionMonitor"),
"admin-audit": t("title.adminAudit"),
plugins: t("title.plugins"),
skills: t("title.skills"),
attachments: t("title.attachments"),
"workspace-paths": t("title.workspacePaths"),
profile: t("title.profile"),
}[page] || page);
}
function mount(node) {
const c = document.getElementById("content");
c.innerHTML = "";
c.appendChild(node);
}
function renderPageShell(opts = {}, children = []) {
const title = String(opts.title || "").trim();
const subtitle = String(opts.subtitle || "").trim();
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean) : [];
const sections = Array.isArray(opts.sections) ? opts.sections.filter((x) => x && x.id && x.label) : [];
const head = [];
if (title || subtitle || actions.length) {
const left = [];
if (title) left.push(el("h1", { class: "page-shell__title", text: title }));
if (subtitle) left.push(el("div", { class: "page-shell__subtitle muted", text: subtitle }));
head.push(
el("div", { class: "page-shell__header" }, [
el("div", { class: "page-shell__headline" }, left),
el("div", { class: "page-shell__actions row" }, actions),
]),
);
if (sections.length > 1) {
head.push(
el(
"div",
{ class: "page-shell__toc", role: "navigation", "aria-label": "page sections" },
sections.map((s) =>
el("button", {
class: "page-shell__tocItem",
type: "button",
text: String(s.label),
onclick: () => {
const target = document.getElementById(String(s.id));
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
},
}),
),
),
);
}
}
return el("section", { class: "page-shell" }, [...head, ...children.filter(Boolean)]);
}
function renderSectionCard(title, subtitle, bodyNodes = [], opts = {}) {
const headlineKids = [el("div", { class: "card__title", text: title })];
if (subtitle) headlineKids.push(el("div", { class: "card__subtitle muted", text: subtitle }));
const headKids = [el("div", { class: "card__headline" }, headlineKids)];
if (Array.isArray(opts.actions) && opts.actions.length) {
headKids.push(el("div", { class: "card__actions row" }, opts.actions));
}
const nodes = [el("div", { class: "card__head" }, headKids)];
bodyNodes.filter(Boolean).forEach((node) => nodes.push(node));
const attrs = { class: "card section-card" };
if (opts.id) attrs.id = String(opts.id);
return el("div", attrs, nodes);
}
function renderMetaChips(entries) {
const items = [];
const src = entries && typeof entries === "object" ? entries : {};
Object.keys(src).forEach((k) => {
const v = src[k];
if (v == null || v === "") return;
items.push(el("span", { class: "meta-chip", text: `${k}=${v}` }));
});
return el("div", { class: "meta-line" }, items.length ? items : [el("span", { class: "muted", text: "—" })]);
}
function shortText(v, maxLen = 200) {
const s = String(v ?? "");
if (s.length <= maxLen) return s;
return s.slice(0, Math.max(0, maxLen - 3)) + "...";
}
function formatSystemLocalDateTime(value) {
const raw = String(value ?? "").trim();
if (!raw) return "";
// Keep non-datetime strings unchanged.
if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) return raw;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
const locale = state.currentLang === "zh" ? "zh-CN" : "en-US";
try {
return d.toLocaleString(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
} catch (_) {
return raw;
}
}
function formatIds(v) {
if (!Array.isArray(v)) return "";
const vals = v.map((x) => String(x ?? "").trim()).filter(Boolean);
if (!vals.length) return "";
if (vals.length > 4) return vals.slice(0, 4).join(", ") + ` (+${vals.length - 4})`;
return vals.join(", ");
}
function yesNo(v) {
return v ? "yes" : "no";
}
let runtimePrewarmReminder = "";
function markPrewarmReminder(reason) {
const why = String(reason || "").trim();
const base = t("stack.configChangedPrewarm");
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
}
/**
* Compact row action menu (button + fixed popover).
* Must NOT use nested &lt;details&gt; inside plugins-fold details — clicks get swallowed.
* items: [{ label, onClick, danger?, disabled? }]
*/
function rowActions(label, items) {
let menuEl = null;
let onDoc = null;
const close = () => {
if (onDoc) {
document.removeEventListener("click", onDoc, true);
onDoc = null;
}
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
menuEl = null;
};
const btn = el("button", {
class: "chat-sess-more row-actions__btn",
type: "button",
text: String(label || "⋯"),
title: "actions",
"aria-label": "actions",
onclick: (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (menuEl) {
close();
try {
btn.blur();
} catch (_) {}
return;
}
const menu = el("div", { class: "chat-sess-menu-pop row-actions__menu u-overlay-fixed" });
menu.style.visibility = "hidden";
(Array.isArray(items) ? items : []).forEach((it) => {
if (!it || !it.label) return;
menu.appendChild(
el("button", {
class: "chat-sess-menu-item" + (it.danger ? " row-actions__item--danger" : ""),
type: "button",
text: String(it.label),
disabled: !!it.disabled,
onclick: async (e2) => {
e2.preventDefault();
e2.stopPropagation();
close();
try {
btn.blur();
} catch (_) {}
if (typeof it.onClick !== "function") return;
try {
await it.onClick();
} catch (err) {
console.warn("row action failed", err);
window.alert(String((err && err.message) || err || "action failed"));
}
},
}),
);
});
document.body.appendChild(menu);
const rect = ev.currentTarget.getBoundingClientRect();
const pad = 8;
const mrect = menu.getBoundingClientRect();
// Prefer opening to the left of the ⋯ (actions column is trailing).
let left = rect.right - mrect.width;
let top = rect.bottom + 4;
if (left < pad) {
left = Math.min(rect.left, window.innerWidth - pad - mrect.width);
}
if (top + mrect.height > window.innerHeight - pad) {
top = Math.max(pad, rect.top - 4 - mrect.height);
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.visibility = "visible";
menuEl = menu;
try {
btn.blur();
} catch (_) {}
onDoc = (e3) => {
if (menu.contains(e3.target) || btn.contains(e3.target)) return;
close();
};
setTimeout(() => document.addEventListener("click", onDoc, true), 0);
},
});
return btn;
}
/** Navigate/reload admin SPA without importing router (avoids cycles). */
function navigateAdmin() {
const fn = state.navigate;
if (typeof fn === "function") return fn();
return Promise.resolve();
}
export {
SESSION_MONITOR_ROLE_FILTER_KEY,
AUTH_TOKEN_KEY,
AUTH_SESSION_KEY,
CHAT_MEMORY_MODE_KEY,
authStoreGet,
authStoreSet,
authStoreRemove,
formatUserLabel,
formatAuditActor,
getRoute,
resolveAdminApiUrl,
resolveChatUrl,
getStoredAuthToken,
scheduleReauthAfter401,
_haltAfter401,
apiGet,
apiGetOptional,
apiGetNoHang,
apiPost,
assertSkillMutationOk,
apiRequest,
apiPostFormData,
apiDeleteJson,
_activeCopyCell,
_selectCopyCell,
_shouldIgnoreCopyShortcut,
_installCellCopyKeyboardShortcut,
attachCellCopyBehavior,
el,
tdCell,
enableTableColumnResize,
setActive,
mount,
renderPageShell,
renderSectionCard,
renderMetaChips,
shortText,
formatSystemLocalDateTime,
formatIds,
yesNo,
runtimePrewarmReminder,
markPrewarmReminder,
rowActions,
navigateAdmin,
state,
t,
tf,
applyI18nStatic,
toggleLang,
};