"use strict"
// ── Constants ──────────────────────────────────────────────────────────────
const PROVIDERS = [
{ id: "anthropic", label: "Claude", kind: "OAuth Pool", mark: "A1", default_model: "claude-sonnet-4.6", oauth: true },
{ id: "openai-codex", label: "Codex", kind: "OAuth Pool", mark: "B2", default_model: "gpt-5.4", oauth: true },
{ id: "google-gemini-cli", label: "Gemini", kind: "OAuth Pool", mark: "C3", default_model: "gemini-3.5-flash", oauth: true },
{ id: "deepseek", label: "DeepSeek", kind: "API Key", mark: "D4", default_model: "deepseek-chat", oauth: false }
]
const ALL_PROVIDERS = [
...PROVIDERS,
{ id: "openrouter", label: "OpenRouter", default_model: "anthropic/claude-opus-4" },
{ id: "nous", label: "Nous Portal", default_model: "nous-hermes-3" },
{ id: "gemini", label: "Gemini API", default_model: "gemini-3-pro-preview" },
{ id: "zai", label: "z.ai / GLM", default_model: "glm-4.7" },
{ id: "kimi-coding", label: "Kimi", default_model: "kimi-k2.5" },
{ id: "minimax", label: "MiniMax", default_model: "minimax-text-01" },
{ id: "nvidia", label: "NVIDIA NIM", default_model: "" },
{ id: "huggingface", label: "Hugging Face", default_model: "" },
{ id: "xai", label: "xAI / Grok", default_model: "grok-4" },
{ id: "ollama-cloud", label: "Ollama Cloud", default_model: "" },
{ id: "azure-foundry", label: "Azure Foundry", default_model: "gpt-4o" },
{ id: "lmstudio", label: "LM Studio", default_model: "" },
{ id: "custom", label: "Custom", default_model: "" }
]
const $ = (s, r = document) => r.querySelector(s)
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s))
// ── Net ────────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts
})
const ct = res.headers.get("content-type") || ""
return ct.includes("json") ? res.json() : res.text()
}
function setMsg(text, kind = "") {
const el = $("#bar-msg")
el.textContent = text || ""
el.style.color = kind === "ok" ? "#a8d27a" : kind === "err" ? "#e88a8a" : "#f6f1e3"
const bar = $(".readout")
bar.classList.remove("busy", "error")
if (kind === "err") bar.classList.add("error")
if (text) setTimeout(() => {
if (el.textContent === text) { el.textContent = ""; bar.classList.remove("error") }
}, 4500)
}
function setBusy(on) {
$(".readout").classList.toggle("busy", !!on)
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
}
// ── Nav ────────────────────────────────────────────────────────────────────
function setRoute(name) {
$$(".dial-item").forEach((el) => el.toggleAttribute("data-active", el.dataset.route === name))
$$(".pane").forEach((el) => el.toggleAttribute("data-active", el.dataset.pane === name))
// Lazy loads
if (name === "providers") loadProviders()
if (name === "routing") loadRouting()
if (name === "tools") loadTools()
if (name === "skills") loadSkillsRich()
if (name === "mcp") loadMcp()
if (name === "cron") loadCron()
if (name === "sessions") loadSessions()
if (name === "hooks") loadHooks()
if (name === "plugins") loadPluginsRich()
if (name === "bundles") loadBundlesRich()
if (name === "curator") loadCuratorRich()
if (name === "memory") loadMemory()
if (name === "kanban") loadKanban()
if (name === "webhooks") loadWebhooks()
if (name === "profiles") loadProfiles()
if (name === "config") loadConfig()
if (name === "security") {} // on-demand only
if (name === "storage") loadStorage()
if (name === "insights") {} // on-demand only
if (name === "system") loadSystemStatus()
if (name === "api-users") loadApiUsers()
}
document.addEventListener("click", (e) => {
const nav = e.target.closest(".dial-item")
if (nav) return setRoute(nav.dataset.route)
// Tab switching
const tab = e.target.closest(".tab")
if (tab) {
const group = tab.closest(".tabs").dataset.tabGroup
const which = tab.dataset.tab
document.querySelectorAll(`.tabs[data-tab-group="${group}"] .tab`).forEach((t) =>
t.toggleAttribute("data-active", t.dataset.tab === which))
document.querySelectorAll(`.tab-body[data-tab-pane^="${group}:"]`).forEach((tb) =>
tb.toggleAttribute("data-active", tb.dataset.tabPane === `${group}:${which}`))
return
}
const btn = e.target.closest("button")
if (!btn) return
const action = btn.dataset.action
if (!action) return
switch (action) {
case "refresh": refreshCurrent(); return
case "save-deepseek": saveDeepseek(); return
case "set-primary": setPrimary(); return
case "save-chain": saveChain(); return
case "reset-chain": loadRouting(); return
case "add-fallback-row": addChainRow(); return
case "add-mcp": addMcp(); return
case "prune-sessions": pruneSessions(); return
case "run-doctor": runDoctor(); return
case "tail-logs": tailLogs(); return
// hooks
case "hooks-doctor": runHooksDoctor(); return
case "hooks-test": runHooksTest(); return
// skills (rich)
case "skills-discover": skillsDiscover(); return
// plugins (rich)
case "plugins-discover": pluginsDiscover(); return
case "plugin-install-manual": pluginInstallManual(); return
// bundles (rich)
case "bundle-create": bundleCreateRich(); return
// curator
case "curator-run": curatorAction("run"); return
case "curator-pause": curatorAction("pause"); return
case "curator-resume": curatorAction("resume"); return
case "curator-prune": curatorAction("prune"); return
// memory
case "save-memory-md": return saveMemoryMd(btn.dataset.file)
case "memory-off": return memoryOff()
// kanban
case "kanban-create": return kanbanCreate()
// webhooks
case "webhook-add": return webhookAdd()
case "webhook-test": return webhookTest()
// profiles
case "profile-create": return profileAction("create")
case "profile-use": return profileAction("use")
case "profile-delete": return profileAction("delete")
case "pairing-approve": return pairingAction("approve")
case "pairing-revoke": return pairingAction("revoke")
case "pairing-clear": return pairingAction("clear-pending")
// config
case "config-check": return runConfigCheck()
case "config-migrate": return runConfigMigrate()
case "config-set": return configSet()
// security
case "security-run": return runSecurity()
// storage
case "backup-full": return runBackup(false)
case "backup-quick": return runBackup(true)
case "checkpoints-prune": return checkpointsAction("prune")
case "checkpoints-clear": return checkpointsAction("clear")
// insights
case "insights-run": return runInsights()
// system extras
case "prompt-size": return runPromptSize()
case "dump": return runDump()
case "portal-info": return runPortalInfo()
case "run-update": return runUpdate()
}
})
function refreshCurrent() {
const active = $(".dial-item[data-active]")
setRoute(active?.dataset.route || "providers")
}
// ── Bootstrap ──────────────────────────────────────────────────────────────
async function bootstrap() {
try {
const paths = await api("/api/paths")
$("#foot-home").textContent = paths.hermesHome.replace(/^.*\\Local\\/, "…\\")
$("#foot-home").title = paths.hermesHome
} catch {}
await loadProviders()
}
async function loadProviders() {
const data = await api("/api/status")
const ver = (data.version || "").replace(/^Hermes Agent\s*/i, "").replace(/^v/, "")
$("#masthead-version").textContent = ver || "—"
$("#bar-version").textContent = ver || "—"
$("#bar-primary").textContent = data.primary?.provider
? `${data.primary.model} ▸ ${data.primary.provider}`
: "—"
$("#bar-chain").textContent = `${data.fallback?.entries?.length ?? 0} hop`
+ ((data.fallback?.entries?.length ?? 0) === 1 ? "" : "s")
renderProviderCards(data)
renderDeepseek(data.deepseekConfigured)
}
function renderProviderCards(data) {
const grid = $("#provider-grid")
grid.innerHTML = ""
const poolByProvider = Object.fromEntries((data.pools || []).map((p) => [p.provider, p]))
for (const p of PROVIDERS) {
const pool = poolByProvider[p.id]
const count = pool?.count ?? 0
const authState = pool?.authState || (p.id === "deepseek"
? (data.deepseekConfigured ? { state: "authenticated", label: "Key set" } : { state: "unauthenticated", label: "No key" })
: { state: "unauthenticated", label: "Unauthenticated" })
const card = document.createElement("div")
card.className = "pcard"
card.dataset.provider = p.id
// Cred list
let credsHtml = ""
if (pool && pool.entries.length) {
credsHtml = pool.entries.map((e) => `
#${e.index}
${escapeHtml(e.raw.replace(/\s+←\s*$/, ""))}
${e.identity ? `${escapeHtml(e.identity)}` : ""}
`).join("")
credsHtml = ``
} else if (p.id === "deepseek") {
credsHtml = `${data.deepseekConfigured ? "── KEY SET ──" : "── NO KEY ──"}
`
} else {
credsHtml = `── EMPTY POOL ──
`
}
// Actions
let actionsHtml = ""
if (p.oauth) {
actionsHtml = `
`
} else {
actionsHtml = `
Get key ↗
`
}
card.innerHTML = `
${p.mark}
${p.kind}
${p.label}
${p.id}${count ? ` · ${count}` : ""}
${escapeHtml(authState.label)}
${credsHtml}
${actionsHtml}
`
card.addEventListener("click", (e) => {
const t = e.target.closest("button")
if (!t) return
if (t.dataset.addOauth) startOauth(t.dataset.addOauth, card)
else if (t.dataset.abort) cancelOauth(t.dataset.abort, card)
else if (t.dataset.oauthSubmit) submitOauthInput(t.dataset.oauthSubmit, card)
else if (t.dataset.remove) removeCred(p.id, Number(t.dataset.remove), t.dataset.removeSource || "", t.dataset.removeFile || "")
else if (t.dataset.reset) resetPool(t.dataset.reset)
})
grid.appendChild(card)
}
}
function renderDeepseek(set) {
const el = $("#deepseek-status")
if (set) {
el.textContent = "Key written to .env — restart Hermes to load"
el.className = "status ok"
} else {
el.textContent = ""
el.className = "status"
}
}
function renderOauthLog(log, output) {
const escaped = escapeHtml(output || "")
log.innerHTML = escaped.replace(/https?:\/\/[^\s<>"'`]+/g, (url) => {
const clean = url.replace(/[),.;]+$/, "")
const tail = url.slice(clean.length)
return `${clean}${escapeHtml(tail)}`
})
log.scrollTop = log.scrollHeight
}
async function startOauth(provider, card) {
const log = card.querySelector(".pcard-log")
const actions = card.querySelector(".pcard-actions")
const input = card.querySelector(".oauth-input")
card.classList.add("busy")
setBusy(true)
log.hidden = false
renderOauthLog(log, `▷ Starting OAuth for ${provider}…\nIf no browser opens automatically, use the authorization link printed below.\n`)
actions.innerHTML = `
`
if (provider === "anthropic") {
input.hidden = false
input.innerHTML = `
`
} else if (provider === "google-gemini-cli") {
input.hidden = false
input.innerHTML = `
`
} else {
input.hidden = true
input.innerHTML = ""
}
const start = await api("/api/auth/add-oauth", {
method: "POST", body: JSON.stringify({ provider })
})
if (start.error) {
log.textContent += "✗ " + start.error + "\n"
card.classList.remove("busy")
setBusy(false)
setTimeout(loadProviders, 500)
return
}
pollOauth(provider, card)
}
let pollers = {}
async function pollOauth(provider, card) {
clearTimeout(pollers[provider])
const log = card.querySelector(".pcard-log")
const tick = async () => {
const p = await api(`/api/auth/progress?provider=${encodeURIComponent(provider)}`)
if (p.error) return
if (p.output) renderOauthLog(log, p.output)
if (!p.done) { pollers[provider] = setTimeout(tick, 900); return }
card.classList.remove("busy")
setBusy(false)
if (p.aborted) setMsg(`OAuth aborted ▸ ${provider}`, "err")
else if (p.exitCode === 0) setMsg(`OAuth committed ▸ ${provider}`, "ok")
else setMsg(`OAuth failed ▸ ${provider} (code ${p.exitCode})`, "err")
await loadProviders()
}
pollers[provider] = setTimeout(tick, 600)
}
async function cancelOauth(provider, card) {
const log = card.querySelector(".pcard-log")
log.textContent += "\n▷ Aborting…\n"
await api("/api/auth/cancel-oauth", { method: "POST", body: JSON.stringify({ provider }) })
}
async function submitOauthInput(provider, card) {
const input = card.querySelector("[data-oauth-input]")
const value = input?.value.trim() || ""
if (!value) return setMsg("Paste the OAuth callback or code first", "err")
const result = await api("/api/auth/submit-input", {
method: "POST",
body: JSON.stringify({ provider, value }),
})
if (result.error) return setMsg(result.error, "err")
input.value = ""
input.disabled = true
card.querySelector("[data-oauth-submit]").disabled = true
setMsg(`OAuth response submitted ▸ ${provider}`, "ok")
}
async function removeCred(provider, index, source = "", file = "") {
const label = source === "mounted-auth" && file ? `${file} from ${provider}` : `credential #${index} from ${provider}`
const prompt = source === "mounted-auth" && file
? `Remove ${label}?\n\nThis moves the mounted auth file into .hermes-control-plane-deleted-auth.`
: `Remove ${label}?`
if (!confirm(prompt)) return
const r = await api("/api/auth/remove", { method: "POST", body: JSON.stringify({ provider, index, source, file }) })
if (r.exitCode === 0) setMsg(source === "mounted-auth" && file ? `Removed ▸ ${file}` : `Removed ▸ ${provider} #${index}`, "ok")
else setMsg(r.error || "Remove failed", "err")
loadProviders()
}
async function resetPool(provider) {
const r = await api("/api/auth/reset", { method: "POST", body: JSON.stringify({ provider }) })
if (r.exitCode === 0) setMsg(`Cooldowns cleared ▸ ${provider}`, "ok")
else setMsg("Reset failed", "err")
loadProviders()
}
async function saveDeepseek() {
const input = $("#deepseek-key")
const key = input.value.trim()
if (!key) { setMsg("Paste a DeepSeek key first", "err"); return }
const r = await api("/api/auth/deepseek", { method: "POST", body: JSON.stringify({ key }) })
if (r.error) { setMsg(r.error, "err"); return }
input.value = ""
setMsg("DeepSeek key saved to .env", "ok")
loadProviders()
}
// ── Routing ────────────────────────────────────────────────────────────────
async function loadRouting() {
const sel = $("#primary-provider")
if (sel && !sel.options.length) {
for (const p of ALL_PROVIDERS) {
const opt = document.createElement("option")
opt.value = p.id
opt.textContent = `${p.label} · ${p.id}`
sel.appendChild(opt)
}
}
const status = await api("/api/status")
if (status.primary?.provider) sel.value = status.primary.provider
$("#primary-model").value = status.primary?.model || ""
const fb = await api("/api/fallback")
const list = $("#chain-edit")
list.innerHTML = ""
const entries = fb.file?.length
? fb.file
: (fb.cli?.entries || []).map((e) => ({ provider: e.provider, model: e.model }))
entries.forEach((e, i) => list.appendChild(chainRow(i, e)))
$("#chain-status").textContent = ""
$("#chain-status").className = "status"
}
function chainRow(idx, entry = {}) {
const li = document.createElement("li")
li.dataset.idx = String(idx)
const providerOpts = ALL_PROVIDERS.map((p) =>
``
).join("")
li.innerHTML = `
${String(idx + 1).padStart(2, "0")}
`
li.addEventListener("click", (e) => {
const b = e.target.closest("button")
if (!b) return
if (b.dataset.move === "up") moveRow(li, -1)
else if (b.dataset.move === "down") moveRow(li, 1)
else if (b.hasAttribute("data-remove-row")) li.remove()
reindex()
})
return li
}
function moveRow(li, dir) {
const sib = dir === -1 ? li.previousElementSibling : li.nextElementSibling
if (!sib) return
if (dir === -1) li.parentElement.insertBefore(li, sib)
else li.parentElement.insertBefore(sib, li)
}
function reindex() {
$$("#chain-edit li").forEach((li, i) => {
li.querySelector(".idx").textContent = String(i + 1).padStart(2, "0")
})
}
function addChainRow() {
const list = $("#chain-edit")
list.appendChild(chainRow(list.children.length, { provider: "openai-codex", model: "" }))
}
async function setPrimary() {
const provider = $("#primary-provider").value
const model = $("#primary-model").value.trim()
if (!provider || !model) { setMsg("Pick provider + model", "err"); return }
const r = await api("/api/model/set", { method: "POST", body: JSON.stringify({ provider, model }) })
if (r.ok) setMsg(`Primary ▸ ${model} via ${provider}`, "ok")
else setMsg(r.error || "Failed", "err")
loadProviders()
}
async function saveChain() {
const entries = $$("#chain-edit li").map((li) => ({
provider: li.querySelector('[data-field="provider"]').value,
model: li.querySelector('[data-field="model"]').value.trim()
})).filter((e) => e.provider && e.model)
const r = await api("/api/fallback/set", { method: "POST", body: JSON.stringify({ entries }) })
if (r.ok) {
setMsg(`Saved ${r.count} fallback entries`, "ok")
$("#chain-status").textContent = `Wrote ${r.count} entries · config.yaml`
$("#chain-status").className = "status ok"
loadProviders()
} else {
setMsg(r.error || "Failed", "err")
$("#chain-status").textContent = r.error || "Failed"
$("#chain-status").className = "status err"
}
}
// ── Tools ──────────────────────────────────────────────────────────────────
async function loadTools() {
const r = await api("/api/tools")
$("#tools-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §03 Skills (rich, card-based + hub marketplace) ────────────────────────
let SKILLS_CACHE = []
async function loadSkillsRich() {
const r = await api("/api/skills/rich")
SKILLS_CACHE = r.skills || []
$("#skills-count").textContent = SKILLS_CACHE.length
// Populate category filter
const cats = [...new Set(SKILLS_CACHE.map((s) => s.category).filter(Boolean))].sort()
const sel = $("#skills-cat-filter")
if (sel && sel.options.length <= 1) {
for (const c of cats) {
const o = document.createElement("option"); o.value = c; o.textContent = c
sel.appendChild(o)
}
}
renderSkillsGrid()
$("#skills-filter").oninput = renderSkillsGrid
$("#skills-cat-filter").onchange = renderSkillsGrid
}
function renderSkillsGrid() {
const grid = $("#skills-grid")
const q = ($("#skills-filter").value || "").trim().toLowerCase()
const cat = $("#skills-cat-filter").value
const filtered = SKILLS_CACHE.filter((s) => {
if (cat && s.category !== cat) return false
if (!q) return true
const hay = `${s.name} ${s.description} ${s.category} ${(s.tags || []).join(" ")}`.toLowerCase()
return hay.includes(q)
})
if (!filtered.length) { grid.innerHTML = `── no skills match ──
`; return }
grid.innerHTML = filtered.map(renderSkillCard).join("")
}
function renderSkillCard(s) {
const tags = (s.tags || []).slice(0, 4).map((t) => `${escapeHtml(t)}`).join("")
const plats = (s.platforms || []).map((p) => `${escapeHtml(p)}`).join("")
return `
${escapeHtml(s.category || "skill")}
${escapeHtml(s.name)}
v${escapeHtml(s.version || "—")}
${s.author ? `· ${escapeHtml(s.author)}` : ""}
${s.license ? `· ${escapeHtml(s.license)}` : ""}
${escapeHtml(s.description || "(no description)")}
${(tags || plats) ? `${tags}${plats}
` : ""}
`
}
async function skillsDiscover() {
const q = $("#skills-search").value.trim()
const source = $("#skills-source").value
const grid = $("#skills-discover-grid")
if (!q) { setMsg("Enter a search term", "err"); return }
grid.innerHTML = `searching ${escapeHtml(source)}…
`
const r = await api(`/api/skills/discover?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}`)
const results = r.results || []
if (!results.length) { grid.innerHTML = `── no results ──
`; return }
grid.innerHTML = results.map(renderDiscoverSkillCard).join("")
grid.querySelectorAll("[data-install-skill]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.dataset.installSkill
btn.disabled = true; btn.textContent = "Installing…"
setBusy(true)
const ir = await api("/api/skills/install", { method: "POST", body: JSON.stringify({ identifier: id }) })
setBusy(false)
btn.textContent = ir.exitCode === 0 ? "✓ Installed" : `✗ (${ir.exitCode})`
setMsg(`skill install ${id} → ${ir.exitCode}`, ir.exitCode === 0 ? "ok" : "err")
if (ir.exitCode === 0) loadSkillsRich()
})
})
}
function renderDiscoverSkillCard(s) {
const id = s.identifier || s.id || s.name
const trust = (s.trust || "").includes("official") ? "official" : (s.trust || "community")
const trustChip = trust === "official" ? `★ official` : `${escapeHtml(trust)}`
return `
${escapeHtml(s.source || "hub")}
${escapeHtml(s.name || id)}
${escapeHtml(id)}
${escapeHtml(s.description || "")}
${trustChip}
`
}
// ── §04 Plugins (rich) ─────────────────────────────────────────────────────
let PLUGINS_CACHE = []
async function loadPluginsRich() {
const r = await api("/api/plugins/rich")
PLUGINS_CACHE = r.plugins || []
$("#plugins-count").textContent = PLUGINS_CACHE.length
renderPluginsGrid()
$("#plugins-filter").oninput = renderPluginsGrid
$("#plugins-status-filter").onchange = renderPluginsGrid
}
function renderPluginsGrid() {
const grid = $("#plugins-grid")
const q = ($("#plugins-filter").value || "").trim().toLowerCase()
const status = $("#plugins-status-filter").value
const filtered = PLUGINS_CACHE.filter((p) => {
if (status && p.status !== status) return false
if (!q) return true
return `${p.name} ${p.description} ${(p.hooks || []).join(" ")} ${p.source}`.toLowerCase().includes(q)
})
if (!filtered.length) { grid.innerHTML = `── no plugins match ──
`; return }
grid.innerHTML = filtered.map(renderPluginCard).join("")
grid.querySelectorAll("button[data-plugin-action]").forEach((btn) => {
btn.addEventListener("click", async () => {
const action = btn.dataset.pluginAction
const name = btn.dataset.pluginName
if (action === "remove" && !confirm(`Remove plugin "${name}"?`)) return
btn.disabled = true; btn.textContent = `${action}…`
const r = await api("/api/plugins/action", { method: "POST", body: JSON.stringify({ action, name }) })
setMsg(`plugin ${action} ${name} → ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadPluginsRich()
})
})
}
function renderPluginCard(p) {
const hooks = (p.hooks || []).map((h) => `${escapeHtml(h)}`).join("")
const statusLabel = p.status || "unknown"
const isUserGit = p.source && /^(git|user)/i.test(p.source)
const actions = []
if (p.status === "enabled") actions.push(``)
else actions.push(``)
if (isUserGit) {
actions.push(``)
actions.push(``)
}
return `
${escapeHtml(statusLabel)}
${escapeHtml(p.name)}
v${escapeHtml(p.version || "—")}
· ${escapeHtml(p.source || "—")}
${p.author ? `· ${escapeHtml(p.author)}` : ""}
${escapeHtml(p.description || "")}
${hooks ? `${hooks}
` : ""}
${actions.join("")}
`
}
async function pluginsDiscover() {
const q = $("#plugins-search").value.trim()
const grid = $("#plugins-discover-grid")
grid.innerHTML = `searching GitHub…
`
const r = await api(`/api/plugins/discover?q=${encodeURIComponent(q)}`)
if (r.error && !r.results?.length) {
grid.innerHTML = `⚠ ${escapeHtml(r.error)}
`
return
}
const items = r.results || []
if (!items.length) { grid.innerHTML = `── no plugins found ──
`; return }
grid.innerHTML = items.map(renderPluginDiscoverCard).join("")
grid.querySelectorAll("[data-install-plugin]").forEach((btn) => {
btn.addEventListener("click", async () => {
const target = btn.dataset.installPlugin
btn.disabled = true; btn.textContent = "Installing…"
setBusy(true)
const ir = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
btn.textContent = ir.exitCode === 0 ? "✓ Installed" : `✗ (${ir.exitCode})`
const log = $("#plugins-log"); log.hidden = false
log.textContent = ir.output || ""
setMsg(`plugin install ${target} → ${ir.exitCode}`, ir.exitCode === 0 ? "ok" : "err")
if (ir.exitCode === 0) loadPluginsRich()
})
})
}
function renderPluginDiscoverCard(p) {
const topics = (p.topics || []).slice(0, 4).map((t) => `${escapeHtml(t)}`).join("")
const lang = p.language ? `${escapeHtml(p.language)}` : ""
return `
${p.stars}
${escapeHtml(p.full_name)}
${escapeHtml(p.description || "(no description)")}
${(topics || lang) ? `${topics}${lang}
` : ""}
`
}
async function pluginInstallManual() {
const target = $("#plugin-install-target").value.trim()
if (!target) { setMsg("target required", "err"); return }
const log = $("#plugins-log"); log.hidden = false; log.textContent = `▷ installing ${target}…\n`
setBusy(true)
const r = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin install → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#plugin-install-target").value = ""; loadPluginsRich() }
}
// ── §05 Bundles (rich) ─────────────────────────────────────────────────────
let BUNDLED_CACHE = []
async function loadBundlesRich() {
const r = await api("/api/bundles/rich")
BUNDLED_CACHE = r.bundledOfficialSkills || []
$("#bundled-count").textContent = `${r.bundledOfficialTotal || 0} skills`
// User bundles
const userGrid = $("#user-bundles")
const ub = r.userBundles || []
if (!ub.length) {
userGrid.innerHTML = `── no custom bundles yet ──
Create one below to wire several skills into a single /slash command.
`
} else {
userGrid.innerHTML = ub.map((b) => `
custom
/${escapeHtml(b.name)}
${escapeHtml(b._dir || b._file || "")}
`).join("")
userGrid.querySelectorAll("[data-bundle-delete]").forEach((btn) => {
btn.addEventListener("click", async () => {
const name = btn.dataset.bundleDelete
if (!confirm(`Delete bundle "${name}"?`)) return
const dr = await api("/api/bundles/delete", { method: "POST", body: JSON.stringify({ name }) })
setMsg(`bundle delete ${name} → ${dr.exitCode}`, dr.exitCode === 0 ? "ok" : "err")
loadBundlesRich()
})
})
}
// Bundled official skills — populate cat filter
const cats = [...new Set(BUNDLED_CACHE.map((b) => b.category).filter(Boolean))].sort()
const sel = $("#bundles-cat-filter")
if (sel && sel.options.length <= 1) {
for (const c of cats) {
const o = document.createElement("option"); o.value = c; o.textContent = c
sel.appendChild(o)
}
}
renderBundledGrid()
$("#bundles-filter").oninput = renderBundledGrid
$("#bundles-cat-filter").onchange = renderBundledGrid
}
function renderBundledGrid() {
const grid = $("#bundled-grid")
const q = ($("#bundles-filter").value || "").trim().toLowerCase()
const cat = $("#bundles-cat-filter").value
const filtered = BUNDLED_CACHE.filter((b) => {
if (cat && b.category !== cat) return false
if (!q) return true
return `${b.name} ${b.category} ${b.description}`.toLowerCase().includes(q)
})
if (!filtered.length) { grid.innerHTML = `── no matches ──
`; return }
grid.innerHTML = filtered.slice(0, 200).map((b) => `
${escapeHtml(b.category || "—")}
${escapeHtml(b.name)}
${escapeHtml(b.hash)}
${escapeHtml(b.description || "(no description on disk)")}
`).join("") + (filtered.length > 200 ? `… ${filtered.length - 200} more · refine filter to see
` : "")
}
async function bundleCreateRich() {
const name = $("#bundle-name").value.trim()
const skillsStr = $("#bundle-skills").value.trim()
if (!name || !skillsStr) { setMsg("name + skills required", "err"); return }
const skills = skillsStr.split(/[,\s]+/).filter(Boolean)
const log = $("#bundles-log"); log.hidden = false; log.textContent = `▷ creating bundle ${name}…\n`
const r = await api("/api/bundles/create", { method: "POST", body: JSON.stringify({ name, skills }) })
log.textContent += r.output || ""
setMsg(`bundle create ${name} → ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) {
$("#bundle-name").value = ""; $("#bundle-skills").value = ""
loadBundlesRich()
}
}
// ── §06 Curator (rich dashboard) ───────────────────────────────────────────
async function loadCuratorRich() {
const r = await api("/api/curator/rich")
const stats = r.stats || {}
const counts = r.counts || {}
const tiles = [
{ label: "State", value: stats.state || "—", sub: stats.interval ? `interval · ${stats.interval}` : "", cls: stats.state === "ENABLED" ? "ok" : "warn" },
{ label: "Total skills", value: counts.total ?? "—", sub: "agent-created", cls: "" },
{ label: "Active", value: counts.active ?? "—", sub: stats["stale after"] ? `stale after ${stats["stale after"]}` : "", cls: "ok" },
{ label: "Archived", value: counts.archived ?? "—", sub: stats["archive after"] ? `auto after ${stats["archive after"]}` : "", cls: counts.archived > 0 ? "warn" : "" }
]
$("#curator-stats").innerHTML = tiles.map((t) => `
${escapeHtml(t.label)}
${escapeHtml(String(t.value))}
${t.sub ? `
${escapeHtml(t.sub)}
` : ""}
`).join("")
const meta = $("#curator-meta")
const metaPairs = [
["runs", stats.runs],
["last run", stats["last run"]],
["last summary", stats["last summary"]],
["interval", stats.interval],
["stale threshold", stats["stale after"]],
["archive threshold", stats["archive after"]]
].filter(([, v]) => v != null && v !== "")
meta.innerHTML = metaPairs.map(([k, v]) => `${escapeHtml(k)}${escapeHtml(v)}`).join("")
const tbody = $("#curator-recent tbody")
tbody.innerHTML = (r.leastRecent || []).map((s) => `
| ${escapeHtml(s.name)} |
${s.activity} |
${s.uses} |
${escapeHtml(s.lastActivity)} |
`).join("") || `| ── no recent data ── |
`
const archived = $("#curator-archived")
archived.innerHTML = (r.archived || []).length
? (r.archived.map((a) => `${escapeHtml(a)}`).join(""))
: `── none archived ──`
}
// ── MCP ────────────────────────────────────────────────────────────────────
async function loadMcp() {
const r = await api("/api/mcp")
const list = $("#mcp-list")
list.innerHTML = ""
if (!r.servers?.length) {
list.innerHTML = `── no servers connected ──`
return
}
for (const s of r.servers) {
const li = document.createElement("li")
li.innerHTML = `
${escapeHtml(s.name)}
· ${escapeHtml(s.detail || "")}
`
li.querySelector("[data-mcp-test]").addEventListener("click", () => {
testMcp(s.name, $("#mcp-log"))
})
li.querySelector("[data-mcp-remove]").addEventListener("click", async () => {
if (!confirm(`Remove MCP server "${s.name}"?`)) return
await api("/api/mcp/remove", { method: "POST", body: JSON.stringify({ name: s.name }) })
setMsg(`Removed ▸ ${s.name}`, "ok")
loadMcp()
})
list.appendChild(li)
}
}
async function addMcp() {
const name = $("#mcp-name").value.trim()
const url = $("#mcp-url").value.trim()
const token = $("#mcp-token").value.trim()
const headers = $("#mcp-headers").value.trim()
const command = $("#mcp-command").value.trim()
const args = $("#mcp-args").value.trim().split(/\s+/).filter(Boolean)
if (!name) { setMsg("Name required", "err"); return }
if (!url && !command) { setMsg("URL or command required", "err"); return }
const log = $("#mcp-log")
log.hidden = false
log.textContent = "▷ Adding…\n"
const r = await api("/api/mcp/add", {
method: "POST", body: JSON.stringify({ name, url, token, headers, command, args })
})
log.textContent += r.output || ""
if (r.exitCode === 0) {
setMsg(`Added ▸ ${name}`, "ok")
$("#mcp-name").value = $("#mcp-url").value = $("#mcp-token").value = ""
$("#mcp-headers").value = $("#mcp-command").value = $("#mcp-args").value = ""
loadMcp()
} else setMsg(`Add failed (${r.exitCode})`, "err")
}
async function testMcp(name, logEl) {
logEl.hidden = false
logEl.textContent = `▷ Testing ${name}…\n`
const r = await api("/api/mcp/test", {
method: "POST", body: JSON.stringify({ name })
})
logEl.textContent += r.output || ""
setMsg(r.exitCode === 0 ? `Connected ▸ ${name}` : `Test failed ▸ ${name}`, r.exitCode === 0 ? "ok" : "err")
}
// ── Cron ───────────────────────────────────────────────────────────────────
async function loadCron() {
const r = await api("/api/cron")
const list = $("#cron-list")
list.innerHTML = ""
if (!r.jobs?.length) {
list.innerHTML = `── no scheduled tasks ──`
return
}
for (const j of r.jobs) {
const li = document.createElement("li")
const paused = j.status.toLowerCase().includes("paused")
li.innerHTML = `
${j.id.slice(0, 8)}
${escapeHtml(j.schedule)}
· ${escapeHtml(j.prompt)}
${escapeHtml(j.status)}
`
li.addEventListener("click", async (e) => {
const b = e.target.closest("button")
if (!b) return
if (b.dataset.cronAction === "remove" && !confirm(`Remove cron ${j.id.slice(0, 8)}?`)) return
await api("/api/cron/action", {
method: "POST", body: JSON.stringify({ id: j.id, action: b.dataset.cronAction })
})
setMsg(`${b.dataset.cronAction} ▸ ${j.id.slice(0, 8)}`, "ok")
loadCron()
})
list.appendChild(li)
}
}
// ── Sessions ───────────────────────────────────────────────────────────────
async function loadSessions() {
const r = await api("/api/sessions")
$("#sessions-count").textContent = `${r.sessions?.length || 0} sessions`
$("#sessions-raw").textContent = (r.raw || "(no output)").trim()
}
async function pruneSessions() {
const days = Number($("#prune-days").value || 30)
if (!confirm(`Prune sessions older than ${days} days?`)) return
const r = await api("/api/sessions/prune", { method: "POST", body: JSON.stringify({ days }) })
if (r.exitCode === 0) setMsg(`Pruned ▸ > ${days}d`, "ok")
else setMsg("Prune failed", "err")
loadSessions()
}
// ── System ─────────────────────────────────────────────────────────────────
async function loadSystemStatus() {
$("#system-raw").textContent = "▷ querying…"
const r = await api("/api/system")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runDoctor() {
$("#system-raw").textContent = "▷ running doctor…"
const r = await api("/api/doctor")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function tailLogs() {
$("#system-raw").textContent = "▷ fetching logs…"
const r = await api("/api/logs")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §08 Hooks ──────────────────────────────────────────────────────────────
async function loadHooks() {
const r = await api("/api/hooks")
$("#hooks-raw").textContent = (r.raw || "(no output)").trim()
}
async function runHooksDoctor() {
$("#hooks-raw").textContent = "▷ running hooks doctor…"
const r = await api("/api/hooks/doctor")
$("#hooks-raw").textContent = (r.raw || "(no output)").trim()
}
async function runHooksTest() {
const event = $("#hook-event").value
const log = $("#hooks-log")
log.hidden = false; log.textContent = `▷ firing ${event}…\n`
const r = await api("/api/hooks/test", { method: "POST", body: JSON.stringify({ event }) })
log.textContent += r.output || ""
setMsg(`hooks test ${event} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §09 Plugins / Bundles / Curator ────────────────────────────────────────
async function loadPlugins() {
const r = await api("/api/plugins")
$("#plugins-raw").textContent = (r.plugins || "").trim() || "(no plugins installed)"
$("#bundles-raw").textContent = (r.bundles || "").trim() || "(no bundles)"
$("#curator-raw").textContent = (r.curator || "").trim() || "(curator status unavailable)"
}
async function pluginInstall() {
const target = $("#plugin-target").value.trim()
if (!target) { setMsg("Provide a plugin target", "err"); return }
const log = $("#plugins-log")
log.hidden = false; log.textContent = `▷ installing ${target}…\n`
setBusy(true)
const r = await api("/api/plugins/install", { method: "POST", body: JSON.stringify({ target }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin install ${target} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#plugin-target").value = ""; loadPlugins() }
}
async function pluginAction(action, name) {
const log = $("#plugins-log")
log.hidden = false; log.textContent = `▷ ${action} ${name || "(all)"}…\n`
setBusy(true)
const r = await api("/api/plugins/action", { method: "POST", body: JSON.stringify({ action, name }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`plugin ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadPlugins()
}
async function curatorAction(action) {
$("#curator-raw").textContent = `▷ curator ${action}…`
const r = await api("/api/curator/action", { method: "POST", body: JSON.stringify({ action }) })
$("#curator-raw").textContent = r.output || "(no output)"
setMsg(`curator ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §10 Memory ─────────────────────────────────────────────────────────────
async function loadMemory() {
const r = await api("/api/memory")
$("#memory-status-raw").textContent = (r.status || "(no provider info)").trim()
$("#memory-md").value = r.memory?.content ?? ""
$("#user-md").value = r.user?.content ?? ""
}
async function saveMemoryMd(file) {
const id = file === "MEMORY.md" ? "memory-md" : "user-md"
const content = $("#" + id).value
const r = await api("/api/memory/save", { method: "POST", body: JSON.stringify({ file, content }) })
if (r.ok) setMsg(`${file} saved`, "ok")
else setMsg("Save failed", "err")
}
async function memoryOff() {
if (!confirm("Disable external memory provider?")) return
const r = await api("/api/memory/off", { method: "POST" })
setMsg(`memory off → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadMemory()
}
// ── §11 Kanban ─────────────────────────────────────────────────────────────
async function loadKanban() {
const r = await api("/api/kanban")
$("#kanban-boards-raw").textContent = (r.boards || "(no boards)").trim()
$("#kanban-tasks-raw").textContent = (r.tasks || "(no tasks)").trim()
}
async function kanbanCreate() {
const title = $("#kanban-title").value.trim()
const board = $("#kanban-board").value.trim()
if (!title) { setMsg("title required", "err"); return }
const log = $("#kanban-log")
log.hidden = false; log.textContent = "▷ creating…\n"
const r = await api("/api/kanban/create", { method: "POST", body: JSON.stringify({ title, board }) })
log.textContent += r.output || ""
setMsg(`kanban create → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#kanban-title").value = ""; loadKanban() }
}
// ── §12 Webhooks ───────────────────────────────────────────────────────────
async function loadWebhooks() {
const r = await api("/api/webhooks")
$("#webhooks-raw").textContent = (r.raw || "(no webhooks)").trim()
}
async function webhookAdd() {
const url = $("#webhook-url").value.trim()
const event = $("#webhook-event").value.trim()
if (!url) { setMsg("url required", "err"); return }
const log = $("#webhooks-log")
log.hidden = false; log.textContent = "▷ subscribing…\n"
const r = await api("/api/webhooks/add", { method: "POST", body: JSON.stringify({ url, event }) })
log.textContent += r.output || ""
setMsg(`webhook add → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#webhook-url").value = ""; $("#webhook-event").value = ""; loadWebhooks() }
}
async function webhookTest() {
const target = $("#webhook-test-target").value.trim()
if (!target) { setMsg("target required", "err"); return }
const log = $("#webhooks-log")
log.hidden = false; log.textContent = "▷ posting test…\n"
const r = await api("/api/webhooks/test", { method: "POST", body: JSON.stringify({ target }) })
log.textContent += r.output || ""
setMsg(`webhook test → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §13 Profiles / Pairing ─────────────────────────────────────────────────
async function loadProfiles() {
const r = await api("/api/profiles")
$("#profiles-raw").textContent = (r.profiles || "(no profiles)").trim()
$("#pairing-raw").textContent = (r.pairing || "(no pairing entries)").trim()
}
async function profileAction(action) {
const name = $("#profile-name").value.trim()
if (!name) { setMsg("profile name required", "err"); return }
if (action === "delete" && !confirm(`Delete profile "${name}"?`)) return
const log = $("#profiles-log")
log.hidden = false; log.textContent = `▷ profile ${action} ${name}…\n`
const r = await api("/api/profiles/action", { method: "POST", body: JSON.stringify({ action, name }) })
log.textContent += r.output || ""
setMsg(`profile ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadProfiles()
}
async function pairingAction(action) {
const subject = $("#pairing-subject").value.trim()
if (action !== "clear-pending" && !subject) { setMsg("code/user required", "err"); return }
const r = await api("/api/pairing/action", { method: "POST", body: JSON.stringify({ action, subject }) })
setMsg(`pairing ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadProfiles()
}
// ── §14 Config ─────────────────────────────────────────────────────────────
async function loadConfig() {
const r = await api("/api/config/show")
$("#config-raw").textContent = (r.raw || "(no output)").trim()
$("#config-path-row").innerHTML =
`FILE${escapeHtml(r.path)}`
}
async function runConfigCheck() {
$("#config-raw").textContent = "▷ checking…"
const r = await api("/api/config/check")
$("#config-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`config check → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
async function runConfigMigrate() {
if (!confirm("Run config migrate? Writes changes to config.yaml.")) return
$("#config-raw").textContent = "▷ migrating…"
const r = await api("/api/config/migrate", { method: "POST" })
$("#config-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`config migrate → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadConfig()
}
async function configSet() {
const key = $("#config-key").value.trim()
const value = $("#config-value").value
if (!key) { setMsg("key required", "err"); return }
const log = $("#config-log")
log.hidden = false; log.textContent = `▷ set ${key} = ${value}…\n`
const r = await api("/api/config/set", { method: "POST", body: JSON.stringify({ key, value }) })
log.textContent += r.output || ""
setMsg(`config set → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
if (r.exitCode === 0) { $("#config-key").value = $("#config-value").value = ""; loadConfig() }
}
// ── §15 Security ───────────────────────────────────────────────────────────
async function runSecurity() {
$("#security-raw").textContent = "▷ scanning OSV.dev (this can take a minute)…"
setBusy(true)
const r = await api("/api/security")
setBusy(false)
$("#security-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`security audit → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── §16 Storage ────────────────────────────────────────────────────────────
async function loadStorage() {
const r = await api("/api/checkpoints")
$("#checkpoints-raw").textContent = (r.raw || "(no output)").trim()
}
async function runBackup(quick) {
const label = $("#backup-label").value.trim()
const log = $("#backup-log")
log.hidden = false; log.textContent = `▷ ${quick ? "quick" : "full"} backup…\n`
setBusy(true)
const r = await api("/api/backup", { method: "POST", body: JSON.stringify({ quick, label }) })
setBusy(false)
log.textContent += r.output || ""
setMsg(`backup → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
async function checkpointsAction(action) {
if (action === "clear" && !confirm("Clear ALL checkpoints? This wipes rollback history.")) return
$("#checkpoints-raw").textContent = `▷ ${action}…`
const r = await api("/api/checkpoints/action", { method: "POST", body: JSON.stringify({ action }) })
$("#checkpoints-raw").textContent = (r.output || "(no output)").trim()
setMsg(`checkpoints ${action} → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
loadStorage()
}
// ── §17 Insights ───────────────────────────────────────────────────────────
async function runInsights() {
const days = Number($("#insights-days").value || 30)
$("#insights-raw").textContent = `▷ analyzing past ${days} days…`
const r = await api(`/api/insights?days=${days}`)
$("#insights-raw").textContent = (r.raw || "(no output)").trim()
}
// ── §18 System extras ──────────────────────────────────────────────────────
async function runPromptSize() {
$("#system-raw").textContent = "▷ measuring prompt size (cli)…"
const r = await api("/api/prompt-size?platform=cli")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runDump() {
$("#system-raw").textContent = "▷ generating setup dump…"
const r = await api("/api/dump")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runPortalInfo() {
$("#system-raw").textContent = "▷ querying Nous Portal…"
const r = await api("/api/portal")
$("#system-raw").textContent = (r.raw || "(no output)").trim()
}
async function runUpdate() {
if (!confirm("Run hermes update? This will modify the install.")) return
$("#system-raw").textContent = "▷ updating (this may take a few minutes)…"
setBusy(true)
const r = await api("/api/update", { method: "POST" })
setBusy(false)
$("#system-raw").textContent = (r.raw || "(no output)").trim()
setMsg(`update → exit ${r.exitCode}`, r.exitCode === 0 ? "ok" : "err")
}
// ── API Users ──────────────────────────────────────────────────────────────
async function apiUserFetch(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts
})
if (res.status === 401) {
window.location.href = "/login"
return null
}
const ct = res.headers.get("content-type") || ""
return ct.includes("json") ? res.json() : res.text()
}
function fmtTokens(n) {
if (!n) return "—"
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + "M tokens/mo"
if (n >= 1_000) return (n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1) + "K tokens/mo"
return n + " tokens/mo"
}
function fmtDate(val) {
if (!val) return "—"
const d = new Date(val)
if (isNaN(d)) return "—"
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
}
function renderApiUsersRows(users) {
const tbody = $("#api-users-tbody")
if (!tbody) return
if (!users || users.length === 0) {
tbody.innerHTML = '| No API users found. |
'
return
}
tbody.innerHTML = users.map((u) => {
const statusClass = u.status === "active" ? "badge-active" : u.status === "revoked" ? "badge-revoked" : "badge-deleted"
const maskedKey = u.key_suffix ? `hms_····${escapeHtml(u.key_suffix)}` : "hms_············"
const preBadge = u.access_pre ? 'pre' : ""
const postBadge = u.access_post ? 'post' : ""
const rate = u.requests_per_minute ? `${u.requests_per_minute} req/min` : "—"
const monthly = fmtTokens(u.monthly_token_limit)
const lastUsed = fmtDate(u.last_used_at)
const expires = fmtDate(u.expires_at)
const revokeOrReactivate = u.status === "active"
? ``
: ``
return `
${escapeHtml(u.display_name || u.id)} ${escapeHtml(u.status || "active")} |
${maskedKey} |
${preBadge}${postBadge} |
${escapeHtml(rate)} ${escapeHtml(monthly)} |
${escapeHtml(lastUsed)} |
${escapeHtml(expires)} |
${revokeOrReactivate}
|
`
}).join("")
}
async function loadApiUsers() {
const data = await apiUserFetch("/api/admin/api-users")
if (!data) return
renderApiUsersRows(Array.isArray(data) ? data : (data.users || []))
}
async function createApiUser(data) {
return apiUserFetch("/api/admin/api-users", {
method: "POST",
body: JSON.stringify(data)
})
}
async function updateApiUser(id, patch) {
return apiUserFetch(`/api/admin/api-users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch)
})
}
async function rotateApiUserKey(id) {
return apiUserFetch(`/api/admin/api-users/${id}/rotate`, { method: "POST" })
}
async function revokeApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}/revoke`, { method: "POST" })
}
async function reactivateApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}/reactivate`, { method: "POST" })
}
async function deleteApiUser(id) {
return apiUserFetch(`/api/admin/api-users/${id}`, { method: "DELETE" })
}
function downloadApiUserLogs(id) {
const a = document.createElement("a")
a.href = `/api/admin/logs/download?api_user_id=${encodeURIComponent(id)}`
a.download = ""
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function openApiUserDialog(mode, existing) {
const dialog = $("#api-user-dialog")
const errorEl = $("#api-user-dialog-error")
errorEl.textContent = ""
$("#api-user-dialog-title").textContent = mode === "edit" ? "Edit API User" : "Create API User"
// DB rows use allow_pre/allow_post; single-user fetch uses same column names
const pre = existing ? (existing.allow_pre ?? false) : true
const post = existing ? (existing.allow_post ?? false) : true
$("#api-user-name").value = existing ? (existing.display_name || "") : ""
$("#api-user-pre").checked = pre
$("#api-user-post").checked = post
$("#api-user-rpm").value = existing ? (existing.requests_per_minute || 60) : 60
$("#api-user-monthly").value = existing ? (existing.monthly_token_limit || 1000000) : 1000000
if (existing && existing.expires_at) {
const d = new Date(existing.expires_at)
$("#api-user-expires").value = isNaN(d) ? "" : d.toISOString().slice(0, 16)
} else {
$("#api-user-expires").value = ""
}
dialog.dataset.editId = existing ? existing.id : ""
dialog.showModal()
}
function openRevealDialog(key) {
const dialog = $("#api-key-reveal-dialog")
$("#reveal-key-text").textContent = key
dialog.showModal()
}
document.addEventListener("click", async (e) => {
if (e.target.id === "new-api-user-btn") {
openApiUserDialog("create", null)
return
}
if (e.target.id === "api-user-cancel-btn") {
$("#api-user-dialog").close()
return
}
if (e.target.id === "reveal-done-btn") {
$("#api-key-reveal-dialog").close()
return
}
if (e.target.id === "reveal-copy-btn") {
const key = $("#reveal-key-text").textContent
if (navigator.clipboard) {
await navigator.clipboard.writeText(key)
setMsg("API key copied to clipboard", "ok")
}
return
}
// Expiration preset buttons inside the dialog
const preset = e.target.dataset.expiresPreset
if (preset !== undefined) {
const input = $("#api-user-expires")
if (preset === "never") {
input.value = ""
} else {
const d = new Date()
if (preset === "30d") d.setDate(d.getDate() + 30)
else if (preset === "90d") d.setDate(d.getDate() + 90)
else if (preset === "1y") d.setFullYear(d.getFullYear() + 1)
input.value = d.toISOString().slice(0, 16)
}
return
}
const action = e.target.dataset.apiUserAction
const id = e.target.dataset.id
if (!action || !id) return
if (action === "edit") {
setBusy(true)
const data = await apiUserFetch(`/api/admin/api-users/${id}`)
setBusy(false)
if (!data || data.error) {
setMsg(data?.error || "Failed to load user", "err")
return
}
openApiUserDialog("edit", data)
return
}
if (action === "rotate") {
if (!confirm("Rotate API key? The current key will be invalidated immediately.")) return
setBusy(true)
const res = await rotateApiUserKey(id)
setBusy(false)
if (!res) return
if (res.error) { setMsg(res.error, "err"); return }
if (res.plaintextKey) openRevealDialog(res.plaintextKey)
setMsg("Key rotated", "ok")
loadApiUsers()
return
}
if (action === "logs") {
downloadApiUserLogs(id)
return
}
if (action === "revoke") {
if (!confirm("Revoke this API user? They will lose access immediately.")) return
setBusy(true)
const res = await revokeApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User revoked", "ok")
loadApiUsers()
return
}
if (action === "reactivate") {
setBusy(true)
const res = await reactivateApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User reactivated", "ok")
loadApiUsers()
return
}
if (action === "delete") {
if (!confirm("Permanently delete this API user? This cannot be undone.")) return
setBusy(true)
const res = await deleteApiUser(id)
setBusy(false)
if (res?.error) { setMsg(res.error, "err"); return }
setMsg("User deleted", "ok")
loadApiUsers()
return
}
}, true)
document.addEventListener("submit", async (e) => {
if (e.target.id !== "api-user-form") return
e.preventDefault()
const dialog = $("#api-user-dialog")
const errorEl = $("#api-user-dialog-error")
const editId = dialog.dataset.editId
const rpmRaw = $("#api-user-rpm").value
const monthlyRaw = $("#api-user-monthly").value
// Validate required numeric fields before hitting the server
const rpm = rpmRaw ? Number(rpmRaw) : null
const monthly = monthlyRaw ? Number(monthlyRaw) : null
if (!rpm || rpm <= 0) {
errorEl.textContent = "Requests per minute is required and must be a positive number."
return
}
if (!monthly || monthly <= 0) {
errorEl.textContent = "Monthly token limit is required and must be a positive number."
return
}
errorEl.textContent = ""
const payload = {
displayName: $("#api-user-name").value.trim(),
allowPre: $("#api-user-pre").checked,
allowPost: $("#api-user-post").checked,
requestsPerMinute: rpm,
monthlyTokenLimit: monthly,
expiresAt: $("#api-user-expires").value ? new Date($("#api-user-expires").value).toISOString() : null,
}
setBusy(true)
let res
if (editId) {
res = await updateApiUser(editId, payload)
} else {
res = await createApiUser(payload)
}
setBusy(false)
if (!res) return
if (res.error) {
errorEl.textContent = res.error
return
}
dialog.close()
if (!editId && res.plaintextKey) openRevealDialog(res.plaintextKey)
setMsg(editId ? "API user updated" : "API user created", "ok")
loadApiUsers()
}, true)
// ── Boot ───────────────────────────────────────────────────────────────────
bootstrap()