mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
b6391d1f90
Round-2 review fixes. The round-1 de-pinning collided with
ConfigStore.get's default-on-miss semantics: the registry defaults
(temperature 1.0, effort "medium") were manufactured onto every
store-backed lane's wire, making the documented "unset -> omit"
terminal unreachable. Unset is now representable end to end, and one
scheme governs every lane: per-model alias value > operator-stored
global setting > in-code model definition (effort only: caps
declaration) > field omitted, inference engine's default rules.
- settings_registry: model.temperature default None, model.reasoning_effort
default "" — the registered defaults ARE the unset sentinels, so the
admin UI and the wire agree. Admin webux renders nullable floats blank
("(inherit model default)") and maps blank-save to reset; the "" effort
choice reads "(inherit)".
- model_turn: resolve_temperature_setting/resolve_effort_setting are the
ONE pair of operator-rung resolvers, shared by resolve_lane, both
session factories, and the /model switch (the 4th-copy mirror is gone;
the switch no longer leaks the previous model's override on store-less
sessions). The caps rung moved out of the lane into model_turn's
effective computation, below a new request-shaped default_reasoning_effort
parameter (utility + output guard pass "low": budget coherence with
their small token caps, not sampling policy — any operator or
model-definition value beats it). The hidden "medium" terminal is gone.
- providers: Protocol + all adapters take reasoning_effort: str | None =
None (the Protocol-signature "medium" was the same manufactured pin one
layer down); ModelCapabilities.default_reasoning_effort defaults "" —
commercial rows all declare theirs explicitly, so only local lanes and
Anthropic change, both to match their real serving defaults (Anthropic
manual-thinking models no longer get implicit thinking-on-medium).
reasoning_template_kwargs distinguishes unset (inject nothing; template
default rules) from the explicit "none" off-switch. apply_temperature
skips temperature unless reasoning is EXPLICITLY off on none-declaring
models (unset leaves the server default in charge, possibly reasoning-on).
- session: ctor takes temperature: float | None / reasoning_effort:
str | None = None; _save_config/resume round-trip unset as "" (the
str(None) era guarded); _run_agent relays session temperature AND
effort on the same-alias fall-through only (a task alias's configured
knobs stay reachable in both directions).
- optimizer: the five meta lanes are decoupled from --temperature/
--reasoning-effort (test-model knobs, per their documented meaning);
registry-less meta lanes omit both fields.
- cli: --temperature/--reasoning-effort default unset and fall through
the model config instead of pinning 0.5/"medium" for every CLI session.
- cleanup from the review's below-cap findings: dead resolve_server_type
deleted (tests re-pointed at _server_type_of), stale ChatSession
comments in _openai_responses fixed, _store_get_or_none extracted,
eval system-turn conversion hoisted out of the per-turn loop, dead
_provider_extra_params patch removed, test_perception uses the shared
mock_completion_result, effort_ladder uses apply_capability_overrides
instead of a SimpleNamespace fake config.
Wire goldens regenerated: the only drift is the manufactured "medium"
effort vanishing from unset-effort requests (Responses reasoning.effort,
Chat/Google reasoning_effort, Anthropic output_config.effort) — pure
removals, no additions. Ladder tests now fake ConfigStore with the REAL
get() semantics (registry default on miss) so a forgiving fake can't
mask this class of bug again.
8659 lines
296 KiB
JavaScript
8659 lines
296 KiB
JavaScript
/* Admin panel — user & token management for turnstone console */
|
||
|
||
let _adminTab = "users";
|
||
let _adminUsers = [];
|
||
let _adminTokenUserId = "";
|
||
let _adminChannelUserId = "";
|
||
let _lastCreatedToken = "";
|
||
let _adminWatches = [];
|
||
let _confirmCallbackFn = null;
|
||
|
||
// Settings whose choices are populated dynamically from the live model
|
||
// alias list, and whose empty-string option renders as "(server default)".
|
||
const ALIAS_SETTING_KEYS = [
|
||
"model.default_alias",
|
||
"model.task_alias",
|
||
"channels.default_model_alias",
|
||
"audio.stt_model_alias",
|
||
"audio.tts_model_alias",
|
||
];
|
||
|
||
// Settings whose empty option means "inherit from a fallback chain", as
|
||
// opposed to "no value" — distinct from the literal "none" choice (e.g.
|
||
// reasoning_effort="none" actually disables reasoning, very different
|
||
// from leaving it unset).
|
||
const INHERIT_EMPTY_LABEL_KEYS = [
|
||
"model.task_effort",
|
||
"model.reasoning_effort",
|
||
"coordinator.reasoning_effort",
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin information architecture — the single source of truth for the rail's
|
||
// Manage groups (rail.js builds from this) AND in-pane tab activation. Each
|
||
// tab carries the permission scope that gates it, so the rail can filter
|
||
// without reaching into admin internals. `perm: null` = ungated (always
|
||
// shown — mirrors the legacy gate, which left node-metadata uncovered).
|
||
// ---------------------------------------------------------------------------
|
||
const ADMIN_IA = [
|
||
{
|
||
group: "Identity",
|
||
tabs: [
|
||
{ tab: "users", label: "Users", perm: "admin.users" },
|
||
{ tab: "tokens", label: "API Tokens", perm: "admin.users" },
|
||
{ tab: "channels", label: "Channels", perm: "admin.users" },
|
||
],
|
||
},
|
||
{
|
||
group: "Automation",
|
||
tabs: [
|
||
{ tab: "schedules", label: "Schedules", perm: "admin.schedules" },
|
||
{ tab: "watches", label: "Watches", perm: "admin.watches" },
|
||
],
|
||
},
|
||
{
|
||
group: "Governance",
|
||
tabs: [
|
||
{ tab: "projects", label: "Projects", perm: "project.read" },
|
||
{ tab: "personas", label: "Personas", perm: "persona.read" },
|
||
{ tab: "roles", label: "Roles", perm: "admin.roles" },
|
||
{ tab: "policies", label: "Policies", perm: "admin.policies" },
|
||
{
|
||
tab: "prompt-policies",
|
||
label: "Prompts",
|
||
perm: "admin.prompt_policies",
|
||
},
|
||
{ tab: "judge", label: "Judge", perm: "admin.judge" },
|
||
],
|
||
},
|
||
{
|
||
group: "Extensions",
|
||
tabs: [
|
||
{ tab: "skills", label: "Skills", perm: "admin.skills" },
|
||
{ tab: "mcp", label: "MCP Servers", perm: "admin.mcp" },
|
||
],
|
||
},
|
||
{
|
||
group: "Observe",
|
||
tabs: [
|
||
{ tab: "usage", label: "Usage", perm: "admin.usage" },
|
||
{ tab: "audit", label: "Audit", perm: "admin.audit" },
|
||
{ tab: "memories", label: "Memories", perm: "admin.memories" },
|
||
],
|
||
},
|
||
{
|
||
group: "System",
|
||
tabs: [
|
||
{ tab: "models", label: "Models", perm: "admin.models" },
|
||
{ tab: "node-metadata", label: "Nodes", perm: null },
|
||
{ tab: "settings", label: "Settings", perm: "admin.settings" },
|
||
{ tab: "tls", label: "TLS", perm: "admin.settings" },
|
||
],
|
||
},
|
||
];
|
||
|
||
function _adminTabMeta(tab) {
|
||
for (const grp of ADMIN_IA) {
|
||
for (const t of grp.tabs) if (t.tab === tab) return t;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Mirror the legacy showAdmin gate exactly: only gate when a permission string
|
||
// is present (unknown perms → show everything); ungated tabs (perm: null) and
|
||
// tabs whose scope the user holds are allowed.
|
||
function adminTabAllowed(tab) {
|
||
const raw = sessionStorage.getItem("turnstone_permissions");
|
||
if (!raw) return true;
|
||
const meta = _adminTabMeta(tab);
|
||
const needed = meta && meta.perm;
|
||
if (!needed) return true;
|
||
return raw.split(",").indexOf(needed) >= 0;
|
||
}
|
||
|
||
function _firstAllowedAdminTab() {
|
||
for (const grp of ADMIN_IA) {
|
||
for (const t of grp.tabs) if (adminTabAllowed(t.tab)) return t.tab;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// No-permissions empty state in the admin content host (ported from the legacy
|
||
// sidebar gate — still reachable when a user holds no admin scope at all).
|
||
function _showAdminNoPermissions() {
|
||
const panels = document.querySelectorAll(".admin-panel");
|
||
for (let j = 0; j < panels.length; j++) panels[j].style.display = "none";
|
||
let empty = document.getElementById("admin-no-permissions");
|
||
if (!empty) {
|
||
empty = document.createElement("div");
|
||
empty.id = "admin-no-permissions";
|
||
empty.className = "dashboard-empty";
|
||
empty.textContent = "You do not have permissions to view any admin tabs.";
|
||
const content = document.getElementById("admin-content");
|
||
if (content) content.appendChild(empty);
|
||
}
|
||
empty.style.display = "";
|
||
}
|
||
|
||
// The rail's Manage groups subscribe here to mirror the active admin tab.
|
||
const _adminTabSubs = [];
|
||
function _notifyAdminTab(tab) {
|
||
for (const cb of _adminTabSubs) {
|
||
try {
|
||
cb(tab);
|
||
} catch (e) {
|
||
/* a faulty subscriber must not break tab switching */
|
||
}
|
||
}
|
||
}
|
||
|
||
// Seam consumed by rail.js (the Manage section): the admin IA, its permission
|
||
// gate, the active-tab subscription, the current tab, and the open-on-tab entry
|
||
// point (a row click opens/focuses the Admin pane on that tab).
|
||
window.TS_ADMIN = window.TS_ADMIN || {};
|
||
window.TS_ADMIN.ia = ADMIN_IA;
|
||
window.TS_ADMIN.isTabAllowed = adminTabAllowed;
|
||
window.TS_ADMIN.onTabChange = function (cb) {
|
||
if (typeof cb === "function") _adminTabSubs.push(cb);
|
||
};
|
||
window.TS_ADMIN.getActiveTab = function () {
|
||
return _adminTab;
|
||
};
|
||
window.TS_ADMIN.openTab = function (tab) {
|
||
showAdmin(tab);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// View switching (called from app.js showHome/drillDown pattern + the rail)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function showAdmin(tab) {
|
||
// The admin surface is a singleton pane now — the L-shell PaneManager owns
|
||
// show/hide and the rail's Manage groups are its navigation. Open/focus the
|
||
// pane (its onMount adopts #view-admin), then activate a tab the user may see.
|
||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||
if (pm && pm.hasType("admin")) pm.openPane("admin");
|
||
|
||
const target =
|
||
tab ||
|
||
(_adminTab && adminTabAllowed(_adminTab)
|
||
? _adminTab
|
||
: _firstAllowedAdminTab());
|
||
if (target) switchAdminTab(target);
|
||
else _showAdminNoPermissions();
|
||
}
|
||
|
||
function switchAdminTab(tab) {
|
||
const tabChanged = tab !== _adminTab;
|
||
_adminTab = tab;
|
||
// Hide no-permissions empty state if it was showing
|
||
const noPerms = document.getElementById("admin-no-permissions");
|
||
if (noPerms) noPerms.style.display = "none";
|
||
const panels = [
|
||
"users",
|
||
"tokens",
|
||
"channels",
|
||
"schedules",
|
||
"watches",
|
||
"projects",
|
||
"personas",
|
||
"roles",
|
||
"policies",
|
||
"skills",
|
||
"usage",
|
||
"audit",
|
||
"memories",
|
||
"models",
|
||
"node-metadata",
|
||
"settings",
|
||
"tls",
|
||
"mcp",
|
||
"prompt-policies",
|
||
"judge",
|
||
];
|
||
for (let p = 0; p < panels.length; p++) {
|
||
const el = document.getElementById("admin-" + panels[p]);
|
||
if (el) el.style.display = panels[p] === tab ? "" : "none";
|
||
}
|
||
|
||
// One #admin-content scroller serves every panel — a leftover offset from
|
||
// a tall tab must not carry into the next (the app.js #main reset is the
|
||
// precedent for pane-local navigation). Same-tab re-entry (showAdmin on
|
||
// pane focus) keeps the user's place.
|
||
if (tabChanged) {
|
||
const contentEl = document.getElementById("admin-content");
|
||
if (contentEl) contentEl.scrollTop = 0;
|
||
}
|
||
|
||
if (tab === "users") loadAdminUsers();
|
||
if (tab === "tokens") _populateTokenUserSelect();
|
||
if (tab === "channels") _populateChannelUserSelect();
|
||
if (tab === "schedules") loadAdminSchedules();
|
||
if (tab === "watches") loadAdminWatches();
|
||
if (tab === "projects") loadAdminProjects();
|
||
if (tab === "personas") loadAdminPersonas();
|
||
if (tab === "roles") loadGovRoles();
|
||
if (tab === "policies") loadGovPolicies();
|
||
if (tab === "skills") loadGovSkills();
|
||
if (tab === "usage") loadGovUsage();
|
||
if (tab === "audit") {
|
||
_populateAuditUserFilter();
|
||
loadGovAudit();
|
||
}
|
||
if (tab === "memories") loadAdminMemories();
|
||
if (tab === "models") loadAdminModels();
|
||
if (tab === "node-metadata") loadAdminNodeMetadata();
|
||
if (tab === "settings") loadSettings();
|
||
if (tab === "tls") loadTlsCerts();
|
||
if (tab === "mcp") loadAdminMcp();
|
||
if (tab === "prompt-policies") loadPromptPolicies();
|
||
if (tab === "judge") loadJudgeTab();
|
||
|
||
// Mirror the active tab into the rail's Manage groups (the in-pane sidebar
|
||
// that used to carry the active state is retired — the rail navigates now).
|
||
_notifyAdminTab(tab);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Row action overflow menu (kebab)
|
||
// ---------------------------------------------------------------------------
|
||
// Shared affordance for every admin table's ACTIONS column. _kebabMenu()
|
||
// returns the markup string; _kebabMenuEl() the equivalent DOM node for the
|
||
// few tables that build rows with createElement. Each menu item carries the
|
||
// SAME data-* attribute its inline-button predecessor had, so every existing
|
||
// click binding (querySelectorAll('[data-...]')) keeps working untouched —
|
||
// only the markup generation changes. _initKebabMenus() wires open/close,
|
||
// outside-click, Escape, scroll/resize dismissal, viewport-aware flip-up, and
|
||
// arrow-key navigation once, via document-level delegation, so it covers rows
|
||
// rendered by both admin.js and governance.js.
|
||
|
||
let _kebabInit = false;
|
||
// Tracks whether any menu is open, so the high-frequency scroll/resize
|
||
// dismiss listeners can skip the DOM query in the common (nothing-open) case.
|
||
let _anyKebabOpen = false;
|
||
|
||
function _kebabBtnClass(kind) {
|
||
if (kind === "danger") return "admin-btn-danger";
|
||
if (kind === "caution") return "admin-btn-caution";
|
||
return "admin-btn-action";
|
||
}
|
||
|
||
function _kebabAttrString(attrs) {
|
||
let out = "";
|
||
if (attrs) {
|
||
const keys = Object.keys(attrs);
|
||
for (let k = 0; k < keys.length; k++) {
|
||
out += " " + keys[k] + '="' + escapeHtml(String(attrs[keys[k]])) + '"';
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function _kebabMenu(items) {
|
||
// items: array of { label, kind?: "danger" | "caution", title?, attrs? }.
|
||
// Falsy entries are dropped so callers can inline conditionals; an empty
|
||
// list yields "" so a row with no applicable actions renders no kebab.
|
||
const real = (items || []).filter(Boolean);
|
||
if (!real.length) return "";
|
||
if (real.length === 1) {
|
||
// A lone action doesn't earn the open-the-menu click — render it as a
|
||
// direct inline button. Keeps the same data-* attrs (so existing
|
||
// bindings still match) and the familiar bordered button look.
|
||
const it = real[0];
|
||
const title = it.title ? ' title="' + escapeHtml(it.title) + '"' : "";
|
||
return (
|
||
'<button type="button" class="' +
|
||
_kebabBtnClass(it.kind) +
|
||
'"' +
|
||
_kebabAttrString(it.attrs) +
|
||
title +
|
||
">" +
|
||
escapeHtml(it.label) +
|
||
"</button>"
|
||
);
|
||
}
|
||
let inner = "";
|
||
for (let i = 0; i < real.length; i++) {
|
||
const it = real[i];
|
||
let cls = "admin-kebab-item";
|
||
if (it.kind === "danger") cls += " admin-kebab-item--danger";
|
||
else if (it.kind === "caution") cls += " admin-kebab-item--caution";
|
||
const title = it.title ? ' title="' + escapeHtml(it.title) + '"' : "";
|
||
inner +=
|
||
'<button type="button" class="' +
|
||
cls +
|
||
'" role="menuitem"' +
|
||
_kebabAttrString(it.attrs) +
|
||
title +
|
||
">" +
|
||
escapeHtml(it.label) +
|
||
"</button>";
|
||
}
|
||
return (
|
||
'<div class="admin-kebab">' +
|
||
'<button type="button" class="admin-kebab-btn" aria-haspopup="true" ' +
|
||
'aria-expanded="false" aria-label="Row actions" title="Row actions">' +
|
||
"⋯" +
|
||
"</button>" +
|
||
'<div class="admin-kebab-menu" role="menu">' +
|
||
inner +
|
||
"</div>" +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function _kebabMenuEl(items) {
|
||
// DOM-node variant for createElement-built rows. Parsed via DOMParser
|
||
// (same as setSafeHtml) rather than innerHTML; _kebabMenu() has already
|
||
// escaped every interpolated value.
|
||
const html = _kebabMenu(items);
|
||
if (!html) return null;
|
||
const parsed = new DOMParser().parseFromString(html, "text/html");
|
||
return parsed.body.firstElementChild;
|
||
}
|
||
|
||
function _closeAllKebabs() {
|
||
if (!_anyKebabOpen) return;
|
||
_anyKebabOpen = false;
|
||
const open = document.querySelectorAll(".admin-kebab.is-open");
|
||
for (let i = 0; i < open.length; i++) {
|
||
open[i].classList.remove("is-open");
|
||
const menu = open[i].querySelector(".admin-kebab-menu");
|
||
if (menu) menu.classList.remove("flip-up", "flip-left");
|
||
const btn = open[i].querySelector(".admin-kebab-btn");
|
||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||
}
|
||
}
|
||
|
||
function _openKebab(kebab) {
|
||
_closeAllKebabs();
|
||
kebab.classList.add("is-open");
|
||
_anyKebabOpen = true;
|
||
const btn = kebab.querySelector(".admin-kebab-btn");
|
||
const menu = kebab.querySelector(".admin-kebab-menu");
|
||
if (btn) btn.setAttribute("aria-expanded", "true");
|
||
if (!menu) return;
|
||
// Steer the menu back into the viewport: flip above the trigger if a
|
||
// downward menu would pass the bottom edge, and open rightward if a
|
||
// right-anchored menu would run off the left edge (narrow viewports where
|
||
// the actions column isn't flush against the right of the screen).
|
||
menu.classList.remove("flip-up", "flip-left");
|
||
const rect = menu.getBoundingClientRect();
|
||
if (rect.bottom > window.innerHeight - 8) menu.classList.add("flip-up");
|
||
if (rect.left < 8) menu.classList.add("flip-left");
|
||
// Move focus into the menu (WAI-ARIA menu-button pattern). preventScroll
|
||
// stops the scroll-dismiss listener from firing as the menu opens.
|
||
const first = menu.querySelector(".admin-kebab-item");
|
||
if (first) first.focus({ preventScroll: true });
|
||
}
|
||
|
||
function _initKebabMenus() {
|
||
if (_kebabInit) return;
|
||
_kebabInit = true;
|
||
|
||
document.addEventListener("click", function (e) {
|
||
const trigger = e.target.closest(".admin-kebab-btn");
|
||
if (trigger) {
|
||
const kebab = trigger.closest(".admin-kebab");
|
||
if (kebab.classList.contains("is-open")) _closeAllKebabs();
|
||
else _openKebab(kebab);
|
||
return;
|
||
}
|
||
// A menu item's own data-* handler runs first (target phase); we just
|
||
// dismiss the menu afterwards as the click bubbles to the document.
|
||
if (e.target.closest(".admin-kebab-item")) {
|
||
_closeAllKebabs();
|
||
return;
|
||
}
|
||
// Any other click closes an open menu.
|
||
_closeAllKebabs();
|
||
});
|
||
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape") {
|
||
const open = document.querySelector(".admin-kebab.is-open");
|
||
if (open) {
|
||
const btn = open.querySelector(".admin-kebab-btn");
|
||
_closeAllKebabs();
|
||
if (btn) btn.focus();
|
||
}
|
||
return;
|
||
}
|
||
const trigger = e.target.closest(".admin-kebab-btn");
|
||
if (trigger && e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
_openKebab(trigger.closest(".admin-kebab"));
|
||
return;
|
||
}
|
||
const menu = e.target.closest(".admin-kebab-menu");
|
||
if (!menu) return;
|
||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
const items = Array.prototype.slice.call(
|
||
menu.querySelectorAll(".admin-kebab-item"),
|
||
);
|
||
const cur = items.indexOf(document.activeElement);
|
||
const delta = e.key === "ArrowDown" ? 1 : -1;
|
||
const next = items[(cur + delta + items.length) % items.length];
|
||
if (next) next.focus();
|
||
} else if (e.key === "Tab") {
|
||
// Don't let Tab walk focus into the page behind an open menu.
|
||
_closeAllKebabs();
|
||
}
|
||
});
|
||
|
||
// The menu is anchored to its cell and rides document scroll, but an
|
||
// independent page/ancestor scroll or a resize can leave it mispositioned —
|
||
// dismiss. Capture phase catches descendant scrolls too, so skip scrolls
|
||
// originating INSIDE the menu (it can overflow-y:auto at high zoom / short
|
||
// viewports) — those must scroll the menu, not close it.
|
||
window.addEventListener(
|
||
"scroll",
|
||
function (e) {
|
||
const t = e.target;
|
||
if (t && t.closest && t.closest(".admin-kebab-menu")) return;
|
||
_closeAllKebabs();
|
||
},
|
||
true,
|
||
);
|
||
window.addEventListener("resize", _closeAllKebabs);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Users
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadAdminUsers() {
|
||
authFetch("/v1/api/admin/users")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load users");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_adminUsers = data.users || [];
|
||
_renderUsers(_adminUsers);
|
||
_populateTokenUserSelect();
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-users-table"),
|
||
'<div class="dashboard-empty">Failed to load users</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderUsers(users) {
|
||
const container = document.getElementById("admin-users-table");
|
||
if (!users.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No users yet. Create one to get started.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < users.length; i++) {
|
||
const u = users[i];
|
||
html +=
|
||
'<div class="admin-row" role="listitem" data-expandable data-user-id="' +
|
||
escapeHtml(u.user_id) +
|
||
'" data-username="' +
|
||
escapeHtml(u.username) +
|
||
'" tabindex="0" aria-expanded="false">' +
|
||
'<span class="admin-col admin-col-username">' +
|
||
'<span class="admin-expand-indicator" aria-hidden="true">\u25b8</span>' +
|
||
escapeHtml(u.username) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-name">' +
|
||
escapeHtml(u.display_name) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
escapeHtml(u.created || "").slice(0, 10) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu([
|
||
{
|
||
label: "roles",
|
||
title: "Manage roles",
|
||
attrs: { "data-user-roles": u.user_id },
|
||
},
|
||
{
|
||
label: "delete",
|
||
kind: "danger",
|
||
title: "Delete user",
|
||
attrs: { "data-delete-user": u.user_id, "data-username": u.username },
|
||
},
|
||
]) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
// Bind roles buttons
|
||
const roleBtns = container.querySelectorAll("[data-user-roles]");
|
||
for (let rj = 0; rj < roleBtns.length; rj++) {
|
||
roleBtns[rj].addEventListener("click", function () {
|
||
showUserRolesModal(this.getAttribute("data-user-roles"));
|
||
});
|
||
}
|
||
// Bind delete buttons via delegation (avoids inline JS injection)
|
||
const btns = container.querySelectorAll("[data-delete-user]");
|
||
for (let j = 0; j < btns.length; j++) {
|
||
btns[j].addEventListener("click", function () {
|
||
confirmDeleteUser(
|
||
this.getAttribute("data-delete-user"),
|
||
this.getAttribute("data-username"),
|
||
);
|
||
});
|
||
}
|
||
// Bind expandable row click + keyboard handlers for OIDC detail panel
|
||
const rows = container.querySelectorAll(".admin-row[data-expandable]");
|
||
for (let k = 0; k < rows.length; k++) {
|
||
(function (row) {
|
||
const _expand = function () {
|
||
const uid = row.getAttribute("data-user-id");
|
||
const uname = row.getAttribute("data-username");
|
||
_toggleOidcPanel(uid, uname, row);
|
||
};
|
||
row.addEventListener("click", function (e) {
|
||
// Clicks on the row's action menu (kebab trigger or any item) must
|
||
// not also toggle the OIDC detail panel.
|
||
if (
|
||
e.target.closest(".admin-kebab") ||
|
||
e.target.closest(".admin-btn-danger") ||
|
||
e.target.closest(".admin-btn-action")
|
||
)
|
||
return;
|
||
_expand();
|
||
});
|
||
row.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
_expand();
|
||
}
|
||
});
|
||
})(rows[k]);
|
||
}
|
||
}
|
||
|
||
function confirmDeleteUser(userId, username) {
|
||
showConfirmModal(
|
||
"Delete User",
|
||
"Delete user \u2018" +
|
||
username +
|
||
"\u2019 and all their tokens and channel links? This cannot be undone.",
|
||
"Delete",
|
||
function () {
|
||
authFetch("/v1/api/admin/users/" + encodeURIComponent(userId), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Delete failed");
|
||
showToast("User '" + username + "' deleted");
|
||
loadAdminUsers();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete user");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OIDC identity expansion in Users tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function _toggleOidcPanel(userId, username, rowEl) {
|
||
const existing = rowEl.nextElementSibling;
|
||
if (existing && existing.classList.contains("oidc-detail-panel")) {
|
||
// Collapse
|
||
existing.style.maxHeight = "0";
|
||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||
if (indicator) indicator.classList.remove("expanded");
|
||
rowEl.setAttribute("aria-expanded", "false");
|
||
setTimeout(function () {
|
||
if (existing.parentNode) existing.remove();
|
||
}, 160);
|
||
return;
|
||
}
|
||
// Collapse any other open panel first
|
||
const openPanels = document.querySelectorAll(
|
||
"#admin-users-table .oidc-detail-panel",
|
||
);
|
||
for (let i = 0; i < openPanels.length; i++) {
|
||
openPanels[i].style.maxHeight = "0";
|
||
const prevRow = openPanels[i].previousElementSibling;
|
||
if (prevRow) {
|
||
const ind = prevRow.querySelector(".admin-expand-indicator");
|
||
if (ind) ind.classList.remove("expanded");
|
||
prevRow.setAttribute("aria-expanded", "false");
|
||
}
|
||
(function (panel) {
|
||
setTimeout(function () {
|
||
if (panel.parentNode) panel.remove();
|
||
}, 160);
|
||
})(openPanels[i]);
|
||
}
|
||
// Mark expanded
|
||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||
if (indicator) indicator.classList.add("expanded");
|
||
rowEl.setAttribute("aria-expanded", "true");
|
||
// Create panel (role="none" so it doesn't break the parent role="list")
|
||
const panel = document.createElement("div");
|
||
panel.className = "oidc-detail-panel";
|
||
panel.setAttribute("role", "none");
|
||
setSafeHtml(
|
||
panel,
|
||
'<div class="oidc-detail-inner">' +
|
||
'<div class="oidc-detail-header">OIDC Identities</div>' +
|
||
'<div class="oidc-detail-body"><span class="oidc-detail-empty">Loading\u2026</span></div>' +
|
||
"</div>",
|
||
);
|
||
rowEl.after(panel);
|
||
// Animate open
|
||
requestAnimationFrame(function () {
|
||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||
});
|
||
// Fetch identities
|
||
authFetch(
|
||
"/v1/api/admin/users/" + encodeURIComponent(userId) + "/oidc-identities",
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderOidcDetail(panel, data.oidc_identities || [], userId, username);
|
||
})
|
||
.catch(function () {
|
||
const body = panel.querySelector(".oidc-detail-body");
|
||
if (body)
|
||
setSafeHtml(
|
||
body,
|
||
'<span class="oidc-detail-empty">Failed to load</span>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _buildOidcRow(oid, userId, username) {
|
||
const shortIssuer = _issuerShortName(oid.issuer || "");
|
||
const shortSubject =
|
||
(oid.subject || "").length > 12
|
||
? (oid.subject || "").slice(0, 12) + "\u2026"
|
||
: oid.subject || "";
|
||
const lastLogin = oid.last_login ? _relativeTime(oid.last_login) : "never";
|
||
return (
|
||
'<div class="oidc-identity-row">' +
|
||
'<span class="oidc-identity-issuer"><span class="scope-badge">' +
|
||
escapeHtml(shortIssuer) +
|
||
"</span></span>" +
|
||
'<span class="oidc-identity-subject" title="' +
|
||
escapeHtml(oid.subject || "") +
|
||
'">' +
|
||
escapeHtml(shortSubject) +
|
||
"</span>" +
|
||
'<span class="oidc-identity-email" title="' +
|
||
escapeHtml(oid.email || "") +
|
||
'">' +
|
||
escapeHtml(oid.email || "\u2014") +
|
||
"</span>" +
|
||
'<span class="oidc-identity-time">' +
|
||
escapeHtml(lastLogin) +
|
||
"</span>" +
|
||
'<span class="oidc-identity-actions">' +
|
||
'<button class="admin-btn-danger" aria-label="Unlink ' +
|
||
escapeHtml(shortIssuer) +
|
||
" identity " +
|
||
escapeHtml(shortSubject) +
|
||
'" data-oidc-issuer="' +
|
||
escapeHtml(oid.issuer || "") +
|
||
'" data-oidc-subject="' +
|
||
escapeHtml(oid.subject || "") +
|
||
'" data-oidc-username="' +
|
||
escapeHtml(username) +
|
||
'" data-oidc-user-id="' +
|
||
escapeHtml(userId) +
|
||
'">unlink</button>' +
|
||
"</span></div>"
|
||
);
|
||
}
|
||
|
||
function _renderOidcDetail(panel, identities, userId, username) {
|
||
const body = panel.querySelector(".oidc-detail-body");
|
||
if (!body) return;
|
||
if (!identities.length) {
|
||
setSafeHtml(
|
||
body,
|
||
'<span class="oidc-detail-empty">No OIDC identities linked</span>',
|
||
);
|
||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < identities.length; i++) {
|
||
html += _buildOidcRow(identities[i], userId, username);
|
||
}
|
||
setSafeHtml(body, html);
|
||
// Update panel height for animation
|
||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||
// Bind unlink buttons
|
||
const btns = body.querySelectorAll("[data-oidc-issuer]");
|
||
for (let j = 0; j < btns.length; j++) {
|
||
btns[j].addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
const issuer = this.getAttribute("data-oidc-issuer");
|
||
const subject = this.getAttribute("data-oidc-subject");
|
||
const uname = this.getAttribute("data-oidc-username");
|
||
const uid = this.getAttribute("data-oidc-user-id");
|
||
_confirmUnlinkOidc(issuer, subject, uname, uid);
|
||
});
|
||
}
|
||
}
|
||
|
||
function _confirmUnlinkOidc(issuer, subject, username, userId) {
|
||
const shortIssuer = _issuerShortName(issuer);
|
||
const shortSubject =
|
||
subject.length > 16 ? subject.slice(0, 16) + "\u2026" : subject;
|
||
showConfirmModal(
|
||
"Unlink OIDC Identity",
|
||
"Unlink " +
|
||
shortIssuer +
|
||
" identity \u2018" +
|
||
shortSubject +
|
||
"\u2019 from user " +
|
||
username +
|
||
"?\n\nThe user will need to log in via OIDC again to re-link.",
|
||
"Unlink",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/oidc-identities?issuer=" +
|
||
encodeURIComponent(issuer) +
|
||
"&subject=" +
|
||
encodeURIComponent(subject),
|
||
{ method: "DELETE" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Unlink failed");
|
||
showToast("OIDC identity unlinked");
|
||
// Refresh the panel content in place (no close/reopen flicker)
|
||
const allRows = document.querySelectorAll(
|
||
"#admin-users-table .admin-row[data-expandable]",
|
||
);
|
||
let targetRow = null;
|
||
for (let ri = 0; ri < allRows.length; ri++) {
|
||
if (allRows[ri].getAttribute("data-user-id") === userId) {
|
||
targetRow = allRows[ri];
|
||
break;
|
||
}
|
||
}
|
||
if (targetRow) {
|
||
const panel = targetRow.nextElementSibling;
|
||
if (panel && panel.classList.contains("oidc-detail-panel")) {
|
||
const body = panel.querySelector(".oidc-detail-body");
|
||
if (body)
|
||
setSafeHtml(
|
||
body,
|
||
'<span class="oidc-detail-empty">Loading\u2026</span>',
|
||
);
|
||
authFetch(
|
||
"/v1/api/admin/users/" +
|
||
encodeURIComponent(userId) +
|
||
"/oidc-identities",
|
||
)
|
||
.then(function (r2) {
|
||
if (!r2.ok) throw new Error("Failed");
|
||
return r2.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderOidcDetail(
|
||
panel,
|
||
data.oidc_identities || [],
|
||
userId,
|
||
username,
|
||
);
|
||
})
|
||
.catch(function () {
|
||
if (body)
|
||
setSafeHtml(
|
||
body,
|
||
'<span class="oidc-detail-empty">Failed to load</span>',
|
||
);
|
||
});
|
||
}
|
||
}
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to unlink OIDC identity");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
function _issuerShortName(issuer) {
|
||
try {
|
||
const host = new URL(issuer).hostname;
|
||
if (host.includes("google")) return "google";
|
||
if (host.includes("microsoftonline") || host.includes("azure"))
|
||
return "azure";
|
||
if (host.includes("okta")) return "okta";
|
||
if (host.includes("auth0")) return "auth0";
|
||
if (host.includes("keycloak")) return "keycloak";
|
||
return host.replace(/^(login|accounts|auth|id|sso)\./, "");
|
||
} catch (e) {
|
||
return issuer || "unknown";
|
||
}
|
||
}
|
||
|
||
function _relativeTime(isoStr) {
|
||
try {
|
||
const then = new Date(
|
||
isoStr + (isoStr.includes("Z") || isoStr.includes("+") ? "" : "Z"),
|
||
);
|
||
const diff = (Date.now() - then.getTime()) / 1000;
|
||
if (diff < 60) return "just now";
|
||
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
|
||
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
|
||
if (diff < 2592000) return Math.floor(diff / 86400) + "d ago";
|
||
return isoStr.slice(0, 10);
|
||
} catch (e) {
|
||
return isoStr || "unknown";
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tokens
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function _populateTokenUserSelect() {
|
||
const sel = document.getElementById("admin-token-user");
|
||
const current = sel.value;
|
||
setSafeHtml(sel, '<option value="">Select user...</option>');
|
||
for (let i = 0; i < _adminUsers.length; i++) {
|
||
const u = _adminUsers[i];
|
||
const opt = document.createElement("option");
|
||
opt.value = u.user_id;
|
||
opt.textContent = u.username + " (" + u.display_name + ")";
|
||
sel.appendChild(opt);
|
||
}
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
function loadAdminTokens() {
|
||
const userId = document.getElementById("admin-token-user").value;
|
||
_adminTokenUserId = userId;
|
||
if (!userId) {
|
||
setSafeHtml(
|
||
document.getElementById("admin-tokens-table"),
|
||
'<div class="dashboard-empty">Select a user to view tokens</div>',
|
||
);
|
||
return;
|
||
}
|
||
authFetch("/v1/api/admin/users/" + encodeURIComponent(userId) + "/tokens")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load tokens");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderTokens(data.tokens || []);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-tokens-table"),
|
||
'<div class="dashboard-empty">Failed to load tokens</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderTokens(tokens) {
|
||
const container = document.getElementById("admin-tokens-table");
|
||
if (!tokens.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No tokens for this user</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < tokens.length; i++) {
|
||
const t = tokens[i];
|
||
const expires = t.expires ? escapeHtml(t.expires).slice(0, 10) : "\u2014";
|
||
html +=
|
||
'<div class="admin-row" role="listitem">' +
|
||
'<span class="admin-col admin-col-prefix"><code>' +
|
||
escapeHtml(t.token_prefix) +
|
||
"\u2026</code></span>" +
|
||
'<span class="admin-col admin-col-tname">' +
|
||
escapeHtml(t.name || "\u2014") +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-scopes">' +
|
||
_renderScopeBadges(t.scopes) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
escapeHtml(t.created || "").slice(0, 10) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-expires">' +
|
||
expires +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu([
|
||
{
|
||
label: "revoke",
|
||
kind: "danger",
|
||
title: "Revoke token",
|
||
attrs: { "data-revoke-token": t.token_id },
|
||
},
|
||
]) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
// Bind revoke buttons via delegation (avoids inline JS injection)
|
||
const rbtns = container.querySelectorAll("[data-revoke-token]");
|
||
for (let j = 0; j < rbtns.length; j++) {
|
||
rbtns[j].addEventListener("click", function () {
|
||
confirmRevokeToken(this.getAttribute("data-revoke-token"));
|
||
});
|
||
}
|
||
}
|
||
|
||
function _renderScopeBadges(scopes) {
|
||
if (!scopes) return "";
|
||
const parts = scopes.split(",");
|
||
let html = "";
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const s = parts[i].trim();
|
||
if (!s) continue;
|
||
let cls = "scope-badge";
|
||
if (s === "approve") cls += " scope-approve";
|
||
else if (s === "write") cls += " scope-write";
|
||
html += '<span class="' + cls + '">' + escapeHtml(s) + "</span>";
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function confirmRevokeToken(tokenId) {
|
||
showConfirmModal(
|
||
"Revoke Token",
|
||
"Revoke this API token? Existing JWTs issued from it will remain valid until they expire (max 24h). This cannot be undone.",
|
||
"Revoke",
|
||
function () {
|
||
authFetch("/v1/api/admin/tokens/" + encodeURIComponent(tokenId), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Revoke failed");
|
||
showToast("Token revoked");
|
||
loadAdminTokens();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to revoke token");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Channels
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function _populateChannelUserSelect() {
|
||
const sel = document.getElementById("admin-channel-user");
|
||
const current = sel.value;
|
||
setSafeHtml(sel, '<option value="">Select user...</option>');
|
||
for (let i = 0; i < _adminUsers.length; i++) {
|
||
const u = _adminUsers[i];
|
||
const opt = document.createElement("option");
|
||
opt.value = u.user_id;
|
||
opt.textContent = u.username + " (" + u.display_name + ")";
|
||
sel.appendChild(opt);
|
||
}
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
function loadAdminChannels() {
|
||
const userId = document.getElementById("admin-channel-user").value;
|
||
_adminChannelUserId = userId;
|
||
if (!userId) {
|
||
setSafeHtml(
|
||
document.getElementById("admin-channels-table"),
|
||
'<div class="dashboard-empty">Select a user to view channel links</div>',
|
||
);
|
||
return;
|
||
}
|
||
setSafeHtml(
|
||
document.getElementById("admin-channels-table"),
|
||
'<div class="dashboard-empty">Loading channel links...</div>',
|
||
);
|
||
authFetch("/v1/api/admin/users/" + encodeURIComponent(userId) + "/channels")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load channels");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderChannels(data.channels || []);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-channels-table"),
|
||
'<div class="dashboard-empty">Failed to load channel links</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderChannels(channels) {
|
||
const container = document.getElementById("admin-channels-table");
|
||
if (!channels.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No channel links for this user</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < channels.length; i++) {
|
||
const c = channels[i];
|
||
// Per-platform badge class (scope-discord / scope-slack) so different
|
||
// adapters render with their own color. Falls back to the generic
|
||
// scope-channel for unknown platforms; the per-platform class wins
|
||
// by being the only class set, not by source order.
|
||
const ctSlug = (c.channel_type || "")
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]/g, "");
|
||
const ctClass =
|
||
ctSlug && (ctSlug === "discord" || ctSlug === "slack")
|
||
? "scope-badge scope-" + ctSlug
|
||
: "scope-badge scope-channel";
|
||
html +=
|
||
'<div class="admin-row" role="listitem">' +
|
||
'<span class="admin-col admin-col-chtype"><span class="' +
|
||
ctClass +
|
||
'">' +
|
||
escapeHtml(c.channel_type) +
|
||
"</span></span>" +
|
||
'<span class="admin-col admin-col-chuid"><code>' +
|
||
escapeHtml(c.channel_user_id) +
|
||
"</code></span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
escapeHtml(c.created || "").slice(0, 10) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu([
|
||
{
|
||
label: "unlink",
|
||
kind: "danger",
|
||
title: "Unlink channel account",
|
||
attrs: {
|
||
"data-unlink-type": c.channel_type,
|
||
"data-unlink-uid": c.channel_user_id,
|
||
},
|
||
},
|
||
]) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
const btns = container.querySelectorAll("[data-unlink-type]");
|
||
for (let j = 0; j < btns.length; j++) {
|
||
btns[j].addEventListener("click", function () {
|
||
confirmUnlinkChannel(
|
||
this.getAttribute("data-unlink-type"),
|
||
this.getAttribute("data-unlink-uid"),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
function confirmUnlinkChannel(channelType, channelUserId) {
|
||
showConfirmModal(
|
||
"Unlink Channel",
|
||
"Unlink " +
|
||
channelType +
|
||
" account \u2018" +
|
||
channelUserId +
|
||
"\u2019? The user will need to re-link via /link to interact with the bot.",
|
||
"Unlink",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/channels/" +
|
||
encodeURIComponent(channelType) +
|
||
"/" +
|
||
encodeURIComponent(channelUserId),
|
||
{ method: "DELETE" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Unlink failed");
|
||
showToast("Channel account unlinked");
|
||
loadAdminChannels();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to unlink channel account");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Schedules
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadAdminSchedules() {
|
||
authFetch("/v1/api/admin/schedules")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load schedules");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderSchedules(data.schedules || []);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-schedules-table"),
|
||
'<div class="dashboard-empty">Failed to load schedules</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderSchedules(schedules) {
|
||
const container = document.getElementById("admin-schedules-table");
|
||
if (!schedules.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No scheduled tasks. Create one to get started.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < schedules.length; i++) {
|
||
const s = schedules[i];
|
||
const typeLabel = s.schedule_type === "cron" ? "cron" : "at";
|
||
const typeCls =
|
||
s.schedule_type === "cron" ? "scope-write" : "scope-approve";
|
||
const schedule =
|
||
s.schedule_type === "cron"
|
||
? s.cron_expr
|
||
: _utcToLocalDatetime(s.at_time).replace("T", " ");
|
||
const target = s.target_mode;
|
||
const nextRun = s.next_run
|
||
? _utcToLocalDatetime(s.next_run).replace("T", " ")
|
||
: "\u2014";
|
||
const enabled = s.enabled;
|
||
let statusCls = enabled ? "sched-active" : "sched-disabled";
|
||
let statusLabel = enabled ? "active" : "disabled";
|
||
let statusDot = enabled ? "\u25cf " : "\u25cb ";
|
||
if (s.schedule_type === "at" && !enabled && s.last_run) {
|
||
statusCls = "sched-expired";
|
||
statusLabel = "completed";
|
||
statusDot = "\u25c9 ";
|
||
}
|
||
html +=
|
||
'<div class="admin-row" role="listitem">' +
|
||
'<span class="admin-col admin-col-sname">' +
|
||
escapeHtml(s.name) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-stype"><span class="scope-badge ' +
|
||
typeCls +
|
||
'">' +
|
||
typeLabel +
|
||
"</span></span>" +
|
||
'<span class="admin-col admin-col-sschedule"><code>' +
|
||
escapeHtml(schedule) +
|
||
"</code></span>" +
|
||
'<span class="admin-col admin-col-starget">' +
|
||
escapeHtml(target) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-snext">' +
|
||
nextRun +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-sstatus"><span class="' +
|
||
statusCls +
|
||
'">' +
|
||
statusDot +
|
||
statusLabel +
|
||
"</span></span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu([
|
||
{
|
||
label: "edit",
|
||
title: "Edit",
|
||
attrs: { "data-edit-sched": s.task_id },
|
||
},
|
||
{
|
||
label: "runs",
|
||
title: "Run history",
|
||
attrs: { "data-runs-sched": s.task_id },
|
||
},
|
||
{
|
||
label: enabled ? "disable" : "enable",
|
||
title: enabled ? "Disable" : "Enable",
|
||
attrs: {
|
||
"data-toggle-sched": s.task_id,
|
||
"data-enabled": enabled ? "1" : "0",
|
||
},
|
||
},
|
||
{
|
||
label: "delete",
|
||
kind: "danger",
|
||
title: "Delete",
|
||
attrs: { "data-delete-sched": s.task_id, "data-sname": s.name },
|
||
},
|
||
]) +
|
||
"</span></div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
// Bind buttons
|
||
const editBtns = container.querySelectorAll("[data-edit-sched]");
|
||
for (let j = 0; j < editBtns.length; j++) {
|
||
editBtns[j].addEventListener("click", function () {
|
||
showEditScheduleModal(this.getAttribute("data-edit-sched"));
|
||
});
|
||
}
|
||
const runsBtns = container.querySelectorAll("[data-runs-sched]");
|
||
for (let k = 0; k < runsBtns.length; k++) {
|
||
runsBtns[k].addEventListener("click", function () {
|
||
showScheduleRuns(this.getAttribute("data-runs-sched"));
|
||
});
|
||
}
|
||
const toggleBtns = container.querySelectorAll("[data-toggle-sched]");
|
||
for (let m = 0; m < toggleBtns.length; m++) {
|
||
toggleBtns[m].addEventListener("click", function () {
|
||
toggleSchedule(
|
||
this.getAttribute("data-toggle-sched"),
|
||
this.getAttribute("data-enabled") === "1",
|
||
);
|
||
});
|
||
}
|
||
const delBtns = container.querySelectorAll("[data-delete-sched]");
|
||
for (let n = 0; n < delBtns.length; n++) {
|
||
delBtns[n].addEventListener("click", function () {
|
||
confirmDeleteSchedule(
|
||
this.getAttribute("data-delete-sched"),
|
||
this.getAttribute("data-sname"),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleSchedule(taskId, currentlyEnabled) {
|
||
authFetch("/v1/api/admin/schedules/" + encodeURIComponent(taskId), {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: !currentlyEnabled }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Toggle failed");
|
||
showToast(currentlyEnabled ? "Schedule disabled" : "Schedule enabled");
|
||
loadAdminSchedules();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to toggle schedule");
|
||
});
|
||
}
|
||
|
||
function confirmDeleteSchedule(taskId, name) {
|
||
showConfirmModal(
|
||
"Delete Schedule",
|
||
"Delete schedule \u2018" +
|
||
name +
|
||
"\u2019 and its run history? This cannot be undone.",
|
||
"Delete",
|
||
function () {
|
||
authFetch("/v1/api/admin/schedules/" + encodeURIComponent(taskId), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Delete failed");
|
||
showToast("Schedule deleted");
|
||
loadAdminSchedules();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete schedule");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// --- Schedule helpers: dropdowns, notify rows, timezone ---
|
||
|
||
function _populateScheduleSelect(selectId, url, labelKey, valueKey, opts) {
|
||
const sel = document.getElementById(selectId);
|
||
// Keep the first option (placeholder) and remove the rest
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
// Add temporary option for pre-selected value so form is correct before fetch completes
|
||
if (opts && opts.selected) {
|
||
const tmp = document.createElement("option");
|
||
tmp.value = opts.selected;
|
||
tmp.textContent = opts.selected;
|
||
tmp.dataset.temporary = "1";
|
||
sel.appendChild(tmp);
|
||
sel.value = opts.selected;
|
||
}
|
||
authFetch(url)
|
||
.then(function (r) {
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
const temp = sel.querySelector("[data-temporary]");
|
||
if (temp) temp.remove();
|
||
let items = opts && opts.listKey ? data[opts.listKey] : data;
|
||
if (!Array.isArray(items)) return;
|
||
if (opts && typeof opts.filter === "function")
|
||
items = items.filter(opts.filter);
|
||
items.forEach(function (item) {
|
||
const opt = document.createElement("option");
|
||
opt.value = item[valueKey];
|
||
opt.textContent =
|
||
opts && opts.display ? opts.display(item) : item[labelKey];
|
||
sel.appendChild(opt);
|
||
});
|
||
if (opts && opts.selected) {
|
||
sel.value = opts.selected;
|
||
if (sel.value !== opts.selected) {
|
||
// The current value isn't in this list — it was filtered out
|
||
// (a disabled/wrong-kind persona) or is outside the caller-scoped
|
||
// feed (a private/archived project the editing admin can't see).
|
||
// Re-add it as a "(current)" option so it round-trips; without this
|
||
// the select falls back to the placeholder and saving an unrelated
|
||
// field would silently CLEAR the setting.
|
||
const keep = document.createElement("option");
|
||
keep.value = opts.selected;
|
||
keep.textContent = opts.selected + " (current)";
|
||
sel.appendChild(keep);
|
||
sel.value = opts.selected;
|
||
}
|
||
}
|
||
// Caller hook for placeholder annotation / other post-load tweaks.
|
||
// Used by the schedule modals to rewrite the bare "Default model"
|
||
// placeholder with the resolved alias so the label matches the
|
||
// home composer (see app.js _populateHomeModelDropdowns).
|
||
if (opts && typeof opts.afterPopulate === "function") {
|
||
try {
|
||
opts.afterPopulate(sel, data, items);
|
||
} catch (_e) {
|
||
/* hook errors must not break the dropdown */
|
||
}
|
||
}
|
||
})
|
||
.catch(function () {
|
||
/* dropdown stays with placeholder or temporary option */
|
||
});
|
||
}
|
||
|
||
// Update the schedule-model placeholder option (first <option>) to
|
||
// "Default — alias (model)" using /v1/api/models's resolved
|
||
// default_alias, mirroring the home composer. Schedules don't carry
|
||
// a coordinator/judge split, so they consume the workstream-creation
|
||
// default rather than coordinator_default_alias / judge_default_alias.
|
||
// Em-dash separator (rather than nested parens) keeps the alias's
|
||
// "(model)" suffix legible.
|
||
function _decorateScheduleModelPlaceholder(sel, data) {
|
||
if (!sel || sel.options.length === 0) return;
|
||
const alias = (data && data.default_alias) || "";
|
||
if (!alias) return;
|
||
let match = null;
|
||
const models = (data && data.models) || [];
|
||
for (let i = 0; i < models.length; i++) {
|
||
if (models[i].alias === alias) {
|
||
match = models[i];
|
||
break;
|
||
}
|
||
}
|
||
let label;
|
||
if (match) {
|
||
label =
|
||
match.alias === match.model
|
||
? match.alias
|
||
: match.alias + " (" + match.model + ")";
|
||
} else {
|
||
label = alias;
|
||
}
|
||
sel.options[0].textContent = "Default — " + label;
|
||
}
|
||
|
||
// Channel platforms shown in admin notify-target rows. Mirror server-side
|
||
// channel adapters; expand here when a new adapter ships (Discord / Slack
|
||
// today, MS Teams / etc. later).
|
||
const _NOTIFY_CHANNEL_TYPES = [
|
||
{
|
||
value: "discord",
|
||
label: "Discord",
|
||
id_hint: "Discord ID (e.g. 123456789012345678)",
|
||
},
|
||
{
|
||
value: "slack",
|
||
label: "Slack",
|
||
id_hint: "Slack ID (e.g. C01234567 or U01234567)",
|
||
},
|
||
];
|
||
|
||
function _notifyIdPlaceholder(channelType) {
|
||
for (let i = 0; i < _NOTIFY_CHANNEL_TYPES.length; i++) {
|
||
if (_NOTIFY_CHANNEL_TYPES[i].value === channelType) {
|
||
return _NOTIFY_CHANNEL_TYPES[i].id_hint;
|
||
}
|
||
}
|
||
return "ID";
|
||
}
|
||
|
||
function _addNotifyRow(prefix, targetType, targetId, channelType) {
|
||
const container = document.getElementById(prefix + "-notify-rows");
|
||
const row = document.createElement("div");
|
||
row.className = "notify-row";
|
||
|
||
const ctSel = document.createElement("select");
|
||
ctSel.setAttribute("aria-label", "Channel platform");
|
||
ctSel.className = "notify-row-ct";
|
||
for (let i = 0; i < _NOTIFY_CHANNEL_TYPES.length; i++) {
|
||
const ctOpt = document.createElement("option");
|
||
ctOpt.value = _NOTIFY_CHANNEL_TYPES[i].value;
|
||
ctOpt.textContent = _NOTIFY_CHANNEL_TYPES[i].label;
|
||
ctSel.appendChild(ctOpt);
|
||
}
|
||
ctSel.value = channelType || "discord";
|
||
|
||
const typeSel = document.createElement("select");
|
||
typeSel.setAttribute("aria-label", "Target type");
|
||
typeSel.className = "notify-row-target";
|
||
const optCh = document.createElement("option");
|
||
optCh.value = "channel_id";
|
||
optCh.textContent = "Channel";
|
||
const optUsr = document.createElement("option");
|
||
optUsr.value = "user_id";
|
||
optUsr.textContent = "User DM";
|
||
typeSel.appendChild(optCh);
|
||
typeSel.appendChild(optUsr);
|
||
if (targetType) typeSel.value = targetType;
|
||
|
||
const idInput = document.createElement("input");
|
||
idInput.type = "text";
|
||
idInput.className = "notify-row-id";
|
||
idInput.placeholder = _notifyIdPlaceholder(ctSel.value);
|
||
idInput.setAttribute("aria-label", "Channel/user ID");
|
||
idInput.spellcheck = false;
|
||
if (targetId) idInput.value = targetId;
|
||
|
||
// Re-hint the ID input when the platform changes — e.g. Discord
|
||
// snowflakes vs Slack C…/U… ids.
|
||
ctSel.addEventListener("change", function () {
|
||
idInput.placeholder = _notifyIdPlaceholder(ctSel.value);
|
||
});
|
||
|
||
const removeBtn = document.createElement("button");
|
||
removeBtn.type = "button";
|
||
removeBtn.className = "notify-row-remove";
|
||
removeBtn.setAttribute("aria-label", "Remove target");
|
||
removeBtn.textContent = "\u00d7";
|
||
removeBtn.onclick = function () {
|
||
row.remove();
|
||
};
|
||
|
||
row.appendChild(ctSel);
|
||
row.appendChild(typeSel);
|
||
row.appendChild(idInput);
|
||
row.appendChild(removeBtn);
|
||
container.appendChild(row);
|
||
idInput.focus();
|
||
}
|
||
|
||
function _collectNotifyTargets(prefix) {
|
||
const rows = document
|
||
.getElementById(prefix + "-notify-rows")
|
||
.querySelectorAll(".notify-row");
|
||
const targets = [];
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const ct =
|
||
(rows[i].querySelector(".notify-row-ct") || {}).value || "discord";
|
||
const type =
|
||
(rows[i].querySelector(".notify-row-target") || {}).value || "channel_id";
|
||
const idEl = rows[i].querySelector(".notify-row-id");
|
||
const id = ((idEl && idEl.value) || "").trim();
|
||
if (!id) continue;
|
||
const t = { channel_type: ct };
|
||
t[type] = id;
|
||
targets.push(t);
|
||
}
|
||
return targets;
|
||
}
|
||
|
||
function _populateNotifyRows(prefix, targets) {
|
||
const container = document.getElementById(prefix + "-notify-rows");
|
||
while (container.firstChild) container.removeChild(container.firstChild);
|
||
if (!Array.isArray(targets)) return;
|
||
targets.forEach(function (t) {
|
||
const targetType = "channel_id" in t ? "channel_id" : "user_id";
|
||
const targetId = t[targetType] || "";
|
||
_addNotifyRow(prefix, targetType, targetId, t.channel_type || "discord");
|
||
});
|
||
}
|
||
|
||
function _localToUtcIso(localDatetimeStr) {
|
||
// datetime-local gives "YYYY-MM-DDTHH:MM" in browser local time
|
||
// Convert to UTC ISO string for the server
|
||
const d = new Date(localDatetimeStr);
|
||
if (isNaN(d.getTime())) return "";
|
||
return d.toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
||
}
|
||
|
||
function _utcToLocalDatetime(utcStr) {
|
||
// Convert UTC ISO string to datetime-local format in browser local time
|
||
if (!utcStr) return "";
|
||
const d = new Date(utcStr);
|
||
if (isNaN(d.getTime())) return utcStr.slice(0, 16);
|
||
const pad = function (n) {
|
||
return n < 10 ? "0" + n : "" + n;
|
||
};
|
||
return (
|
||
d.getFullYear() +
|
||
"-" +
|
||
pad(d.getMonth() + 1) +
|
||
"-" +
|
||
pad(d.getDate()) +
|
||
"T" +
|
||
pad(d.getHours()) +
|
||
":" +
|
||
pad(d.getMinutes())
|
||
);
|
||
}
|
||
|
||
// --- Schedule shelf (create + edit) ---
|
||
// The cron DSL is compiled, not typed: the segmented Runs control builds
|
||
// schedule_type/cron_expr/at_time, and the NEXT RUNS read-out previews the
|
||
// result through the server's croniter (POST /v1/api/admin/schedules/preview)
|
||
// as the user edits. Cron mode is the raw escape hatch with the same live
|
||
// read-out. Storage is unchanged: on edit the saved expression is
|
||
// reverse-parsed back into the friendly mode when its shape matches
|
||
// (_cronToScheduleMode), else the editor opens in Cron mode.
|
||
|
||
let _schWired = false;
|
||
let _schPreviewTimer = null;
|
||
let _schPreviewSeq = 0;
|
||
let _schShelfHandle = null;
|
||
|
||
function _schMode() {
|
||
const seg = document.getElementById("sch-seg");
|
||
const on = seg.querySelector('[aria-pressed="true"]');
|
||
return on ? on.getAttribute("data-mode") : "daily";
|
||
}
|
||
|
||
function _schSetMode(mode) {
|
||
const seg = document.getElementById("sch-seg");
|
||
seg.querySelectorAll("button[data-mode]").forEach(function (b) {
|
||
b.setAttribute(
|
||
"aria-pressed",
|
||
b.getAttribute("data-mode") === mode ? "true" : "false",
|
||
);
|
||
});
|
||
document
|
||
.querySelectorAll("#schedule-shelf [data-sch-pane]")
|
||
.forEach(function (pane) {
|
||
pane.hidden = pane.getAttribute("data-sch-pane") !== mode;
|
||
});
|
||
}
|
||
|
||
function _schSelectedDays() {
|
||
const out = [];
|
||
document
|
||
.querySelectorAll('#sch-days button[aria-pressed="true"]')
|
||
.forEach(function (b) {
|
||
out.push(parseInt(b.getAttribute("data-day"), 10));
|
||
});
|
||
return out.sort();
|
||
}
|
||
|
||
function _schSetDays(days) {
|
||
document.querySelectorAll("#sch-days button").forEach(function (b) {
|
||
const d = parseInt(b.getAttribute("data-day"), 10);
|
||
b.setAttribute("aria-pressed", days.indexOf(d) >= 0 ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
function _schTimeParts(id) {
|
||
const v = document.getElementById(id).value || "06:00";
|
||
const parts = v.split(":");
|
||
return [parseInt(parts[0], 10) || 0, parseInt(parts[1], 10) || 0];
|
||
}
|
||
|
||
// Compile the builder state down to the wire fields. Returns
|
||
// {schedule_type, cron_expr, at_time} or {error}.
|
||
function _schCompile() {
|
||
const mode = _schMode();
|
||
if (mode === "once") {
|
||
const local = document.getElementById("sch-at").value || "";
|
||
if (!local) return { error: "Pick a date and time" };
|
||
return {
|
||
schedule_type: "at",
|
||
cron_expr: "",
|
||
at_time: _localToUtcIso(local),
|
||
};
|
||
}
|
||
if (mode === "cron") {
|
||
const expr = (document.getElementById("sch-cron").value || "").trim();
|
||
if (!expr) return { error: "Cron expression is required" };
|
||
return { schedule_type: "cron", cron_expr: expr, at_time: "" };
|
||
}
|
||
let h, m;
|
||
if (mode === "daily") {
|
||
[h, m] = _schTimeParts("sch-time-daily");
|
||
return {
|
||
schedule_type: "cron",
|
||
cron_expr: m + " " + h + " * * *",
|
||
at_time: "",
|
||
};
|
||
}
|
||
if (mode === "weekly") {
|
||
const days = _schSelectedDays();
|
||
if (!days.length) return { error: "Select at least one day" };
|
||
[h, m] = _schTimeParts("sch-time-weekly");
|
||
return {
|
||
schedule_type: "cron",
|
||
cron_expr: m + " " + h + " * * " + days.join(","),
|
||
at_time: "",
|
||
};
|
||
}
|
||
if (mode === "monthly") {
|
||
const dom = parseInt(document.getElementById("sch-dom").value, 10);
|
||
if (!dom || dom < 1 || dom > 31)
|
||
return { error: "Day of month must be 1-31" };
|
||
[h, m] = _schTimeParts("sch-time-monthly");
|
||
return {
|
||
schedule_type: "cron",
|
||
cron_expr: m + " " + h + " " + dom + " * *",
|
||
at_time: "",
|
||
};
|
||
}
|
||
// interval
|
||
const n = parseInt(document.getElementById("sch-every").value, 10);
|
||
if (!n || n < 1) return { error: "Interval must be at least 1" };
|
||
const unit = document.getElementById("sch-unit").value;
|
||
const expr = unit === "hours" ? "0 */" + n + " * * *" : "*/" + n + " * * * *";
|
||
return { schedule_type: "cron", cron_expr: expr, at_time: "" };
|
||
}
|
||
|
||
const _SCH_DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||
|
||
function _schDescribe() {
|
||
const mode = _schMode();
|
||
const t = function (id) {
|
||
const parts = _schTimeParts(id);
|
||
const pad = function (n) {
|
||
return n < 10 ? "0" + n : "" + n;
|
||
};
|
||
return pad(parts[0]) + ":" + pad(parts[1]);
|
||
};
|
||
if (mode === "daily") return "every day at " + t("sch-time-daily") + " UTC";
|
||
if (mode === "weekly") {
|
||
const names = _schSelectedDays().map(function (d) {
|
||
return _SCH_DAY_NAMES[d];
|
||
});
|
||
return names.length
|
||
? "every " + names.join(", ") + " at " + t("sch-time-weekly") + " UTC"
|
||
: "no days selected";
|
||
}
|
||
if (mode === "monthly")
|
||
return (
|
||
"monthly on day " +
|
||
(document.getElementById("sch-dom").value || "?") +
|
||
" at " +
|
||
t("sch-time-monthly") +
|
||
" UTC"
|
||
);
|
||
if (mode === "interval")
|
||
return (
|
||
"every " +
|
||
(document.getElementById("sch-every").value || "?") +
|
||
" " +
|
||
document.getElementById("sch-unit").value
|
||
);
|
||
if (mode === "once") return "one time";
|
||
return "custom cron";
|
||
}
|
||
|
||
// Reverse-parse a saved expression into builder state. Only the exact
|
||
// shapes the builder emits round-trip; anything else opens in Cron mode.
|
||
function _cronToScheduleMode(expr) {
|
||
let m = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(expr);
|
||
if (m) return { mode: "daily", h: +m[2], min: +m[1] };
|
||
m = /^(\d{1,2}) (\d{1,2}) \* \* ([0-7](?:,[0-7])*)$/.exec(expr);
|
||
if (m)
|
||
return {
|
||
mode: "weekly",
|
||
h: +m[2],
|
||
min: +m[1],
|
||
days: m[3].split(",").map(function (d) {
|
||
return +d % 7; // cron allows 7 for Sunday
|
||
}),
|
||
};
|
||
m = /^(\d{1,2}) (\d{1,2}) (\d{1,2}) \* \*$/.exec(expr);
|
||
if (m) return { mode: "monthly", h: +m[2], min: +m[1], dom: +m[3] };
|
||
m = /^0 \*\/(\d+) \* \* \*$/.exec(expr);
|
||
if (m) return { mode: "interval", every: +m[1], unit: "hours" };
|
||
m = /^\*\/(\d+) \* \* \* \*$/.exec(expr);
|
||
if (m) return { mode: "interval", every: +m[1], unit: "minutes" };
|
||
return { mode: "cron" };
|
||
}
|
||
|
||
function _schSetTime(id, h, min) {
|
||
const pad = function (n) {
|
||
return n < 10 ? "0" + n : "" + n;
|
||
};
|
||
document.getElementById(id).value = pad(h) + ":" + pad(min);
|
||
}
|
||
|
||
// Apply a saved schedule's timing to the builder (edit mode).
|
||
function _schApplyTiming(scheduleType, cronExpr, atTime) {
|
||
if (scheduleType === "at") {
|
||
document.getElementById("sch-at").value = _utcToLocalDatetime(atTime);
|
||
_schSetMode("once");
|
||
return;
|
||
}
|
||
const parsed = _cronToScheduleMode(cronExpr || "");
|
||
if (parsed.mode === "daily")
|
||
_schSetTime("sch-time-daily", parsed.h, parsed.min);
|
||
else if (parsed.mode === "weekly") {
|
||
_schSetTime("sch-time-weekly", parsed.h, parsed.min);
|
||
_schSetDays(parsed.days);
|
||
} else if (parsed.mode === "monthly") {
|
||
_schSetTime("sch-time-monthly", parsed.h, parsed.min);
|
||
document.getElementById("sch-dom").value = parsed.dom;
|
||
} else if (parsed.mode === "interval") {
|
||
document.getElementById("sch-every").value = parsed.every;
|
||
document.getElementById("sch-unit").value = parsed.unit;
|
||
} else {
|
||
document.getElementById("sch-cron").value = cronExpr || "";
|
||
}
|
||
_schSetMode(parsed.mode);
|
||
}
|
||
|
||
function _schFmtUtc(iso) {
|
||
// Server emits "YYYY-MM-DDTHH:MM:SS" (UTC, no suffix) or "+00:00" ISO.
|
||
const d = new Date(/[Z+]/.test(iso) ? iso : iso + "Z");
|
||
if (isNaN(d.getTime())) return iso;
|
||
const pad = function (n) {
|
||
return n < 10 ? "0" + n : "" + n;
|
||
};
|
||
return (
|
||
_SCH_DAY_NAMES[d.getUTCDay()] +
|
||
" " +
|
||
d.getUTCFullYear() +
|
||
"-" +
|
||
pad(d.getUTCMonth() + 1) +
|
||
"-" +
|
||
pad(d.getUTCDate()) +
|
||
" " +
|
||
pad(d.getUTCHours()) +
|
||
":" +
|
||
pad(d.getUTCMinutes()) +
|
||
" UTC"
|
||
);
|
||
}
|
||
|
||
function _schRel(iso) {
|
||
const d = new Date(/[Z+]/.test(iso) ? iso : iso + "Z");
|
||
if (isNaN(d.getTime())) return "";
|
||
const mins = Math.round((d.getTime() - Date.now()) / 60000);
|
||
if (mins < 1) return "now";
|
||
if (mins < 60) return "in " + mins + "m";
|
||
if (mins < 48 * 60) return "in " + Math.round(mins / 60) + "h";
|
||
return "in " + Math.round(mins / (24 * 60)) + "d";
|
||
}
|
||
|
||
function _schRenderRuns(runs, errText) {
|
||
const rows = document.getElementById("sch-runs-out");
|
||
while (rows.firstChild) rows.removeChild(rows.firstChild);
|
||
if (errText) {
|
||
const err = document.createElement("div");
|
||
err.className = "err";
|
||
err.textContent = errText;
|
||
rows.appendChild(err);
|
||
return;
|
||
}
|
||
runs.forEach(function (iso) {
|
||
const row = document.createElement("div");
|
||
row.className = "readout-row";
|
||
const when = document.createElement("span");
|
||
when.textContent = _schFmtUtc(iso);
|
||
const rel = document.createElement("span");
|
||
rel.className = "rel";
|
||
rel.textContent = _schRel(iso);
|
||
row.appendChild(when);
|
||
row.appendChild(rel);
|
||
rows.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function _schRenderCompiled(compiled) {
|
||
const meta = document.getElementById("sch-compiled-out");
|
||
while (meta.firstChild) meta.removeChild(meta.firstChild);
|
||
if (compiled.error) return;
|
||
if (compiled.schedule_type === "at") {
|
||
meta.appendChild(document.createTextNode("runs once at "));
|
||
const span = document.createElement("span");
|
||
span.className = "mono";
|
||
span.textContent = compiled.at_time;
|
||
meta.appendChild(span);
|
||
} else {
|
||
meta.appendChild(document.createTextNode("compiles to "));
|
||
const span = document.createElement("span");
|
||
span.className = "mono";
|
||
span.textContent = compiled.cron_expr;
|
||
meta.appendChild(span);
|
||
}
|
||
}
|
||
|
||
// Debounced read-out refresh: description + compiled text render instantly,
|
||
// the next-runs list arrives from the croniter preview endpoint.
|
||
function _schPreview() {
|
||
document.getElementById("sch-desc-out").textContent = _schDescribe();
|
||
const compiled = _schCompile();
|
||
_schRenderCompiled(compiled);
|
||
if (compiled.error) {
|
||
_schRenderRuns([], compiled.error);
|
||
return;
|
||
}
|
||
if (_schPreviewTimer) clearTimeout(_schPreviewTimer);
|
||
const seq = ++_schPreviewSeq;
|
||
_schPreviewTimer = setTimeout(function () {
|
||
authFetch("/v1/api/admin/schedules/preview", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(compiled),
|
||
})
|
||
.then(function (r) {
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
if (seq !== _schPreviewSeq) return; // a newer edit superseded us
|
||
if (!data.valid) _schRenderRuns([], data.error || "Invalid schedule");
|
||
else _schRenderRuns(data.next || []);
|
||
})
|
||
.catch(function () {
|
||
if (seq === _schPreviewSeq) _schRenderRuns([], "Preview unavailable");
|
||
});
|
||
}, 250);
|
||
}
|
||
|
||
function _schWire() {
|
||
if (_schWired) return;
|
||
_schWired = true;
|
||
document.getElementById("sch-seg").addEventListener("click", function (e) {
|
||
const b = e.target.closest("button[data-mode]");
|
||
if (!b) return;
|
||
_schSetMode(b.getAttribute("data-mode"));
|
||
_schPreview();
|
||
});
|
||
document.getElementById("sch-days").addEventListener("click", function (e) {
|
||
const b = e.target.closest("button[data-day]");
|
||
if (!b) return;
|
||
b.setAttribute(
|
||
"aria-pressed",
|
||
b.getAttribute("aria-pressed") === "true" ? "false" : "true",
|
||
);
|
||
_schPreview();
|
||
});
|
||
[
|
||
"sch-time-daily",
|
||
"sch-time-weekly",
|
||
"sch-time-monthly",
|
||
"sch-dom",
|
||
"sch-every",
|
||
"sch-unit",
|
||
"sch-at",
|
||
"sch-cron",
|
||
].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
el.addEventListener("input", _schPreview);
|
||
el.addEventListener("change", _schPreview);
|
||
});
|
||
document.getElementById("sch-target").addEventListener("change", function () {
|
||
const isNode = this.value === "node";
|
||
document.getElementById("sch-node-group").hidden = !isNode;
|
||
if (isNode) document.getElementById("sch-node").focus();
|
||
});
|
||
document
|
||
.getElementById("sch-notify-add")
|
||
.addEventListener("click", function () {
|
||
_addNotifyRow("sch");
|
||
});
|
||
document
|
||
.getElementById("sch-submit")
|
||
.addEventListener("click", _submitScheduleShelf);
|
||
}
|
||
|
||
// Shared open path: reset to a clean create state, then edit overwrites.
|
||
function _schResetForm() {
|
||
document
|
||
.getElementById("schedule-shelf-error")
|
||
.classList.remove("is-visible");
|
||
document.getElementById("sch-id").value = "";
|
||
document.getElementById("sch-name").value = "";
|
||
document.getElementById("sch-desc").value = "";
|
||
document.getElementById("sch-time-daily").value = "06:00";
|
||
document.getElementById("sch-time-weekly").value = "06:00";
|
||
document.getElementById("sch-time-monthly").value = "06:00";
|
||
_schSetDays([]);
|
||
document.getElementById("sch-dom").value = "1";
|
||
document.getElementById("sch-every").value = "4";
|
||
document.getElementById("sch-unit").value = "hours";
|
||
document.getElementById("sch-at").value = "";
|
||
document.getElementById("sch-cron").value = "";
|
||
document.getElementById("sch-target").value = "auto";
|
||
document.getElementById("sch-node").value = "";
|
||
document.getElementById("sch-node-group").hidden = true;
|
||
document.getElementById("sch-message").value = "";
|
||
document.getElementById("sch-autoapprove").checked = false;
|
||
document.getElementById("sch-enabled").checked = true;
|
||
_populateNotifyRows("sch", []);
|
||
_schSetMode("daily");
|
||
}
|
||
|
||
function _schPopulateSelects(
|
||
selectedModel,
|
||
selectedSkill,
|
||
selectedPersona,
|
||
selectedProject,
|
||
) {
|
||
_populateScheduleSelect("sch-model", "/v1/api/models", "alias", "alias", {
|
||
listKey: "models",
|
||
selected: selectedModel || "",
|
||
display: function (m) {
|
||
return m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
|
||
},
|
||
afterPopulate: _decorateScheduleModelPlaceholder,
|
||
});
|
||
_populateScheduleSelect(
|
||
"sch-template",
|
||
"/v1/api/admin/skills",
|
||
"name",
|
||
"name",
|
||
{
|
||
listKey: "skills",
|
||
selected: selectedSkill || "",
|
||
display: function (s) {
|
||
return s.name;
|
||
},
|
||
},
|
||
);
|
||
// Schedules dispatch interactive workstreams, so only offer personas
|
||
// eligible for that kind; the label matches the home/create picker
|
||
// (display name, falling back to the slug).
|
||
_populateScheduleSelect("sch-persona", "/v1/api/personas", "name", "name", {
|
||
listKey: "personas",
|
||
selected: selectedPersona || "",
|
||
filter: function (p) {
|
||
return (p.applies_to_kinds || []).indexOf("interactive") !== -1;
|
||
},
|
||
display: function (p) {
|
||
return p.display_name || p.name;
|
||
},
|
||
});
|
||
_populateScheduleSelect(
|
||
"sch-project",
|
||
"/v1/api/projects",
|
||
"name",
|
||
"project_id",
|
||
{
|
||
listKey: "projects",
|
||
selected: selectedProject || "",
|
||
display: function (p) {
|
||
return p.name;
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
function _schOpen(title, tag, kind, submitLabel) {
|
||
const shelf = document.getElementById("schedule-shelf");
|
||
document.getElementById("schedule-shelf-title").textContent = title;
|
||
document.getElementById("schedule-shelf-tag").textContent = tag;
|
||
shelf.setAttribute("data-kind", kind);
|
||
document.getElementById("sch-submit").textContent = submitLabel;
|
||
_schShelfHandle = window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("sch-name").focus();
|
||
_schPreview();
|
||
}
|
||
|
||
function showCreateScheduleModal() {
|
||
_schWire();
|
||
_schResetForm();
|
||
document.getElementById("sch-enabled-row").hidden = true;
|
||
_schPopulateSelects("", "", "", "");
|
||
_schOpen("New schedule", "SCH-NEW", "create", "Create");
|
||
}
|
||
|
||
function showEditScheduleModal(taskId) {
|
||
_schWire();
|
||
authFetch("/v1/api/admin/schedules/" + encodeURIComponent(taskId))
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Not found");
|
||
return r.json();
|
||
})
|
||
.then(function (s) {
|
||
_schResetForm();
|
||
document.getElementById("sch-id").value = s.task_id;
|
||
document.getElementById("sch-name").value = s.name || "";
|
||
document.getElementById("sch-desc").value = s.description || "";
|
||
_schApplyTiming(s.schedule_type, s.cron_expr, s.at_time);
|
||
const isSpecificNode =
|
||
s.target_mode &&
|
||
s.target_mode !== "auto" &&
|
||
s.target_mode !== "pool" &&
|
||
s.target_mode !== "all";
|
||
document.getElementById("sch-target").value = isSpecificNode
|
||
? "node"
|
||
: s.target_mode;
|
||
document.getElementById("sch-node").value = isSpecificNode
|
||
? s.target_mode
|
||
: "";
|
||
document.getElementById("sch-node-group").hidden = !isSpecificNode;
|
||
_schPopulateSelects(
|
||
s.model || "",
|
||
s.skill || "",
|
||
s.persona || "",
|
||
s.project_id || "",
|
||
);
|
||
document.getElementById("sch-message").value = s.initial_message || "";
|
||
document.getElementById("sch-autoapprove").checked = !!s.auto_approve;
|
||
document.getElementById("sch-enabled").checked = !!s.enabled;
|
||
document.getElementById("sch-enabled-row").hidden = false;
|
||
_populateNotifyRows("sch", s.notify_targets || []);
|
||
_schOpen(
|
||
"Edit schedule — " + (s.name || s.task_id),
|
||
"SCH-EDIT",
|
||
"edit",
|
||
"Save",
|
||
);
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to load schedule");
|
||
});
|
||
}
|
||
|
||
function _submitScheduleShelf() {
|
||
const shelf = document.getElementById("schedule-shelf");
|
||
const errEl = document.getElementById("schedule-shelf-error");
|
||
const taskId = document.getElementById("sch-id").value;
|
||
const name = (document.getElementById("sch-name").value || "").trim();
|
||
const message = (document.getElementById("sch-message").value || "").trim();
|
||
|
||
if (!name) return _showModalError(errEl, "Name is required");
|
||
if (!message) return _showModalError(errEl, "Initial message is required");
|
||
const compiled = _schCompile();
|
||
if (compiled.error) return _showModalError(errEl, compiled.error);
|
||
|
||
let targetMode = document.getElementById("sch-target").value;
|
||
if (targetMode === "node") {
|
||
targetMode = (document.getElementById("sch-node").value || "").trim();
|
||
if (!targetMode) return _showModalError(errEl, "Node ID is required");
|
||
}
|
||
|
||
const body = {
|
||
name: name,
|
||
description: (document.getElementById("sch-desc").value || "").trim(),
|
||
schedule_type: compiled.schedule_type,
|
||
cron_expr: compiled.cron_expr,
|
||
at_time: compiled.at_time,
|
||
target_mode: targetMode,
|
||
model: (document.getElementById("sch-model").value || "").trim(),
|
||
skill: (document.getElementById("sch-template").value || "").trim(),
|
||
persona: (document.getElementById("sch-persona").value || "").trim(),
|
||
project_id: (document.getElementById("sch-project").value || "").trim(),
|
||
initial_message: message,
|
||
auto_approve: document.getElementById("sch-autoapprove").checked,
|
||
notify_targets: _collectNotifyTargets("sch"),
|
||
};
|
||
if (taskId) body.enabled = document.getElementById("sch-enabled").checked;
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch(
|
||
taskId
|
||
? "/v1/api/admin/schedules/" + encodeURIComponent(taskId)
|
||
: "/v1/api/admin/schedules",
|
||
{
|
||
method: taskId ? "PUT" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
if (_schShelfHandle) _schShelfHandle.close();
|
||
showToast(
|
||
taskId ? "Schedule updated" : "Schedule '" + name + "' created",
|
||
);
|
||
loadAdminSchedules();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to save schedule");
|
||
});
|
||
}
|
||
|
||
// --- Schedule Runs Modal ---
|
||
|
||
function showScheduleRuns(taskId) {
|
||
authFetch(
|
||
"/v1/api/admin/schedules/" + encodeURIComponent(taskId) + "/runs?limit=50",
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Not found");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
const runs = data.runs || [];
|
||
const container = document.getElementById("schedule-runs-table");
|
||
if (!runs.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No runs yet</div>',
|
||
);
|
||
} else {
|
||
let html =
|
||
'<div class="admin-colheaders sched-runs-grid" aria-hidden="true">' +
|
||
'<span class="admin-col">STARTED</span>' +
|
||
'<span class="admin-col">NODE</span>' +
|
||
'<span class="admin-col">STATUS</span>' +
|
||
'<span class="admin-col">ERROR</span></div>';
|
||
for (let i = 0; i < runs.length; i++) {
|
||
const r = runs[i];
|
||
const statusCls =
|
||
r.status === "dispatched"
|
||
? "sched-active"
|
||
: r.status === "failed"
|
||
? "sched-expired"
|
||
: "";
|
||
html +=
|
||
'<div class="admin-row sched-runs-grid">' +
|
||
'<span class="admin-col">' +
|
||
escapeHtml(r.started || "")
|
||
.slice(0, 19)
|
||
.replace("T", " ") +
|
||
"</span>" +
|
||
'<span class="admin-col">' +
|
||
escapeHtml(r.node_id || "\u2014") +
|
||
"</span>" +
|
||
'<span class="admin-col"><span class="' +
|
||
statusCls +
|
||
'">' +
|
||
escapeHtml(r.status) +
|
||
"</span></span>" +
|
||
'<span class="admin-col">' +
|
||
escapeHtml(r.error || "\u2014") +
|
||
"</span></div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
}
|
||
window.TurnstoneHatch.openShelf(
|
||
document.getElementById("schedule-runs-shelf"),
|
||
);
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to load run history");
|
||
});
|
||
}
|
||
|
||
function hideScheduleRunsModal() {
|
||
window.TurnstoneHatch.closeShelf(
|
||
document.getElementById("schedule-runs-shelf"),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Watches
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function _populateWatchNodeSelect() {
|
||
const sel = document.getElementById("admin-watch-node");
|
||
const current = sel.value;
|
||
const seen = {};
|
||
setSafeHtml(sel, '<option value="">All nodes</option>');
|
||
for (let i = 0; i < _adminWatches.length; i++) {
|
||
const nid = _adminWatches[i].node_id || "";
|
||
if (nid && !seen[nid]) {
|
||
seen[nid] = true;
|
||
const opt = document.createElement("option");
|
||
opt.value = nid;
|
||
opt.textContent = nid;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
function loadAdminWatches() {
|
||
authFetch("/v1/api/admin/watches")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load watches");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_adminWatches = data.watches || [];
|
||
_populateWatchNodeSelect();
|
||
const nodeFilter = document.getElementById("admin-watch-node").value;
|
||
let filtered = _adminWatches;
|
||
if (nodeFilter) {
|
||
filtered = _adminWatches.filter(function (w) {
|
||
return w.node_id === nodeFilter;
|
||
});
|
||
}
|
||
_renderWatches(filtered);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-watches-table"),
|
||
'<div class="dashboard-empty">Failed to load watches</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _formatInterval(secs) {
|
||
if (!secs || secs <= 0) return "\u2014";
|
||
if (secs >= 3600) return Math.round(secs / 3600) + "h";
|
||
if (secs >= 60) return Math.round(secs / 60) + "m";
|
||
return secs + "s";
|
||
}
|
||
|
||
function _renderWatches(watches) {
|
||
const container = document.getElementById("admin-watches-table");
|
||
if (!watches.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No active watches. Watches are created when workstreams use the watch tool.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < watches.length; i++) {
|
||
const w = watches[i];
|
||
const name = w.name || w.watch_id || "\u2014";
|
||
const nodeShort = (w.node_id || "").slice(0, 8);
|
||
const cmd = w.command || "";
|
||
const cmdTrunc = cmd.length > 40 ? cmd.slice(0, 40) + "\u2026" : cmd;
|
||
const interval = _formatInterval(w.interval_secs);
|
||
const pollMax = w.max_polls ? w.max_polls : "\u221e";
|
||
const pollLabel = (w.poll_count || 0) + "/" + pollMax;
|
||
const cond = w.stop_on || "on change";
|
||
const condTrunc = cond.length > 30 ? cond.slice(0, 30) + "\u2026" : cond;
|
||
const active = w.active;
|
||
const statusCls = active ? "watch-active" : "watch-completed";
|
||
const statusLabel = active ? "active" : "done";
|
||
const statusDot = active ? "\u25cf " : "\u25cb ";
|
||
// Only active watches can be cancelled; completed rows render no menu.
|
||
const cancelBtn = _kebabMenu([
|
||
active
|
||
? {
|
||
label: "cancel",
|
||
kind: "danger",
|
||
title: "Cancel watch",
|
||
attrs: {
|
||
"data-cancel-watch": w.watch_id,
|
||
"data-watch-node": w.node_id || "",
|
||
"data-watch-name": name,
|
||
},
|
||
}
|
||
: null,
|
||
]);
|
||
html +=
|
||
'<div class="admin-row" role="listitem">' +
|
||
'<span class="admin-col admin-col-wname">' +
|
||
escapeHtml(name) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-wnode" title="' +
|
||
escapeHtml(w.node_id || "") +
|
||
'"><code>' +
|
||
escapeHtml(nodeShort) +
|
||
"</code></span>" +
|
||
'<span class="admin-col admin-col-wcmd" title="' +
|
||
escapeHtml(cmd) +
|
||
'"><code>' +
|
||
escapeHtml(cmdTrunc) +
|
||
"</code></span>" +
|
||
'<span class="admin-col admin-col-winterval">' +
|
||
escapeHtml(interval) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-wpoll"><code>' +
|
||
escapeHtml(pollLabel) +
|
||
"</code></span>" +
|
||
'<span class="admin-col admin-col-wcond" title="' +
|
||
escapeHtml(cond) +
|
||
'">' +
|
||
escapeHtml(condTrunc) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-wstatus"><span class="' +
|
||
statusCls +
|
||
'">' +
|
||
statusDot +
|
||
statusLabel +
|
||
"</span></span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
cancelBtn +
|
||
"</span></div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
// Bind cancel buttons
|
||
const btns = container.querySelectorAll("[data-cancel-watch]");
|
||
for (let j = 0; j < btns.length; j++) {
|
||
btns[j].addEventListener("click", function () {
|
||
_cancelWatch(
|
||
this.getAttribute("data-cancel-watch"),
|
||
this.getAttribute("data-watch-node"),
|
||
this.getAttribute("data-watch-name"),
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
function _cancelWatch(watchId, nodeId, name) {
|
||
showConfirmModal(
|
||
"Cancel Watch",
|
||
"Cancel watch \u2018" + name + "\u2019? This will stop future polling.",
|
||
"Stop watch",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/watches/" + encodeURIComponent(watchId) + "/cancel",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ node_id: nodeId }),
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Cancel failed");
|
||
showToast("Watch '" + name + "' cancelled");
|
||
loadAdminWatches();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to cancel watch");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Create Channel Link Modal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _channelWired = false;
|
||
|
||
function _channelWire() {
|
||
if (_channelWired) return;
|
||
_channelWired = true;
|
||
const ctSel = document.getElementById("cc-type");
|
||
ctSel.addEventListener("change", function () {
|
||
document.getElementById("cc-uid").placeholder = _notifyIdPlaceholder(
|
||
ctSel.value,
|
||
);
|
||
});
|
||
document
|
||
.getElementById("cc-submit")
|
||
.addEventListener("click", submitCreateChannel);
|
||
}
|
||
|
||
function showCreateChannelModal() {
|
||
if (!_adminChannelUserId) {
|
||
showToast("Select a user first");
|
||
return;
|
||
}
|
||
_channelWire();
|
||
const shelf = document.getElementById("channel-shelf");
|
||
document.getElementById("channel-shelf-error").classList.remove("is-visible");
|
||
const ctSel = document.getElementById("cc-type");
|
||
const uidInput = document.getElementById("cc-uid");
|
||
ctSel.value = "discord";
|
||
uidInput.value = "";
|
||
uidInput.placeholder = _notifyIdPlaceholder(ctSel.value);
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
ctSel.focus();
|
||
}
|
||
|
||
function hideCreateChannelModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("channel-shelf"));
|
||
}
|
||
|
||
function submitCreateChannel() {
|
||
const shelf = document.getElementById("channel-shelf");
|
||
const channelType = document.getElementById("cc-type").value;
|
||
const channelUserId = (document.getElementById("cc-uid").value || "").trim();
|
||
const errEl = document.getElementById("channel-shelf-error");
|
||
|
||
if (!channelUserId)
|
||
return _showModalError(errEl, "External user ID is required");
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch(
|
||
"/v1/api/admin/users/" +
|
||
encodeURIComponent(_adminChannelUserId) +
|
||
"/channels",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
channel_type: channelType,
|
||
channel_user_id: channelUserId,
|
||
}),
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (r.status === 409)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Already linked");
|
||
});
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
hideCreateChannelModal();
|
||
showToast("Channel account linked");
|
||
loadAdminChannels();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to link channel account");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Create User Modal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _userWired = false;
|
||
|
||
function _userWire() {
|
||
if (_userWired) return;
|
||
_userWired = true;
|
||
document
|
||
.getElementById("cu-submit")
|
||
.addEventListener("click", submitCreateUser);
|
||
}
|
||
|
||
function showCreateUserModal() {
|
||
_userWire();
|
||
const shelf = document.getElementById("user-shelf");
|
||
document.getElementById("user-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("cu-username").value = "";
|
||
document.getElementById("cu-displayname").value = "";
|
||
document.getElementById("cu-password").value = "";
|
||
document.getElementById("cu-confirm").value = "";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("cu-username").focus();
|
||
}
|
||
|
||
function hideCreateUserModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("user-shelf"));
|
||
}
|
||
|
||
function submitCreateUser() {
|
||
const shelf = document.getElementById("user-shelf");
|
||
const username = (document.getElementById("cu-username").value || "").trim();
|
||
const displayName = (
|
||
document.getElementById("cu-displayname").value || ""
|
||
).trim();
|
||
const password = document.getElementById("cu-password").value || "";
|
||
const confirm = document.getElementById("cu-confirm").value || "";
|
||
const errEl = document.getElementById("user-shelf-error");
|
||
|
||
if (!username) return _showModalError(errEl, "Username is required");
|
||
if (!displayName) return _showModalError(errEl, "Display name is required");
|
||
if (!password) return _showModalError(errEl, "Password is required");
|
||
if (password.length < 8)
|
||
return _showModalError(errEl, "Password must be at least 8 characters");
|
||
if (password !== confirm)
|
||
return _showModalError(errEl, "Passwords do not match");
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch("/v1/api/admin/users", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: username,
|
||
display_name: displayName,
|
||
password: password,
|
||
}),
|
||
})
|
||
.then(function (r) {
|
||
if (r.status === 409) throw new Error("Username already taken");
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
hideCreateUserModal();
|
||
showToast("User '" + username + "' created");
|
||
loadAdminUsers();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to create user");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Projects (resource containers) — list + create/edit + member whitelist.
|
||
// Clones the Users tab (loadAdminUsers / showCreateUserModal / _renderUsers).
|
||
// The tab gates on project.read; the server re-gates each mutation on
|
||
// project.{create,write,delete} + per-project ownership, so a read-only viewer
|
||
// sees the list but every action 403s (surfaced inline / as a toast).
|
||
// ---------------------------------------------------------------------------
|
||
let _adminProjects = [];
|
||
let _projectShelfWired = false;
|
||
let _projectMembersWired = false;
|
||
|
||
function loadAdminProjects() {
|
||
authFetch("/v1/api/projects?include_archived=1")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load projects");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_adminProjects = data.projects || [];
|
||
_renderProjects(_adminProjects);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-projects-table"),
|
||
'<div class="dashboard-empty">Failed to load projects</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderProjects(projects) {
|
||
const container = document.getElementById("admin-projects-table");
|
||
if (!projects.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No projects yet. Create one to get started.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < projects.length; i++) {
|
||
const p = projects[i];
|
||
const archived = p.state === "archived";
|
||
html +=
|
||
'<div class="admin-row" role="listitem" data-expandable data-project-id="' +
|
||
escapeHtml(p.project_id) +
|
||
'" tabindex="0" aria-expanded="false">' +
|
||
'<span class="admin-col admin-col-username">' +
|
||
'<span class="admin-expand-indicator" aria-hidden="true">▸</span>' +
|
||
escapeHtml(p.name) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-name">' +
|
||
(p.visibility === "public" ? "Public" : "Private") +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
(archived ? "Archived" : "Active") +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu([
|
||
{
|
||
label: "edit",
|
||
title: "Rename / visibility",
|
||
attrs: { "data-edit-project": p.project_id },
|
||
},
|
||
{
|
||
label: "members",
|
||
title: "Manage members",
|
||
attrs: { "data-project-members": p.project_id },
|
||
},
|
||
{
|
||
label: archived ? "unarchive" : "archive",
|
||
title: archived ? "Reactivate project" : "Archive project",
|
||
attrs: {
|
||
"data-archive-project": p.project_id,
|
||
"data-archive-state": archived ? "active" : "archived",
|
||
},
|
||
},
|
||
{
|
||
label: "delete",
|
||
kind: "danger",
|
||
title: "Delete project",
|
||
attrs: {
|
||
"data-delete-project": p.project_id,
|
||
"data-project-name": p.name,
|
||
},
|
||
},
|
||
]) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
_bindProjectRowActions(container);
|
||
}
|
||
|
||
function _bindProjectRowActions(container) {
|
||
container.querySelectorAll("[data-edit-project]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
showEditProjectModal(this.getAttribute("data-edit-project"));
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-project-members]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
showProjectMembersModal(this.getAttribute("data-project-members"));
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-archive-project]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
_setProjectState(
|
||
this.getAttribute("data-archive-project"),
|
||
this.getAttribute("data-archive-state"),
|
||
);
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-delete-project]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
confirmDeleteProject(
|
||
this.getAttribute("data-delete-project"),
|
||
this.getAttribute("data-project-name"),
|
||
);
|
||
});
|
||
});
|
||
// Expandable per-project resources panel (workstreams / attachments /
|
||
// memory) — same interaction contract as the Users tab's OIDC panel.
|
||
container
|
||
.querySelectorAll(".admin-row[data-expandable]")
|
||
.forEach(function (row) {
|
||
const _expand = function () {
|
||
_toggleProjectPanel(row.getAttribute("data-project-id"), row);
|
||
};
|
||
row.addEventListener("click", function (e) {
|
||
// Clicks on the row's kebab menu must not also toggle the panel.
|
||
if (
|
||
e.target.closest(".admin-kebab") ||
|
||
e.target.closest(".admin-btn-danger") ||
|
||
e.target.closest(".admin-btn-action")
|
||
)
|
||
return;
|
||
_expand();
|
||
});
|
||
row.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
_expand();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _toggleProjectPanel(projectId, rowEl) {
|
||
const existing = rowEl.nextElementSibling;
|
||
if (existing && existing.classList.contains("proj-detail-panel")) {
|
||
// Collapse
|
||
existing.style.maxHeight = "0";
|
||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||
if (indicator) indicator.classList.remove("expanded");
|
||
rowEl.setAttribute("aria-expanded", "false");
|
||
setTimeout(function () {
|
||
if (existing.parentNode) existing.remove();
|
||
}, 160);
|
||
return;
|
||
}
|
||
// Collapse any other open panel first (single-open accordion).
|
||
const openPanels = document.querySelectorAll(
|
||
"#admin-projects-table .proj-detail-panel",
|
||
);
|
||
for (let i = 0; i < openPanels.length; i++) {
|
||
openPanels[i].style.maxHeight = "0";
|
||
const prevRow = openPanels[i].previousElementSibling;
|
||
if (prevRow) {
|
||
const ind = prevRow.querySelector(".admin-expand-indicator");
|
||
if (ind) ind.classList.remove("expanded");
|
||
prevRow.setAttribute("aria-expanded", "false");
|
||
}
|
||
(function (panel) {
|
||
setTimeout(function () {
|
||
if (panel.parentNode) panel.remove();
|
||
}, 160);
|
||
})(openPanels[i]);
|
||
}
|
||
const indicator = rowEl.querySelector(".admin-expand-indicator");
|
||
if (indicator) indicator.classList.add("expanded");
|
||
rowEl.setAttribute("aria-expanded", "true");
|
||
const panel = document.createElement("div");
|
||
panel.className = "proj-detail-panel";
|
||
panel.setAttribute("role", "none");
|
||
setSafeHtml(
|
||
panel,
|
||
'<div class="proj-detail-inner">' +
|
||
'<div class="proj-detail-body"><span class="proj-detail-empty">Loading…</span></div>' +
|
||
"</div>",
|
||
);
|
||
rowEl.after(panel);
|
||
requestAnimationFrame(function () {
|
||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||
});
|
||
authFetch("/v1/api/projects/" + encodeURIComponent(projectId) + "/resources")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderProjectResources(panel, data);
|
||
})
|
||
.catch(function () {
|
||
const body = panel.querySelector(".proj-detail-body");
|
||
if (body)
|
||
setSafeHtml(
|
||
body,
|
||
'<span class="proj-detail-empty">Failed to load</span>',
|
||
);
|
||
});
|
||
}
|
||
|
||
const _PROJ_ATT_ICONS = { image: "\u{1f5bc}", audio: "\u{1f3b5}" };
|
||
|
||
function _projAttachmentHref(att) {
|
||
// Content serving is ws-scoped and node-local: interactive workstreams
|
||
// route through the console's transparent node proxy; coordinator
|
||
// workstreams (no node_id recorded on the attachment's ws row here)
|
||
// serve from the console's own coord attachment routes.
|
||
const tail =
|
||
"v1/api/workstreams/" +
|
||
encodeURIComponent(att.ws_id) +
|
||
"/attachments/" +
|
||
encodeURIComponent(att.attachment_id) +
|
||
"/content";
|
||
return att.node_id
|
||
? "/node/" + encodeURIComponent(att.node_id) + "/" + tail
|
||
: "/" + tail;
|
||
}
|
||
|
||
function _projFmtSize(n) {
|
||
if (typeof n !== "number" || n < 0) return "";
|
||
if (n < 1024) return n + " B";
|
||
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||
return (n / 1048576).toFixed(1) + " MB";
|
||
}
|
||
|
||
function _renderProjectResources(panel, data) {
|
||
const body = panel.querySelector(".proj-detail-body");
|
||
if (!body) return;
|
||
const wss = data.workstreams || [];
|
||
// node_id lives on the workstream rows; the attachment rows carry only
|
||
// their first-referencing ws_id — join here for download URLs.
|
||
const nodeByWs = {};
|
||
for (let i = 0; i < wss.length; i++) nodeByWs[wss[i].ws_id] = wss[i].node_id;
|
||
let html =
|
||
'<div class="proj-detail-header">Workstreams (' + wss.length + ")</div>";
|
||
if (!wss.length) {
|
||
html += '<span class="proj-detail-empty">No workstreams</span>';
|
||
} else {
|
||
for (let i = 0; i < wss.length; i++) {
|
||
const w = wss[i];
|
||
html +=
|
||
'<div class="proj-detail-row">' +
|
||
'<span class="proj-detail-main">' +
|
||
escapeHtml(w.title || w.name || w.ws_id.substring(0, 12)) +
|
||
"</span>" +
|
||
'<span class="proj-detail-dim">' +
|
||
escapeHtml(String(w.kind || "")) +
|
||
" · " +
|
||
escapeHtml(String(w.state || "")) +
|
||
" · " +
|
||
escapeHtml(String(w.updated || "").slice(0, 10)) +
|
||
" · " +
|
||
escapeHtml(w.ws_id.substring(0, 7)) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
}
|
||
const atts = data.attachments || [];
|
||
html +=
|
||
'<div class="proj-detail-header">Attachments (' + atts.length + ")</div>";
|
||
if (!atts.length) {
|
||
html += '<span class="proj-detail-empty">No attachments</span>';
|
||
} else {
|
||
for (let i = 0; i < atts.length; i++) {
|
||
const a = atts[i];
|
||
const icon = _PROJ_ATT_ICONS[a.kind] || "\u{1f4c4}";
|
||
a.node_id = nodeByWs[a.ws_id] || "";
|
||
html +=
|
||
'<div class="proj-detail-row">' +
|
||
'<span class="proj-detail-main">' +
|
||
'<span aria-hidden="true">' +
|
||
icon +
|
||
"</span> " +
|
||
'<a class="proj-detail-link" target="_blank" rel="noopener" href="' +
|
||
escapeHtml(_projAttachmentHref(a)) +
|
||
'">' +
|
||
escapeHtml(a.filename || a.attachment_id.substring(0, 12)) +
|
||
"</a>" +
|
||
"</span>" +
|
||
'<span class="proj-detail-dim">' +
|
||
escapeHtml(_projFmtSize(a.size_bytes)) +
|
||
" · " +
|
||
escapeHtml(String(a.created || "").slice(0, 10)) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
}
|
||
html +=
|
||
'<div class="proj-detail-header">Memory</div>' +
|
||
'<div class="proj-detail-row"><span class="proj-detail-main">' +
|
||
String(data.memory_count || 0) +
|
||
" project-scoped memor" +
|
||
(data.memory_count === 1 ? "y" : "ies") +
|
||
"</span></div>";
|
||
setSafeHtml(body, html);
|
||
// Re-measure after content lands so the animated max-height fits.
|
||
requestAnimationFrame(function () {
|
||
panel.style.maxHeight = panel.scrollHeight + "px";
|
||
});
|
||
}
|
||
|
||
function _projectById(pid) {
|
||
for (let i = 0; i < _adminProjects.length; i++)
|
||
if (_adminProjects[i].project_id === pid) return _adminProjects[i];
|
||
return null;
|
||
}
|
||
|
||
// Refresh the shared picker/rail cache after any project mutation so the
|
||
// launcher dropdowns + the rail's group-by-project pick up the change.
|
||
function _afterProjectMutation() {
|
||
loadAdminProjects();
|
||
if (window.TurnstoneProjects) window.TurnstoneProjects.refreshProjects();
|
||
}
|
||
|
||
function _projectShelfWire() {
|
||
if (_projectShelfWired) return;
|
||
_projectShelfWired = true;
|
||
document
|
||
.getElementById("cp-submit")
|
||
.addEventListener("click", submitProjectShelf);
|
||
}
|
||
|
||
function showCreateProjectModal() {
|
||
_projectShelfWire();
|
||
const shelf = document.getElementById("project-shelf");
|
||
document.getElementById("project-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("cp-project-id").value = "";
|
||
document.getElementById("cp-name").value = "";
|
||
document.getElementById("cp-visibility").value = "private";
|
||
document.getElementById("project-shelf-title").textContent = "New project";
|
||
document.getElementById("cp-submit").textContent = "Create";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("cp-name").focus();
|
||
}
|
||
|
||
function showEditProjectModal(pid) {
|
||
const p = _projectById(pid);
|
||
if (!p) return;
|
||
_projectShelfWire();
|
||
const shelf = document.getElementById("project-shelf");
|
||
document.getElementById("project-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("cp-project-id").value = p.project_id;
|
||
document.getElementById("cp-name").value = p.name;
|
||
document.getElementById("cp-visibility").value = p.visibility || "private";
|
||
document.getElementById("project-shelf-title").textContent = "Edit project";
|
||
document.getElementById("cp-submit").textContent = "Save";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("cp-name").focus();
|
||
}
|
||
|
||
function submitProjectShelf() {
|
||
const shelf = document.getElementById("project-shelf");
|
||
const pid = document.getElementById("cp-project-id").value;
|
||
const name = (document.getElementById("cp-name").value || "").trim();
|
||
const visibility = document.getElementById("cp-visibility").value;
|
||
const errEl = document.getElementById("project-shelf-error");
|
||
if (!name) return _showModalError(errEl, "Name is required");
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
const editing = !!pid;
|
||
const url = editing
|
||
? "/v1/api/projects/" + encodeURIComponent(pid)
|
||
: "/v1/api/projects";
|
||
authFetch(url, {
|
||
method: editing ? "PATCH" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: name, visibility: visibility }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
window.TurnstoneHatch.closeShelf(shelf);
|
||
showToast(editing ? "Project updated" : "Project '" + name + "' created");
|
||
_afterProjectMutation();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to save project");
|
||
});
|
||
}
|
||
|
||
function _setProjectState(pid, state) {
|
||
authFetch("/v1/api/projects/" + encodeURIComponent(pid), {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ state: state }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
showToast(state === "archived" ? "Project archived" : "Project restored");
|
||
_afterProjectMutation();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to update project");
|
||
});
|
||
}
|
||
|
||
function confirmDeleteProject(pid, name) {
|
||
showConfirmModal(
|
||
"Delete project",
|
||
"Delete project ‘" +
|
||
name +
|
||
"’ and its scoped memory? Conversations stay but lose their project link. This cannot be undone.",
|
||
"Delete",
|
||
function () {
|
||
authFetch("/v1/api/projects/" + encodeURIComponent(pid), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
showToast("Project deleted");
|
||
_afterProjectMutation();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete project");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Personas — workstream capability/prompt templates (Service Hatch shelf).
|
||
// Archive-only lifecycle (PATCH enabled=false); no DELETE — a workstream's
|
||
// stamped provenance stays explicable forever. Edits never touch existing
|
||
// workstreams: they run on the snapshot stamped at creation.
|
||
// ===========================================================================
|
||
|
||
let _adminPersonas = [];
|
||
let _personaShelfWired = false;
|
||
|
||
// Builtin tool inventories per kind for the visibility checklist ride the
|
||
// GET /v1/api/admin/personas response (tool_inventory, derived server-side
|
||
// from core/tools.py) — deliberately NO hand-mirrored fallback constant
|
||
// here, which would silently drift every time a tool ships. Until the
|
||
// first list response lands the checklist renders empty; the free-text
|
||
// "extra tools" input still accepts any name in that window.
|
||
let _personaToolInventory = null; // {interactive: [...], coordinator: [...]}
|
||
|
||
function loadAdminPersonas() {
|
||
authFetch("/v1/api/admin/personas")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load personas");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_adminPersonas = data.personas || [];
|
||
if (data.tool_inventory) _personaToolInventory = data.tool_inventory;
|
||
_renderPersonas(_adminPersonas);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-personas-table"),
|
||
'<div class="dashboard-empty">Failed to load personas</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
// One-line envelope summary for the list: prompt/tools/MCP/memory levers.
|
||
function _personaEnvelope(p) {
|
||
const bits = [];
|
||
bits.push(p.base_prompt ? "custom prompt" : "stock prompt");
|
||
if (p.tool_allowlist === null || p.tool_allowlist === undefined) {
|
||
bits.push("all tools");
|
||
} else if (!p.tool_allowlist.length) {
|
||
bits.push("no tools");
|
||
} else {
|
||
bits.push(p.tool_allowlist.length + " tools");
|
||
}
|
||
if (!p.mcp_enabled) bits.push("no MCP");
|
||
if (!p.memory_enabled) bits.push("no memory");
|
||
return bits.join(", ");
|
||
}
|
||
|
||
function _renderPersonas(personas) {
|
||
const container = document.getElementById("admin-personas-table");
|
||
if (!personas.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No personas. Run migrations to seed the builtin set, or create one.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < personas.length; i++) {
|
||
const p = personas[i];
|
||
const archived = !p.enabled;
|
||
const label = p.display_name || p.name;
|
||
html +=
|
||
'<div class="admin-row" role="listitem" tabindex="0">' +
|
||
'<span class="admin-col admin-col-username" title="' +
|
||
escapeHtml(p.description || "") +
|
||
'">' +
|
||
escapeHtml(label) +
|
||
(p.is_default
|
||
? ' <span class="scope-badge scope-default">default</span>'
|
||
: "") +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-name">' +
|
||
escapeHtml((p.applies_to_kinds || []).join(", ")) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
escapeHtml(_personaEnvelope(p)) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-created">' +
|
||
(archived ? "Archived" : "Active") +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
_kebabMenu(
|
||
[
|
||
{
|
||
label: "edit",
|
||
title: "Edit levers (existing workstreams keep their stamp)",
|
||
attrs: { "data-edit-persona": p.persona_id },
|
||
},
|
||
]
|
||
.concat(
|
||
p.is_default || archived
|
||
? []
|
||
: [
|
||
{
|
||
label: "set default",
|
||
title: "Set as the kind default (demotes the incumbent)",
|
||
attrs: { "data-default-persona": p.persona_id },
|
||
},
|
||
],
|
||
)
|
||
.concat(
|
||
p.is_default
|
||
? [] // the default is un-archivable — flip the flag elsewhere first
|
||
: [
|
||
{
|
||
label: archived ? "unarchive" : "archive",
|
||
title: archived
|
||
? "Reactivate persona"
|
||
: "Archive persona (existing workstreams unaffected)",
|
||
attrs: {
|
||
"data-archive-persona": p.persona_id,
|
||
"data-archive-enabled": archived ? "1" : "0",
|
||
},
|
||
},
|
||
],
|
||
),
|
||
) +
|
||
"</span>" +
|
||
"</div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
_bindPersonaRowActions(container);
|
||
}
|
||
|
||
function _bindPersonaRowActions(container) {
|
||
container.querySelectorAll("[data-edit-persona]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
showEditPersonaModal(this.getAttribute("data-edit-persona"));
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-default-persona]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
_patchPersona(
|
||
this.getAttribute("data-default-persona"),
|
||
{ is_default: true },
|
||
"Default persona updated",
|
||
);
|
||
});
|
||
});
|
||
container.querySelectorAll("[data-archive-persona]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
const enable = this.getAttribute("data-archive-enabled") === "1";
|
||
_patchPersona(
|
||
this.getAttribute("data-archive-persona"),
|
||
{ enabled: enable },
|
||
enable ? "Persona restored" : "Persona archived",
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _personaById(pid) {
|
||
for (let i = 0; i < _adminPersonas.length; i++)
|
||
if (_adminPersonas[i].persona_id === pid) return _adminPersonas[i];
|
||
return null;
|
||
}
|
||
|
||
// Refresh the shared picker cache after any persona mutation so the launcher
|
||
// dropdowns + the saved-table labels pick up the change.
|
||
function _afterPersonaMutation() {
|
||
loadAdminPersonas();
|
||
if (window.TurnstonePersonas) window.TurnstonePersonas.refreshPersonas();
|
||
}
|
||
|
||
function _patchPersona(pid, body, okToast) {
|
||
authFetch("/v1/api/admin/personas/" + encodeURIComponent(pid), {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
showToast(okToast);
|
||
_afterPersonaMutation();
|
||
})
|
||
.catch(function (err) {
|
||
showToast(err.message || "Failed to update persona");
|
||
});
|
||
}
|
||
|
||
function _personaShelfWire() {
|
||
if (_personaShelfWired) return;
|
||
_personaShelfWired = true;
|
||
document
|
||
.getElementById("pr-submit")
|
||
.addEventListener("click", submitPersonaShelf);
|
||
document
|
||
.getElementById("pr-tools-mode")
|
||
.addEventListener("change", _personaToolsModeChanged);
|
||
document.getElementById("pr-kinds").addEventListener("change", function () {
|
||
// Re-render the ACTIVE kind's inventory carrying the checked names
|
||
// over, so dual-kind tools (memory, notify, skills, tool_search)
|
||
// survive the flip; checked names outside the new kind's inventory
|
||
// migrate to the extra field instead of silently dropping from the
|
||
// allowlist the operator is editing.
|
||
const kept = [];
|
||
document
|
||
.querySelectorAll("#pr-tools-checklist [data-persona-tool]")
|
||
.forEach(function (input) {
|
||
if (input.checked) kept.push(input.value);
|
||
});
|
||
_renderPersonaToolChecklist(kept);
|
||
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||
const known = (_personaToolInventory || {})[kind] || [];
|
||
const extra = document.getElementById("pr-tools-extra");
|
||
const extras = (extra.value || "")
|
||
.split(",")
|
||
.map(function (s) {
|
||
return s.trim();
|
||
})
|
||
.filter(Boolean);
|
||
kept.forEach(function (n) {
|
||
if (known.indexOf(n) < 0 && extras.indexOf(n) < 0) extras.push(n);
|
||
});
|
||
extra.value = extras.join(", ");
|
||
});
|
||
}
|
||
|
||
function _personaToolsModeChanged() {
|
||
const mode = document.getElementById("pr-tools-mode").value;
|
||
document.getElementById("pr-tools-picker").hidden = mode !== "list";
|
||
}
|
||
|
||
function _renderPersonaToolChecklist(checked) {
|
||
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||
const host = document.getElementById("pr-tools-checklist");
|
||
const inventory = _personaToolInventory || {};
|
||
const names = inventory[kind] || [];
|
||
host.replaceChildren();
|
||
names.forEach(function (name) {
|
||
const label = document.createElement("label");
|
||
label.className = "toggle-switch";
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.value = name;
|
||
input.checked = checked.indexOf(name) >= 0;
|
||
input.setAttribute("data-persona-tool", "");
|
||
const track = document.createElement("span");
|
||
track.className = "toggle-track";
|
||
track.setAttribute("aria-hidden", "true");
|
||
const text = document.createElement("span");
|
||
text.className = "toggle-label";
|
||
text.textContent = name;
|
||
label.append(input, track, text);
|
||
host.append(label);
|
||
});
|
||
}
|
||
|
||
function _personaToolsFromForm() {
|
||
const mode = document.getElementById("pr-tools-mode").value;
|
||
if (mode === "all") return null;
|
||
if (mode === "none") return [];
|
||
const names = [];
|
||
document
|
||
.querySelectorAll("#pr-tools-checklist [data-persona-tool]")
|
||
.forEach(function (input) {
|
||
if (input.checked) names.push(input.value);
|
||
});
|
||
(document.getElementById("pr-tools-extra").value || "")
|
||
.split(",")
|
||
.map(function (s) {
|
||
return s.trim();
|
||
})
|
||
.filter(Boolean)
|
||
.forEach(function (name) {
|
||
if (names.indexOf(name) < 0) names.push(name);
|
||
});
|
||
return names;
|
||
}
|
||
|
||
function _personaFillToolsForm(allowlist) {
|
||
const modeSel = document.getElementById("pr-tools-mode");
|
||
const extra = document.getElementById("pr-tools-extra");
|
||
if (allowlist === null || allowlist === undefined) {
|
||
modeSel.value = "all";
|
||
_renderPersonaToolChecklist([]);
|
||
extra.value = "";
|
||
} else if (!allowlist.length) {
|
||
modeSel.value = "none";
|
||
_renderPersonaToolChecklist([]);
|
||
extra.value = "";
|
||
} else {
|
||
modeSel.value = "list";
|
||
const kind = document.getElementById("pr-kinds").value || "interactive";
|
||
const known = (_personaToolInventory || {})[kind] || [];
|
||
_renderPersonaToolChecklist(allowlist);
|
||
extra.value = allowlist
|
||
.filter(function (n) {
|
||
return known.indexOf(n) < 0;
|
||
})
|
||
.join(", ");
|
||
}
|
||
_personaToolsModeChanged();
|
||
}
|
||
|
||
function showCreatePersonaModal() {
|
||
_personaShelfWire();
|
||
const shelf = document.getElementById("persona-shelf");
|
||
document.getElementById("persona-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("pr-persona-id").value = "";
|
||
document.getElementById("pr-name").value = "";
|
||
document.getElementById("pr-name").disabled = false;
|
||
document.getElementById("pr-display-name").value = "";
|
||
document.getElementById("pr-description").value = "";
|
||
document.getElementById("pr-kinds").value = "interactive";
|
||
document.getElementById("pr-base-prompt").value = "";
|
||
document.getElementById("pr-base-prompt").placeholder =
|
||
"Required — the base system prompt for this persona";
|
||
document.getElementById("pr-mcp").checked = true;
|
||
document.getElementById("pr-memory").checked = true;
|
||
_personaFillToolsForm(null);
|
||
document.getElementById("persona-shelf-title").textContent = "New persona";
|
||
document.getElementById("pr-submit").textContent = "Create";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("pr-name").focus();
|
||
}
|
||
|
||
function showEditPersonaModal(pid) {
|
||
const p = _personaById(pid);
|
||
if (!p) return;
|
||
_personaShelfWire();
|
||
const shelf = document.getElementById("persona-shelf");
|
||
document.getElementById("persona-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("pr-persona-id").value = p.persona_id;
|
||
// The slug is immutable — shown for context, not editable.
|
||
document.getElementById("pr-name").value = p.name;
|
||
document.getElementById("pr-name").disabled = true;
|
||
document.getElementById("pr-display-name").value = p.display_name || "";
|
||
document.getElementById("pr-description").value = p.description || "";
|
||
document.getElementById("pr-kinds").value =
|
||
(p.applies_to_kinds || ["interactive"])[0] || "interactive";
|
||
document.getElementById("pr-base-prompt").value = p.base_prompt || "";
|
||
document.getElementById("pr-base-prompt").placeholder =
|
||
"Blank keeps this built-in's shipped prompt";
|
||
document.getElementById("pr-mcp").checked = !!p.mcp_enabled;
|
||
document.getElementById("pr-memory").checked = !!p.memory_enabled;
|
||
_personaFillToolsForm(
|
||
p.tool_allowlist === undefined ? null : p.tool_allowlist,
|
||
);
|
||
document.getElementById("persona-shelf-title").textContent = "Edit persona";
|
||
document.getElementById("pr-submit").textContent = "Save";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("pr-display-name").focus();
|
||
}
|
||
|
||
function submitPersonaShelf() {
|
||
const shelf = document.getElementById("persona-shelf");
|
||
const pid = document.getElementById("pr-persona-id").value;
|
||
const name = (document.getElementById("pr-name").value || "").trim();
|
||
const errEl = document.getElementById("persona-shelf-error");
|
||
const editing = !!pid;
|
||
if (!editing && !name) return _showModalError(errEl, "Name is required");
|
||
|
||
const prompt = document.getElementById("pr-base-prompt").value;
|
||
if (!editing && !prompt.trim())
|
||
return _showModalError(errEl, "Base prompt is required");
|
||
const original = editing ? _personaById(pid) || {} : {};
|
||
const wasDefault = editing && !!original.is_default;
|
||
const body = {
|
||
display_name: (
|
||
document.getElementById("pr-display-name").value || ""
|
||
).trim(),
|
||
description: (document.getElementById("pr-description").value || "").trim(),
|
||
base_prompt: prompt.trim() ? prompt : null,
|
||
tool_allowlist: _personaToolsFromForm(),
|
||
mcp_enabled: document.getElementById("pr-mcp").checked,
|
||
memory_enabled: document.getElementById("pr-memory").checked,
|
||
applies_to_kinds: [document.getElementById("pr-kinds").value],
|
||
};
|
||
if (!editing) body.name = name;
|
||
if (editing) {
|
||
const originalKinds = original.applies_to_kinds || [];
|
||
if (wasDefault) {
|
||
// Storage forbids changing a default persona's kinds — don't send it.
|
||
delete body.applies_to_kinds;
|
||
} else if (
|
||
originalKinds.length !== 1 &&
|
||
body.applies_to_kinds[0] === originalKinds[0]
|
||
) {
|
||
// The single-value select can only show one kind; on a multi-kind
|
||
// persona an unrelated edit must not silently strip the others.
|
||
// Send kinds only when the operator actually changed the selection.
|
||
delete body.applies_to_kinds;
|
||
}
|
||
}
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
const url = editing
|
||
? "/v1/api/admin/personas/" + encodeURIComponent(pid)
|
||
: "/v1/api/admin/personas";
|
||
authFetch(url, {
|
||
method: editing ? "PATCH" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
window.TurnstoneHatch.closeShelf(shelf);
|
||
showToast(editing ? "Persona updated" : "Persona '" + name + "' created");
|
||
_afterPersonaMutation();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to save persona");
|
||
});
|
||
}
|
||
|
||
// --- Members (whitelist users for read+write; "public" visibility above grants
|
||
// read to any project.read holder — the "* all users" lever). ------------
|
||
function _projectMembersWire() {
|
||
if (_projectMembersWired) return;
|
||
_projectMembersWired = true;
|
||
document
|
||
.getElementById("pm-add-btn")
|
||
.addEventListener("click", _addProjectMember);
|
||
}
|
||
|
||
function showProjectMembersModal(pid) {
|
||
_projectMembersWire();
|
||
const shelf = document.getElementById("project-members-shelf");
|
||
document
|
||
.getElementById("project-members-shelf-error")
|
||
.classList.remove("is-visible");
|
||
document.getElementById("pm-project-id").value = pid;
|
||
setSafeHtml(
|
||
document.getElementById("pm-members-container"),
|
||
'<div class="dashboard-empty">Loading…</div>',
|
||
);
|
||
_populateMemberUserSelect();
|
||
_loadProjectMembers(pid);
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
// Move focus into the shelf (the create/edit shelf focuses its name input;
|
||
// mirror that here so keyboard users land on the add-member control).
|
||
document.getElementById("pm-add-user").focus();
|
||
}
|
||
|
||
function _populateMemberUserSelect() {
|
||
const sel = document.getElementById("pm-add-user");
|
||
function fill(users) {
|
||
let html = '<option value="">Select a user…</option>';
|
||
for (let i = 0; i < users.length; i++) {
|
||
html +=
|
||
'<option value="' +
|
||
escapeHtml(users[i].user_id) +
|
||
'">' +
|
||
escapeHtml(users[i].username) +
|
||
"</option>";
|
||
}
|
||
setSafeHtml(sel, html);
|
||
}
|
||
// Reuse the Users tab's already-loaded list when present; else fetch it (an
|
||
// admin managing projects normally also holds admin.users — if not, the
|
||
// fetch 403s and the picker stays empty, the documented v1 limitation).
|
||
if (_adminUsers && _adminUsers.length) {
|
||
fill(_adminUsers);
|
||
return;
|
||
}
|
||
authFetch("/v1/api/admin/users")
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : { users: [] };
|
||
})
|
||
.then(function (data) {
|
||
_adminUsers = data.users || [];
|
||
fill(_adminUsers);
|
||
})
|
||
.catch(function () {
|
||
fill([]);
|
||
});
|
||
}
|
||
|
||
function _loadProjectMembers(pid) {
|
||
authFetch("/v1/api/projects/" + encodeURIComponent(pid) + "/members")
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : { members: [] };
|
||
})
|
||
.then(function (data) {
|
||
_renderProjectMembers(data.members || []);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("pm-members-container"),
|
||
'<div class="dashboard-empty">Failed to load members</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _userNameFor(uid) {
|
||
if (_adminUsers)
|
||
for (let i = 0; i < _adminUsers.length; i++)
|
||
if (_adminUsers[i].user_id === uid) return _adminUsers[i].username;
|
||
return uid;
|
||
}
|
||
|
||
function _renderProjectMembers(members) {
|
||
const container = document.getElementById("pm-members-container");
|
||
if (!members.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No members yet. Add users for write access, or set the project Public for read access.</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < members.length; i++) {
|
||
const uid = members[i];
|
||
html +=
|
||
'<div class="admin-row" role="listitem">' +
|
||
'<span class="admin-col admin-col-username">' +
|
||
escapeHtml(_userNameFor(uid)) +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-actions">' +
|
||
'<button class="admin-btn-danger" type="button" data-remove-member="' +
|
||
escapeHtml(uid) +
|
||
'" aria-label="Remove ' +
|
||
escapeHtml(_userNameFor(uid)) +
|
||
'">Remove</button>' +
|
||
"</span></div>";
|
||
}
|
||
setSafeHtml(container, html);
|
||
container.querySelectorAll("[data-remove-member]").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
_removeProjectMember(this.getAttribute("data-remove-member"));
|
||
});
|
||
});
|
||
}
|
||
|
||
function _addProjectMember() {
|
||
const pid = document.getElementById("pm-project-id").value;
|
||
const uid = document.getElementById("pm-add-user").value;
|
||
const errEl = document.getElementById("project-members-shelf-error");
|
||
if (!uid) return _showModalError(errEl, "Select a user to add");
|
||
errEl.classList.remove("is-visible");
|
||
authFetch("/v1/api/projects/" + encodeURIComponent(pid) + "/members", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ user_id: uid }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
document.getElementById("pm-add-user").value = "";
|
||
_renderProjectMembers(data.members || []);
|
||
})
|
||
.catch(function (err) {
|
||
_showModalError(errEl, err.message || "Failed to add member");
|
||
});
|
||
}
|
||
|
||
function _removeProjectMember(uid) {
|
||
const pid = document.getElementById("pm-project-id").value;
|
||
authFetch(
|
||
"/v1/api/projects/" +
|
||
encodeURIComponent(pid) +
|
||
"/members/" +
|
||
encodeURIComponent(uid),
|
||
{ method: "DELETE" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_renderProjectMembers(data.members || []);
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to remove member");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Create Token Modal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _tokenWired = false;
|
||
|
||
function _tokenWire() {
|
||
if (_tokenWired) return;
|
||
_tokenWired = true;
|
||
document
|
||
.getElementById("ct-submit")
|
||
.addEventListener("click", submitCreateToken);
|
||
}
|
||
|
||
function showCreateTokenModal() {
|
||
if (!_adminTokenUserId) {
|
||
showToast("Select a user first");
|
||
return;
|
||
}
|
||
_tokenWire();
|
||
const shelf = document.getElementById("token-shelf");
|
||
document.getElementById("token-shelf-error").classList.remove("is-visible");
|
||
document.getElementById("ct-name").value = "";
|
||
document.getElementById("ct-scopes").value = "read,write,approve";
|
||
document.getElementById("ct-expires").value = "";
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("ct-name").focus();
|
||
}
|
||
|
||
function hideCreateTokenModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("token-shelf"));
|
||
}
|
||
|
||
function submitCreateToken() {
|
||
const shelf = document.getElementById("token-shelf");
|
||
const name = (document.getElementById("ct-name").value || "").trim();
|
||
const scopes = document.getElementById("ct-scopes").value;
|
||
const expiresDays = document.getElementById("ct-expires").value;
|
||
const errEl = document.getElementById("token-shelf-error");
|
||
|
||
const body = { name: name, scopes: scopes };
|
||
if (expiresDays) body.expires_days = parseInt(expiresDays, 10);
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch(
|
||
"/v1/api/admin/users/" + encodeURIComponent(_adminTokenUserId) + "/tokens",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
hideCreateTokenModal();
|
||
_lastCreatedToken = data.token;
|
||
showTokenCreatedModal(data.token);
|
||
loadAdminTokens();
|
||
})
|
||
.catch(function (err) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
_showModalError(errEl, err.message || "Failed to create token");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Token Created Modal (show-once)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function showTokenCreatedModal(token) {
|
||
document.getElementById("token-created-value").textContent = token;
|
||
window.TurnstoneHatch.openDialog(
|
||
document.getElementById("token-created-dialog"),
|
||
{
|
||
onClose: function () {
|
||
_lastCreatedToken = "";
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
function hideTokenCreatedModal() {
|
||
const d = document.getElementById("token-created-dialog");
|
||
if (d.open) d.close();
|
||
}
|
||
|
||
function copyCreatedToken() {
|
||
if (!_lastCreatedToken) return;
|
||
if (navigator.clipboard) {
|
||
navigator.clipboard.writeText(_lastCreatedToken).then(function () {
|
||
showToast("Token copied to clipboard");
|
||
});
|
||
} else {
|
||
// Fallback: select the text
|
||
const el = document.getElementById("token-created-value");
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
showToast("Select and copy the token");
|
||
}
|
||
}
|
||
|
||
// Escape closes any open settings help popover (the settings panels and the
|
||
// shelf form help buttons share the same popover component).
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key !== "Escape") return;
|
||
const openHelp = document.querySelector('.settings-help-popover[style=""]');
|
||
if (openHelp) {
|
||
e.preventDefault();
|
||
// Layered dismissal: a popover inside an open shelf consumes this
|
||
// Escape entirely — hatch.js's document listener (registered later, on
|
||
// first openShelf) must not also close the shelf underneath it.
|
||
e.stopImmediatePropagation();
|
||
_closeAllSettingsHelp();
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Confirm Modal (reusable styled replacement for confirm())
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function showConfirmModal(title, message, actionLabel, callback) {
|
||
_confirmCallbackFn = callback;
|
||
document.getElementById("confirm-title").textContent = title;
|
||
document.getElementById("confirm-message").textContent = message;
|
||
const btn = document.getElementById("confirm-submit");
|
||
btn.textContent = actionLabel;
|
||
btn.disabled = false;
|
||
// Cancel carries the autofocus: Enter on a freshly-opened destructive
|
||
// confirm must not fire the action (deliberate change from the legacy
|
||
// action-focused behavior).
|
||
window.TurnstoneHatch.openDialog(document.getElementById("confirm-dialog"), {
|
||
onClose: function () {
|
||
_confirmCallbackFn = null;
|
||
},
|
||
});
|
||
}
|
||
|
||
function hideConfirmModal() {
|
||
const d = document.getElementById("confirm-dialog");
|
||
if (d.open) d.close();
|
||
}
|
||
|
||
function _confirmCallback() {
|
||
const fn = _confirmCallbackFn;
|
||
_confirmCallbackFn = null;
|
||
hideConfirmModal();
|
||
if (fn) fn();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings — form-based editor grouped by section
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _settingsOriginal = {}; // original values for dirty detection
|
||
|
||
// Section display order
|
||
const _settingsSectionOrder = [
|
||
"model",
|
||
"session",
|
||
"tools",
|
||
"server",
|
||
"cluster",
|
||
"channels",
|
||
"mcp",
|
||
"ratelimit",
|
||
"health",
|
||
"judge",
|
||
"skills",
|
||
"memory",
|
||
];
|
||
|
||
function _settingsSectionLabel(section) {
|
||
const labels = {
|
||
model: "Model",
|
||
session: "Session",
|
||
tools: "Tools",
|
||
server: "Server",
|
||
cluster: "Cluster",
|
||
channels: "Channels",
|
||
audio: "Voice",
|
||
mcp: "MCP",
|
||
ratelimit: "Rate Limiting",
|
||
health: "Health",
|
||
judge: "Judge",
|
||
skills: "Skills",
|
||
memory: "Memory",
|
||
};
|
||
return labels[section] || section;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TLS tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadTlsCerts() {
|
||
const statusEl = document.getElementById("tls-ca-status");
|
||
const listEl = document.getElementById("tls-cert-list");
|
||
if (!statusEl || !listEl) return;
|
||
|
||
// Fetch CA status and cert list in parallel
|
||
Promise.all([
|
||
authFetch("/v1/api/admin/tls/ca").then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
}),
|
||
authFetch("/v1/api/admin/tls/certs").then(function (r) {
|
||
if (!r.ok) return { certs: [] };
|
||
return r.json();
|
||
}),
|
||
])
|
||
.then(function (results) {
|
||
const data = results[0];
|
||
const certData = results[1];
|
||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||
|
||
if (!data.enabled) {
|
||
const msg = document.createElement("div");
|
||
msg.className = "dashboard-empty";
|
||
msg.textContent =
|
||
"TLS is not enabled. Set tls.enabled = true in Settings.";
|
||
statusEl.appendChild(msg);
|
||
return;
|
||
}
|
||
|
||
// CA status bar
|
||
const bar = document.createElement("div");
|
||
bar.className = "tls-ca-bar";
|
||
const caLabel = document.createElement("span");
|
||
caLabel.textContent = "CA: " + data.ca_cn;
|
||
const countLabel = document.createElement("span");
|
||
countLabel.textContent = "Certificates: " + data.cert_count;
|
||
bar.appendChild(caLabel);
|
||
bar.appendChild(countLabel);
|
||
statusEl.appendChild(bar);
|
||
|
||
const certs = certData.certs || [];
|
||
if (certs.length === 0) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "dashboard-empty";
|
||
empty.textContent = "No certificates issued yet.";
|
||
listEl.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// Cert rows
|
||
certs.forEach(function (c) {
|
||
const row = document.createElement("div");
|
||
row.className = "admin-row";
|
||
row.setAttribute("role", "listitem");
|
||
|
||
const colDomain = document.createElement("span");
|
||
colDomain.className = "admin-col";
|
||
colDomain.textContent = c.domain;
|
||
|
||
const colSans = document.createElement("span");
|
||
colSans.className = "admin-col";
|
||
colSans.textContent = (c.domains || [c.domain]).join(", ");
|
||
|
||
const colIssued = document.createElement("span");
|
||
colIssued.className = "admin-col";
|
||
colIssued.textContent = (c.issued_at || "")
|
||
.slice(0, 16)
|
||
.replace("T", " ");
|
||
|
||
const colExpires = document.createElement("span");
|
||
colExpires.className = "admin-col";
|
||
const expires = new Date(c.expires_at);
|
||
const isExpired = expires < new Date();
|
||
colExpires.textContent =
|
||
(isExpired ? "EXPIRED " : "") +
|
||
(c.expires_at || "").slice(0, 16).replace("T", " ");
|
||
if (isExpired) colExpires.style.color = "var(--red)";
|
||
|
||
const colActions = document.createElement("span");
|
||
colActions.className = "admin-col admin-col-actions";
|
||
const kebab = _kebabMenuEl([
|
||
{
|
||
label: "Renew",
|
||
attrs: {
|
||
"data-tls-renew": c.domain,
|
||
"aria-label": "Renew certificate for " + c.domain,
|
||
},
|
||
},
|
||
{
|
||
label: "Delete",
|
||
kind: "danger",
|
||
attrs: {
|
||
"data-tls-delete": c.domain,
|
||
"aria-label": "Delete certificate for " + c.domain,
|
||
},
|
||
},
|
||
]);
|
||
colActions.appendChild(kebab);
|
||
|
||
row.appendChild(colDomain);
|
||
row.appendChild(colSans);
|
||
row.appendChild(colIssued);
|
||
row.appendChild(colExpires);
|
||
row.appendChild(colActions);
|
||
listEl.appendChild(row);
|
||
});
|
||
// Bind by attribute (matching the Models call site) rather than by
|
||
// positional index into the kebab items, so this survives any future
|
||
// single-item degrade (where _kebabMenu returns a bare button).
|
||
listEl.querySelectorAll("[data-tls-renew]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
tlsRenewCert(this.getAttribute("data-tls-renew"));
|
||
});
|
||
});
|
||
listEl.querySelectorAll("[data-tls-delete]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
tlsDeleteCert(this.getAttribute("data-tls-delete"));
|
||
});
|
||
});
|
||
})
|
||
.catch(function () {
|
||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||
const errMsg = document.createElement("div");
|
||
errMsg.className = "dashboard-empty";
|
||
errMsg.textContent = "Failed to load TLS status";
|
||
statusEl.appendChild(errMsg);
|
||
});
|
||
}
|
||
|
||
function tlsRenewCert(domain) {
|
||
showConfirmModal(
|
||
"Renew Certificate",
|
||
"Force renew certificate for \u2018" + domain + "\u2019?",
|
||
"Renew",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/tls/certs/" + encodeURIComponent(domain) + "/renew",
|
||
{ method: "POST" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Renew failed");
|
||
showToast("Certificate renewed for " + domain);
|
||
loadTlsCerts();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to renew certificate", "error");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
function tlsDeleteCert(domain) {
|
||
showConfirmModal(
|
||
"Delete Certificate",
|
||
"Delete certificate for \u2018" + domain + "\u2019? This cannot be undone.",
|
||
"Delete",
|
||
function () {
|
||
authFetch("/v1/api/admin/tls/certs/" + encodeURIComponent(domain), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Delete failed");
|
||
showToast("Certificate deleted for " + domain);
|
||
loadTlsCerts();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete certificate", "error");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadSettings() {
|
||
const el = document.getElementById("admin-settings-content");
|
||
if (!el) return;
|
||
|
||
Promise.all([
|
||
authFetch("/v1/api/admin/settings").then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load settings");
|
||
return r.json();
|
||
}),
|
||
authFetch("/v1/api/admin/settings/schema").then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load schema");
|
||
return r.json();
|
||
}),
|
||
authFetch("/v1/api/admin/model-definitions").then(function (r) {
|
||
if (!r.ok) return { models: [] };
|
||
return r.json();
|
||
}),
|
||
])
|
||
.then(function (results) {
|
||
const valuesArr = results[0].settings || [];
|
||
const schemaArr = results[1].schema || [];
|
||
const modelDefs = results[2].models || [];
|
||
|
||
// Build schema lookup
|
||
const schemaMap = {};
|
||
for (let i = 0; i < schemaArr.length; i++) {
|
||
schemaMap[schemaArr[i].key] = schemaArr[i];
|
||
}
|
||
|
||
// Merge values + schema. Skip role-assignment settings owned by the
|
||
// Models → Roles sub-tab. Derive the skip-set straight from MODEL_ROLES
|
||
// (alias + optional effort key) so a newly-added role can't drift back
|
||
// into this list — perception was exactly that miss, and stt/tts/reranker
|
||
// had quietly leaked the same way. judge.* keeps its own prefix skip
|
||
// below: it covers more than the two judge role aliases (the output-guard
|
||
// toggle, thresholds, ...), all of which live on the Judge tab.
|
||
const merged = {};
|
||
const roleKeys = {};
|
||
for (let ri = 0; ri < MODEL_ROLES.length; ri++) {
|
||
roleKeys[MODEL_ROLES[ri].aliasKey] = 1;
|
||
if (MODEL_ROLES[ri].effortKey) roleKeys[MODEL_ROLES[ri].effortKey] = 1;
|
||
}
|
||
for (let j = 0; j < valuesArr.length; j++) {
|
||
const v = valuesArr[j];
|
||
if (v.key.startsWith("judge.")) continue;
|
||
if (roleKeys[v.key]) continue;
|
||
const s = schemaMap[v.key] || {};
|
||
merged[v.key] = {
|
||
key: v.key,
|
||
value: v.value,
|
||
source: v.source,
|
||
type: v.type || s.type || "str",
|
||
default_value: s.default !== undefined ? s.default : "",
|
||
description: v.description || s.description || "",
|
||
section: v.section || s.section || "",
|
||
is_secret: v.is_secret || false,
|
||
min_value: s.min_value,
|
||
max_value: s.max_value,
|
||
choices: s.choices || null,
|
||
restart_required: v.restart_required || false,
|
||
changed_by: v.changed_by || "",
|
||
updated: v.updated || "",
|
||
help: s.help || "",
|
||
reference_url: s.reference_url || "",
|
||
};
|
||
}
|
||
|
||
// Inject dynamic choices for model alias settings from model definitions.
|
||
const enabledAliases = [""];
|
||
for (let m = 0; m < modelDefs.length; m++) {
|
||
if (modelDefs[m].enabled) enabledAliases.push(modelDefs[m].alias);
|
||
}
|
||
if (enabledAliases.length > 1) {
|
||
for (let ak = 0; ak < ALIAS_SETTING_KEYS.length; ak++) {
|
||
const aliasKey = ALIAS_SETTING_KEYS[ak];
|
||
if (merged[aliasKey]) merged[aliasKey].choices = enabledAliases;
|
||
}
|
||
}
|
||
|
||
_settingsOriginal = {};
|
||
|
||
// Group by section
|
||
const grouped = {};
|
||
const keys = Object.keys(merged);
|
||
for (let k = 0; k < keys.length; k++) {
|
||
const item = merged[keys[k]];
|
||
const sec = item.section || "other";
|
||
if (!grouped[sec]) grouped[sec] = [];
|
||
grouped[sec].push(item);
|
||
}
|
||
|
||
_renderSettings(el, grouped);
|
||
})
|
||
.catch(function (err) {
|
||
// NOTE: escapeHtml sanitises err.message before insertion.
|
||
setSafeHtml(
|
||
el,
|
||
'<div class="dashboard-empty">Failed to load settings: ' +
|
||
escapeHtml(err.message || String(err)) +
|
||
"</div>",
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderSettings(container, grouped) {
|
||
let html = "";
|
||
|
||
for (let i = 0; i < _settingsSectionOrder.length; i++) {
|
||
const sec = _settingsSectionOrder[i];
|
||
const items = grouped[sec];
|
||
if (!items || items.length === 0) continue;
|
||
|
||
html +=
|
||
'<div class="settings-section" data-section="' +
|
||
sec +
|
||
'" data-collapsed>';
|
||
html +=
|
||
'<div class="settings-section-header" role="button" tabindex="0" aria-expanded="false" aria-controls="settings-body-' +
|
||
sec +
|
||
'">';
|
||
html += "<span>" + _settingsSectionLabel(sec) + "</span>";
|
||
html += "</div>";
|
||
html +=
|
||
'<div class="settings-section-body" id="settings-body-' + sec + '">';
|
||
|
||
for (let j = 0; j < items.length; j++) {
|
||
html += _renderSettingRow(items[j]);
|
||
}
|
||
|
||
html += "</div></div>";
|
||
}
|
||
|
||
// Render any sections not in the explicit order
|
||
const allSections = Object.keys(grouped);
|
||
for (let s = 0; s < allSections.length; s++) {
|
||
if (_settingsSectionOrder.indexOf(allSections[s]) === -1) {
|
||
const extra = grouped[allSections[s]];
|
||
html +=
|
||
'<div class="settings-section" data-section="' +
|
||
allSections[s] +
|
||
'" data-collapsed>';
|
||
html +=
|
||
'<div class="settings-section-header" role="button" tabindex="0" aria-expanded="false" aria-controls="settings-body-' +
|
||
allSections[s] +
|
||
'">';
|
||
html += "<span>" + _settingsSectionLabel(allSections[s]) + "</span>";
|
||
html += "</div>";
|
||
html +=
|
||
'<div class="settings-section-body" id="settings-body-' +
|
||
allSections[s] +
|
||
'">';
|
||
for (let x = 0; x < extra.length; x++) {
|
||
html += _renderSettingRow(extra[x]);
|
||
}
|
||
html += "</div></div>";
|
||
}
|
||
}
|
||
|
||
setSafeHtml(container, html);
|
||
|
||
// Bind handlers via data-* attributes — a key with an apostrophe
|
||
// would otherwise break out of the JS string when the HTML parser
|
||
// decodes ' before JS evaluation.
|
||
const sectionHeaders = container.querySelectorAll(".settings-section-header");
|
||
for (let sh = 0; sh < sectionHeaders.length; sh++) {
|
||
sectionHeaders[sh].addEventListener("click", function () {
|
||
_toggleSettingsSection(this);
|
||
});
|
||
sectionHeaders[sh].addEventListener("keydown", function (e) {
|
||
_onSettingsHeaderKey(e, this);
|
||
});
|
||
}
|
||
// Help-button clicks are handled by the document-delegated listener
|
||
// below; no per-button binding is needed here (and re-binding on each
|
||
// render would leak listeners onto reused DOM).
|
||
|
||
// Store original values for dirty detection + wire per-key change
|
||
// handlers off data-setting-key (no key interpolation into JS).
|
||
const inputs = container.querySelectorAll("[data-setting-key]");
|
||
for (let n = 0; n < inputs.length; n++) {
|
||
const inp = inputs[n];
|
||
const key = inp.getAttribute("data-setting-key");
|
||
if (inp.type === "checkbox") {
|
||
_settingsOriginal[key] = inp.checked;
|
||
} else {
|
||
_settingsOriginal[key] = inp.value;
|
||
}
|
||
const evtName =
|
||
inp.type === "checkbox" || inp.tagName === "SELECT" ? "change" : "input";
|
||
inp.addEventListener(evtName, function () {
|
||
_onSettingChange(this);
|
||
});
|
||
}
|
||
const saveBtns = container.querySelectorAll("[data-save-key]");
|
||
for (let sb = 0; sb < saveBtns.length; sb++) {
|
||
saveBtns[sb].addEventListener("click", function () {
|
||
_saveSettingValue(this.getAttribute("data-save-key"));
|
||
});
|
||
}
|
||
const resetBtns = container.querySelectorAll("[data-reset-key]");
|
||
for (let rb = 0; rb < resetBtns.length; rb++) {
|
||
resetBtns[rb].addEventListener("click", function () {
|
||
_resetSetting(this.getAttribute("data-reset-key"));
|
||
});
|
||
}
|
||
}
|
||
|
||
function _renderSettingRow(item) {
|
||
const shortKey =
|
||
item.key.indexOf(".") !== -1
|
||
? item.key.substring(item.key.indexOf(".") + 1)
|
||
: item.key;
|
||
const escapedKey = escapeHtml(item.key);
|
||
const escapedShort = escapeHtml(shortKey);
|
||
const escapedDesc = escapeHtml(item.description);
|
||
|
||
// Description (short TLDR) renders inline below the key. The ? button
|
||
// gates the long-form help paragraph + any reference_url link.
|
||
const hasHelp = !!(item.help || item.reference_url);
|
||
const helpId = escapedKey + "-help";
|
||
|
||
let html = '<div class="settings-row" data-row-key="' + escapedKey + '">';
|
||
|
||
// Label column
|
||
html += '<div class="settings-label-col">';
|
||
html += '<div class="settings-label">';
|
||
html += escapeHtml(shortKey);
|
||
if (hasHelp) {
|
||
html +=
|
||
' <button type="button" class="settings-help-btn" ' +
|
||
'data-help-target="' +
|
||
helpId +
|
||
'" aria-label="Help for ' +
|
||
escapedShort +
|
||
'" aria-expanded="false" title="More info"></button>';
|
||
}
|
||
html += "</div>";
|
||
if (item.description) {
|
||
html += '<div class="settings-desc">' + escapedDesc + "</div>";
|
||
}
|
||
if (hasHelp) {
|
||
html +=
|
||
'<div id="' +
|
||
helpId +
|
||
'" class="settings-help-popover" style="display:none">';
|
||
if (item.help) {
|
||
html +=
|
||
'<span class="settings-help-text">' + escapeHtml(item.help) + "</span>";
|
||
}
|
||
if (item.reference_url) {
|
||
html +=
|
||
' <a href="' +
|
||
escapeHtml(item.reference_url) +
|
||
'" target="_blank" rel="noopener" class="settings-help-ref">learn more</a>';
|
||
}
|
||
html += "</div>";
|
||
}
|
||
html += "</div>";
|
||
|
||
// Input column
|
||
html += '<div class="settings-input">';
|
||
if (item.is_secret) {
|
||
html +=
|
||
'<input type="password" data-setting-key="' +
|
||
escapedKey +
|
||
'" aria-label="Secret value for ' +
|
||
escapedShort +
|
||
'" autocomplete="off" value="" placeholder="' +
|
||
(item.source === "storage" ? "***" : "not set") +
|
||
'">';
|
||
} else if (item.type === "bool") {
|
||
const checked =
|
||
item.value === true || item.value === "true" ? " checked" : "";
|
||
html +=
|
||
'<label class="settings-toggle"><input type="checkbox" data-setting-key="' +
|
||
escapedKey +
|
||
'" aria-label="' +
|
||
escapedShort +
|
||
'"' +
|
||
checked +
|
||
'><span class="settings-toggle-slider"></span></label>';
|
||
} else if (item.choices && item.choices.length > 0) {
|
||
html +=
|
||
'<select data-setting-key="' +
|
||
escapedKey +
|
||
'" aria-label="' +
|
||
escapedShort +
|
||
'">';
|
||
for (let c = 0; c < item.choices.length; c++) {
|
||
const sel = item.choices[c] === String(item.value) ? " selected" : "";
|
||
let label;
|
||
if (item.choices[c] !== "") {
|
||
label = escapeHtml(item.choices[c]);
|
||
} else if (ALIAS_SETTING_KEYS.indexOf(item.key) !== -1) {
|
||
label = "(server default)";
|
||
} else if (INHERIT_EMPTY_LABEL_KEYS.indexOf(item.key) !== -1) {
|
||
label = "(inherit)";
|
||
} else {
|
||
label = "(none)";
|
||
}
|
||
html +=
|
||
'<option value="' +
|
||
escapeHtml(item.choices[c]) +
|
||
'"' +
|
||
sel +
|
||
">" +
|
||
label +
|
||
"</option>";
|
||
}
|
||
html += "</select>";
|
||
} else if (item.type === "int" || item.type === "float") {
|
||
const step = item.type === "float" ? "0.01" : "1";
|
||
const minAttr =
|
||
item.min_value !== null && item.min_value !== undefined
|
||
? ' min="' + item.min_value + '"'
|
||
: "";
|
||
const maxAttr =
|
||
item.max_value !== null && item.max_value !== undefined
|
||
? ' max="' + item.max_value + '"'
|
||
: "";
|
||
// A null registry default means "unset = inherit" (e.g.
|
||
// model.temperature): blank is a saveable state, not a validation
|
||
// error — the save handler maps it to reset-to-default.
|
||
const nullableAttr =
|
||
item.default_value === null
|
||
? ' data-nullable="1" placeholder="(inherit model default)"'
|
||
: "";
|
||
html +=
|
||
'<input type="number" data-setting-key="' +
|
||
escapedKey +
|
||
'" aria-label="' +
|
||
escapedShort +
|
||
'" value="' +
|
||
escapeHtml(String(item.value != null ? item.value : "")) +
|
||
'" step="' +
|
||
step +
|
||
'"' +
|
||
minAttr +
|
||
maxAttr +
|
||
nullableAttr +
|
||
">";
|
||
} else {
|
||
// str
|
||
html +=
|
||
'<input type="text" data-setting-key="' +
|
||
escapedKey +
|
||
'" aria-label="' +
|
||
escapedShort +
|
||
'" value="' +
|
||
escapeHtml(String(item.value != null ? item.value : "")) +
|
||
'">';
|
||
}
|
||
html += "</div>";
|
||
|
||
// Actions column
|
||
html += '<div class="settings-actions">';
|
||
|
||
// Restart badge (left of source badge, hidden until dirty or post-save)
|
||
if (item.restart_required) {
|
||
html +=
|
||
'<span class="settings-restart-badge" data-restart-key="' +
|
||
escapedKey +
|
||
'">restart</span>';
|
||
}
|
||
|
||
// Source badge
|
||
if (item.source === "storage") {
|
||
html += '<span class="scope-badge scope-write">storage</span>';
|
||
} else {
|
||
html += '<span class="scope-badge settings-badge-default">default</span>';
|
||
}
|
||
|
||
// Save button (hidden until value changes)
|
||
html +=
|
||
'<button class="settings-save-btn" data-save-key="' +
|
||
escapedKey +
|
||
'">save</button>';
|
||
|
||
// Reset link (when stored — including secrets, to clear legacy overrides)
|
||
if (item.source === "storage") {
|
||
html +=
|
||
'<button class="settings-reset-btn" data-reset-key="' +
|
||
escapedKey +
|
||
'">reset</button>';
|
||
}
|
||
|
||
html += "</div>";
|
||
html += "</div>";
|
||
return html;
|
||
}
|
||
|
||
function _toggleSettingsHelp(e, btn) {
|
||
// Stop both flavours of cascade: bubble to label/checkbox parents (would
|
||
// toggle the form control behind the button) AND any default action on
|
||
// the button click itself (irrelevant for type="button" but cheap insurance).
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const targetId = btn.getAttribute("data-help-target");
|
||
if (!targetId) return;
|
||
const popover = document.getElementById(targetId);
|
||
if (!popover) return;
|
||
const isVisible = popover.style.display !== "none";
|
||
_closeAllSettingsHelp(popover);
|
||
popover.style.display = isVisible ? "none" : "";
|
||
btn.setAttribute("aria-expanded", isVisible ? "false" : "true");
|
||
}
|
||
|
||
// Single document-delegated handler for every .settings-help-btn — both the
|
||
// settings-tab buttons emitted by _renderSettingRow and the static skill-modal
|
||
// buttons in index.html. Bound once at module load.
|
||
document.addEventListener("click", function (e) {
|
||
const btn = e.target.closest(".settings-help-btn[data-help-target]");
|
||
if (btn) _toggleSettingsHelp(e, btn);
|
||
});
|
||
|
||
function _closeAllSettingsHelp(except) {
|
||
const allOpen = document.querySelectorAll('.settings-help-popover[style=""]');
|
||
for (let i = 0; i < allOpen.length; i++) {
|
||
if (allOpen[i] === except) continue;
|
||
const popover = allOpen[i];
|
||
popover.style.display = "none";
|
||
if (!popover.id) continue;
|
||
const helpBtn = document.querySelector(
|
||
'.settings-help-btn[data-help-target="' + popover.id + '"]',
|
||
);
|
||
if (helpBtn) helpBtn.setAttribute("aria-expanded", "false");
|
||
}
|
||
}
|
||
|
||
function _onSettingsHeaderKey(e, el) {
|
||
if ((e.key === "Enter" || e.key === " ") && !e.repeat) {
|
||
e.preventDefault();
|
||
_toggleSettingsSection(el);
|
||
}
|
||
}
|
||
|
||
function _toggleSettingsSection(headerEl) {
|
||
const section = headerEl.parentElement;
|
||
if (section.hasAttribute("data-collapsed")) {
|
||
section.removeAttribute("data-collapsed");
|
||
headerEl.setAttribute("aria-expanded", "true");
|
||
} else {
|
||
section.setAttribute("data-collapsed", "");
|
||
headerEl.setAttribute("aria-expanded", "false");
|
||
}
|
||
}
|
||
|
||
function _onSettingChange(inp) {
|
||
// ``inp`` is the input element the listener fired on — passed in by
|
||
// the per-input event-listener callback so we avoid a document-wide
|
||
// selector per keystroke. Save button + restart badge for this key
|
||
// live inside the same ``.settings-row`` (which carries the unique
|
||
// ``data-row-key``).
|
||
const row = inp.closest(".settings-row");
|
||
if (!row) return;
|
||
const saveBtn = row.querySelector("[data-save-key]");
|
||
if (!saveBtn) return;
|
||
const key = inp.getAttribute("data-setting-key");
|
||
|
||
let current;
|
||
if (inp.type === "checkbox") {
|
||
current = inp.checked;
|
||
} else {
|
||
current = inp.value;
|
||
}
|
||
|
||
const orig = _settingsOriginal[key];
|
||
let dirty;
|
||
if (inp.type === "checkbox") {
|
||
dirty = current !== orig;
|
||
} else if (inp.type === "number" && current !== "" && orig !== "") {
|
||
// Compare numerically to avoid false positives (0.1 vs 0.10)
|
||
dirty = Number(current) !== Number(orig);
|
||
} else {
|
||
dirty = String(current) !== String(orig);
|
||
}
|
||
|
||
// Disable save for empty number fields (server will reject)
|
||
const emptyNumber = inp.type === "number" && current === "";
|
||
if (dirty && !emptyNumber) {
|
||
saveBtn.classList.add("visible");
|
||
} else {
|
||
saveBtn.classList.remove("visible");
|
||
}
|
||
|
||
// Show/hide restart badge alongside dirty state (but keep it if already saved)
|
||
const restartBadge = row.querySelector("[data-restart-key]");
|
||
if (restartBadge && !restartBadge.classList.contains("saved")) {
|
||
restartBadge.classList.toggle("visible", dirty);
|
||
}
|
||
}
|
||
|
||
function _saveSettingValue(key) {
|
||
const inp = document.querySelector('[data-setting-key="' + key + '"]');
|
||
const saveBtn = document.querySelector('[data-save-key="' + key + '"]');
|
||
if (!inp) return;
|
||
|
||
let value;
|
||
if (inp.type === "checkbox") {
|
||
value = inp.checked;
|
||
} else if (inp.type === "number") {
|
||
if (inp.value === "") {
|
||
if (inp.getAttribute("data-nullable") === "1") {
|
||
// Blank on a nullable-default setting means "inherit": clear any
|
||
// stored override (reset), or nothing to do if already default.
|
||
if (document.querySelector('[data-reset-key="' + key + '"]')) {
|
||
_resetSetting(key);
|
||
} else if (saveBtn) {
|
||
saveBtn.classList.remove("visible");
|
||
}
|
||
return;
|
||
}
|
||
showToast("Value is required");
|
||
return;
|
||
}
|
||
value = Number(inp.value);
|
||
} else if (inp.type === "password") {
|
||
if (inp.value === "") {
|
||
// Nothing to save — user didn't enter a value.
|
||
if (saveBtn) saveBtn.classList.remove("visible");
|
||
return;
|
||
}
|
||
value = inp.value;
|
||
} else {
|
||
value = inp.value;
|
||
}
|
||
|
||
if (saveBtn) {
|
||
saveBtn.textContent = "saving\u2026";
|
||
saveBtn.disabled = true;
|
||
}
|
||
|
||
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: value }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Save failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
// Update original so dirty detection resets
|
||
if (inp.type === "checkbox") {
|
||
_settingsOriginal[key] = inp.checked;
|
||
} else if (inp.type === "password") {
|
||
// Clear the field after save; show "***" placeholder.
|
||
inp.value = "";
|
||
inp.placeholder = "***";
|
||
_settingsOriginal[key] = "";
|
||
} else {
|
||
_settingsOriginal[key] = inp.value;
|
||
}
|
||
if (saveBtn) {
|
||
saveBtn.textContent = "save";
|
||
saveBtn.disabled = false;
|
||
saveBtn.classList.remove("visible");
|
||
}
|
||
|
||
// Update source badge to "storage"
|
||
const row = document.querySelector('[data-row-key="' + key + '"]');
|
||
if (row) {
|
||
const badge = row.querySelector(".scope-badge");
|
||
if (badge) {
|
||
badge.className = "scope-badge scope-write";
|
||
badge.textContent = "storage";
|
||
}
|
||
// Add reset button if not present
|
||
if (!row.querySelector('[data-reset-key="' + key + '"]')) {
|
||
const actions = row.querySelector(".settings-actions");
|
||
if (actions) {
|
||
const resetBtn = document.createElement("button");
|
||
resetBtn.className = "settings-reset-btn";
|
||
resetBtn.setAttribute("data-reset-key", key);
|
||
resetBtn.textContent = "reset";
|
||
resetBtn.onclick = function () {
|
||
_resetSetting(key);
|
||
};
|
||
actions.appendChild(resetBtn);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show restart badge post-save (stays until page reload = restart)
|
||
const restartBadge = document.querySelector(
|
||
'[data-restart-key="' + key + '"]',
|
||
);
|
||
if (restartBadge) {
|
||
restartBadge.classList.add("visible");
|
||
restartBadge.classList.add("saved");
|
||
}
|
||
|
||
// Brief row flash for visual feedback
|
||
if (
|
||
row &&
|
||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||
) {
|
||
row.style.background = "var(--accent-glow)";
|
||
setTimeout(function () {
|
||
row.style.background = "";
|
||
}, 600);
|
||
}
|
||
|
||
showToast(
|
||
"Saved " + key + (restartBadge ? " \u2014 restart required" : ""),
|
||
);
|
||
|
||
// If this is a theme setting, apply it immediately. Don't call
|
||
// onThemeChange — it would fire a redundant PUT since the settings
|
||
// save above already persisted the value.
|
||
if (key === "interface.theme") {
|
||
const isLight = value === "light";
|
||
document.documentElement.dataset.theme = isLight ? "light" : "";
|
||
localStorage.setItem(
|
||
"turnstone_interface.theme",
|
||
isLight ? "light" : "dark",
|
||
);
|
||
const themeBtn = document.getElementById("theme-toggle");
|
||
if (themeBtn) {
|
||
themeBtn.textContent = isLight ? "\u2600" : "\u263E";
|
||
themeBtn.title = isLight
|
||
? "Switch to dark theme"
|
||
: "Switch to light theme";
|
||
themeBtn.setAttribute(
|
||
"aria-label",
|
||
isLight ? "Switch to dark theme" : "Switch to light theme",
|
||
);
|
||
}
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
if (saveBtn) {
|
||
saveBtn.textContent = "save";
|
||
saveBtn.disabled = false;
|
||
}
|
||
showToast("Error: " + (err.message || err));
|
||
});
|
||
}
|
||
|
||
function _resetSetting(key) {
|
||
showConfirmModal(
|
||
"Reset Setting",
|
||
"Reset \u2018" +
|
||
key +
|
||
"\u2019 to its default value? The stored override will be removed.",
|
||
"Reset",
|
||
function () {
|
||
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
|
||
method: "DELETE",
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Reset failed");
|
||
});
|
||
showToast("Reset " + key + " to default");
|
||
loadSettings();
|
||
})
|
||
.catch(function (err) {
|
||
showToast("Error: " + (err.message || err));
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Show an error message in a shelf's sh-alert element. The .is-visible class
|
||
// is the canonical toggle — see `.sh-alert` in hatch.css. Do NOT use
|
||
// `el.style.display = "block"` here; the CSS rule `.sh-alert { display: none }`
|
||
// is selector-equivalent and will hide the element again as soon as the
|
||
// inline style is cleared. Hide side is `el.classList.remove("is-visible")`.
|
||
function _showModalError(el, msg) {
|
||
el.textContent = msg;
|
||
el.classList.add("is-visible");
|
||
// The alert sits at the top of the scrollable body; the submit button in
|
||
// the sticky foot. At scroll-bottom a failed submit would otherwise paint
|
||
// nothing in view.
|
||
if (el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
/* ── MCP Servers tab ─────────────────────────────────────────────────────── */
|
||
|
||
let _mcpServers = [];
|
||
let _mcpWired = false;
|
||
let _mcpImportWired = false;
|
||
let _mcpInstallWired = false;
|
||
let _mcpInstallServer = null;
|
||
let _mcpCurrentView = "servers";
|
||
let _registryResults = [];
|
||
let _registryCursor = null;
|
||
let _registryQuery = "";
|
||
|
||
function loadAdminMcp() {
|
||
authFetch("/v1/api/admin/mcp-servers")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_mcpServers = data.servers || [];
|
||
_renderMcpServers(_mcpServers);
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
document.getElementById("admin-mcp-table"),
|
||
'<div class="dashboard-empty">Failed to load MCP servers</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _wireMcpTokenDropButtons(el, attr, opts) {
|
||
// Shared binder for the two per-server token-drop list actions —
|
||
// "Bulk-revoke" (oauth_user consents) and "Flush cache" (oauth_obo minted
|
||
// tokens). Both post to the same bulk-revoke endpoint; only the operator-
|
||
// facing copy differs, so the confirm/fetch/toast/reload flow lives once.
|
||
el.querySelectorAll("[" + attr + "]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const name = this.getAttribute(attr);
|
||
const count = this.getAttribute("data-mcp-consent-count") || "?";
|
||
showConfirmModal(
|
||
opts.title,
|
||
opts.message(name, count),
|
||
opts.confirmLabel,
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/mcp-servers/" +
|
||
encodeURIComponent(name) +
|
||
"/bulk-revoke",
|
||
{ method: "POST" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function (j) {
|
||
showToast(opts.successToast(name, j.rows_deleted || 0));
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function () {
|
||
showToast(opts.failToast(name));
|
||
});
|
||
},
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _renderMcpServers(items) {
|
||
const el = document.getElementById("admin-mcp-table");
|
||
if (!items.length) {
|
||
setSafeHtml(
|
||
el,
|
||
'<div class="dashboard-empty">No MCP servers configured</div>',
|
||
);
|
||
return;
|
||
}
|
||
let html = "";
|
||
for (let i = 0; i < items.length; i++) {
|
||
const s = items[i];
|
||
const statusEntries = s.status || {};
|
||
const nodeIds = Object.keys(statusEntries);
|
||
let anyConnected = false;
|
||
let anyError = false;
|
||
let firstError = "";
|
||
let totalTools = 0,
|
||
totalRes = 0,
|
||
totalPrompts = 0;
|
||
// Phase 9: aggregate the most-recent refresh entry across nodes
|
||
// so the admin pill reflects "the freshest known state" rather
|
||
// than picking an arbitrary node.
|
||
let newestRefreshAt = null;
|
||
let newestRefreshOutcome = null;
|
||
for (let j = 0; j < nodeIds.length; j++) {
|
||
const ns = statusEntries[nodeIds[j]];
|
||
if (ns.connected) {
|
||
anyConnected = true;
|
||
totalTools += ns.tools || 0;
|
||
totalRes += ns.resources || 0;
|
||
totalPrompts += ns.prompts || 0;
|
||
}
|
||
if (ns.error) {
|
||
anyError = true;
|
||
if (!firstError) firstError = ns.error;
|
||
}
|
||
if (
|
||
typeof ns.last_refresh_at === "number" &&
|
||
(newestRefreshAt === null || ns.last_refresh_at > newestRefreshAt)
|
||
) {
|
||
newestRefreshAt = ns.last_refresh_at;
|
||
newestRefreshOutcome = ns.last_refresh_outcome || null;
|
||
}
|
||
}
|
||
|
||
let dotClass = "mcp-status-dot disabled";
|
||
let rowClass = "mcp-row-disabled";
|
||
let statusText = "disabled";
|
||
// Pool-backed (oauth_user/oauth_obo) servers hold NO cluster-level session —
|
||
// they connect per-user on demand — so "connecting"/"idle" reads as broken
|
||
// when the resting state (zero warm users) is normal.
|
||
const isPool = s.auth_type === "oauth_user" || s.auth_type === "oauth_obo";
|
||
if (!s.enabled) {
|
||
statusText = "disabled";
|
||
} else if (anyConnected) {
|
||
dotClass = "mcp-status-dot connected";
|
||
rowClass = "mcp-row-connected";
|
||
statusText = "connected";
|
||
} else if (anyError) {
|
||
dotClass = "mcp-status-dot error";
|
||
rowClass = "mcp-row-error";
|
||
statusText = "error";
|
||
} else if (isPool) {
|
||
dotClass = "mcp-status-dot disabled";
|
||
rowClass = "mcp-row-disabled";
|
||
statusText = "per-user";
|
||
} else if (s.enabled && s.source !== "config" && nodeIds.length === 0) {
|
||
dotClass = "mcp-status-dot connecting";
|
||
rowClass = "mcp-row-disabled";
|
||
statusText = "connecting";
|
||
} else {
|
||
dotClass = "mcp-status-dot disabled";
|
||
rowClass = "mcp-row-disabled";
|
||
statusText = "idle";
|
||
}
|
||
|
||
// Phase 9: refresh pill shows the short-relative age (e.g. "12m") with
|
||
// outcome-tinted color (ok vs err) and the full ISO timestamp + outcome
|
||
// in the tooltip. Pill is omitted (and the cell stays unchanged from
|
||
// its pre-Phase-9 shape) when no node has yet recorded a refresh
|
||
// outcome for this server.
|
||
let refreshPill = "";
|
||
if (newestRefreshAt !== null) {
|
||
const ageSeconds = Math.max(
|
||
0,
|
||
Math.floor(Date.now() / 1000 - newestRefreshAt),
|
||
);
|
||
let ageShort;
|
||
if (ageSeconds < 60) ageShort = ageSeconds + "s";
|
||
else if (ageSeconds < 3600) ageShort = Math.floor(ageSeconds / 60) + "m";
|
||
else if (ageSeconds < 86400)
|
||
ageShort = Math.floor(ageSeconds / 3600) + "h";
|
||
else ageShort = Math.floor(ageSeconds / 86400) + "d";
|
||
const outcomeText = newestRefreshOutcome || "unknown";
|
||
const pillCls =
|
||
outcomeText === "ok" ? "mcp-refresh-pill-ok" : "mcp-refresh-pill-err";
|
||
const pillTitle =
|
||
"Last refresh " +
|
||
new Date(newestRefreshAt * 1000).toISOString() +
|
||
" (" +
|
||
outcomeText +
|
||
")";
|
||
refreshPill =
|
||
' <span class="mcp-refresh-pill ' +
|
||
pillCls +
|
||
'" title="' +
|
||
escapeHtml(pillTitle) +
|
||
'">' +
|
||
escapeHtml(ageShort) +
|
||
"</span>";
|
||
}
|
||
|
||
const transportCls =
|
||
s.transport === "stdio" ? "mcp-transport-stdio" : "mcp-transport-http";
|
||
const toolsVal = anyConnected
|
||
? totalTools
|
||
: '<span class="mcp-count-dim">--</span>';
|
||
const resVal = anyConnected
|
||
? totalRes
|
||
: '<span class="mcp-count-dim">--</span>';
|
||
const promptsVal = anyConnected
|
||
? totalPrompts
|
||
: '<span class="mcp-count-dim">--</span>';
|
||
|
||
const isConfig = s.source === "config";
|
||
const isRegistry = !!s.registry_name;
|
||
const nameBadge = isConfig
|
||
? ' <span class="scope-badge scope-config">config</span>'
|
||
: isRegistry
|
||
? ' <span class="scope-badge scope-registry">registry</span>'
|
||
: ' <span class="scope-badge scope-manual">manual</span>';
|
||
const detailAttr = isConfig
|
||
? 'data-mcp-detail-name="' + escapeHtml(s.name) + '"'
|
||
: 'data-mcp-detail="' + escapeHtml(s.server_id) + '"';
|
||
// Phase 9: surface the connect/bulk-revoke affordances only for
|
||
// user-OAuth servers, and bulk-revoke only once a user has consented.
|
||
// oauth_obo has NO per-server connect (it uses the org sign-in); its
|
||
// "flush cache" drops minted tokens so users re-mint (honest label — it is
|
||
// not a durable revoke; that is governed by the identity provider).
|
||
const isOauth = s.auth_type === "oauth_user";
|
||
const isObo = s.auth_type === "oauth_obo";
|
||
const consentCount =
|
||
typeof s.consented_users_count === "number" ? s.consented_users_count : 0;
|
||
const actions = _kebabMenu([
|
||
{ label: "refresh", attrs: { "data-mcp-refresh": s.name } },
|
||
{ label: "reconnect", attrs: { "data-mcp-reconnect": s.name } },
|
||
isOauth
|
||
? { label: "connect", attrs: { "data-mcp-oauth-connect": s.name } }
|
||
: null,
|
||
isOauth && consentCount > 0
|
||
? {
|
||
label: "bulk-revoke (" + consentCount + ")",
|
||
kind: "danger",
|
||
title:
|
||
"Drop all " + consentCount + " user consents for this server",
|
||
attrs: {
|
||
"data-mcp-bulk-revoke": s.name,
|
||
"data-mcp-consent-count": consentCount,
|
||
},
|
||
}
|
||
: null,
|
||
isObo && consentCount > 0
|
||
? {
|
||
label: "flush cache (" + consentCount + ")",
|
||
kind: "danger",
|
||
title:
|
||
"Drop " +
|
||
consentCount +
|
||
" users' minted tokens; they re-mint on next use unless access is removed at your identity provider",
|
||
attrs: {
|
||
"data-mcp-cache-flush": s.name,
|
||
"data-mcp-consent-count": consentCount,
|
||
},
|
||
}
|
||
: null,
|
||
// Config-sourced servers are read-only here (edited via config.toml).
|
||
isConfig
|
||
? null
|
||
: { label: "edit", attrs: { "data-mcp-edit": s.server_id } },
|
||
isConfig
|
||
? null
|
||
: {
|
||
label: "delete",
|
||
kind: "danger",
|
||
attrs: {
|
||
"data-mcp-delete": s.server_id,
|
||
"data-mcp-name": s.name,
|
||
},
|
||
},
|
||
]);
|
||
|
||
html +=
|
||
'<div class="admin-row mcp-grid ' +
|
||
rowClass +
|
||
'" role="listitem">' +
|
||
'<span class="admin-col admin-col-mname"><a href="#" ' +
|
||
detailAttr +
|
||
">" +
|
||
escapeHtml(s.name) +
|
||
"</a>" +
|
||
nameBadge +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-mtransport"><span class="mcp-transport-badge ' +
|
||
transportCls +
|
||
'">' +
|
||
(s.transport === "streamable-http" ? "remote" : escapeHtml(s.transport)) +
|
||
"</span></span>" +
|
||
'<span class="admin-col admin-col-mtools">' +
|
||
toolsVal +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-mres">' +
|
||
resVal +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-mprompts">' +
|
||
promptsVal +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-mstatus"' +
|
||
(firstError ? ' title="' + escapeHtml(firstError) + '"' : "") +
|
||
'><span class="' +
|
||
dotClass +
|
||
'" aria-hidden="true"></span>' +
|
||
escapeHtml(statusText) +
|
||
refreshPill +
|
||
"</span>" +
|
||
'<span class="admin-col admin-col-mactions">' +
|
||
actions +
|
||
"</span></div>";
|
||
}
|
||
setSafeHtml(el, html);
|
||
|
||
// Bind event handlers
|
||
el.querySelectorAll("[data-mcp-detail]").forEach(function (a) {
|
||
a.addEventListener("click", function (e) {
|
||
e.preventDefault();
|
||
showMcpDetailModal(this.getAttribute("data-mcp-detail"));
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-mcp-detail-name]").forEach(function (a) {
|
||
a.addEventListener("click", function (e) {
|
||
e.preventDefault();
|
||
showMcpDetailByName(this.getAttribute("data-mcp-detail-name"));
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-mcp-edit]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
showEditMcpModal(this.getAttribute("data-mcp-edit"));
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-mcp-refresh]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const name = this.getAttribute("data-mcp-refresh");
|
||
authFetch(
|
||
"/v1/api/admin/mcp-servers/" + encodeURIComponent(name) + "/refresh",
|
||
{ method: "POST" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Refreshed " + name);
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to refresh " + name);
|
||
});
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-mcp-reconnect]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const name = this.getAttribute("data-mcp-reconnect");
|
||
authFetch(
|
||
"/v1/api/admin/mcp-servers/" + encodeURIComponent(name) + "/reconnect",
|
||
{ method: "POST" },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Reconnected " + name);
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to reconnect " + name);
|
||
});
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-mcp-oauth-connect]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const name = this.getAttribute("data-mcp-oauth-connect");
|
||
// Open the OAuth /start endpoint in a new window so the redirect
|
||
// chain (AS → callback → return_url) doesn't displace the admin UI.
|
||
const url =
|
||
"/v1/api/mcp/oauth/start?server=" +
|
||
encodeURIComponent(name) +
|
||
"&return_url=" +
|
||
encodeURIComponent(window.location.href);
|
||
window.open(url, "_blank", "noopener");
|
||
});
|
||
});
|
||
_wireMcpTokenDropButtons(el, "data-mcp-bulk-revoke", {
|
||
title: "Bulk-revoke MCP consents",
|
||
message: function (name, count) {
|
||
return (
|
||
"Drop all " +
|
||
count +
|
||
' user consents for server "' +
|
||
name +
|
||
'"? Users will need to re-consent on next use. Upstream revoke is not attempted in bulk; tokens at the authorization server will expire naturally.'
|
||
);
|
||
},
|
||
confirmLabel: "Bulk-revoke",
|
||
successToast: function (name, n) {
|
||
return "Bulk-revoked " + n + " row(s) for " + name;
|
||
},
|
||
failToast: function (name) {
|
||
return "Failed to bulk-revoke " + name;
|
||
},
|
||
});
|
||
_wireMcpTokenDropButtons(el, "data-mcp-cache-flush", {
|
||
title: "Flush minted tokens",
|
||
message: function (name, count) {
|
||
return (
|
||
"Drop " +
|
||
count +
|
||
' users’ minted tokens for server "' +
|
||
name +
|
||
'"? This forces a fresh mint on next use (e.g. after changing the audience). It does NOT cut off access — users re-mint from their org sign-in; to revoke a user, remove their access at your identity provider or unlink their identity.'
|
||
);
|
||
},
|
||
confirmLabel: "Flush cache",
|
||
successToast: function (name, n) {
|
||
return "Flushed " + n + " token(s) for " + name;
|
||
},
|
||
failToast: function (name) {
|
||
return "Failed to flush cache for " + name;
|
||
},
|
||
});
|
||
el.querySelectorAll("[data-mcp-delete]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const sid = this.getAttribute("data-mcp-delete");
|
||
const sname = this.getAttribute("data-mcp-name");
|
||
showConfirmModal(
|
||
"Delete MCP Server",
|
||
'Delete server "' + sname + '"?',
|
||
"Delete",
|
||
function () {
|
||
authFetch("/v1/api/admin/mcp-servers/" + sid, { method: "DELETE" })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Server deleted");
|
||
_flagMcpSyncPending();
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete server");
|
||
});
|
||
},
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function toggleMcpTransport() {
|
||
const v = document.getElementById("mcp-transport").value;
|
||
document.getElementById("mcp-stdio-fields").hidden = v !== "stdio";
|
||
document.getElementById("mcp-http-fields").hidden = v !== "streamable-http";
|
||
// Re-evaluate auth-field visibility because the headers row lives
|
||
// inside mcp-http-fields and gets toggled there.
|
||
toggleMcpAuthFields();
|
||
}
|
||
|
||
function _selectedMcpAuthType() {
|
||
const radios = document.getElementsByName("mcp-auth-type");
|
||
for (let i = 0; i < radios.length; i++) {
|
||
if (radios[i].checked) return radios[i].value;
|
||
}
|
||
return "static";
|
||
}
|
||
|
||
function toggleMcpAuthFields() {
|
||
const authType = _selectedMcpAuthType();
|
||
const isOauthUser = authType === "oauth_user";
|
||
const isObo = authType === "oauth_obo";
|
||
// The OAuth fields block is shared by oauth_user and oauth_obo; obo shows
|
||
// only the audience (+ scopes for the rfc8693 profile), hiding the
|
||
// oauth_user-only client/registration inputs it does not use.
|
||
const oauthDiv = document.getElementById("mcp-oauth-fields");
|
||
if (oauthDiv) oauthDiv.hidden = !(isOauthUser || isObo);
|
||
const userOnly = document.getElementById("mcp-oauth-user-only");
|
||
if (userOnly) userOnly.hidden = !isOauthUser;
|
||
const oboNote = document.getElementById("mcp-obo-note");
|
||
if (oboNote) oboNote.hidden = !isObo;
|
||
// For obo the audience is required (the mint engine hard-requires it) and
|
||
// scopes apply only under the rfc8693 grant profile; retitle the hints so
|
||
// the operator isn't misled (no impl vocabulary in the copy).
|
||
const audHint = document.getElementById("mcp-oauth-audience-hint");
|
||
if (audHint)
|
||
audHint.textContent = isObo ? "required" : "auto-populated from URL";
|
||
const scopesHint = document.getElementById("mcp-oauth-scopes-hint");
|
||
if (scopesHint)
|
||
scopesHint.textContent = isObo
|
||
? "only used by the rfc8693 sign-in profile"
|
||
: "space-separated";
|
||
// The "Headers" textarea (inside mcp-http-fields) is only meaningful
|
||
// for static auth; hide it for 'none' / 'oauth_user' / 'oauth_obo' so
|
||
// operators don't accidentally configure stale credentials.
|
||
const headersInput = document.getElementById("mcp-headers");
|
||
if (headersInput) {
|
||
const headersLabel = document.querySelector('label[for="mcp-headers"]');
|
||
const show = authType === "static";
|
||
headersInput.hidden = !show;
|
||
if (headersLabel) headersLabel.hidden = !show;
|
||
}
|
||
}
|
||
|
||
function _wireMcpAudienceAutofill() {
|
||
// Idempotent — only attach the listener once per page lifetime.
|
||
const urlInput = document.getElementById("mcp-url");
|
||
if (!urlInput || urlInput.dataset.audAutofill === "1") return;
|
||
urlInput.dataset.audAutofill = "1";
|
||
urlInput.addEventListener("blur", function () {
|
||
// oauth_user only: there the audience IS the server URL (resource
|
||
// indicator). For sign-in passthrough the audience is the identity-
|
||
// provider-side application identifier — prefilling the MCP URL there
|
||
// passes every validation layer and then fails every token mint, so
|
||
// the autofill stays off.
|
||
if (_selectedMcpAuthType() === "oauth_obo") return;
|
||
const aud = document.getElementById("mcp-oauth-audience");
|
||
if (aud && !aud.value.trim()) aud.value = urlInput.value.trim();
|
||
});
|
||
}
|
||
|
||
function _onMcpAuthTypeChange() {
|
||
// Audience and Scopes are auth-type-specific: for oauth_user the audience is
|
||
// an RFC 8707 resource indicator (~ the MCP URL) and scopes are AS-consent
|
||
// scopes; for sign-in passthrough the audience is the identity-provider-side
|
||
// application identifier and scopes (rfc8693 only) are the token-exchange
|
||
// scope. Carrying one type's value into the other passes validation and then
|
||
// fails every mint/consent, so clear both when the auth type changes — the
|
||
// operator re-enters the correct values for the new mode (the backend
|
||
// likewise refuses to carry these columns across a flip). A same-type edit
|
||
// never fires this (the radio didn't change), so pre-filled values are kept.
|
||
document.getElementById("mcp-oauth-audience").value = "";
|
||
document.getElementById("mcp-oauth-scopes").value = "";
|
||
toggleMcpAuthFields();
|
||
}
|
||
|
||
function _mcpWire() {
|
||
if (_mcpWired) return;
|
||
_mcpWired = true;
|
||
document
|
||
.getElementById("mcp-transport")
|
||
.addEventListener("change", toggleMcpTransport);
|
||
const authRadios = document.getElementsByName("mcp-auth-type");
|
||
for (let i = 0; i < authRadios.length; i++) {
|
||
authRadios[i].addEventListener("change", _onMcpAuthTypeChange);
|
||
}
|
||
document
|
||
.getElementById("mcp-create-submit")
|
||
.addEventListener("click", submitCreateMcp);
|
||
_wireMcpAudienceAutofill();
|
||
}
|
||
|
||
function _mcpOpen(title, tag, kind, submitLabel) {
|
||
const shelf = document.getElementById("mcp-shelf");
|
||
document.getElementById("mcp-shelf-title").textContent = title;
|
||
document.getElementById("mcp-shelf-tag").textContent = tag;
|
||
shelf.setAttribute("data-kind", kind);
|
||
document.getElementById("mcp-create-submit").textContent = submitLabel;
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("mcp-name").focus();
|
||
}
|
||
|
||
function _mcpResetForm() {
|
||
document.getElementById("mcp-edit-id").value = "";
|
||
document.getElementById("mcp-name").value = "";
|
||
document.getElementById("mcp-transport").value = "stdio";
|
||
document.getElementById("mcp-command").value = "";
|
||
document.getElementById("mcp-args").value = "";
|
||
document.getElementById("mcp-env").value = "";
|
||
document.getElementById("mcp-url").value = "";
|
||
document.getElementById("mcp-headers").value = "";
|
||
document.getElementById("mcp-auto-approve").checked = false;
|
||
document.getElementById("mcp-enabled").checked = true;
|
||
// Reset auth radios + OAuth subfields to the 'static' default.
|
||
document.getElementById("mcp-auth-static").checked = true;
|
||
document.getElementById("mcp-auth-none").checked = false;
|
||
document.getElementById("mcp-auth-oauth").checked = false;
|
||
document.getElementById("mcp-auth-obo").checked = false;
|
||
document.getElementById("mcp-oauth-as-url").value = "";
|
||
document.getElementById("mcp-oauth-registration").value = "preregistered";
|
||
document.getElementById("mcp-oauth-client-id").value = "";
|
||
document.getElementById("mcp-oauth-client-secret").value = "";
|
||
document.getElementById("mcp-oauth-scopes").value = "";
|
||
document.getElementById("mcp-oauth-audience").value = "";
|
||
document.getElementById("mcp-create-error").classList.remove("is-visible");
|
||
toggleMcpTransport();
|
||
toggleMcpAuthFields();
|
||
}
|
||
|
||
function showCreateMcpModal() {
|
||
_mcpWire();
|
||
_mcpResetForm();
|
||
_mcpOpen("New MCP server", "MCP-NEW", "create", "Create");
|
||
}
|
||
|
||
function showEditMcpModal(serverId) {
|
||
_mcpWire();
|
||
// Fetch with reveal=true to get actual secret values for editing
|
||
authFetch("/v1/api/admin/mcp-servers/" + serverId + "?reveal=true")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed to load server");
|
||
return r.json();
|
||
})
|
||
.then(function (s) {
|
||
_mcpResetForm();
|
||
document.getElementById("mcp-edit-id").value = serverId;
|
||
document.getElementById("mcp-name").value = s.name;
|
||
document.getElementById("mcp-transport").value = s.transport;
|
||
document.getElementById("mcp-command").value = s.command || "";
|
||
try {
|
||
const argsList = JSON.parse(s.args || "[]");
|
||
document.getElementById("mcp-args").value = argsList.join("\n");
|
||
} catch (e) {
|
||
document.getElementById("mcp-args").value = "";
|
||
}
|
||
try {
|
||
const envObj = JSON.parse(s.env || "{}");
|
||
document.getElementById("mcp-env").value = Object.keys(envObj)
|
||
.map(function (k) {
|
||
return k + "=" + envObj[k];
|
||
})
|
||
.join("\n");
|
||
} catch (e) {
|
||
document.getElementById("mcp-env").value = "";
|
||
}
|
||
document.getElementById("mcp-url").value = s.url || "";
|
||
try {
|
||
const hdrObj = JSON.parse(s.headers || "{}");
|
||
document.getElementById("mcp-headers").value = Object.keys(hdrObj)
|
||
.map(function (k) {
|
||
return k + ": " + hdrObj[k];
|
||
})
|
||
.join("\n");
|
||
} catch (e) {
|
||
document.getElementById("mcp-headers").value = "";
|
||
}
|
||
document.getElementById("mcp-auto-approve").checked =
|
||
s.auto_approve || false;
|
||
document.getElementById("mcp-enabled").checked = s.enabled !== false;
|
||
const authType = s.auth_type || "static";
|
||
document.getElementById("mcp-auth-none").checked = authType === "none";
|
||
document.getElementById("mcp-auth-static").checked =
|
||
authType === "static";
|
||
document.getElementById("mcp-auth-oauth").checked =
|
||
authType === "oauth_user";
|
||
document.getElementById("mcp-auth-obo").checked =
|
||
authType === "oauth_obo";
|
||
document.getElementById("mcp-oauth-as-url").value =
|
||
s.oauth_authorization_server_url || "";
|
||
document.getElementById("mcp-oauth-registration").value =
|
||
s.oauth_registration_mode || "preregistered";
|
||
document.getElementById("mcp-oauth-client-id").value =
|
||
s.oauth_client_id || "";
|
||
// Secret field always blank — write-only, never read back.
|
||
document.getElementById("mcp-oauth-client-secret").value = "";
|
||
document.getElementById("mcp-oauth-scopes").value = s.oauth_scopes || "";
|
||
document.getElementById("mcp-oauth-audience").value =
|
||
s.oauth_audience || "";
|
||
toggleMcpTransport();
|
||
toggleMcpAuthFields();
|
||
_mcpOpen("Edit MCP server — " + s.name, "MCP-EDIT", "edit", "Save");
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to load server details");
|
||
});
|
||
}
|
||
|
||
function hideCreateMcpModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("mcp-shelf"));
|
||
}
|
||
|
||
function _parseMcpForm() {
|
||
const name = document.getElementById("mcp-name").value.trim();
|
||
const transport = document.getElementById("mcp-transport").value;
|
||
if (!name) return { error: "Name is required" };
|
||
if (!/^[a-zA-Z0-9._-]+$/.test(name))
|
||
return { error: "Name must match [a-zA-Z0-9._-]+" };
|
||
if (name.indexOf("__") >= 0) return { error: "Name must not contain '__'" };
|
||
|
||
const authType = _selectedMcpAuthType();
|
||
const payload = {
|
||
name: name,
|
||
transport: transport,
|
||
auto_approve: document.getElementById("mcp-auto-approve").checked,
|
||
enabled: document.getElementById("mcp-enabled").checked,
|
||
auth_type: authType,
|
||
};
|
||
|
||
if (transport === "stdio") {
|
||
payload.command = document.getElementById("mcp-command").value.trim();
|
||
const argsText = document.getElementById("mcp-args").value.trim();
|
||
payload.args = argsText
|
||
? argsText
|
||
.split("\n")
|
||
.map(function (l) {
|
||
return l.trim();
|
||
})
|
||
.filter(Boolean)
|
||
: [];
|
||
const envText = document.getElementById("mcp-env").value.trim();
|
||
const envObj = {};
|
||
if (envText) {
|
||
envText.split("\n").forEach(function (line) {
|
||
const eq = line.indexOf("=");
|
||
if (eq > 0)
|
||
envObj[line.substring(0, eq).trim()] = line.substring(eq + 1).trim();
|
||
});
|
||
}
|
||
payload.env = envObj;
|
||
} else {
|
||
payload.url = document.getElementById("mcp-url").value.trim();
|
||
if (authType === "static") {
|
||
const hdrText = document.getElementById("mcp-headers").value.trim();
|
||
const hdrObj = {};
|
||
if (hdrText) {
|
||
hdrText.split("\n").forEach(function (line) {
|
||
const colon = line.indexOf(":");
|
||
if (colon > 0)
|
||
hdrObj[line.substring(0, colon).trim()] = line
|
||
.substring(colon + 1)
|
||
.trim();
|
||
});
|
||
}
|
||
payload.headers = hdrObj;
|
||
} else {
|
||
// 'none' / 'oauth_user' / 'oauth_obo' — clear static headers state.
|
||
payload.headers = {};
|
||
}
|
||
}
|
||
|
||
if (authType === "oauth_user") {
|
||
payload.oauth_authorization_server_url = document
|
||
.getElementById("mcp-oauth-as-url")
|
||
.value.trim();
|
||
payload.oauth_registration_mode = document.getElementById(
|
||
"mcp-oauth-registration",
|
||
).value;
|
||
payload.oauth_client_id = document
|
||
.getElementById("mcp-oauth-client-id")
|
||
.value.trim();
|
||
payload.oauth_scopes = document
|
||
.getElementById("mcp-oauth-scopes")
|
||
.value.trim();
|
||
payload.oauth_audience = document
|
||
.getElementById("mcp-oauth-audience")
|
||
.value.trim();
|
||
const secret = document.getElementById("mcp-oauth-client-secret").value;
|
||
// Submit only when the operator typed a value; redacted in audit log.
|
||
if (secret) payload.oauth_client_secret = secret;
|
||
} else if (authType === "oauth_obo") {
|
||
// Sign-in passthrough uses only the audience (+ optional rfc8693 scopes);
|
||
// the client/registration/secret columns are oauth_user-only and cleared
|
||
// server-side. Audience is required — catch it here for an inline error
|
||
// rather than a round-trip 400.
|
||
const audience = document.getElementById("mcp-oauth-audience").value.trim();
|
||
if (!audience)
|
||
return { error: "Audience is required for sign-in passthrough servers" };
|
||
payload.oauth_audience = audience;
|
||
// Always send the visible Scopes value — the backend distinguishes a
|
||
// same-type no-op re-send (dropped) from a genuine change / flip on its
|
||
// side, so the form doesn't need omit-when-unchanged logic (which used to
|
||
// collide with the backend's flip handling and silently drop scopes).
|
||
payload.oauth_scopes = document
|
||
.getElementById("mcp-oauth-scopes")
|
||
.value.trim();
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
function submitCreateMcp() {
|
||
const shelf = document.getElementById("mcp-shelf");
|
||
const errEl = document.getElementById("mcp-create-error");
|
||
const form = _parseMcpForm();
|
||
if (form.error) {
|
||
errEl.textContent = form.error;
|
||
errEl.classList.add("is-visible");
|
||
return;
|
||
}
|
||
const editId = document.getElementById("mcp-edit-id").value;
|
||
const method = editId ? "PUT" : "POST";
|
||
const url = editId
|
||
? "/v1/api/admin/mcp-servers/" + editId
|
||
: "/v1/api/admin/mcp-servers";
|
||
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch(url, {
|
||
method: method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(form),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
hideCreateMcpModal();
|
||
showToast(editId ? "Server updated" : "Server created");
|
||
_flagMcpSyncPending();
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function (e) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
errEl.textContent = e.message;
|
||
errEl.classList.add("is-visible");
|
||
});
|
||
}
|
||
|
||
function _flagMcpSyncPending() {
|
||
const btn = document.getElementById("mcp-sync-btn");
|
||
if (btn) btn.classList.add("mcp-sync-pending");
|
||
}
|
||
|
||
function _clearMcpSyncPending() {
|
||
const btn = document.getElementById("mcp-sync-btn");
|
||
if (btn) btn.classList.remove("mcp-sync-pending");
|
||
}
|
||
|
||
function reloadMcpNodes() {
|
||
authFetch("/v1/api/admin/mcp-servers/reload", { method: "POST" })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
const results = data.results || {};
|
||
const nodeIds = Object.keys(results);
|
||
let totalAdded = 0,
|
||
totalRemoved = 0;
|
||
for (let i = 0; i < nodeIds.length; i++) {
|
||
const nr = results[nodeIds[i]];
|
||
totalAdded += (nr.added || []).length;
|
||
totalRemoved += (nr.removed || []).length;
|
||
}
|
||
let msg = "Reload sent to " + nodeIds.length + " node(s)";
|
||
if (totalAdded) msg += ", +" + totalAdded + " added";
|
||
if (totalRemoved) msg += ", -" + totalRemoved + " removed";
|
||
showToast(msg);
|
||
_clearMcpSyncPending();
|
||
setTimeout(loadAdminMcp, 1500);
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to reload nodes");
|
||
});
|
||
}
|
||
|
||
function showMcpDetailByName(name) {
|
||
for (let i = 0; i < _mcpServers.length; i++) {
|
||
if (_mcpServers[i].name === name) {
|
||
return _openMcpDetail(_mcpServers[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function showMcpDetailModal(serverId) {
|
||
for (let i = 0; i < _mcpServers.length; i++) {
|
||
if (_mcpServers[i].server_id === serverId) {
|
||
return _openMcpDetail(_mcpServers[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _openMcpDetail(s) {
|
||
if (!s) return;
|
||
|
||
let html = '<div class="modal-columns">';
|
||
html += '<div class="modal-col">';
|
||
html += '<div class="mcp-detail-section"><h3>Configuration</h3>';
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Transport: <span class="mcp-transport-badge ' +
|
||
(s.transport === "stdio" ? "mcp-transport-stdio" : "mcp-transport-http") +
|
||
'">' +
|
||
escapeHtml(s.transport) +
|
||
"</span></p>";
|
||
if (s.transport === "stdio") {
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Command: <code>' +
|
||
escapeHtml(s.command || "") +
|
||
"</code></p>";
|
||
try {
|
||
const a = JSON.parse(s.args || "[]");
|
||
if (a.length)
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Args: <code>' +
|
||
escapeHtml(a.join(" ")) +
|
||
"</code></p>";
|
||
} catch (e) {}
|
||
} else {
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">URL: <code>' +
|
||
escapeHtml(s.url || "") +
|
||
"</code></p>";
|
||
}
|
||
html += "</div>";
|
||
if (s.registry_name) {
|
||
html += '<div class="mcp-detail-section"><h3>Registry</h3>';
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Name: <code>' +
|
||
escapeHtml(s.registry_name) +
|
||
"</code></p>";
|
||
if (s.registry_version) {
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Version: <code>' +
|
||
escapeHtml(s.registry_version) +
|
||
"</code></p>";
|
||
}
|
||
try {
|
||
const meta =
|
||
typeof s.registry_meta === "string"
|
||
? JSON.parse(s.registry_meta)
|
||
: s.registry_meta || {};
|
||
if (meta.description) {
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">' +
|
||
escapeHtml(meta.description) +
|
||
"</p>";
|
||
}
|
||
if (meta.website_url && /^https?:\/\//i.test(meta.website_url)) {
|
||
html +=
|
||
'<p style="font-size:12px"><a href="' +
|
||
escapeHtml(meta.website_url) +
|
||
'" target="_blank" rel="noopener noreferrer" style="color:var(--magenta)">' +
|
||
escapeHtml(meta.website_url) +
|
||
"</a></p>";
|
||
}
|
||
} catch (e) {}
|
||
html += "</div>";
|
||
}
|
||
html += "</div>";
|
||
|
||
html += '<div class="modal-col">';
|
||
const statusEntries = s.status || {};
|
||
const nodeIds = Object.keys(statusEntries);
|
||
html += '<div class="mcp-detail-section"><h3>Node Status</h3>';
|
||
if (nodeIds.length === 0) {
|
||
html +=
|
||
'<p style="font-size:12px;color:var(--fg-dim)">Not connected on any node</p>';
|
||
} else {
|
||
html += '<ul class="mcp-detail-list">';
|
||
for (let j = 0; j < nodeIds.length; j++) {
|
||
const ns = statusEntries[nodeIds[j]];
|
||
const dot = ns.connected
|
||
? '<span class="mcp-status-dot connected"></span>'
|
||
: '<span class="mcp-status-dot error"></span>';
|
||
let nodeInfo =
|
||
escapeHtml(nodeIds[j]) +
|
||
" — " +
|
||
(ns.tools || 0) +
|
||
" tools, " +
|
||
(ns.resources || 0) +
|
||
" resources, " +
|
||
(ns.prompts || 0) +
|
||
" prompts";
|
||
if (ns.error) {
|
||
nodeInfo +=
|
||
'<br><span style="color:var(--red);font-size:11px">' +
|
||
escapeHtml(ns.error) +
|
||
"</span>";
|
||
}
|
||
// The dot alone would make the healthy state color-only — pair it
|
||
// with a text token, mirroring the error-text sibling above.
|
||
if (ns.connected) {
|
||
nodeInfo +=
|
||
'<br><span style="color:var(--fg-dim);font-size:11px">connected</span>';
|
||
}
|
||
html += "<li>" + dot + nodeInfo + "</li>";
|
||
}
|
||
html += "</ul>";
|
||
}
|
||
html += "</div></div></div>";
|
||
|
||
document.getElementById("mcp-detail-title").textContent = s.name;
|
||
setSafeHtml(document.getElementById("mcp-detail-content"), html);
|
||
window.TurnstoneHatch.openShelf(document.getElementById("mcp-detail-shelf"));
|
||
}
|
||
|
||
function hideMcpDetailModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("mcp-detail-shelf"));
|
||
}
|
||
|
||
function _mcpImportWire() {
|
||
if (_mcpImportWired) return;
|
||
_mcpImportWired = true;
|
||
document
|
||
.getElementById("mcp-import-submit")
|
||
.addEventListener("click", submitImportMcp);
|
||
}
|
||
|
||
function showImportMcpModal() {
|
||
_mcpImportWire();
|
||
const shelf = document.getElementById("mcp-import-shelf");
|
||
document.getElementById("mcp-import-json").value = "";
|
||
document.getElementById("mcp-import-error").classList.remove("is-visible");
|
||
window.TurnstoneHatch.openShelf(shelf);
|
||
document.getElementById("mcp-import-json").focus();
|
||
}
|
||
|
||
function hideImportMcpModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("mcp-import-shelf"));
|
||
}
|
||
|
||
function submitImportMcp() {
|
||
const raw = document.getElementById("mcp-import-json").value.trim();
|
||
if (!raw) {
|
||
const e = document.getElementById("mcp-import-error");
|
||
e.textContent = "Paste a JSON config";
|
||
e.classList.add("is-visible");
|
||
return;
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch (ex) {
|
||
const e2 = document.getElementById("mcp-import-error");
|
||
e2.textContent = "Invalid JSON: " + ex.message;
|
||
e2.classList.add("is-visible");
|
||
return;
|
||
}
|
||
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
|
||
const e3 = document.getElementById("mcp-import-error");
|
||
e3.textContent = 'No "mcpServers" key found in JSON';
|
||
e3.classList.add("is-visible");
|
||
return;
|
||
}
|
||
const shelf = document.getElementById("mcp-import-shelf");
|
||
const errEl = document.getElementById("mcp-import-error");
|
||
errEl.classList.remove("is-visible");
|
||
window.TurnstoneHatch.setBusy(shelf, true);
|
||
authFetch("/v1/api/admin/mcp-servers/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ config: parsed }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
hideImportMcpModal();
|
||
let msg = "Imported " + (data.imported || []).length;
|
||
if ((data.skipped || []).length)
|
||
msg += ", skipped " + data.skipped.length;
|
||
if ((data.errors || []).length)
|
||
msg += ", " + data.errors.length + " error(s)";
|
||
showToast(msg);
|
||
if ((data.imported || []).length) _flagMcpSyncPending();
|
||
loadAdminMcp();
|
||
})
|
||
.catch(function (e) {
|
||
window.TurnstoneHatch.setBusy(shelf, false);
|
||
errEl.textContent = e.message;
|
||
errEl.classList.add("is-visible");
|
||
});
|
||
}
|
||
|
||
/* ── MCP Registry ────────────────────────────────────────────────────────── */
|
||
|
||
function switchMcpView(view) {
|
||
_mcpCurrentView = view;
|
||
const btns = document.querySelectorAll("#admin-mcp .mcp-view-btn");
|
||
for (let i = 0; i < btns.length; i++) {
|
||
const isActive = btns[i].getAttribute("data-mcp-view") === view;
|
||
btns[i].classList.toggle("active", isActive);
|
||
btns[i].setAttribute("aria-selected", isActive ? "true" : "false");
|
||
btns[i].setAttribute("tabindex", isActive ? "0" : "-1");
|
||
}
|
||
document.getElementById("mcp-view-servers").style.display =
|
||
view === "servers" ? "" : "none";
|
||
document.getElementById("mcp-view-registry").style.display =
|
||
view === "registry" ? "" : "none";
|
||
document.getElementById("mcp-servers-toolbar").style.display =
|
||
view === "servers" ? "" : "none";
|
||
if (view === "servers") loadAdminMcp();
|
||
if (view === "registry") {
|
||
const q = document.getElementById("mcp-registry-q");
|
||
if (q) q.focus();
|
||
if (!_registryResults.length) searchMcpRegistry();
|
||
}
|
||
}
|
||
|
||
function searchMcpRegistry(append) {
|
||
const q = document.getElementById("mcp-registry-q").value.trim();
|
||
if (!append) {
|
||
_registryResults = [];
|
||
_registryCursor = null;
|
||
_registryQuery = q;
|
||
const filterEl = document.getElementById("mcp-registry-filter");
|
||
if (filterEl) filterEl.value = "";
|
||
}
|
||
let url = "/v1/api/admin/mcp-registry/search?limit=20";
|
||
if (_registryQuery) url += "&search=" + encodeURIComponent(_registryQuery);
|
||
if (append && _registryCursor)
|
||
url += "&cursor=" + encodeURIComponent(_registryCursor);
|
||
|
||
const resultsEl = document.getElementById("mcp-registry-results");
|
||
if (!append) {
|
||
setSafeHtml(resultsEl, '<div class="dashboard-empty">Searching…</div>');
|
||
}
|
||
const searchBtn = document.getElementById("mcp-registry-search-btn");
|
||
const moreBtn = document.getElementById("mcp-registry-more");
|
||
if (searchBtn) searchBtn.disabled = true;
|
||
if (moreBtn) moreBtn.disabled = true;
|
||
|
||
authFetch(url)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Search failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_registryResults = append
|
||
? _registryResults.concat(data.servers || [])
|
||
: data.servers || [];
|
||
_registryCursor = data.next_cursor || null;
|
||
_renderRegistryResults();
|
||
})
|
||
.catch(function (e) {
|
||
if (!append) {
|
||
setSafeHtml(
|
||
resultsEl,
|
||
'<div class="dashboard-empty">' + escapeHtml(e.message) + "</div>",
|
||
);
|
||
}
|
||
})
|
||
.finally(function () {
|
||
if (searchBtn) searchBtn.disabled = false;
|
||
if (moreBtn) moreBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function loadMoreRegistry() {
|
||
if (_registryCursor) searchMcpRegistry(true);
|
||
}
|
||
|
||
function _applyRegistryFilter() {
|
||
_renderRegistryResults();
|
||
}
|
||
|
||
function _renderRegistryResults() {
|
||
const el = document.getElementById("mcp-registry-results");
|
||
if (!_registryResults.length) {
|
||
setSafeHtml(el, '<div class="dashboard-empty">No servers found</div>');
|
||
document.getElementById("mcp-registry-pagination").style.display = "none";
|
||
return;
|
||
}
|
||
|
||
// Client-side type filter
|
||
const filterEl = document.getElementById("mcp-registry-filter");
|
||
const typeFilter = filterEl ? filterEl.value : "";
|
||
|
||
let html = "";
|
||
let visibleCount = 0;
|
||
for (let i = 0; i < _registryResults.length; i++) {
|
||
const srv = _registryResults[i];
|
||
const hasRemote = srv.remotes && srv.remotes.length > 0;
|
||
const pkgTypes = (srv.packages || []).map(function (p) {
|
||
return p.registry_type;
|
||
});
|
||
|
||
// Apply type filter
|
||
if (typeFilter === "remote" && !hasRemote) continue;
|
||
if (typeFilter === "npm" && pkgTypes.indexOf("npm") === -1) continue;
|
||
if (typeFilter === "pypi" && pkgTypes.indexOf("pypi") === -1) continue;
|
||
visibleCount++;
|
||
|
||
// Action button
|
||
const srvLabel = escapeHtml(srv.title || srv.name);
|
||
let actionHtml = "";
|
||
if (srv.installed && srv.update_available) {
|
||
actionHtml =
|
||
'<button class="mcp-install-btn mcp-update-btn" data-reg-install="' +
|
||
i +
|
||
'" aria-label="Update ' +
|
||
srvLabel +
|
||
'">Update</button>';
|
||
} else if (srv.installed) {
|
||
actionHtml = '<span class="mcp-installed-badge">Installed</span>';
|
||
} else {
|
||
actionHtml =
|
||
'<button class="mcp-install-btn" data-reg-install="' +
|
||
i +
|
||
'" aria-label="Install ' +
|
||
srvLabel +
|
||
'">Install</button>';
|
||
}
|
||
|
||
// Source type badges
|
||
let sourceBadges = "";
|
||
if (hasRemote) {
|
||
sourceBadges +=
|
||
'<span class="scope-badge mcp-transport-http">remote</span>';
|
||
}
|
||
for (let p = 0; p < (srv.packages || []).length; p++) {
|
||
sourceBadges +=
|
||
'<span class="scope-badge mcp-transport-stdio">' +
|
||
escapeHtml(srv.packages[p].registry_type) +
|
||
"</span>";
|
||
}
|
||
|
||
// Repo link for trust signal
|
||
let repoLink = "";
|
||
const repoUrl = (srv.repository || {}).url || "";
|
||
if (repoUrl && /^https?:\/\//i.test(repoUrl)) {
|
||
repoLink =
|
||
' <a href="' +
|
||
escapeHtml(repoUrl) +
|
||
'" target="_blank" rel="noopener noreferrer" class="mcp-reg-card-repo"' +
|
||
' aria-label="Source repository for ' +
|
||
srvLabel +
|
||
'"><span aria-hidden="true">\u2197</span></a>';
|
||
}
|
||
|
||
html +=
|
||
'<div class="mcp-reg-card" role="listitem">' +
|
||
'<div class="mcp-reg-card-info">' +
|
||
'<div class="mcp-reg-card-name">' +
|
||
escapeHtml(srv.title || srv.name) +
|
||
repoLink +
|
||
"</div>" +
|
||
(srv.description
|
||
? '<div class="mcp-reg-card-desc">' +
|
||
escapeHtml(srv.description) +
|
||
"</div>"
|
||
: "") +
|
||
'<div class="mcp-reg-card-meta">' +
|
||
sourceBadges +
|
||
"</div></div>" +
|
||
'<div class="mcp-reg-card-actions">' +
|
||
(srv.version
|
||
? '<span class="mcp-reg-card-version">v' +
|
||
escapeHtml(srv.version) +
|
||
"</span>"
|
||
: "") +
|
||
actionHtml +
|
||
"</div></div>";
|
||
}
|
||
|
||
if (!visibleCount && _registryResults.length) {
|
||
setSafeHtml(
|
||
el,
|
||
'<div class="dashboard-empty">No servers match the selected filter</div>',
|
||
);
|
||
} else {
|
||
setSafeHtml(el, html);
|
||
}
|
||
|
||
// Pagination
|
||
const pagEl = document.getElementById("mcp-registry-pagination");
|
||
const moreBtn = document.getElementById("mcp-registry-more");
|
||
const countEl = document.getElementById("mcp-registry-count");
|
||
const isFiltered = typeFilter && visibleCount < _registryResults.length;
|
||
if (_registryCursor) {
|
||
pagEl.style.display = "";
|
||
moreBtn.style.display = "";
|
||
countEl.textContent = isFiltered
|
||
? visibleCount +
|
||
" of " +
|
||
_registryResults.length +
|
||
" loaded (more available)"
|
||
: "Showing " + visibleCount + " results";
|
||
} else {
|
||
pagEl.style.display = visibleCount > 0 ? "" : "none";
|
||
if (moreBtn) moreBtn.style.display = "none";
|
||
countEl.textContent = isFiltered
|
||
? visibleCount + " of " + _registryResults.length + " match filter"
|
||
: visibleCount + " result" + (visibleCount !== 1 ? "s" : "");
|
||
}
|
||
|
||
// Bind install buttons
|
||
el.querySelectorAll("[data-reg-install]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const idx = parseInt(this.getAttribute("data-reg-install"), 10);
|
||
_initiateRegistryInstall(_registryResults[idx]);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ── Registry Install Flow ───────────────────────────────────────────────── */
|
||
|
||
function _initiateRegistryInstall(srv) {
|
||
_mcpInstallServer = srv;
|
||
const hasRemote = srv.remotes && srv.remotes.length > 0;
|
||
const hasPackage = srv.packages && srv.packages.length > 0;
|
||
|
||
// Check if remote needs configuration
|
||
let remoteNeedsConfig = false;
|
||
if (hasRemote) {
|
||
const remote = srv.remotes[0];
|
||
for (let hi = 0; hi < (remote.headers || []).length; hi++) {
|
||
if (remote.headers[hi].is_required) {
|
||
remoteNeedsConfig = true;
|
||
break;
|
||
}
|
||
}
|
||
const varKeys = Object.keys(remote.variables || {});
|
||
for (let vi = 0; vi < varKeys.length; vi++) {
|
||
if (remote.variables[varKeys[vi]].is_required) {
|
||
remoteNeedsConfig = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// One-click: remote with no config needed and no package alternative
|
||
if (hasRemote && !remoteNeedsConfig && !hasPackage) {
|
||
// Disable the clicked Install button for loading feedback
|
||
const cardBtns = document.querySelectorAll("[data-reg-install]");
|
||
for (let bi = 0; bi < cardBtns.length; bi++) {
|
||
const idx = parseInt(cardBtns[bi].getAttribute("data-reg-install"), 10);
|
||
if (_registryResults[idx] && _registryResults[idx].name === srv.name) {
|
||
cardBtns[bi].disabled = true;
|
||
cardBtns[bi].textContent = "Installing\u2026";
|
||
break;
|
||
}
|
||
}
|
||
_doRegistryInstall(srv.name, "remote", 0, {}, {}, {});
|
||
return;
|
||
}
|
||
|
||
// Otherwise show the install modal
|
||
_showInstallMcpModal(srv, hasRemote, hasPackage);
|
||
}
|
||
|
||
function _mcpInstallWire() {
|
||
if (_mcpInstallWired) return;
|
||
_mcpInstallWired = true;
|
||
document
|
||
.getElementById("mcp-install-submit")
|
||
.addEventListener("click", submitInstallMcp);
|
||
}
|
||
|
||
function _showInstallMcpModal(srv, hasRemote, hasPackage) {
|
||
_mcpInstallWire();
|
||
document.getElementById("mcp-install-error").classList.remove("is-visible");
|
||
|
||
// Summary
|
||
setSafeHtml(
|
||
document.getElementById("mcp-install-summary"),
|
||
'<div class="mcp-install-summary-name">' +
|
||
escapeHtml(srv.title || srv.name) +
|
||
"</div>" +
|
||
(srv.description
|
||
? '<div class="mcp-install-summary-desc">' +
|
||
escapeHtml(srv.description) +
|
||
"</div>"
|
||
: ""),
|
||
);
|
||
|
||
// Source selector (only if both remote AND package)
|
||
const srcEl = document.getElementById("mcp-install-source-select");
|
||
if (hasRemote && hasPackage) {
|
||
let srcHtml = '<div class="mcp-install-source-group">';
|
||
srcHtml +=
|
||
'<label class="mcp-install-source-label">' +
|
||
'<input type="radio" name="mcp-install-src" value="remote" checked> ' +
|
||
'Remote <span class="mcp-install-source-type">streamable-http</span>' +
|
||
"</label>";
|
||
for (let pi = 0; pi < srv.packages.length; pi++) {
|
||
srcHtml +=
|
||
'<label class="mcp-install-source-label">' +
|
||
'<input type="radio" name="mcp-install-src" value="package-' +
|
||
pi +
|
||
'"> ' +
|
||
'Package <span class="mcp-install-source-type">' +
|
||
escapeHtml(srv.packages[pi].registry_type) +
|
||
" / " +
|
||
escapeHtml(srv.packages[pi].identifier) +
|
||
"</span></label>";
|
||
}
|
||
srcHtml += "</div>";
|
||
setSafeHtml(srcEl, srcHtml);
|
||
const srcRadios = srcEl.querySelectorAll('input[name="mcp-install-src"]');
|
||
for (let sr = 0; sr < srcRadios.length; sr++) {
|
||
srcRadios[sr].addEventListener("change", _updateInstallFields);
|
||
}
|
||
} else {
|
||
srcEl.replaceChildren();
|
||
}
|
||
|
||
_updateInstallFields();
|
||
window.TurnstoneHatch.openDialog(
|
||
document.getElementById("mcp-install-dialog"),
|
||
{
|
||
onClose: function () {
|
||
_mcpInstallServer = null;
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
function _updateInstallFields() {
|
||
const srv = _mcpInstallServer;
|
||
if (!srv) return;
|
||
const fieldsEl = document.getElementById("mcp-install-fields");
|
||
const srcRadio = document.querySelector(
|
||
'input[name="mcp-install-src"]:checked',
|
||
);
|
||
const srcVal = srcRadio ? srcRadio.value : "";
|
||
|
||
let source = "remote";
|
||
let pkgIndex = 0;
|
||
if (srcVal.startsWith("package-")) {
|
||
source = "package";
|
||
pkgIndex = parseInt(srcVal.replace("package-", ""), 10);
|
||
} else if (!srv.remotes || !srv.remotes.length) {
|
||
source = "package";
|
||
}
|
||
|
||
let html = "";
|
||
if (source === "package") {
|
||
const pkg = srv.packages && srv.packages[pkgIndex];
|
||
const pkgId = pkg ? pkg.identifier : "";
|
||
const pkgType = pkg ? pkg.registry_type : "";
|
||
const runner =
|
||
pkgType === "npm" ? "npx" : pkgType === "pypi" ? "uvx" : pkgType;
|
||
html +=
|
||
'<div class="mcp-registry-notice" role="alert" style="margin-bottom:14px">' +
|
||
'<span class="mcp-registry-notice-icon" aria-hidden="true">⚠</span>' +
|
||
"This will download and execute <code>" +
|
||
escapeHtml(pkgId) +
|
||
"</code> via <code>" +
|
||
escapeHtml(runner) +
|
||
"</code> on all cluster nodes. " +
|
||
"Verify the package source before proceeding.</div>";
|
||
}
|
||
if (source === "remote" && srv.remotes && srv.remotes.length > 0) {
|
||
const remote = srv.remotes[0];
|
||
// URL variables
|
||
const varKeys = Object.keys(remote.variables || {});
|
||
for (let vi = 0; vi < varKeys.length; vi++) {
|
||
const v = remote.variables[varKeys[vi]];
|
||
html +=
|
||
'<label for="mcp-inst-var-' +
|
||
vi +
|
||
'">' +
|
||
escapeHtml(varKeys[vi]) +
|
||
(v.is_required
|
||
? ' <span style="color:var(--red)" aria-hidden="true">*</span>'
|
||
: ' <span class="label-hint">optional</span>') +
|
||
"</label>";
|
||
if (v.choices && v.choices.length) {
|
||
html +=
|
||
'<select id="mcp-inst-var-' +
|
||
vi +
|
||
'" data-var-name="' +
|
||
escapeHtml(varKeys[vi]) +
|
||
'"' +
|
||
(v.is_required ? " required" : "") +
|
||
">";
|
||
if (!v.is_required) html += '<option value="">--</option>';
|
||
for (let ci = 0; ci < v.choices.length; ci++) {
|
||
const sel = v.choices[ci] === (v["default"] || "") ? " selected" : "";
|
||
html +=
|
||
'<option value="' +
|
||
escapeHtml(v.choices[ci]) +
|
||
'"' +
|
||
sel +
|
||
">" +
|
||
escapeHtml(v.choices[ci]) +
|
||
"</option>";
|
||
}
|
||
html += "</select>";
|
||
} else {
|
||
html +=
|
||
'<input type="text" id="mcp-inst-var-' +
|
||
vi +
|
||
'" data-var-name="' +
|
||
escapeHtml(varKeys[vi]) +
|
||
'" placeholder="' +
|
||
escapeHtml(v.description || "") +
|
||
'" value="' +
|
||
escapeHtml(v["default"] || "") +
|
||
'"' +
|
||
(v.is_required ? " required" : "") +
|
||
">";
|
||
}
|
||
}
|
||
// Required headers
|
||
for (let hi = 0; hi < (remote.headers || []).length; hi++) {
|
||
const h = remote.headers[hi];
|
||
html +=
|
||
'<label for="mcp-inst-hdr-' +
|
||
hi +
|
||
'">' +
|
||
escapeHtml(h.name) +
|
||
(h.is_required
|
||
? ' <span style="color:var(--red)" aria-hidden="true">*</span>'
|
||
: ' <span class="label-hint">optional</span>') +
|
||
"</label>";
|
||
html +=
|
||
'<input type="' +
|
||
(h.is_secret ? "password" : "text") +
|
||
'" id="mcp-inst-hdr-' +
|
||
hi +
|
||
'" data-hdr-name="' +
|
||
escapeHtml(h.name) +
|
||
'" placeholder="' +
|
||
escapeHtml(h.description || "") +
|
||
'"' +
|
||
(h.is_required ? " required" : "") +
|
||
">";
|
||
}
|
||
} else if (source === "package" && srv.packages && srv.packages[pkgIndex]) {
|
||
const pkg = srv.packages[pkgIndex];
|
||
const evs = pkg.environment_variables || [];
|
||
for (let ei = 0; ei < evs.length; ei++) {
|
||
const ev = evs[ei];
|
||
html +=
|
||
'<label for="mcp-inst-env-' +
|
||
ei +
|
||
'">' +
|
||
escapeHtml(ev.name) +
|
||
(ev.is_required
|
||
? ' <span style="color:var(--red)" aria-hidden="true">*</span>'
|
||
: ' <span class="label-hint">optional</span>') +
|
||
"</label>";
|
||
html +=
|
||
'<input type="' +
|
||
(ev.is_secret ? "password" : "text") +
|
||
'" id="mcp-inst-env-' +
|
||
ei +
|
||
'" data-env-name="' +
|
||
escapeHtml(ev.name) +
|
||
'" placeholder="' +
|
||
escapeHtml(ev.description || "") +
|
||
'" value="' +
|
||
escapeHtml(ev["default"] || "") +
|
||
'"' +
|
||
(ev.is_required ? " required" : "") +
|
||
">";
|
||
}
|
||
}
|
||
|
||
if (!html) {
|
||
html =
|
||
'<p style="font-size:12px;color:var(--fg-dim);margin:8px 0">' +
|
||
"No configuration required — click Install to proceed.</p>";
|
||
}
|
||
setSafeHtml(fieldsEl, html);
|
||
fieldsEl.setAttribute("data-source", source);
|
||
fieldsEl.setAttribute("data-pkg-index", String(pkgIndex));
|
||
}
|
||
|
||
function hideInstallMcpModal() {
|
||
const d = document.getElementById("mcp-install-dialog");
|
||
if (d.open) d.close();
|
||
}
|
||
|
||
function submitInstallMcp() {
|
||
const srv = _mcpInstallServer;
|
||
if (!srv) return;
|
||
const fieldsEl = document.getElementById("mcp-install-fields");
|
||
const source = fieldsEl.getAttribute("data-source") || "remote";
|
||
const pkgIndex = parseInt(fieldsEl.getAttribute("data-pkg-index") || "0", 10);
|
||
const index = source === "remote" ? 0 : pkgIndex;
|
||
|
||
const variables = {};
|
||
fieldsEl.querySelectorAll("[data-var-name]").forEach(function (el) {
|
||
variables[el.getAttribute("data-var-name")] = el.value;
|
||
});
|
||
const headers = {};
|
||
fieldsEl.querySelectorAll("[data-hdr-name]").forEach(function (el) {
|
||
if (el.value) headers[el.getAttribute("data-hdr-name")] = el.value;
|
||
});
|
||
const env = {};
|
||
fieldsEl.querySelectorAll("[data-env-name]").forEach(function (el) {
|
||
if (el.value) env[el.getAttribute("data-env-name")] = el.value;
|
||
});
|
||
|
||
_doRegistryInstall(srv.name, source, index, variables, env, headers);
|
||
}
|
||
|
||
function _doRegistryInstall(
|
||
registryName,
|
||
source,
|
||
index,
|
||
variables,
|
||
env,
|
||
headers,
|
||
) {
|
||
// Two entry points share this: the one-click card path (no dialog) and the
|
||
// install dialog's submit. The dialog's busy lock only applies when it's open.
|
||
const dialog = document.getElementById("mcp-install-dialog");
|
||
if (dialog.open) window.TurnstoneHatch.setBusy(dialog, true);
|
||
|
||
authFetch("/v1/api/admin/mcp-registry/install", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
registry_name: registryName,
|
||
source: source,
|
||
index: index,
|
||
variables: variables,
|
||
env: env,
|
||
headers: headers,
|
||
}),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Install failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
window.TurnstoneHatch.setBusy(dialog, false);
|
||
if (dialog.open) hideInstallMcpModal();
|
||
const serverName = data.name || registryName;
|
||
showToast("Installed " + serverName + " — connecting to nodes\u2026");
|
||
// Re-search to update installed status
|
||
if (_mcpCurrentView === "registry" && _registryResults.length) {
|
||
searchMcpRegistry(false);
|
||
}
|
||
// Poll for connection status after a delay
|
||
if (data.server_id) {
|
||
_pollInstallStatus(data.server_id, serverName, 0);
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
window.TurnstoneHatch.setBusy(dialog, false);
|
||
const errEl = document.getElementById("mcp-install-error");
|
||
if (errEl && dialog.open) {
|
||
errEl.textContent = e.message;
|
||
errEl.classList.add("is-visible");
|
||
} else {
|
||
showToast("Install failed: " + e.message);
|
||
// Re-render to reset card button states
|
||
_renderRegistryResults();
|
||
}
|
||
});
|
||
}
|
||
|
||
function _pollInstallStatus(serverId, serverName, attempt) {
|
||
if (attempt >= 3) return; // give up after ~9s
|
||
setTimeout(function () {
|
||
authFetch("/v1/api/admin/mcp-servers/" + encodeURIComponent(serverId))
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : null;
|
||
})
|
||
.then(function (data) {
|
||
if (!data) return;
|
||
const status = data.status || {};
|
||
const nodeIds = Object.keys(status);
|
||
let anyConnected = false;
|
||
const errors = [];
|
||
for (let i = 0; i < nodeIds.length; i++) {
|
||
const ns = status[nodeIds[i]];
|
||
if (ns.connected) anyConnected = true;
|
||
if (ns.error) errors.push(ns.error);
|
||
}
|
||
if (anyConnected) {
|
||
let tools = 0;
|
||
for (let j = 0; j < nodeIds.length; j++) {
|
||
if (status[nodeIds[j]].connected) {
|
||
tools = status[nodeIds[j]].tools || 0;
|
||
break;
|
||
}
|
||
}
|
||
let msg = serverName + " connected";
|
||
if (tools)
|
||
msg += " (" + tools + " tool" + (tools !== 1 ? "s" : "") + ")";
|
||
if (errors.length)
|
||
msg +=
|
||
", " +
|
||
errors.length +
|
||
" node error" +
|
||
(errors.length !== 1 ? "s" : "");
|
||
showToast(msg);
|
||
if (_mcpCurrentView === "servers") loadAdminMcp();
|
||
} else if (errors.length) {
|
||
showToast(serverName + ": " + errors[0]);
|
||
if (_mcpCurrentView === "servers") loadAdminMcp();
|
||
} else {
|
||
_pollInstallStatus(serverId, serverName, attempt + 1);
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}, 3000);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Models tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _modelDefs = [];
|
||
let _modelDefaultAlias = "";
|
||
// Reranker calibration fields extracted out of the capabilities textarea in the
|
||
// edit modal (like server_compat), held here so they survive an unrelated edit
|
||
// and are re-merged on save. Reset per modal open.
|
||
let _rerankCalFields = {};
|
||
|
||
// Capability tile matrix — sparse-override semantics. The tiles display
|
||
// merge(dataclass defaults, known-model table baseline, explicit overrides);
|
||
// only EXPLICIT keys persist (saved keys + tiles the user toggled), so a
|
||
// known model keeps tracking future table updates instead of being pinned.
|
||
const _MODEL_CAP_KEYS = [
|
||
"supports_tools",
|
||
"supports_streaming",
|
||
"supports_vision",
|
||
"supports_pdf",
|
||
"supports_web_search",
|
||
"supports_temperature",
|
||
"supports_effort",
|
||
"supports_verbosity",
|
||
"supports_pro_mode",
|
||
"supports_transcription",
|
||
"supports_speech_synthesis",
|
||
"supports_audio_input",
|
||
"supports_rerank",
|
||
];
|
||
const _MODEL_CAP_DEFAULTS = {
|
||
supports_tools: true,
|
||
supports_streaming: true,
|
||
supports_vision: false,
|
||
supports_pdf: false,
|
||
supports_web_search: false,
|
||
supports_temperature: true,
|
||
supports_effort: false,
|
||
supports_verbosity: false,
|
||
supports_pro_mode: false,
|
||
supports_transcription: false,
|
||
supports_speech_synthesis: false,
|
||
supports_audio_input: false,
|
||
supports_rerank: false,
|
||
};
|
||
let _modelCapsBaseline = {}; // known-model table values (display + delta base)
|
||
let _modelCapsExplicit = {}; // keys that persist: saved-in-JSON + user-toggled
|
||
|
||
function _modelTileEl(key) {
|
||
return document.querySelector('#model-capgrid input[data-cap="' + key + '"]');
|
||
}
|
||
function _modelGetTile(key) {
|
||
const el = _modelTileEl(key);
|
||
return el ? el.checked : _MODEL_CAP_DEFAULTS[key];
|
||
}
|
||
function _modelRenderTiles() {
|
||
_MODEL_CAP_KEYS.forEach(function (k) {
|
||
const el = _modelTileEl(k);
|
||
if (!el) return;
|
||
if (k in _modelCapsExplicit) el.checked = !!_modelCapsExplicit[k];
|
||
else if (k in _modelCapsBaseline) el.checked = !!_modelCapsBaseline[k];
|
||
else el.checked = _MODEL_CAP_DEFAULTS[k];
|
||
});
|
||
_updateModelResponseControls();
|
||
}
|
||
|
||
const _MODEL_RESPONSE_CONTROLS = [
|
||
{
|
||
key: "verbosity",
|
||
supportKey: "supports_verbosity",
|
||
elementId: "model-output-verbosity",
|
||
fieldId: "model-output-verbosity-field",
|
||
values: ["low", "medium", "high"],
|
||
},
|
||
{
|
||
key: "reasoning_mode",
|
||
supportKey: "supports_pro_mode",
|
||
elementId: "model-reasoning-mode",
|
||
fieldId: "model-reasoning-mode-field",
|
||
values: ["standard", "pro"],
|
||
},
|
||
];
|
||
let _modelResponseInitialIdentity = "";
|
||
let _modelResponseCurrentIdentity = "";
|
||
let _modelResponseCaptured = {};
|
||
let _modelResponseDirty = {};
|
||
|
||
function _modelIdentity() {
|
||
const provider = document.getElementById("model-provider").value;
|
||
const model = document.getElementById("model-name").value.trim();
|
||
const surface =
|
||
provider === "openai-compatible"
|
||
? document.getElementById("model-api-surface").value
|
||
: "";
|
||
return provider + "\n" + model + "\n" + surface;
|
||
}
|
||
|
||
function _modelUsesResponsesSurface() {
|
||
const provider = document.getElementById("model-provider").value;
|
||
if (provider === "openai") return true;
|
||
return (
|
||
provider === "openai-compatible" &&
|
||
document.getElementById("model-api-surface").value === "responses"
|
||
);
|
||
}
|
||
|
||
function _modelResponseValueValid(spec, value) {
|
||
return typeof value === "string" && spec.values.indexOf(value) !== -1;
|
||
}
|
||
|
||
function _updateModelResponseControls() {
|
||
const group = document.getElementById("model-response-controls");
|
||
if (!group) return;
|
||
const responseSurface = _modelUsesResponsesSurface();
|
||
const sameIdentity =
|
||
_modelResponseInitialIdentity &&
|
||
_modelIdentity() === _modelResponseInitialIdentity;
|
||
let anyVisible = false;
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
const field = document.getElementById(spec.fieldId);
|
||
const select = document.getElementById(spec.elementId);
|
||
if (!field || !select) return;
|
||
// A value _captureModelResponseControls lifted out of the row's JSON
|
||
// stays visible (and re-saveable) while the identity still matches the
|
||
// row being edited, deliberately NOT consulting the capability
|
||
// baseline: the baseline arrives async (or never, on the compat lane),
|
||
// and yielding to it would hide the pinned value and silently drop it
|
||
// on save — the same lift-then-restore contract as server_compat,
|
||
// rerank calibration, and thinking_param. Wire safety is server-side:
|
||
// emission gates on the merged supports_* flag, so a pinned value on
|
||
// an unsupported model is inert; "Provider default" explicitly clears
|
||
// it. An explicit tile override (either polarity) supersedes the
|
||
// fallback — unchecking the tile is the operator's way to retire it.
|
||
const capturedFallback =
|
||
sameIdentity &&
|
||
!(spec.supportKey in _modelCapsExplicit) &&
|
||
_modelResponseValueValid(spec, select.value);
|
||
const visible =
|
||
responseSurface && (_modelGetTile(spec.supportKey) || capturedFallback);
|
||
field.hidden = !visible;
|
||
anyVisible = anyVisible || visible;
|
||
});
|
||
group.hidden = !anyVisible;
|
||
}
|
||
|
||
function _resetModelResponseControls() {
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
const select = document.getElementById(spec.elementId);
|
||
if (select) select.value = "";
|
||
});
|
||
_modelResponseInitialIdentity = "";
|
||
_modelResponseCurrentIdentity = _modelIdentity();
|
||
_modelResponseCaptured = {};
|
||
_modelResponseDirty = {};
|
||
_updateModelResponseControls();
|
||
}
|
||
|
||
function _captureModelResponseControls(capsObj) {
|
||
if (!_modelUsesResponsesSurface()) return;
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
const select = document.getElementById(spec.elementId);
|
||
if (!select) return;
|
||
const explicitlyUnsupported =
|
||
spec.supportKey in _modelCapsExplicit &&
|
||
!_modelCapsExplicit[spec.supportKey];
|
||
const value = capsObj[spec.key];
|
||
if (!explicitlyUnsupported && _modelResponseValueValid(spec, value)) {
|
||
select.value = value;
|
||
_modelResponseCaptured[spec.key] = value;
|
||
delete capsObj[spec.key];
|
||
}
|
||
});
|
||
}
|
||
|
||
function _mergeModelResponseControls(caps) {
|
||
if (!_modelUsesResponsesSurface()) return;
|
||
const sameIdentity =
|
||
_modelResponseInitialIdentity &&
|
||
_modelIdentity() === _modelResponseInitialIdentity;
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
// Dirty (select touched this session) lets the select override a
|
||
// stale JSON key, but only for the identity that made it dirty —
|
||
// after a model/provider/surface change the flag describes the OLD
|
||
// row, and honoring it would delete a key hand-typed into the
|
||
// Advanced JSON for the new one.
|
||
if (_modelResponseDirty[spec.key] && sameIdentity) delete caps[spec.key];
|
||
else if (spec.key in caps) return; // Advanced JSON wins.
|
||
const select = document.getElementById(spec.elementId);
|
||
if (!select || !_modelResponseValueValid(spec, select.value)) return;
|
||
// Same capturedFallback contract as _updateModelResponseControls
|
||
// (rationale there): a lifted same-identity value must re-save, or an
|
||
// unrelated edit silently drops it from the row.
|
||
const capturedFallback =
|
||
sameIdentity && !(spec.supportKey in _modelCapsExplicit);
|
||
if (_modelGetTile(spec.supportKey) || capturedFallback) {
|
||
caps[spec.key] = select.value;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _rememberModelResponseControl(spec) {
|
||
_modelResponseDirty[spec.key] = true;
|
||
if (_modelIdentity() !== _modelResponseInitialIdentity) return;
|
||
const select = document.getElementById(spec.elementId);
|
||
if (select && _modelResponseValueValid(spec, select.value)) {
|
||
_modelResponseCaptured[spec.key] = select.value;
|
||
} else {
|
||
delete _modelResponseCaptured[spec.key];
|
||
}
|
||
}
|
||
|
||
// Roles surfaced in the Models → Roles sub-tab. Each entry maps a
|
||
// settings-registry key onto a UX label. ``effortKey`` is optional —
|
||
// roles whose registry entry has a paired ``*.reasoning_effort``
|
||
// setting render a second selector inline. Adding a new role (e.g.
|
||
// ``perception.audio.model``) is purely additive: drop a row here once
|
||
// the SettingDef lands in turnstone/core/settings_registry.py.
|
||
// ``fallbackKind`` controls how the empty/blank option in the alias
|
||
// dropdown is labelled. Coordinator and Judge fall back to a single
|
||
// well-defined alias (model.default_alias / coordinator alias) so we
|
||
// surface that concrete model in the placeholder. The Task agent
|
||
// cascades through ``[model].task_model → [model].agent_model →
|
||
// session model`` per turnstone/core/settings_registry.py — there's
|
||
// no single "default" to advertise, so the blank reads "(inherit)"
|
||
// to match the vocabulary of the reasoning-effort dropdowns.
|
||
const MODEL_ROLES = [
|
||
{
|
||
label: "Coordinator",
|
||
description:
|
||
"Console-hosted coordinator sessions that drive child workstreams.",
|
||
aliasKey: "coordinator.model_alias",
|
||
effortKey: "coordinator.reasoning_effort",
|
||
fallbackKind: "default",
|
||
},
|
||
{
|
||
label: "Judge",
|
||
description:
|
||
"Intent-validation judge that scores tool calls before approval.",
|
||
aliasKey: "judge.model",
|
||
fallbackKind: "default",
|
||
},
|
||
{
|
||
label: "Output guard judge",
|
||
description:
|
||
"Output-guard judge that semantically evaluates tool results for camouflaged prompt injection (active when judge.output_guard_llm is enabled).",
|
||
aliasKey: "judge.output_guard_model",
|
||
fallbackKind: "default",
|
||
},
|
||
{
|
||
label: "Task agent",
|
||
description:
|
||
"task_agent sub-agent — runs autonomous subtasks dispatched by the parent.",
|
||
aliasKey: "model.task_alias",
|
||
effortKey: "model.task_effort",
|
||
fallbackKind: "inherit",
|
||
},
|
||
{
|
||
label: "Channel adapter",
|
||
description:
|
||
"Workstreams created by channel adapters (Discord, Slack) when no model is specified at creation time.",
|
||
aliasKey: "channels.default_model_alias",
|
||
fallbackKind: "default",
|
||
},
|
||
{
|
||
label: "Speech-to-text",
|
||
description:
|
||
"Transcribes microphone audio in the workstream composer (voice input), and is the preferred transcript for audio attachments. Point at a transcription model (Whisper-style) or an audio-capable omni model — the omni path transcribes via chat. Empty disables the mic affordance; audio attachments then fall back to the Perception model if one is configured.",
|
||
aliasKey: "audio.stt_model_alias",
|
||
fallbackKind: "disabled",
|
||
mediaCapability: "supports_transcription",
|
||
mediaRole: "stt",
|
||
},
|
||
{
|
||
label: "Text-to-speech",
|
||
description:
|
||
"Synthesizes assistant replies for playback (voice output). Empty disables the play affordance.",
|
||
aliasKey: "audio.tts_model_alias",
|
||
fallbackKind: "disabled",
|
||
mediaCapability: "supports_speech_synthesis",
|
||
mediaRole: "tts",
|
||
},
|
||
{
|
||
label: "Perception",
|
||
description:
|
||
"Bottom-tier fallback for attachments the primary model can't ingest natively (image / PDF / audio): the perception model perceives the attachment and its description is passed to the primary as text. Point at a vision-capable or omni model and enable the matching capabilities on it — supports_vision for image/PDF, supports_audio_input for audio. Empty disables the fallback; native handling and the speech-to-text role still take precedence.",
|
||
aliasKey: "perception.model_alias",
|
||
fallbackKind: "disabled",
|
||
disabledLabel: "(disabled — no fallback)",
|
||
},
|
||
{
|
||
label: "Reranker",
|
||
description:
|
||
"Reranks web_search results. Point at a model whose base_url is a Cohere/Jina-compatible /rerank endpoint and whose capabilities include supports_rerank. Empty disables reranking. Enabling a reranker sends web_search results AND BM25 retrieval candidates (tool/skill descriptions and memory content) to this endpoint; self-hosted endpoints keep it on your infrastructure.",
|
||
aliasKey: "tools.reranker_alias",
|
||
fallbackKind: "disabled",
|
||
disabledLabel: "(disabled — reranking off)",
|
||
mediaCapability: "supports_rerank",
|
||
mediaRole: "rerank",
|
||
},
|
||
];
|
||
|
||
// Whether a model definition is eligible for an audio role. Mirrors
|
||
// turnstone/core/audio.py model_supports_role: an explicit capability flag
|
||
// wins; otherwise infer from well-known OpenAI audio model names so a stock
|
||
// gpt-4o-mini-transcribe / -tts / whisper alias shows up without hand-ticking.
|
||
// Known-model-name hints, mirrored VERBATIM from _AUDIO_MODEL_HINTS in
|
||
// turnstone/core/audio.py (the canonical source). A substring match marks
|
||
// eligibility. Keep these two lists in sync — the Python side is pinned by
|
||
// tests/test_audio.py so any change there is deliberate.
|
||
const AUDIO_MODEL_HINTS = {
|
||
stt: ["transcribe", "whisper", "-asr"],
|
||
tts: ["tts-", "-tts"],
|
||
};
|
||
|
||
// Providers whose client speaks the OpenAI-SDK audio surface. Mirror
|
||
// _AUDIO_SDK_PROVIDERS / _provider_carries_audio in turnstone/core/audio.py:
|
||
// anthropic(-compatible) has no audio content block, so it can't serve the
|
||
// voice roles regardless of capability flags.
|
||
function _providerCarriesAudio(provider) {
|
||
return (
|
||
provider === "openai" ||
|
||
provider === "openai-compatible" ||
|
||
provider === "google" ||
|
||
provider === "xai"
|
||
);
|
||
}
|
||
|
||
function _audioModelEligible(md, capFlag, mediaRole) {
|
||
let caps = md && md.capabilities;
|
||
if (typeof caps === "string") {
|
||
try {
|
||
caps = JSON.parse(caps || "{}");
|
||
} catch (e) {
|
||
caps = {};
|
||
}
|
||
}
|
||
if (!caps || typeof caps !== "object") caps = {};
|
||
// Voice roles (stt/tts) ride the OpenAI-SDK audio surface; an Anthropic
|
||
// (-compatible) provider can't serve them even with a capability flag set.
|
||
// A blank/unset provider defaults to "openai" — matching the backend's
|
||
// _provider_carries_audio (ModelConfig.provider defaults to "openai") so a
|
||
// provider-less model isn't wrongly excluded. Reranker is NOT an audio role
|
||
// (it hits a /rerank endpoint), so it is not provider-gated here.
|
||
if (
|
||
(mediaRole === "stt" || mediaRole === "tts") &&
|
||
!_providerCarriesAudio((md && md.provider) || "openai")
|
||
) {
|
||
return false;
|
||
}
|
||
// An omni model (chat audio input) can serve STT via the chat transcription
|
||
// path — mirror model_supports_role in turnstone/core/audio.py.
|
||
if (mediaRole === "stt" && caps.supports_audio_input) return true;
|
||
if (Object.prototype.hasOwnProperty.call(caps, capFlag))
|
||
return !!caps[capFlag];
|
||
const name = ((md && md.model) || "").toLowerCase();
|
||
const hints = AUDIO_MODEL_HINTS[mediaRole] || [];
|
||
return hints.some(function (h) {
|
||
return name.indexOf(h) !== -1;
|
||
});
|
||
}
|
||
|
||
// Roles sub-tab reads/writes via ``/v1/api/admin/settings`` which
|
||
// requires ``admin.settings`` — different from the ``admin.models``
|
||
// permission gating the Models tab itself. When the user has Models
|
||
// access but not Settings, hide the sub-tab button + force the
|
||
// Definitions panel visible so they don't see a perpetual 403 loader.
|
||
function _modelRolesAccessible() {
|
||
const perms = sessionStorage.getItem("turnstone_permissions") || "";
|
||
return perms.split(",").indexOf("admin.settings") !== -1;
|
||
}
|
||
|
||
function _applyModelRolesPermission() {
|
||
const btn = document.getElementById("models-tab-roles");
|
||
if (!btn) return;
|
||
if (_modelRolesAccessible()) {
|
||
btn.style.display = "";
|
||
return;
|
||
}
|
||
btn.style.display = "none";
|
||
// If Roles was the active sub-tab, snap back to Definitions so the
|
||
// user isn't staring at a hidden panel.
|
||
if (btn.classList.contains("active")) {
|
||
switchModelsSection("models-list");
|
||
}
|
||
}
|
||
|
||
function loadAdminModels() {
|
||
_applyModelRolesPermission();
|
||
authFetch("/v1/api/admin/model-definitions")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_modelDefs = data.models || [];
|
||
_modelDefaultAlias = data.default_alias || "";
|
||
_renderModels(_modelDefs);
|
||
// Roles sub-tab piggybacks on the model list; skip it when the
|
||
// user has no settings permission since the underlying API will
|
||
// 403 anyway.
|
||
if (_modelRolesAccessible()) {
|
||
loadAdminModelRoles();
|
||
}
|
||
})
|
||
.catch(function () {
|
||
const el = document.getElementById("admin-models-table");
|
||
el.textContent = "";
|
||
const d = document.createElement("div");
|
||
d.className = "dashboard-empty";
|
||
d.textContent = "Failed to load models";
|
||
el.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function switchModelsSection(section) {
|
||
const sections = document.querySelectorAll("#admin-models .models-section");
|
||
for (let i = 0; i < sections.length; i++) sections[i].style.display = "none";
|
||
const switcher = document.querySelector(
|
||
"#admin-models .admin-subtab-switcher",
|
||
);
|
||
const btns = switcher ? switcher.querySelectorAll(".admin-subtab-btn") : [];
|
||
for (let k = 0; k < btns.length; k++) {
|
||
const isActive = btns[k].getAttribute("data-section") === section;
|
||
btns[k].classList.toggle("active", isActive);
|
||
btns[k].setAttribute("aria-selected", isActive ? "true" : "false");
|
||
btns[k].setAttribute("tabindex", isActive ? "0" : "-1");
|
||
}
|
||
const target = document.getElementById(section + "-section");
|
||
if (target) target.style.display = "";
|
||
}
|
||
|
||
// Arrow key navigation for Models sub-tabs (matches the Judge tab).
|
||
(function () {
|
||
const switcher = document.querySelector(
|
||
"#admin-models .admin-subtab-switcher",
|
||
);
|
||
if (!switcher) return;
|
||
switcher.addEventListener("keydown", function (e) {
|
||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||
const btns = switcher.querySelectorAll(".admin-subtab-btn");
|
||
const secs = [];
|
||
for (let i = 0; i < btns.length; i++)
|
||
secs.push(btns[i].getAttribute("data-section"));
|
||
const current = switcher.querySelector(".admin-subtab-btn.active");
|
||
let idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
|
||
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
|
||
else idx = (idx - 1 + secs.length) % secs.length;
|
||
e.preventDefault();
|
||
switchModelsSection(secs[idx]);
|
||
btns[idx].focus();
|
||
});
|
||
})();
|
||
|
||
function _modelRolesError(container, msg) {
|
||
while (container.firstChild) container.removeChild(container.firstChild);
|
||
const d = document.createElement("div");
|
||
d.className = "dashboard-empty";
|
||
d.textContent = msg;
|
||
container.appendChild(d);
|
||
}
|
||
|
||
function loadAdminModelRoles() {
|
||
const c = document.getElementById("admin-models-roles-container");
|
||
if (!c) return;
|
||
// Reads ``_modelDefs`` / ``_modelDefaultAlias`` populated by the most
|
||
// recent ``loadAdminModels`` — both entry points into the Models tab
|
||
// (initial open + ``models_changed`` SSE refresh) go through
|
||
// ``loadAdminModels`` first, so the cached snapshot is fresh. Role
|
||
// saves don't change model definitions, so the snapshot stays
|
||
// accurate after ``_saveModelRole`` chains back here.
|
||
Promise.all([
|
||
authFetch("/v1/api/admin/settings").then(function (r) {
|
||
if (!r.ok) throw new Error("settings " + r.status);
|
||
return r.json();
|
||
}),
|
||
authFetch("/v1/api/admin/settings/schema").then(function (r) {
|
||
if (!r.ok) throw new Error("schema " + r.status);
|
||
return r.json();
|
||
}),
|
||
])
|
||
.then(function (results) {
|
||
const values = {};
|
||
const arr = results[0].settings || [];
|
||
for (let i = 0; i < arr.length; i++) values[arr[i].key] = arr[i];
|
||
const schema = {};
|
||
const sa = results[1].schema || [];
|
||
for (let j = 0; j < sa.length; j++) schema[sa[j].key] = sa[j];
|
||
_renderModelRoles(c, values, schema);
|
||
})
|
||
.catch(function () {
|
||
_modelRolesError(c, "Failed to load roles");
|
||
});
|
||
}
|
||
|
||
function _renderModelRoles(container, values, schema) {
|
||
const enabledAliases = [];
|
||
for (let i = 0; i < _modelDefs.length; i++) {
|
||
if (_modelDefs[i].enabled) enabledAliases.push(_modelDefs[i]);
|
||
}
|
||
container.textContent = "";
|
||
for (let r = 0; r < MODEL_ROLES.length; r++) {
|
||
const role = MODEL_ROLES[r];
|
||
const aliasInfo = values[role.aliasKey];
|
||
if (!aliasInfo) continue; // setting not registered (e.g. older server)
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "model-role-row";
|
||
|
||
// The dropdown's selected-option text is the single source of
|
||
// truth for default vs override — when nothing is set it shows
|
||
// "(default — <alias>)", otherwise it shows the chosen alias. No
|
||
// separate badge: redundant with the select, and prone to
|
||
// confusing color contrasts on freshly-rendered rows.
|
||
const head = document.createElement("div");
|
||
head.className = "model-role-head";
|
||
const nameEl = document.createElement("span");
|
||
nameEl.className = "model-role-label";
|
||
nameEl.textContent = role.label;
|
||
head.appendChild(nameEl);
|
||
row.appendChild(head);
|
||
|
||
if (role.description) {
|
||
const desc = document.createElement("div");
|
||
desc.className = "model-role-desc";
|
||
desc.textContent = role.description;
|
||
row.appendChild(desc);
|
||
}
|
||
|
||
const controls = document.createElement("div");
|
||
controls.className = "model-role-controls";
|
||
|
||
// Alias dropdown
|
||
const aliasWrap = document.createElement("label");
|
||
aliasWrap.className = "model-role-control";
|
||
const aliasLabel = document.createElement("span");
|
||
aliasLabel.className = "model-role-control-label";
|
||
aliasLabel.textContent = "Model";
|
||
aliasWrap.appendChild(aliasLabel);
|
||
const aliasSel = document.createElement("select");
|
||
aliasSel.setAttribute("data-role-key", role.aliasKey);
|
||
aliasSel.setAttribute(
|
||
"aria-label",
|
||
role.label + " model (empty = default)",
|
||
);
|
||
// Format the blank/inherit option in the same "alias (model)" shape
|
||
// as the other rows so the dropdown reads consistently — without
|
||
// this the empty row was bare "(default — flatspark)" while every
|
||
// other row carried a "(/models/...)" suffix. Plan/Task agent
|
||
// fall back through a multi-step chain (config.toml → agent_model
|
||
// → session) that has no single concrete "default", so they get
|
||
// a plain "(inherit)" instead of the misleading
|
||
// "(default — <coordinator-alias>)".
|
||
const blank = document.createElement("option");
|
||
blank.value = "";
|
||
if (role.fallbackKind === "disabled") {
|
||
blank.textContent = role.disabledLabel || "(disabled — voice off)";
|
||
} else if (role.fallbackKind === "inherit") {
|
||
blank.textContent = "(inherit)";
|
||
} else {
|
||
let defaultDef = null;
|
||
if (_modelDefaultAlias) {
|
||
for (let dm = 0; dm < enabledAliases.length; dm++) {
|
||
if (enabledAliases[dm].alias === _modelDefaultAlias) {
|
||
defaultDef = enabledAliases[dm];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (defaultDef) {
|
||
const defLabel =
|
||
defaultDef.alias === defaultDef.model
|
||
? defaultDef.alias
|
||
: defaultDef.alias + " (" + defaultDef.model + ")";
|
||
blank.textContent = "(default — " + defLabel + ")";
|
||
} else if (_modelDefaultAlias) {
|
||
blank.textContent = "(default — " + _modelDefaultAlias + ")";
|
||
} else {
|
||
blank.textContent = "(default)";
|
||
}
|
||
}
|
||
aliasSel.appendChild(blank);
|
||
// Audio roles only list aliases capable of the role (capability flag or
|
||
// known-model inference); other roles list every enabled alias.
|
||
let roleAliases = enabledAliases;
|
||
if (role.mediaCapability) {
|
||
roleAliases = enabledAliases.filter(function (md) {
|
||
return _audioModelEligible(md, role.mediaCapability, role.mediaRole);
|
||
});
|
||
}
|
||
const currentAlias = aliasInfo.value || "";
|
||
let matched = false;
|
||
for (let m = 0; m < roleAliases.length; m++) {
|
||
const md = roleAliases[m];
|
||
const opt = document.createElement("option");
|
||
opt.value = md.alias;
|
||
opt.textContent =
|
||
md.alias === md.model ? md.alias : md.alias + " (" + md.model + ")";
|
||
if (currentAlias && currentAlias === md.alias) {
|
||
opt.selected = true;
|
||
matched = true;
|
||
}
|
||
aliasSel.appendChild(opt);
|
||
}
|
||
if (currentAlias && !matched) {
|
||
const manual = document.createElement("option");
|
||
manual.value = currentAlias;
|
||
manual.textContent = currentAlias + " (manual)";
|
||
manual.selected = true;
|
||
aliasSel.appendChild(manual);
|
||
}
|
||
aliasSel.addEventListener("change", function () {
|
||
_saveModelRole(this.getAttribute("data-role-key"), this.value);
|
||
});
|
||
aliasWrap.appendChild(aliasSel);
|
||
controls.appendChild(aliasWrap);
|
||
|
||
// Optional reasoning effort dropdown
|
||
if (role.effortKey && values[role.effortKey] && schema[role.effortKey]) {
|
||
const effortWrap = document.createElement("label");
|
||
effortWrap.className = "model-role-control";
|
||
const effortLabel = document.createElement("span");
|
||
effortLabel.className = "model-role-control-label";
|
||
effortLabel.textContent = "Reasoning effort";
|
||
effortWrap.appendChild(effortLabel);
|
||
const effortSel = document.createElement("select");
|
||
effortSel.setAttribute("data-role-key", role.effortKey);
|
||
effortSel.setAttribute("aria-label", role.label + " reasoning effort");
|
||
const choices = schema[role.effortKey].choices || [];
|
||
const currentEffort = values[role.effortKey].value;
|
||
for (let c2 = 0; c2 < choices.length; c2++) {
|
||
const eo = document.createElement("option");
|
||
eo.value = choices[c2];
|
||
eo.textContent = choices[c2] === "" ? "(inherit)" : choices[c2];
|
||
if (currentEffort === choices[c2]) eo.selected = true;
|
||
effortSel.appendChild(eo);
|
||
}
|
||
effortSel.addEventListener("change", function () {
|
||
_saveModelRole(this.getAttribute("data-role-key"), this.value);
|
||
});
|
||
effortWrap.appendChild(effortSel);
|
||
controls.appendChild(effortWrap);
|
||
}
|
||
|
||
row.appendChild(controls);
|
||
container.appendChild(row);
|
||
}
|
||
|
||
if (!container.children.length) {
|
||
_modelRolesError(container, "No model roles configured");
|
||
}
|
||
}
|
||
|
||
function _saveModelRole(key, value) {
|
||
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: value }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Saved");
|
||
loadAdminModelRoles();
|
||
})
|
||
.catch(function (e) {
|
||
showToast("Error: " + (e && e.message ? e.message : "save failed"));
|
||
});
|
||
}
|
||
|
||
function _renderModels(items) {
|
||
const el = document.getElementById("admin-models-table");
|
||
// Clear previous content
|
||
el.textContent = "";
|
||
if (!items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "dashboard-empty";
|
||
empty.textContent = "No model definitions configured";
|
||
el.appendChild(empty);
|
||
return;
|
||
}
|
||
for (let i = 0; i < items.length; i++) {
|
||
const m = items[i];
|
||
const isConfig = m.source === "config";
|
||
|
||
// Status
|
||
const dotClass = m.enabled
|
||
? "model-status-dot enabled"
|
||
: "model-status-dot disabled";
|
||
const rowClass = m.enabled ? "model-row-enabled" : "model-row-disabled";
|
||
const statusText = m.enabled ? "enabled" : "disabled";
|
||
|
||
// Context window formatting (0 = auto-detect)
|
||
const ctxText = m.context_window
|
||
? m.context_window >= 1000
|
||
? Math.round(m.context_window / 1000) + "k"
|
||
: String(m.context_window)
|
||
: "auto";
|
||
|
||
// Provider badge class
|
||
const providerCls =
|
||
m.provider === "anthropic"
|
||
? "model-provider-anthropic"
|
||
: m.provider === "google"
|
||
? "model-provider-google"
|
||
: m.provider === "openai-compatible" ||
|
||
m.provider === "anthropic-compatible"
|
||
? "model-provider-compat"
|
||
: "model-provider-openai";
|
||
|
||
// Build row via DOM
|
||
const row = document.createElement("div");
|
||
row.className = "admin-row models-grid " + rowClass;
|
||
row.setAttribute("role", "listitem");
|
||
|
||
// Alias + source badge + default badge
|
||
const isDefault = m.alias === _modelDefaultAlias;
|
||
const colAlias = document.createElement("span");
|
||
colAlias.className = "admin-col";
|
||
colAlias.textContent = m.alias;
|
||
const badge = document.createElement("span");
|
||
badge.className = isConfig
|
||
? "scope-badge scope-config"
|
||
: "scope-badge scope-db";
|
||
badge.textContent = isConfig ? "config" : "db";
|
||
colAlias.appendChild(document.createTextNode(" "));
|
||
colAlias.appendChild(badge);
|
||
if (isDefault) {
|
||
const defBadge = document.createElement("span");
|
||
defBadge.className = "scope-badge scope-default";
|
||
defBadge.textContent = "default";
|
||
colAlias.appendChild(document.createTextNode(" "));
|
||
colAlias.appendChild(defBadge);
|
||
}
|
||
// Per-model sampling override indicators
|
||
const overrides = [];
|
||
if (m.temperature != null) overrides.push("temp=" + m.temperature);
|
||
if (m.max_tokens != null) overrides.push("max_tok=" + m.max_tokens);
|
||
if (m.reasoning_effort != null)
|
||
overrides.push("effort=" + m.reasoning_effort);
|
||
let displayCaps = m.capabilities;
|
||
if (typeof displayCaps === "string") {
|
||
try {
|
||
displayCaps = JSON.parse(displayCaps || "{}");
|
||
} catch (e) {
|
||
displayCaps = {};
|
||
}
|
||
}
|
||
if (!_isPlainObject(displayCaps)) displayCaps = {};
|
||
if (
|
||
displayCaps.supports_verbosity !== false &&
|
||
["low", "medium", "high"].indexOf(displayCaps.verbosity) !== -1
|
||
)
|
||
overrides.push("verbosity=" + displayCaps.verbosity);
|
||
if (
|
||
displayCaps.supports_pro_mode !== false &&
|
||
["standard", "pro"].indexOf(displayCaps.reasoning_mode) !== -1
|
||
)
|
||
overrides.push("mode=" + displayCaps.reasoning_mode);
|
||
// Reasoning persistence flags surface only when non-default
|
||
// (persist=False is the operator opt-out; replay=True is the
|
||
// operator opt-in). Default values are silent.
|
||
if (m.surface_persisted_reasoning === false) overrides.push("surface=off");
|
||
if (m.replay_reasoning_to_model === true) overrides.push("replay=on");
|
||
if (overrides.length) {
|
||
const ovrSpan = document.createElement("span");
|
||
ovrSpan.className = "model-overrides-hint";
|
||
ovrSpan.textContent = overrides.join(", ");
|
||
ovrSpan.title = "Per-model overrides (override global defaults)";
|
||
ovrSpan.setAttribute(
|
||
"aria-label",
|
||
"Per-model overrides: " + overrides.join(", "),
|
||
);
|
||
colAlias.appendChild(document.createElement("br"));
|
||
colAlias.appendChild(ovrSpan);
|
||
}
|
||
row.appendChild(colAlias);
|
||
|
||
// Model ID
|
||
const colModel = document.createElement("span");
|
||
colModel.className = "admin-col";
|
||
const code = document.createElement("code");
|
||
code.textContent = m.model;
|
||
colModel.appendChild(code);
|
||
row.appendChild(colModel);
|
||
|
||
// Provider
|
||
const colProvider = document.createElement("span");
|
||
colProvider.className = "admin-col";
|
||
const provBadge = document.createElement("span");
|
||
provBadge.className = "model-provider-badge " + providerCls;
|
||
provBadge.textContent = m.provider;
|
||
colProvider.appendChild(provBadge);
|
||
row.appendChild(colProvider);
|
||
|
||
// Context window
|
||
const colCtx = document.createElement("span");
|
||
colCtx.className = "admin-col";
|
||
colCtx.textContent = ctxText;
|
||
row.appendChild(colCtx);
|
||
|
||
// Status
|
||
const colStatus = document.createElement("span");
|
||
colStatus.className = "admin-col";
|
||
const dot = document.createElement("span");
|
||
dot.className = dotClass;
|
||
dot.setAttribute("aria-hidden", "true");
|
||
colStatus.appendChild(dot);
|
||
colStatus.appendChild(document.createTextNode(statusText));
|
||
row.appendChild(colStatus);
|
||
|
||
// Actions
|
||
const colActions = document.createElement("span");
|
||
colActions.className = "admin-col admin-col-actions";
|
||
const modelKebab = _kebabMenuEl([
|
||
// Config-sourced models are read-only here; only the live default may
|
||
// be changed. Hide "set default" on the row that already is default.
|
||
!isDefault && m.enabled
|
||
? {
|
||
label: "set default",
|
||
title: "Set " + m.alias + " as default model",
|
||
attrs: {
|
||
"data-model-set-default": m.alias,
|
||
"aria-label": "Set " + m.alias + " as default model",
|
||
},
|
||
}
|
||
: null,
|
||
isConfig
|
||
? null
|
||
: {
|
||
label: "edit",
|
||
title: "Edit " + m.alias,
|
||
attrs: { "data-model-edit": m.definition_id },
|
||
},
|
||
isConfig
|
||
? null
|
||
: {
|
||
label: "delete",
|
||
kind: "danger",
|
||
title: "Delete " + m.alias,
|
||
attrs: {
|
||
"data-model-delete": m.definition_id,
|
||
"data-model-alias": m.alias,
|
||
},
|
||
},
|
||
]);
|
||
if (modelKebab) colActions.appendChild(modelKebab);
|
||
row.appendChild(colActions);
|
||
|
||
el.appendChild(row);
|
||
}
|
||
|
||
// Bind event handlers
|
||
el.querySelectorAll("[data-model-set-default]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const alias = this.getAttribute("data-model-set-default");
|
||
const self = this;
|
||
self.disabled = true;
|
||
self.textContent = "setting\u2026";
|
||
authFetch("/v1/api/admin/settings/model.default_alias", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: alias }),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
showToast("Default model set to " + alias);
|
||
_flagModelSyncPending();
|
||
loadAdminModels();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to set default model");
|
||
self.disabled = false;
|
||
self.textContent = "set default";
|
||
});
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-model-edit]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
showEditModelModal(this.getAttribute("data-model-edit"));
|
||
});
|
||
});
|
||
el.querySelectorAll("[data-model-delete]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const did = this.getAttribute("data-model-delete");
|
||
const dalias = this.getAttribute("data-model-alias");
|
||
showConfirmModal(
|
||
"Delete Model",
|
||
'Delete model "' + dalias + '"?',
|
||
"Delete",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/model-definitions/" + encodeURIComponent(did),
|
||
{
|
||
method: "DELETE",
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Model deleted");
|
||
_flagModelSyncPending();
|
||
loadAdminModels();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to delete model");
|
||
});
|
||
},
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _isPlainObject(v) {
|
||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||
}
|
||
|
||
function _toggleThinkingParam() {
|
||
const mode = document.getElementById("model-thinking-mode").value;
|
||
const row = document.getElementById("model-thinking-param-row");
|
||
row.hidden = !mode;
|
||
// Set default when first enabling
|
||
const paramEl = document.getElementById("model-thinking-param");
|
||
if (mode && !paramEl.value) paramEl.value = "enable_thinking";
|
||
}
|
||
|
||
function showCreateModelModal() {
|
||
document.getElementById("model-edit-id").value = "";
|
||
document.getElementById("model-create-title").textContent = "New model";
|
||
document.getElementById("model-shelf-tag").textContent = "MDL-NEW";
|
||
document.getElementById("model-shelf").setAttribute("data-kind", "create");
|
||
document.getElementById("model-create-submit").textContent = "Create";
|
||
document.getElementById("model-create-error").classList.remove("is-visible");
|
||
document.getElementById("model-alias").value = "";
|
||
document.getElementById("model-name").value = "";
|
||
document.getElementById("model-provider").value = "openai";
|
||
document.getElementById("model-base-url").value = "";
|
||
document.getElementById("model-api-key").value = "";
|
||
document.getElementById("model-api-key").placeholder = "sk-...";
|
||
document.getElementById("model-ctx-window").value = "0";
|
||
document.getElementById("model-temperature").value = "";
|
||
document.getElementById("model-max-tokens").value = "";
|
||
document.getElementById("model-reasoning-effort").value = "";
|
||
document.getElementById("model-server-type").value = "";
|
||
document.getElementById("model-api-surface").value = "";
|
||
document.getElementById("model-thinking-mode").value = "";
|
||
document.getElementById("model-thinking-param").value = "";
|
||
document.getElementById("model-thinking-param-row").hidden = true;
|
||
document.getElementById("model-effort-param").value = "";
|
||
_annotateEffortSelect(
|
||
document.getElementById("model-reasoning-effort"),
|
||
null,
|
||
);
|
||
document.getElementById("model-extra-body").value = "";
|
||
document.getElementById("model-capabilities").value = "";
|
||
// Clear validation error styling from prior submit attempts
|
||
["model-extra-body", "model-capabilities"].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
el.removeAttribute("aria-invalid");
|
||
el.style.borderColor = "";
|
||
});
|
||
document.getElementById("model-enabled").checked = true;
|
||
document.getElementById("model-surface-persisted-reasoning").checked = true;
|
||
document.getElementById("model-replay-reasoning").checked = false;
|
||
document.getElementById("model-detect-result").hidden = true;
|
||
document.getElementById("model-detect-btn").disabled = false;
|
||
document.getElementById("model-detect-btn").textContent = "Detect";
|
||
// Reranker calibration: reset extracted fields and hide the chip + the
|
||
// Re-calibrate button (edit mode re-shows them after loading the def).
|
||
_rerankCalFields = {};
|
||
const _calChip = document.getElementById("model-calibration-chip");
|
||
if (_calChip) _calChip.style.display = "none";
|
||
const _recalBtn = document.getElementById("model-recalibrate-btn");
|
||
if (_recalBtn) _recalBtn.hidden = true;
|
||
_modelCapsSeq++; // invalidate lookups from a prior shelf lifecycle
|
||
_modelCapsBaseline = {};
|
||
_modelCapsExplicit = {};
|
||
_resetModelResponseControls();
|
||
_modelRenderTiles();
|
||
document.getElementById("model-autofill").hidden = true;
|
||
_refreshModelSuggestions();
|
||
_applyProviderDefaults();
|
||
window.TurnstoneHatch.openShelf(document.getElementById("model-shelf"));
|
||
document.getElementById("model-alias").focus();
|
||
}
|
||
|
||
function showEditModelModal(definitionId) {
|
||
authFetch(
|
||
"/v1/api/admin/model-definitions/" + encodeURIComponent(definitionId),
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (m) {
|
||
showCreateModelModal();
|
||
document.getElementById("model-edit-id").value = definitionId;
|
||
document.getElementById("model-create-title").textContent =
|
||
"Edit model — " + (m.alias || definitionId);
|
||
document.getElementById("model-shelf-tag").textContent = "MDL-EDIT";
|
||
document.getElementById("model-shelf").setAttribute("data-kind", "edit");
|
||
document.getElementById("model-create-submit").textContent = "Save";
|
||
document.getElementById("model-alias").value = m.alias || "";
|
||
document.getElementById("model-name").value = m.model || "";
|
||
document.getElementById("model-provider").value = m.provider || "openai";
|
||
document.getElementById("model-base-url").value = m.base_url || "";
|
||
document.getElementById("model-api-key").value = "";
|
||
document.getElementById("model-api-key").placeholder =
|
||
"\u2022\u2022\u2022 (leave blank to keep existing)";
|
||
document.getElementById("model-ctx-window").value =
|
||
m.context_window != null ? m.context_window : 0;
|
||
document.getElementById("model-temperature").value =
|
||
m.temperature != null ? m.temperature : "";
|
||
document.getElementById("model-max-tokens").value =
|
||
m.max_tokens != null ? m.max_tokens : "";
|
||
document.getElementById("model-reasoning-effort").value =
|
||
m.reasoning_effort != null ? m.reasoning_effort : "";
|
||
// Parse capabilities JSON and extract server_compat for structured fields
|
||
let capsObj = {};
|
||
try {
|
||
capsObj = JSON.parse(m.capabilities || "{}");
|
||
} catch (e) {
|
||
/* keep empty */
|
||
}
|
||
// Defend against null/array/primitive values in the DB
|
||
if (!_isPlainObject(capsObj)) capsObj = {};
|
||
const sc = _isPlainObject(capsObj.server_compat)
|
||
? capsObj.server_compat
|
||
: {};
|
||
// Only extract thinking_mode into the dropdown when the UI can
|
||
// represent it ("", "manual" = effort-knob controlled, "adaptive" =
|
||
// always on). Unrepresentable/garbage values keep thinking_mode in
|
||
// the raw capabilities JSON so they aren't silently lost on save.
|
||
// Both compat lanes round-trip the dropdown: it drives the
|
||
// effort-knob → chat_template_kwargs mapping
|
||
// (merge_reasoning_template_kwargs).
|
||
const tmVal = capsObj.thinking_mode || "";
|
||
const tmCaptured =
|
||
tmVal === "" || tmVal === "manual" || tmVal === "adaptive";
|
||
if (tmCaptured) {
|
||
document.getElementById("model-thinking-mode").value = tmVal;
|
||
document.getElementById("model-thinking-param").value =
|
||
capsObj.thinking_param || "";
|
||
} else {
|
||
document.getElementById("model-thinking-mode").value = "";
|
||
document.getElementById("model-thinking-param").value = "";
|
||
}
|
||
_toggleThinkingParam();
|
||
// effort_param is a plain string — always representable, so it lifts
|
||
// into its field unconditionally (stripped from the raw JSON below).
|
||
document.getElementById("model-effort-param").value =
|
||
capsObj.effort_param || "";
|
||
// Server compat: server_type, api_surface, and extra_body workarounds
|
||
document.getElementById("model-server-type").value = sc.server_type || "";
|
||
document.getElementById("model-api-surface").value = sc.api_surface || "";
|
||
const eb = sc.extra_body || {};
|
||
const ebText = JSON.stringify(eb, null, 2);
|
||
document.getElementById("model-extra-body").value =
|
||
ebText === "{}" ? "" : ebText;
|
||
// Extract reranker calibration fields out of the displayed capabilities
|
||
// (like server_compat) so an unrelated edit doesn't drop them — held in
|
||
// _rerankCalFields and re-merged on save. Capture supports_rerank for the
|
||
// chip/button before it stays in the visible JSON.
|
||
const isReranker = !!capsObj.supports_rerank;
|
||
_rerankCalFields = {};
|
||
["rerank_threshold", "rerank_scale", "rerank_separated"].forEach(
|
||
function (k) {
|
||
if (k in capsObj) {
|
||
_rerankCalFields[k] = capsObj[k];
|
||
delete capsObj[k];
|
||
}
|
||
},
|
||
);
|
||
// Lift the capability keys out of the JSON into the tiles — they are
|
||
// the row's explicit overrides and the textarea holds the remainder.
|
||
_modelCapsExplicit = {};
|
||
_MODEL_CAP_KEYS.forEach(function (k) {
|
||
if (k in capsObj) {
|
||
_modelCapsExplicit[k] = !!capsObj[k];
|
||
delete capsObj[k];
|
||
}
|
||
});
|
||
_modelResponseInitialIdentity = _modelIdentity();
|
||
_modelResponseCurrentIdentity = _modelResponseInitialIdentity;
|
||
_captureModelResponseControls(capsObj);
|
||
_modelRenderTiles();
|
||
_modelCapsRefreshBaseline();
|
||
_scheduleEffortLadder();
|
||
// Remove structured fields from capabilities display — only delete
|
||
// thinking_mode/thinking_param when the UI successfully captured them.
|
||
delete capsObj.server_compat;
|
||
if (tmCaptured) {
|
||
delete capsObj.thinking_mode;
|
||
// Strip thinking_param only when a mode value round-trips through
|
||
// the dropdown. With mode unset the save path writes neither key,
|
||
// so a stored thinking_param must stay in the raw JSON — deleting
|
||
// it here would silently drop it on the next unrelated edit-save.
|
||
if (tmVal) delete capsObj.thinking_param;
|
||
}
|
||
// Strip effort_param only for the lanes whose save path re-adds it
|
||
// (the same provider gate) — on other providers the field is
|
||
// hidden and the save path never writes it, so deleting it here
|
||
// would silently drop a stored key on an unrelated edit-save.
|
||
if (
|
||
m.provider === "openai-compatible" ||
|
||
m.provider === "anthropic-compatible"
|
||
) {
|
||
delete capsObj.effort_param;
|
||
}
|
||
const capsText = JSON.stringify(capsObj, null, 2);
|
||
document.getElementById("model-capabilities").value =
|
||
capsText === "{}" ? "" : capsText;
|
||
// Calibration chip + Re-calibrate button: only for rerankers with an id.
|
||
_paintCalibrationChip(
|
||
Object.assign({ supports_rerank: isReranker }, _rerankCalFields),
|
||
);
|
||
const recalBtn = document.getElementById("model-recalibrate-btn");
|
||
if (recalBtn) recalBtn.hidden = !isReranker;
|
||
document.getElementById("model-enabled").checked = m.enabled !== false;
|
||
// Reasoning persistence flags — defaults match the dataclass
|
||
// defaults (persist=true, replay=false) when the API returns
|
||
// them as undefined (legacy / pre-052 row).
|
||
document.getElementById("model-surface-persisted-reasoning").checked =
|
||
m.surface_persisted_reasoning !== false;
|
||
document.getElementById("model-replay-reasoning").checked =
|
||
m.replay_reasoning_to_model === true;
|
||
_applyProviderDefaults();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to load model details");
|
||
});
|
||
}
|
||
|
||
function hideCreateModelModal() {
|
||
window.TurnstoneHatch.closeShelf(document.getElementById("model-shelf"));
|
||
}
|
||
|
||
function submitCreateModel() {
|
||
const alias = document.getElementById("model-alias").value.trim();
|
||
const modelName = document.getElementById("model-name").value.trim();
|
||
if (!alias) {
|
||
_showModelError("Alias is required");
|
||
return;
|
||
}
|
||
if (!modelName) {
|
||
_showModelError("Model ID is required");
|
||
return;
|
||
}
|
||
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
|
||
_showModelError("Alias must be alphanumeric (with . _ -)");
|
||
return;
|
||
}
|
||
|
||
const capsEl = document.getElementById("model-capabilities");
|
||
const capsText = capsEl.value.trim();
|
||
let caps = {};
|
||
capsEl.removeAttribute("aria-invalid");
|
||
capsEl.style.borderColor = "";
|
||
if (capsText) {
|
||
try {
|
||
caps = JSON.parse(capsText);
|
||
} catch (e) {
|
||
capsEl.setAttribute("aria-invalid", "true");
|
||
capsEl.style.borderColor = "var(--red)";
|
||
_showModelError("Invalid JSON in capabilities");
|
||
return;
|
||
}
|
||
if (!_isPlainObject(caps)) {
|
||
capsEl.setAttribute("aria-invalid", "true");
|
||
capsEl.style.borderColor = "var(--red)";
|
||
_showModelError(
|
||
"Capabilities must be a JSON object (not array or primitive)",
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const providerVal = document.getElementById("model-provider").value;
|
||
|
||
// Thinking mode → capabilities. thinking_mode round-trips through the
|
||
// dropdown for every provider, including anthropic-compatible (where
|
||
// manual mode maps the session effort knob onto the template's
|
||
// thinking toggle via chat_template_kwargs —
|
||
// merge_reasoning_template_kwargs).
|
||
const thinkingMode = document.getElementById("model-thinking-mode").value;
|
||
if (thinkingMode) {
|
||
caps.thinking_mode = thinkingMode;
|
||
// Preserve thinking_param so Granite/DeepSeek "thinking" key
|
||
// isn't silently reverted to the default "enable_thinking".
|
||
const savedParam = document.getElementById("model-thinking-param").value;
|
||
if (savedParam) caps.thinking_param = savedParam;
|
||
}
|
||
// Effort param (graded chat-template effort key, e.g. gpt-oss
|
||
// "reasoning_effort") round-trips like thinking_param: lifted out of
|
||
// the raw JSON on edit-load, re-added here when the field is set.
|
||
// Gated on the local-server lanes (like the server_compat block
|
||
// below): the field is hidden for other providers, so a lingering
|
||
// value from a provider switch must never persist.
|
||
if (
|
||
providerVal === "openai-compatible" ||
|
||
providerVal === "anthropic-compatible"
|
||
) {
|
||
const effortParam = document
|
||
.getElementById("model-effort-param")
|
||
.value.trim();
|
||
if (effortParam) caps.effort_param = effortParam;
|
||
}
|
||
|
||
// Build server_compat from structured fields. Only meaningful for the
|
||
// compat lanes (openai-compatible: all fields; anthropic-compatible: the
|
||
// extra-body JSON only) — for other providers the section is hidden
|
||
// but the form values can linger after a provider switch, so gate the
|
||
// whole block on the active provider to keep persisted state honest.
|
||
const serverCompat = {};
|
||
const ebEl = document.getElementById("model-extra-body");
|
||
ebEl.removeAttribute("aria-invalid");
|
||
ebEl.style.borderColor = "";
|
||
if (
|
||
providerVal === "openai-compatible" ||
|
||
providerVal === "anthropic-compatible"
|
||
) {
|
||
if (providerVal === "openai-compatible") {
|
||
const serverType = document.getElementById("model-server-type").value;
|
||
if (serverType) serverCompat.server_type = serverType;
|
||
const apiSurface = document.getElementById("model-api-surface").value;
|
||
if (apiSurface) serverCompat.api_surface = apiSurface;
|
||
}
|
||
const ebText = ebEl.value.trim();
|
||
if (ebText) {
|
||
try {
|
||
const ebParsed = JSON.parse(ebText);
|
||
if (!_isPlainObject(ebParsed)) {
|
||
throw new Error("not an object");
|
||
}
|
||
serverCompat.extra_body = ebParsed;
|
||
} catch (e) {
|
||
ebEl.setAttribute("aria-invalid", "true");
|
||
ebEl.style.borderColor = "var(--red)";
|
||
_showModelError("Extra body params must be a JSON object");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (Object.keys(serverCompat).length > 0) {
|
||
caps.server_compat = serverCompat;
|
||
}
|
||
|
||
// Capability tiles: persist exactly the explicit keys (saved-in-JSON +
|
||
// user-toggled). A key typed directly into the raw JSON wins (operator
|
||
// override) — same philosophy as the calibration re-merge below.
|
||
Object.keys(_modelCapsExplicit).forEach(function (k) {
|
||
if (!(k in caps)) caps[k] = _modelGetTile(k);
|
||
});
|
||
_mergeModelResponseControls(caps);
|
||
|
||
// Re-merge reranker calibration fields extracted on edit so an unrelated edit
|
||
// doesn't silently drop the calibration. A field typed directly into the
|
||
// capabilities JSON wins (operator override).
|
||
Object.keys(_rerankCalFields).forEach(function (k) {
|
||
if (!(k in caps)) caps[k] = _rerankCalFields[k];
|
||
});
|
||
|
||
const form = {
|
||
alias: alias,
|
||
model: modelName,
|
||
provider: document.getElementById("model-provider").value,
|
||
base_url: document.getElementById("model-base-url").value.trim(),
|
||
context_window:
|
||
parseInt(document.getElementById("model-ctx-window").value, 10) || 0,
|
||
capabilities: caps,
|
||
enabled: document.getElementById("model-enabled").checked,
|
||
};
|
||
|
||
// Per-model sampling overrides — null when empty (use global default)
|
||
const tempVal = document.getElementById("model-temperature").value.trim();
|
||
if (tempVal !== "") {
|
||
const t = parseFloat(tempVal);
|
||
if (isNaN(t) || t < 0 || t > 2) {
|
||
_showModelError("Temperature must be between 0 and 2");
|
||
return;
|
||
}
|
||
form.temperature = t;
|
||
} else {
|
||
form.temperature = null;
|
||
}
|
||
const mtVal = document.getElementById("model-max-tokens").value.trim();
|
||
if (mtVal !== "") {
|
||
const mt = parseInt(mtVal, 10);
|
||
if (isNaN(mt) || mt < 1) {
|
||
_showModelError("Max tokens must be at least 1");
|
||
return;
|
||
}
|
||
form.max_tokens = mt;
|
||
} else {
|
||
form.max_tokens = null;
|
||
}
|
||
const reVal = document.getElementById("model-reasoning-effort").value;
|
||
if (reVal !== "") {
|
||
form.reasoning_effort = reVal;
|
||
} else {
|
||
form.reasoning_effort = null;
|
||
}
|
||
|
||
// Reasoning persistence flags — always serialize so a flip from
|
||
// default takes effect on PUT (the server's update path keys off
|
||
// "field present in body").
|
||
form.surface_persisted_reasoning = document.getElementById(
|
||
"model-surface-persisted-reasoning",
|
||
).checked;
|
||
form.replay_reasoning_to_model = document.getElementById(
|
||
"model-replay-reasoning",
|
||
).checked;
|
||
|
||
const apiKey = document.getElementById("model-api-key").value;
|
||
if (apiKey) form.api_key = apiKey;
|
||
|
||
const editId = document.getElementById("model-edit-id").value;
|
||
const method = editId ? "PUT" : "POST";
|
||
const url = editId
|
||
? "/v1/api/admin/model-definitions/" + encodeURIComponent(editId)
|
||
: "/v1/api/admin/model-definitions";
|
||
|
||
window.TurnstoneHatch.setBusy(document.getElementById("model-shelf"), true);
|
||
authFetch(url, {
|
||
method: method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(form),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
hideCreateModelModal();
|
||
showToast(editId ? "Model updated" : "Model created");
|
||
_flagModelSyncPending();
|
||
loadAdminModels();
|
||
})
|
||
.catch(function (e) {
|
||
_showModelError(e.message);
|
||
})
|
||
.finally(function () {
|
||
window.TurnstoneHatch.setBusy(
|
||
document.getElementById("model-shelf"),
|
||
false,
|
||
);
|
||
});
|
||
}
|
||
|
||
function _showModelError(msg) {
|
||
const e = document.getElementById("model-create-error");
|
||
e.textContent = msg;
|
||
e.classList.add("is-visible");
|
||
}
|
||
|
||
function _detectResultLine(text, color) {
|
||
const div = document.createElement("div");
|
||
div.style.marginTop = "3px";
|
||
if (color) div.style.color = "var(--" + color + ")";
|
||
div.textContent = text;
|
||
return div;
|
||
}
|
||
|
||
// Pure: map a capabilities object to the reranker-calibration verdict chip.
|
||
// rerank_scale is the "has been calibrated" marker; with rerank_separated the
|
||
// per-model floor applies, without it the serving is suspect (no clean split).
|
||
function renderCalibrationVerdict(caps) {
|
||
const c = _isPlainObject(caps) ? caps : {};
|
||
if (c.rerank_scale && c.rerank_separated) {
|
||
return {
|
||
text: "✓ calibrated · floor " + Number(c.rerank_threshold).toFixed(2),
|
||
cls: "ok",
|
||
};
|
||
}
|
||
if (c.rerank_scale) {
|
||
return {
|
||
text: "⚠ no clean separation — check serving (--chat-template?)",
|
||
cls: "warn",
|
||
};
|
||
}
|
||
return { text: "not calibrated", cls: "muted" };
|
||
}
|
||
|
||
const _CALIBRATION_CHIP_COLORS = {
|
||
ok: "var(--green)",
|
||
warn: "var(--yellow)",
|
||
muted: "var(--fg-dim)",
|
||
};
|
||
|
||
// Paint (or hide) the calibration chip from a capabilities object. Hidden when
|
||
// the model is not a reranker (no supports_rerank) so non-rerank models are
|
||
// unaffected.
|
||
function _paintCalibrationChip(caps) {
|
||
const chip = document.getElementById("model-calibration-chip");
|
||
if (!chip) return;
|
||
const c = _isPlainObject(caps) ? caps : {};
|
||
if (!c.supports_rerank) {
|
||
chip.style.display = "none";
|
||
chip.textContent = "";
|
||
return;
|
||
}
|
||
const v = renderCalibrationVerdict(c);
|
||
chip.textContent = v.text;
|
||
chip.style.color = _CALIBRATION_CHIP_COLORS[v.cls] || "var(--fg-dim)";
|
||
chip.style.borderColor =
|
||
v.cls === "muted" ? "var(--border)" : _CALIBRATION_CHIP_COLORS[v.cls];
|
||
chip.style.display = "inline-block";
|
||
}
|
||
|
||
function _clearDetectResult() {
|
||
const rd = document.getElementById("model-detect-result");
|
||
if (rd) {
|
||
rd.hidden = true;
|
||
rd.textContent = "";
|
||
rd.style.borderColor = "";
|
||
}
|
||
}
|
||
|
||
// Best-effort: is the model being edited a reranker? Reads supports_rerank from
|
||
// the capabilities textarea (the chip/calibration flow keeps it there).
|
||
function _editingReranker() {
|
||
return _modelGetTile("supports_rerank");
|
||
}
|
||
|
||
function detectModel() {
|
||
const btn = document.getElementById("model-detect-btn");
|
||
const resultDiv = document.getElementById("model-detect-result");
|
||
const isReranker = _editingReranker();
|
||
btn.disabled = true;
|
||
btn.setAttribute("aria-busy", "true");
|
||
// Calibrate-on-detect adds a ~20s endpoint probe, so signal it.
|
||
btn.textContent = isReranker ? "Calibrating\u2026" : "Detecting\u2026";
|
||
resultDiv.hidden = true;
|
||
resultDiv.textContent = "";
|
||
|
||
const form = {
|
||
provider: document.getElementById("model-provider").value,
|
||
base_url: document.getElementById("model-base-url").value.trim(),
|
||
model: document.getElementById("model-name").value.trim(),
|
||
};
|
||
const apiKey = document.getElementById("model-api-key").value;
|
||
if (apiKey) form.api_key = apiKey;
|
||
const editId = document.getElementById("model-edit-id").value;
|
||
if (editId) form.definition_id = editId;
|
||
// Tell the server to also calibrate when this is a reranker.
|
||
if (isReranker) form.supports_rerank = true;
|
||
|
||
authFetch("/v1/api/admin/model-definitions/detect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(form),
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Detect failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
resultDiv.hidden = false;
|
||
resultDiv.textContent = "";
|
||
if (d.error && !d.reachable) {
|
||
resultDiv.appendChild(
|
||
_detectResultLine("\u2717 Failed: " + d.error, "red"),
|
||
);
|
||
resultDiv.style.borderColor = "var(--red)";
|
||
return;
|
||
}
|
||
let line1 = "\u2713 Connected";
|
||
if (d.available_models && d.available_models.length) {
|
||
line1 += " \u2014 " + d.available_models.length + " model(s) available";
|
||
}
|
||
resultDiv.appendChild(_detectResultLine(line1, "green"));
|
||
|
||
if (d.model_found === false) {
|
||
const models = d.available_models || [];
|
||
let msg =
|
||
'\u26A0 Model "' +
|
||
form.model +
|
||
'" not found in ' +
|
||
models.length +
|
||
" available model(s)";
|
||
if (models.length > 0) {
|
||
const shown = models.slice(0, 8);
|
||
msg += ": " + shown.join(", ");
|
||
if (models.length > 8)
|
||
msg += ", \u2026 +" + (models.length - 8) + " more";
|
||
}
|
||
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
|
||
}
|
||
|
||
if (d.available_models && d.available_models.length > 0) {
|
||
const dl = document.getElementById("model-name-suggestions");
|
||
if (dl) {
|
||
dl.textContent = "";
|
||
d.available_models.forEach(function (m) {
|
||
const opt = document.createElement("option");
|
||
opt.value = m;
|
||
dl.appendChild(opt);
|
||
});
|
||
}
|
||
}
|
||
if (d.context_window) {
|
||
resultDiv.appendChild(
|
||
_detectResultLine(
|
||
"Context window: " + d.context_window.toLocaleString() + " tokens",
|
||
),
|
||
);
|
||
const ctxInput = document.getElementById("model-ctx-window");
|
||
if (parseInt(ctxInput.value, 10) === 0) {
|
||
ctxInput.value = d.context_window;
|
||
}
|
||
}
|
||
if (d.server_type) {
|
||
resultDiv.appendChild(
|
||
_detectResultLine("Server type: " + d.server_type),
|
||
);
|
||
// Auto-fill server type if not already set and value is a known option
|
||
const stEl = document.getElementById("model-server-type");
|
||
const stOpts = Array.from(stEl.options).map(function (o) {
|
||
return o.value;
|
||
});
|
||
if (!stEl.value && stOpts.indexOf(d.server_type) !== -1)
|
||
stEl.value = d.server_type;
|
||
}
|
||
// Auto-fill capabilities from suggested profile
|
||
if (d.suggested_capabilities) {
|
||
const sc2 = d.suggested_capabilities;
|
||
const tmEl = document.getElementById("model-thinking-mode");
|
||
if (!tmEl.value && sc2.thinking_mode) {
|
||
tmEl.value = sc2.thinking_mode;
|
||
}
|
||
if (sc2.thinking_param) {
|
||
const tpEl = document.getElementById("model-thinking-param");
|
||
if (!tpEl.value) tpEl.value = sc2.thinking_param;
|
||
}
|
||
_toggleThinkingParam();
|
||
}
|
||
// Auto-fill server compat from suggested profile
|
||
if (d.suggested_server_compat) {
|
||
const ssc = d.suggested_server_compat;
|
||
const stEl2 = document.getElementById("model-server-type");
|
||
const stOpts2 = Array.from(stEl2.options).map(function (o) {
|
||
return o.value;
|
||
});
|
||
if (
|
||
!stEl2.value &&
|
||
ssc.server_type &&
|
||
stOpts2.indexOf(ssc.server_type) !== -1
|
||
)
|
||
stEl2.value = ssc.server_type;
|
||
// Restrict to the known set so a hostile detect response can't
|
||
// smuggle a non-listed value into the form.
|
||
const _SURFACE_SUGGESTABLE = { chat: 1, responses: 1 };
|
||
if (ssc.api_surface && _SURFACE_SUGGESTABLE[ssc.api_surface]) {
|
||
const asEl = document.getElementById("model-api-surface");
|
||
if (!asEl.value) asEl.value = ssc.api_surface;
|
||
}
|
||
if (ssc.extra_body) {
|
||
const ebEl2 = document.getElementById("model-extra-body");
|
||
if (!ebEl2.value.trim()) {
|
||
const ebJson = JSON.stringify(ssc.extra_body, null, 2);
|
||
if (ebJson !== "{}") ebEl2.value = ebJson;
|
||
}
|
||
}
|
||
}
|
||
if (d.suggested_capabilities || d.suggested_server_compat) {
|
||
resultDiv.appendChild(
|
||
_detectResultLine("\u2713 Compatibility profile suggested", "green"),
|
||
);
|
||
}
|
||
// Calibrate-on-detect result: merge the returned calibration fields so a
|
||
// subsequent save persists them, and paint the chip. The note covers the
|
||
// graceful "could not calibrate" case (endpoint unreachable as a rerank).
|
||
if (isReranker) {
|
||
if (_isPlainObject(d.capabilities)) {
|
||
["rerank_threshold", "rerank_scale", "rerank_separated"].forEach(
|
||
function (k) {
|
||
if (k in d.capabilities) _rerankCalFields[k] = d.capabilities[k];
|
||
},
|
||
);
|
||
}
|
||
_paintCalibrationChip(
|
||
Object.assign({ supports_rerank: true }, _rerankCalFields),
|
||
);
|
||
if (d.rerank_calibration_note) {
|
||
resultDiv.appendChild(
|
||
_detectResultLine("\u26a0 " + d.rerank_calibration_note, "yellow"),
|
||
);
|
||
}
|
||
}
|
||
resultDiv.style.borderColor = "var(--green)";
|
||
})
|
||
.catch(function (e) {
|
||
if (e.message === "auth") return;
|
||
resultDiv.hidden = false;
|
||
resultDiv.textContent = "";
|
||
resultDiv.appendChild(_detectResultLine("\u2717 " + e.message, "red"));
|
||
resultDiv.style.borderColor = "var(--red)";
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.removeAttribute("aria-busy");
|
||
btn.textContent = "Detect";
|
||
});
|
||
}
|
||
|
||
// Re-calibrate a saved reranker: POST to its calibrate endpoint (persists the
|
||
// floor server-side), then re-render the chip from the verdict.
|
||
function recalibrateModel() {
|
||
const editId = document.getElementById("model-edit-id").value;
|
||
if (!editId) return;
|
||
const btn = document.getElementById("model-recalibrate-btn");
|
||
const resultDiv = document.getElementById("model-detect-result");
|
||
btn.disabled = true;
|
||
btn.setAttribute("aria-busy", "true");
|
||
btn.textContent = "Calibrating…";
|
||
authFetch(
|
||
"/v1/api/admin/model-definitions/" +
|
||
encodeURIComponent(editId) +
|
||
"/calibrate",
|
||
{ method: "POST", headers: { "Content-Type": "application/json" } },
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r.json().then(function (d) {
|
||
throw new Error(d.error || "Calibrate failed");
|
||
});
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
resultDiv.hidden = false;
|
||
resultDiv.textContent = "";
|
||
if (d.error) {
|
||
resultDiv.appendChild(_detectResultLine("✗ " + d.error, "red"));
|
||
resultDiv.style.borderColor = "var(--red)";
|
||
// Nothing was persisted — keep the prior (still-valid) verdict on the
|
||
// chip by repainting from the retained fields rather than wiping it.
|
||
_paintCalibrationChip(
|
||
Object.assign({ supports_rerank: true }, _rerankCalFields),
|
||
);
|
||
return;
|
||
}
|
||
// Persisted server-side; mirror into _rerankCalFields + the chip.
|
||
_rerankCalFields.rerank_scale = d.raw_scale;
|
||
_rerankCalFields.rerank_separated = d.separated;
|
||
_rerankCalFields.rerank_threshold =
|
||
d.suggested_threshold != null ? d.suggested_threshold : 0;
|
||
_paintCalibrationChip(
|
||
Object.assign({ supports_rerank: true }, _rerankCalFields),
|
||
);
|
||
resultDiv.appendChild(
|
||
_detectResultLine(
|
||
d.separated
|
||
? "✓ Calibrated · floor " +
|
||
Number(_rerankCalFields.rerank_threshold).toFixed(2)
|
||
: "⚠ No clean separation — check serving",
|
||
d.separated ? "green" : "yellow",
|
||
),
|
||
);
|
||
resultDiv.style.borderColor = d.separated
|
||
? "var(--green)"
|
||
: "var(--yellow)";
|
||
})
|
||
.catch(function (e) {
|
||
if (e.message === "auth") return;
|
||
resultDiv.hidden = false;
|
||
resultDiv.textContent = "";
|
||
resultDiv.appendChild(_detectResultLine("✗ " + e.message, "red"));
|
||
resultDiv.style.borderColor = "var(--red)";
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.removeAttribute("aria-busy");
|
||
btn.textContent = "Re-calibrate";
|
||
});
|
||
}
|
||
|
||
/* Capability auto-fill: when the user types a known model name or
|
||
changes the provider, look up static capabilities and refresh the
|
||
context window, capability tiles, and conditional response controls. */
|
||
let _capsTimer = null;
|
||
let _modelCapsSeq = 0;
|
||
function _onModelFieldChange() {
|
||
clearTimeout(_capsTimer);
|
||
const nextIdentity = _modelIdentity();
|
||
if (
|
||
_modelResponseCurrentIdentity &&
|
||
nextIdentity !== _modelResponseCurrentIdentity
|
||
) {
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
const select = document.getElementById(spec.elementId);
|
||
if (!select) return;
|
||
const captured = _modelResponseCaptured[spec.key];
|
||
select.value =
|
||
nextIdentity === _modelResponseInitialIdentity &&
|
||
_modelResponseValueValid(spec, captured)
|
||
? captured
|
||
: "";
|
||
});
|
||
}
|
||
_modelResponseCurrentIdentity = nextIdentity;
|
||
_modelCapsSeq++; // invalidate any capability lookup already in flight
|
||
_modelCapsBaseline = {};
|
||
const banner = document.getElementById("model-autofill");
|
||
if (banner) banner.hidden = true;
|
||
_modelRenderTiles();
|
||
_capsTimer = setTimeout(_modelCapsRefreshBaseline, 500);
|
||
_scheduleEffortLadder();
|
||
}
|
||
|
||
/* Effort-ladder annotation: each knob position states, in plain words,
|
||
what the request will carry — from the server-computed projection
|
||
(providers/effort_ladder.py — equal "effective" tokens ⇒ identical
|
||
requests). A position whose delivered level matches its name stays
|
||
plain ("Max"); a snapped position says so ("Low — sends high"); the
|
||
adaptive none position warns "thinking stays on"; budget detail
|
||
lives in the tooltip. Never label a position after a sibling that
|
||
shares its token — that rendered "Max (= minimal)", implying a
|
||
downgrade the wire doesn't contain. Defined here and shared as a
|
||
page global with governance.js (skill launch config), which loads
|
||
after this file. */
|
||
function _annotateEffortSelect(sel, ladder) {
|
||
if (!sel) return;
|
||
const byVal = {};
|
||
(ladder || []).forEach(function (row) {
|
||
byVal[row.value] = row.effective;
|
||
});
|
||
Array.from(sel.options).forEach(function (opt) {
|
||
if (!opt.value) return; // "" = inherit-the-default option
|
||
if (!opt.dataset.baseLabel) opt.dataset.baseLabel = opt.textContent;
|
||
let label = opt.dataset.baseLabel;
|
||
let title = "";
|
||
const eff = byVal[opt.value];
|
||
if (eff !== undefined) {
|
||
title = "sends: " + eff;
|
||
if (eff === "default") {
|
||
label += " — model default";
|
||
} else if (eff === "on") {
|
||
// Local adaptive lane, none position: we send only the thinking
|
||
// toggle (no effort grade exists), so the knob can't turn
|
||
// thinking off — that is the whole story here.
|
||
label += " — thinking stays on";
|
||
} else if (eff === "adaptive") {
|
||
// Native Anthropic adaptive: thinking is ALWAYS on and an effort
|
||
// level rides output_config on every graded position, so
|
||
// "thinking stays on" is true everywhere and not what sets the
|
||
// none position apart. What "none" uniquely means is that no
|
||
// effort is pinned — the model self-regulates it.
|
||
label += " — model sets effort";
|
||
} else if (eff !== "off") {
|
||
// "off" needs no echo on the none position. Otherwise strip
|
||
// the toggle prefix and budget qualifier ("on+high" → "high",
|
||
// "high·budget:16384" → "high", bare "budget:4096" → "" = no
|
||
// suffix); the tooltip keeps the full token.
|
||
const wire = eff.replace(/^on\+/, "").replace(/(^|·)budget:\d+$/, "");
|
||
if (wire && wire !== opt.value) label += " — sends " + wire;
|
||
}
|
||
}
|
||
opt.textContent = label;
|
||
opt.title = title;
|
||
});
|
||
}
|
||
|
||
let _effortLadderTimer = null;
|
||
let _effortLadderSeq = 0;
|
||
function _scheduleEffortLadder() {
|
||
clearTimeout(_effortLadderTimer);
|
||
_effortLadderTimer = setTimeout(_refreshModelEffortLadder, 500);
|
||
}
|
||
function _refreshModelEffortLadder() {
|
||
const shelf = document.getElementById("model-shelf");
|
||
const sel = document.getElementById("model-reasoning-effort");
|
||
if (!shelf || !shelf.open || !sel) return;
|
||
const provider = document.getElementById("model-provider").value;
|
||
const modelName = document.getElementById("model-name").value.trim();
|
||
if (!modelName) {
|
||
_effortLadderSeq++; // invalidate any in-flight response
|
||
_annotateEffortSelect(sel, null);
|
||
return;
|
||
}
|
||
// Assemble the same capabilities the save path would persist: raw
|
||
// JSON base, structured thinking/effort fields overlaid. Mid-edit
|
||
// invalid JSON annotates from the structured fields alone.
|
||
let caps = {};
|
||
const rawText = document.getElementById("model-capabilities").value.trim();
|
||
if (rawText) {
|
||
try {
|
||
const parsed = JSON.parse(rawText);
|
||
if (_isPlainObject(parsed)) caps = parsed;
|
||
} catch (e) {
|
||
/* fall through */
|
||
}
|
||
}
|
||
const tm = document.getElementById("model-thinking-mode").value;
|
||
if (tm) {
|
||
caps.thinking_mode = tm;
|
||
const tp = document.getElementById("model-thinking-param").value;
|
||
if (tp) caps.thinking_param = tp;
|
||
}
|
||
const ep = document.getElementById("model-effort-param").value.trim();
|
||
if (ep) caps.effort_param = ep;
|
||
const seq = ++_effortLadderSeq;
|
||
authFetch("/v1/api/admin/models/effort-ladder", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
provider: provider,
|
||
model: modelName,
|
||
capabilities: caps,
|
||
api_surface: document.getElementById("model-api-surface").value || "",
|
||
}),
|
||
})
|
||
.then(function (r) {
|
||
return r.ok ? r.json() : null;
|
||
})
|
||
.then(function (d) {
|
||
if (seq !== _effortLadderSeq) return; // superseded by a newer edit
|
||
_annotateEffortSelect(sel, d && d.ladder);
|
||
})
|
||
.catch(function () {
|
||
/* silent — annotation only */
|
||
});
|
||
}
|
||
|
||
/* Known-model lookup feeding the tile matrix: the table becomes the display
|
||
BASELINE (with the provenance banner), explicit overrides stay on top, and
|
||
nothing is written into the raw JSON — known models keep tracking the
|
||
table at runtime instead of being pinned at save time. */
|
||
function _modelCapsRefreshBaseline() {
|
||
const shelf = document.getElementById("model-shelf");
|
||
if (!shelf || !shelf.open) return;
|
||
const provider = document.getElementById("model-provider").value;
|
||
const modelName = document.getElementById("model-name").value.trim();
|
||
const banner = document.getElementById("model-autofill");
|
||
const seq = ++_modelCapsSeq;
|
||
if (
|
||
!modelName ||
|
||
provider === "openai-compatible" ||
|
||
provider === "anthropic-compatible"
|
||
) {
|
||
_modelCapsBaseline = {};
|
||
banner.hidden = true;
|
||
_modelRenderTiles();
|
||
return;
|
||
}
|
||
// Two type-then-pause cycles can have both fetches in flight; a reordered
|
||
// older response must not clobber the tiles (the _schPreviewSeq pattern).
|
||
authFetch(
|
||
"/v1/api/admin/model-capabilities?provider=" +
|
||
encodeURIComponent(provider) +
|
||
"&model=" +
|
||
encodeURIComponent(modelName),
|
||
)
|
||
.then(function (r) {
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
if (seq !== _modelCapsSeq) return; // superseded by a newer edit
|
||
if (!d.known || !d.capabilities) {
|
||
_modelCapsBaseline = {};
|
||
banner.hidden = true;
|
||
_modelRenderTiles();
|
||
return;
|
||
}
|
||
const ctxInput = document.getElementById("model-ctx-window");
|
||
if (parseInt(ctxInput.value, 10) === 0 && d.capabilities.context_window) {
|
||
ctxInput.value = d.capabilities.context_window;
|
||
}
|
||
_modelCapsBaseline = {};
|
||
_MODEL_CAP_KEYS.forEach(function (k) {
|
||
if (k in d.capabilities) _modelCapsBaseline[k] = !!d.capabilities[k];
|
||
});
|
||
document.getElementById("model-autofill-name").textContent = modelName;
|
||
banner.hidden = false;
|
||
_modelRenderTiles();
|
||
})
|
||
.catch(function () {
|
||
/* silent */
|
||
});
|
||
}
|
||
/* Provider-specific placeholder hints for base_url and model ID fields.
|
||
Keep URLs in sync with _PROVIDER_DEFAULT_URLS in console/server.py
|
||
and GOOGLE_DEFAULT_BASE_URL in core/providers/_google.py. */
|
||
const _providerDefaults = {
|
||
openai: {
|
||
urlPlaceholder: "https://api.openai.com/v1",
|
||
modelPlaceholder: "gpt-5",
|
||
},
|
||
anthropic: {
|
||
urlPlaceholder: "https://api.anthropic.com",
|
||
modelPlaceholder: "claude-",
|
||
},
|
||
google: {
|
||
urlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||
modelPlaceholder: "gemini-",
|
||
},
|
||
"openai-compatible": {
|
||
urlPlaceholder: "e.g. https://your-provider.com/v1",
|
||
modelPlaceholder: "GLM5",
|
||
},
|
||
"anthropic-compatible": {
|
||
urlPlaceholder: "e.g. http://your-vllm-host:8000",
|
||
modelPlaceholder: "deepseek-ai/DeepSeek-V4-Flash",
|
||
},
|
||
};
|
||
|
||
/* Update placeholders when provider changes. */
|
||
function _applyProviderDefaults() {
|
||
const provider = document.getElementById("model-provider").value;
|
||
const def = _providerDefaults[provider];
|
||
if (!def) return;
|
||
document.getElementById("model-base-url").placeholder = def.urlPlaceholder;
|
||
document.getElementById("model-name").placeholder = def.modelPlaceholder;
|
||
// Server compat section only applies to local model servers
|
||
const scSection = document.getElementById("model-server-compat-section");
|
||
if (scSection) {
|
||
// hidden attr, not style.display — `.hatch [hidden]` is !important and
|
||
// an inline display can never un-hide it.
|
||
scSection.hidden =
|
||
provider !== "openai-compatible" && provider !== "anthropic-compatible";
|
||
}
|
||
// Within the section, server type / API surface are openai-compatible
|
||
// knobs (Detect heuristics + chat-vs-responses surface pick) and stay
|
||
// hidden on the anthropic-compatible lane. Thinking mode applies to
|
||
// BOTH compat lanes: on anthropic-compatible it opts the model into
|
||
// the effort-knob → chat_template_kwargs mapping.
|
||
const serverFieldsRow = document.getElementById("model-server-fields-row");
|
||
if (serverFieldsRow) {
|
||
serverFieldsRow.hidden = provider === "anthropic-compatible";
|
||
}
|
||
_updateModelResponseControls();
|
||
}
|
||
|
||
/* Populate the model name datalist with known model prefixes for the
|
||
selected provider. Called on page load and provider change. */
|
||
function _refreshModelSuggestions() {
|
||
const dl = document.getElementById("model-name-suggestions");
|
||
if (!dl) return;
|
||
const provider = document.getElementById("model-provider").value;
|
||
authFetch(
|
||
"/v1/api/admin/model-capabilities/known?provider=" +
|
||
encodeURIComponent(provider),
|
||
)
|
||
.then(function (r) {
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
dl.textContent = "";
|
||
(d.models || []).forEach(function (m) {
|
||
const opt = document.createElement("option");
|
||
opt.value = m;
|
||
dl.appendChild(opt);
|
||
});
|
||
})
|
||
.catch(function () {
|
||
dl.textContent = "";
|
||
});
|
||
}
|
||
|
||
/* Register listeners once at page load */
|
||
(function () {
|
||
const confirmBtn = document.getElementById("confirm-submit");
|
||
if (confirmBtn) confirmBtn.addEventListener("click", _confirmCallback);
|
||
const tcCopy = document.getElementById("tc-copy");
|
||
if (tcCopy) tcCopy.addEventListener("click", copyCreatedToken);
|
||
const nameEl = document.getElementById("model-name");
|
||
const provEl = document.getElementById("model-provider");
|
||
const tmEl = document.getElementById("model-thinking-mode");
|
||
if (tmEl) tmEl.addEventListener("change", _toggleThinkingParam);
|
||
if (tmEl) tmEl.addEventListener("change", _scheduleEffortLadder);
|
||
_MODEL_RESPONSE_CONTROLS.forEach(function (spec) {
|
||
const select = document.getElementById(spec.elementId);
|
||
if (select)
|
||
select.addEventListener("change", function () {
|
||
_rememberModelResponseControl(spec);
|
||
});
|
||
});
|
||
["model-thinking-param", "model-effort-param", "model-capabilities"].forEach(
|
||
function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener("input", _scheduleEffortLadder);
|
||
},
|
||
);
|
||
const rawCapsEl = document.getElementById("model-capabilities");
|
||
if (rawCapsEl)
|
||
rawCapsEl.addEventListener("input", function () {
|
||
_modelResponseDirty = {};
|
||
});
|
||
const apiSurfEl = document.getElementById("model-api-surface");
|
||
if (apiSurfEl) apiSurfEl.addEventListener("change", _onModelFieldChange);
|
||
const grid = document.getElementById("model-capgrid");
|
||
if (grid) {
|
||
grid.addEventListener("change", function (e) {
|
||
const cap = e.target && e.target.getAttribute("data-cap");
|
||
if (!cap) return;
|
||
// a toggle IS the override decision — the key persists from here on
|
||
_modelCapsExplicit[cap] = e.target.checked;
|
||
if (cap === "supports_verbosity" || cap === "supports_pro_mode") {
|
||
const spec = _MODEL_RESPONSE_CONTROLS.find(function (item) {
|
||
return item.supportKey === cap;
|
||
});
|
||
if (spec && !e.target.checked) {
|
||
const select = document.getElementById(spec.elementId);
|
||
if (select) select.value = "";
|
||
delete _modelResponseCaptured[spec.key];
|
||
}
|
||
_updateModelResponseControls();
|
||
}
|
||
if (cap === "supports_rerank") {
|
||
const recalBtn = document.getElementById("model-recalibrate-btn");
|
||
if (recalBtn)
|
||
recalBtn.hidden = !(
|
||
e.target.checked && document.getElementById("model-edit-id").value
|
||
);
|
||
_paintCalibrationChip(
|
||
Object.assign(
|
||
{ supports_rerank: e.target.checked },
|
||
_rerankCalFields,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
const modelSubmit = document.getElementById("model-create-submit");
|
||
if (modelSubmit) modelSubmit.addEventListener("click", submitCreateModel);
|
||
const detectBtn = document.getElementById("model-detect-btn");
|
||
if (detectBtn) detectBtn.addEventListener("click", detectModel);
|
||
const recalBtn2 = document.getElementById("model-recalibrate-btn");
|
||
if (recalBtn2) recalBtn2.addEventListener("click", recalibrateModel);
|
||
if (nameEl) nameEl.addEventListener("input", _onModelFieldChange);
|
||
if (provEl) {
|
||
provEl.addEventListener("change", _onModelFieldChange);
|
||
provEl.addEventListener("change", _refreshModelSuggestions);
|
||
provEl.addEventListener("change", _clearDetectResult);
|
||
provEl.addEventListener("change", _applyProviderDefaults);
|
||
}
|
||
/* Clear stale detect results when probe-relevant inputs change */
|
||
["model-base-url", "model-api-key"].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener("input", _clearDetectResult);
|
||
});
|
||
})();
|
||
|
||
function _flagModelSyncPending() {
|
||
const btn = document.getElementById("model-sync-btn");
|
||
if (btn) btn.classList.add("model-sync-pending");
|
||
}
|
||
function _clearModelSyncPending() {
|
||
const btn = document.getElementById("model-sync-btn");
|
||
if (btn) btn.classList.remove("model-sync-pending");
|
||
}
|
||
|
||
function reloadModelNodes() {
|
||
const btn = document.getElementById("model-sync-btn");
|
||
btn.disabled = true;
|
||
btn.textContent = "Syncing...";
|
||
authFetch("/v1/api/admin/model-definitions/reload", { method: "POST" })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error();
|
||
return r.json();
|
||
})
|
||
.then(function () {
|
||
showToast("Model reload dispatched");
|
||
_clearModelSyncPending();
|
||
loadAdminModels();
|
||
})
|
||
.catch(function () {
|
||
showToast("Failed to sync models");
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.textContent = "Sync to Nodes";
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Node Metadata tab
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _nodeMetaCache = {};
|
||
|
||
function loadAdminNodeMetadata() {
|
||
const container = document.getElementById("admin-node-metadata-content");
|
||
if (!container) return;
|
||
setSafeHtml(container, '<div class="dashboard-empty">Loading\u2026</div>');
|
||
|
||
// Single bulk fetch for all node metadata
|
||
authFetch("/v1/api/admin/node-metadata")
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("Failed");
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
_nodeMetaCache = data.nodes || {};
|
||
_renderNodeMetadata();
|
||
})
|
||
.catch(function () {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">Failed to load node metadata</div>',
|
||
);
|
||
});
|
||
}
|
||
|
||
function _renderNodeMetadata() {
|
||
const container = document.getElementById("admin-node-metadata-content");
|
||
if (!container) return;
|
||
const nodeIds = Object.keys(_nodeMetaCache).sort();
|
||
if (!nodeIds.length) {
|
||
setSafeHtml(
|
||
container,
|
||
'<div class="dashboard-empty">No nodes registered</div>',
|
||
);
|
||
return;
|
||
}
|
||
|
||
let html = "";
|
||
nodeIds.forEach(function (nid) {
|
||
const meta = _nodeMetaCache[nid] || [];
|
||
html +=
|
||
'<div class="settings-section" data-section="nm-' +
|
||
escapeHtml(nid) +
|
||
'" data-collapsed>';
|
||
html +=
|
||
'<div class="settings-section-header" role="button" tabindex="0" aria-expanded="false" ';
|
||
html += 'aria-controls="nm-body-' + escapeHtml(nid) + '">';
|
||
html +=
|
||
"<span>" +
|
||
escapeHtml(nid) +
|
||
" <small>(" +
|
||
meta.length +
|
||
" keys)</small></span>";
|
||
html += "</div>";
|
||
html +=
|
||
'<div class="settings-section-body" id="nm-body-' +
|
||
escapeHtml(nid) +
|
||
'">';
|
||
|
||
// Table of metadata — all values passed through escapeHtml()
|
||
if (meta.length) {
|
||
html += '<table class="nm-table">';
|
||
html +=
|
||
'<caption class="sr-only">Metadata for node ' +
|
||
escapeHtml(nid) +
|
||
"</caption>";
|
||
html += '<thead><tr><th scope="col">Key</th>';
|
||
html += '<th scope="col">Value</th>';
|
||
html += '<th scope="col">Source</th>';
|
||
html +=
|
||
'<th scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>';
|
||
meta.forEach(function (m) {
|
||
const valStr =
|
||
typeof m.value === "object"
|
||
? JSON.stringify(m.value)
|
||
: String(m.value);
|
||
const isAuto = m.source === "auto";
|
||
html += "<tr>";
|
||
html += '<td class="nm-key">' + escapeHtml(m.key) + "</td>";
|
||
html +=
|
||
'<td class="nm-val" title="' +
|
||
escapeHtml(valStr) +
|
||
'">' +
|
||
escapeHtml(valStr) +
|
||
"</td>";
|
||
html +=
|
||
'<td><span class="nm-source-badge nm-source-' +
|
||
escapeHtml(m.source) +
|
||
'">' +
|
||
escapeHtml(m.source) +
|
||
"</span></td>";
|
||
html += "<td>";
|
||
if (!isAuto) {
|
||
html +=
|
||
'<button class="admin-btn-danger nm-del-btn" aria-label="Delete ' +
|
||
escapeHtml(m.key) +
|
||
'" data-node="' +
|
||
escapeHtml(nid) +
|
||
'" data-key="' +
|
||
escapeHtml(m.key) +
|
||
'">Del</button>';
|
||
}
|
||
html += "</td></tr>";
|
||
});
|
||
html += "</tbody></table>";
|
||
} else {
|
||
html +=
|
||
'<div class="dashboard-empty" style="padding:8px">No metadata</div>';
|
||
}
|
||
|
||
// Add metadata form
|
||
html += '<div class="nm-add-row">';
|
||
html +=
|
||
'<input id="nm-key-' +
|
||
escapeHtml(nid) +
|
||
'" type="text" placeholder="key" aria-label="Metadata key">';
|
||
html +=
|
||
'<input id="nm-val-' +
|
||
escapeHtml(nid) +
|
||
'" type="text" placeholder="value (JSON or string)" aria-label="Metadata value">';
|
||
html +=
|
||
'<button class="admin-btn-action nm-add-btn" data-node="' +
|
||
escapeHtml(nid) +
|
||
'" style="white-space:nowrap">Add</button>';
|
||
html += "</div>";
|
||
|
||
html += "</div></div>";
|
||
});
|
||
setSafeHtml(container, html);
|
||
|
||
// Bind section-header click/keydown (no inline handlers — keys come
|
||
// from operator-controlled node IDs and would be a footgun in a
|
||
// ``onclick="..._('NID')"`` attribute).
|
||
const nmHeaders = container.querySelectorAll(".settings-section-header");
|
||
for (let nh = 0; nh < nmHeaders.length; nh++) {
|
||
nmHeaders[nh].addEventListener("click", function () {
|
||
_toggleSettingsSection(this);
|
||
});
|
||
nmHeaders[nh].addEventListener("keydown", function (e) {
|
||
_onSettingsHeaderKey(e, this);
|
||
});
|
||
}
|
||
|
||
// Bind button handlers (data-* attrs carry node/key context)
|
||
const delBtns = container.querySelectorAll(".nm-del-btn");
|
||
for (let d = 0; d < delBtns.length; d++) {
|
||
delBtns[d].addEventListener("click", function () {
|
||
_deleteNodeMeta(
|
||
this.getAttribute("data-node"),
|
||
this.getAttribute("data-key"),
|
||
);
|
||
});
|
||
}
|
||
const addBtns = container.querySelectorAll(".nm-add-btn");
|
||
for (let a = 0; a < addBtns.length; a++) {
|
||
addBtns[a].addEventListener("click", function () {
|
||
_addNodeMeta(this.getAttribute("data-node"));
|
||
});
|
||
}
|
||
}
|
||
|
||
function _addNodeMeta(nodeId) {
|
||
const keyEl = document.getElementById("nm-key-" + nodeId);
|
||
const valEl = document.getElementById("nm-val-" + nodeId);
|
||
if (!keyEl || !valEl) return;
|
||
const key = keyEl.value.trim();
|
||
const rawVal = valEl.value.trim();
|
||
if (!key) {
|
||
showToast("Key is required", "error");
|
||
return;
|
||
}
|
||
|
||
let value;
|
||
try {
|
||
value = JSON.parse(rawVal);
|
||
} catch (e) {
|
||
value = rawVal;
|
||
}
|
||
|
||
authFetch(
|
||
"/v1/api/admin/nodes/" +
|
||
encodeURIComponent(nodeId) +
|
||
"/metadata/" +
|
||
encodeURIComponent(key),
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ value: value }),
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r
|
||
.json()
|
||
.catch(function () {
|
||
return {};
|
||
})
|
||
.then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
showToast("Metadata set");
|
||
loadAdminNodeMetadata();
|
||
})
|
||
.catch(function (e) {
|
||
showToast(e.message, "error");
|
||
});
|
||
}
|
||
|
||
function _deleteNodeMeta(nodeId, key) {
|
||
showConfirmModal(
|
||
"Delete Metadata",
|
||
'Delete key "' + key + '" from node ' + nodeId + "?",
|
||
"Delete",
|
||
function () {
|
||
authFetch(
|
||
"/v1/api/admin/nodes/" +
|
||
encodeURIComponent(nodeId) +
|
||
"/metadata/" +
|
||
encodeURIComponent(key),
|
||
{
|
||
method: "DELETE",
|
||
},
|
||
)
|
||
.then(function (r) {
|
||
if (!r.ok)
|
||
return r
|
||
.json()
|
||
.catch(function () {
|
||
return {};
|
||
})
|
||
.then(function (d) {
|
||
throw new Error(d.error || "Failed");
|
||
});
|
||
showToast("Metadata deleted");
|
||
loadAdminNodeMetadata();
|
||
})
|
||
.catch(function (e) {
|
||
showToast(e.message, "error");
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
// Wire the shared row-action menu once. admin.js loads at the end of the
|
||
// console <body>, so the document-level delegation below covers every admin
|
||
// table (including those rendered by governance.js, which loads after).
|
||
_initKebabMenus();
|