f473be4033
Add an optional auth-token (bearer) field and a custom-headers textarea to
the MCP 'Add server' pane. URL-mode servers are now registered by writing
hermes config directly via the venv's config helpers (save_env_value +
save_config) instead of shelling into the interactive, network-probing
'hermes mcp add --url' flow that hung/504'd on auth challenges.
- Token is stored in ~/.hermes/.env (mode 600) and referenced in config.yaml
as 'Authorization: Bearer ${MCP_<NAME>_API_KEY}' — never plaintext config.
- Registration is non-blocking: no live probe, so a slow/unreachable server
can't hang the request.
- Add a per-server 'Test' button (POST /api/mcp/test -> hermes mcp test).
- Contract tests for the new fields, payload, no-probe path, and test route.
1532 lines
66 KiB
JavaScript
1532 lines
66 KiB
JavaScript
"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) => `
|
||
<li class="${e.active ? "active" : ""}">
|
||
<span class="ix">#${e.index}</span>
|
||
<span class="label">
|
||
<span>${escapeHtml(e.raw.replace(/\s+←\s*$/, ""))}</span>
|
||
${e.identity ? `<span class="account-id">${escapeHtml(e.identity)}</span>` : ""}
|
||
</span>
|
||
<button class="x" title="Remove" data-remove="${e.index}" data-remove-source="${escapeHtml(e.source || "")}" data-remove-file="${escapeHtml(e.file || "")}">×</button>
|
||
</li>`).join("")
|
||
credsHtml = `<ul class="pcard-creds">${credsHtml}</ul>`
|
||
} else if (p.id === "deepseek") {
|
||
credsHtml = `<div class="pcard-empty">${data.deepseekConfigured ? "── KEY SET ──" : "── NO KEY ──"}</div>`
|
||
} else {
|
||
credsHtml = `<div class="pcard-empty">── EMPTY POOL ──</div>`
|
||
}
|
||
|
||
// Actions
|
||
let actionsHtml = ""
|
||
if (p.oauth) {
|
||
actionsHtml = `
|
||
<button class="btn-primary grow" data-add-oauth="${p.id}">+ Add OAuth</button>
|
||
<button class="btn-mini" data-reset="${p.id}" title="Clear cooldowns">↺</button>
|
||
`
|
||
} else {
|
||
actionsHtml = `
|
||
<a class="btn-mini grow" style="text-align:center;text-decoration:none"
|
||
href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener">Get key ↗</a>
|
||
`
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="pcard-mark">${p.mark}</div>
|
||
<div>
|
||
<div class="pcard-kind">${p.kind}</div>
|
||
<h3 class="pcard-title">${p.label}</h3>
|
||
<div class="pcard-sub">${p.id}${count ? ` · ${count}` : ""}</div>
|
||
<div class="auth-pill" data-state="${escapeHtml(authState.state)}">${escapeHtml(authState.label)}</div>
|
||
</div>
|
||
${credsHtml}
|
||
<div class="pcard-actions">${actionsHtml}</div>
|
||
<pre class="pcard-log" hidden></pre>
|
||
<div class="oauth-input" hidden></div>
|
||
`
|
||
|
||
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 `<a href="${clean}" target="_blank" rel="noopener">${clean}</a>${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 = `
|
||
<button class="btn grow" disabled>Awaiting browser…</button>
|
||
<button class="btn-danger" data-abort="${provider}">Abort</button>
|
||
`
|
||
if (provider === "anthropic") {
|
||
input.hidden = false
|
||
input.innerHTML = `
|
||
<label>After authorizing Claude, paste the authorization code:</label>
|
||
<div><input data-oauth-input type="text" autocomplete="off" placeholder="Authorization code"><button class="btn-mini" data-oauth-submit="${provider}">Submit</button></div>
|
||
`
|
||
} else if (provider === "google-gemini-cli") {
|
||
input.hidden = false
|
||
input.innerHTML = `
|
||
<label>After Google redirects to localhost and fails, paste the full URL:</label>
|
||
<div><input data-oauth-input type="text" autocomplete="off" placeholder="http://127.0.0.1:8085/oauth2callback?..."><button class="btn-mini" data-oauth-submit="${provider}">Submit</button></div>
|
||
`
|
||
} 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) =>
|
||
`<option value="${p.id}" ${p.id === entry.provider ? "selected" : ""}>${p.label}</option>`
|
||
).join("")
|
||
li.innerHTML = `
|
||
<span class="idx">${String(idx + 1).padStart(2, "0")}</span>
|
||
<select data-field="provider">${providerOpts}</select>
|
||
<input data-field="model" value="${escapeHtml(entry.model || "")}" placeholder="model id" />
|
||
<div class="moves">
|
||
<button data-move="up" title="Move up">↑</button>
|
||
<button data-move="down" title="Move down">↓</button>
|
||
</div>
|
||
<button class="btn-danger btn-mini" data-remove-row title="Remove">×</button>
|
||
`
|
||
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 = `<div class="empty-pane">── no skills match ──</div>`; return }
|
||
grid.innerHTML = filtered.map(renderSkillCard).join("")
|
||
}
|
||
|
||
function renderSkillCard(s) {
|
||
const tags = (s.tags || []).slice(0, 4).map((t) => `<span class="chip">${escapeHtml(t)}</span>`).join("")
|
||
const plats = (s.platforms || []).map((p) => `<span class="chip platform">${escapeHtml(p)}</span>`).join("")
|
||
return `
|
||
<article class="scard" data-skill="${escapeHtml(s.name)}">
|
||
<div class="scard-tag">${escapeHtml(s.category || "skill")}</div>
|
||
<h3 class="scard-title">${escapeHtml(s.name)}</h3>
|
||
<div class="scard-sub">
|
||
<span><strong>v${escapeHtml(s.version || "—")}</strong></span>
|
||
${s.author ? `<span>· ${escapeHtml(s.author)}</span>` : ""}
|
||
${s.license ? `<span>· ${escapeHtml(s.license)}</span>` : ""}
|
||
</div>
|
||
<p class="scard-desc">${escapeHtml(s.description || "(no description)")}</p>
|
||
${(tags || plats) ? `<div class="scard-chips">${tags}${plats}</div>` : ""}
|
||
</article>`
|
||
}
|
||
|
||
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 = `<div class="empty-pane">searching <code>${escapeHtml(source)}</code>…</div>`
|
||
const r = await api(`/api/skills/discover?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}`)
|
||
const results = r.results || []
|
||
if (!results.length) { grid.innerHTML = `<div class="empty-pane">── no results ──</div>`; 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" ? `<span class="chip" style="color:var(--hazard);border-color:var(--hazard-line);background:var(--hazard-soft)">★ official</span>` : `<span class="chip">${escapeHtml(trust)}</span>`
|
||
return `
|
||
<article class="scard">
|
||
<div class="scard-tag">${escapeHtml(s.source || "hub")}</div>
|
||
<h3 class="scard-title">${escapeHtml(s.name || id)}</h3>
|
||
<div class="scard-sub"><span>${escapeHtml(id)}</span></div>
|
||
<p class="scard-desc">${escapeHtml(s.description || "")}</p>
|
||
<div class="scard-chips">${trustChip}</div>
|
||
<div class="scard-actions">
|
||
<button class="btn-primary grow" data-install-skill="${escapeHtml(id)}">+ Install</button>
|
||
</div>
|
||
</article>`
|
||
}
|
||
|
||
// ── §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 = `<div class="empty-pane">── no plugins match ──</div>`; 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) => `<span class="chip hook">${escapeHtml(h)}</span>`).join("")
|
||
const statusLabel = p.status || "unknown"
|
||
const isUserGit = p.source && /^(git|user)/i.test(p.source)
|
||
const actions = []
|
||
if (p.status === "enabled") actions.push(`<button class="btn-mini" data-plugin-action="disable" data-plugin-name="${escapeHtml(p.name)}">Disable</button>`)
|
||
else actions.push(`<button class="btn-mini" data-plugin-action="enable" data-plugin-name="${escapeHtml(p.name)}">Enable</button>`)
|
||
if (isUserGit) {
|
||
actions.push(`<button class="btn-mini" data-plugin-action="update" data-plugin-name="${escapeHtml(p.name)}">Update</button>`)
|
||
actions.push(`<button class="btn-danger btn-mini" data-plugin-action="remove" data-plugin-name="${escapeHtml(p.name)}">Remove</button>`)
|
||
}
|
||
return `
|
||
<article class="scard" data-status="${escapeHtml(statusLabel)}">
|
||
<div class="scard-tag">${escapeHtml(statusLabel)}</div>
|
||
<h3 class="scard-title">${escapeHtml(p.name)}</h3>
|
||
<div class="scard-sub">
|
||
<span><strong>v${escapeHtml(p.version || "—")}</strong></span>
|
||
<span>· ${escapeHtml(p.source || "—")}</span>
|
||
${p.author ? `<span>· ${escapeHtml(p.author)}</span>` : ""}
|
||
</div>
|
||
<p class="scard-desc">${escapeHtml(p.description || "")}</p>
|
||
${hooks ? `<div class="scard-chips">${hooks}</div>` : ""}
|
||
<div class="scard-actions">${actions.join("")}</div>
|
||
</article>`
|
||
}
|
||
|
||
async function pluginsDiscover() {
|
||
const q = $("#plugins-search").value.trim()
|
||
const grid = $("#plugins-discover-grid")
|
||
grid.innerHTML = `<div class="empty-pane">searching GitHub…</div>`
|
||
const r = await api(`/api/plugins/discover?q=${encodeURIComponent(q)}`)
|
||
if (r.error && !r.results?.length) {
|
||
grid.innerHTML = `<div class="empty-pane">⚠ ${escapeHtml(r.error)}</div>`
|
||
return
|
||
}
|
||
const items = r.results || []
|
||
if (!items.length) { grid.innerHTML = `<div class="empty-pane">── no plugins found ──</div>`; 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) => `<span class="chip">${escapeHtml(t)}</span>`).join("")
|
||
const lang = p.language ? `<span class="chip lang">${escapeHtml(p.language)}</span>` : ""
|
||
return `
|
||
<article class="scard">
|
||
<div class="scard-tag scard-stars">${p.stars}</div>
|
||
<h3 class="scard-title">${escapeHtml(p.full_name)}</h3>
|
||
<div class="scard-sub"><a href="${escapeHtml(p.html_url)}" target="_blank" rel="noopener" style="color:var(--ink-3)">${escapeHtml(p.html_url)}</a></div>
|
||
<p class="scard-desc">${escapeHtml(p.description || "(no description)")}</p>
|
||
${(topics || lang) ? `<div class="scard-chips">${topics}${lang}</div>` : ""}
|
||
<div class="scard-actions">
|
||
<button class="btn-primary grow" data-install-plugin="${escapeHtml(p.full_name)}">+ Install</button>
|
||
<a class="btn-mini" href="${escapeHtml(p.html_url)}" target="_blank" rel="noopener" style="text-decoration:none">View ↗</a>
|
||
</div>
|
||
</article>`
|
||
}
|
||
|
||
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 = `<div class="empty-pane">── no custom bundles yet ──<br>Create one below to wire several skills into a single <code>/slash</code> command.</div>`
|
||
} else {
|
||
userGrid.innerHTML = ub.map((b) => `
|
||
<article class="scard user">
|
||
<div class="scard-tag">custom</div>
|
||
<h3 class="scard-title">/${escapeHtml(b.name)}</h3>
|
||
<div class="scard-sub"><span>${escapeHtml(b._dir || b._file || "")}</span></div>
|
||
<div class="scard-actions">
|
||
<button class="btn-danger btn-mini grow" data-bundle-delete="${escapeHtml(b.name)}">Delete</button>
|
||
</div>
|
||
</article>`).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 = `<div class="empty-pane">── no matches ──</div>`; return }
|
||
grid.innerHTML = filtered.slice(0, 200).map((b) => `
|
||
<article class="scard">
|
||
<div class="scard-tag">${escapeHtml(b.category || "—")}</div>
|
||
<h3 class="scard-title">${escapeHtml(b.name)}</h3>
|
||
<div class="scard-sub"><span>${escapeHtml(b.hash)}</span></div>
|
||
<p class="scard-desc">${escapeHtml(b.description || "(no description on disk)")}</p>
|
||
</article>`).join("") + (filtered.length > 200 ? `<div class="empty-pane">… ${filtered.length - 200} more · refine filter to see</div>` : "")
|
||
}
|
||
|
||
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) => `
|
||
<div class="stat-tile ${t.cls}">
|
||
<div class="stat-label">${escapeHtml(t.label)}</div>
|
||
<div class="stat-value">${escapeHtml(String(t.value))}</div>
|
||
${t.sub ? `<div class="stat-sub">${escapeHtml(t.sub)}</div>` : ""}
|
||
</div>`).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]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join("")
|
||
|
||
const tbody = $("#curator-recent tbody")
|
||
tbody.innerHTML = (r.leastRecent || []).map((s) => `
|
||
<tr>
|
||
<td>${escapeHtml(s.name)}</td>
|
||
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${s.activity}</td>
|
||
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${s.uses}</td>
|
||
<td style="font-family:var(--ff-mono);color:var(--ink-3)">${escapeHtml(s.lastActivity)}</td>
|
||
</tr>`).join("") || `<tr><td colspan="4" style="text-align:center;color:var(--ink-4)">── no recent data ──</td></tr>`
|
||
|
||
const archived = $("#curator-archived")
|
||
archived.innerHTML = (r.archived || []).length
|
||
? (r.archived.map((a) => `<li><span class="id">${escapeHtml(a)}</span></li>`).join(""))
|
||
: `<li class="empty">── none archived ──</li>`
|
||
}
|
||
|
||
// ── MCP ────────────────────────────────────────────────────────────────────
|
||
async function loadMcp() {
|
||
const r = await api("/api/mcp")
|
||
const list = $("#mcp-list")
|
||
list.innerHTML = ""
|
||
if (!r.servers?.length) {
|
||
list.innerHTML = `<li class="empty">── no servers connected ──</li>`
|
||
return
|
||
}
|
||
for (const s of r.servers) {
|
||
const li = document.createElement("li")
|
||
li.innerHTML = `
|
||
<span>
|
||
<strong>${escapeHtml(s.name)}</strong>
|
||
<span class="id"> · ${escapeHtml(s.detail || "")}</span>
|
||
</span>
|
||
<span class="row-actions">
|
||
<button class="btn-ghost btn-mini" data-mcp-test="${escapeHtml(s.name)}">Test</button>
|
||
<button class="btn-danger btn-mini" data-mcp-remove="${escapeHtml(s.name)}">Remove</button>
|
||
</span>
|
||
`
|
||
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 = `<li class="empty">── no scheduled tasks ──</li>`
|
||
return
|
||
}
|
||
for (const j of r.jobs) {
|
||
const li = document.createElement("li")
|
||
const paused = j.status.toLowerCase().includes("paused")
|
||
li.innerHTML = `
|
||
<span>
|
||
<span class="id">${j.id.slice(0, 8)}</span>
|
||
<strong>${escapeHtml(j.schedule)}</strong>
|
||
· ${escapeHtml(j.prompt)}
|
||
<span class="badge">${escapeHtml(j.status)}</span>
|
||
</span>
|
||
<span class="row">
|
||
<button class="btn-mini" data-cron-action="run">Run</button>
|
||
<button class="btn-mini" data-cron-action="${paused ? "resume" : "pause"}">${paused ? "Resume" : "Pause"}</button>
|
||
<button class="btn-danger btn-mini" data-cron-action="remove">Remove</button>
|
||
</span>
|
||
`
|
||
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 =
|
||
`<span class="kv-k">FILE</span><span class="kv-v" style="max-width:none">${escapeHtml(r.path)}</span>`
|
||
}
|
||
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 = '<tr><td colspan="7" class="empty-cell">No API users found.</td></tr>'
|
||
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 ? '<span class="badge badge-pre">pre</span>' : ""
|
||
const postBadge = u.access_post ? '<span class="badge badge-post">post</span>' : ""
|
||
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"
|
||
? `<button class="btn-mini" data-api-user-action="revoke" data-id="${escapeHtml(u.id)}">Revoke</button>`
|
||
: `<button class="btn-mini" data-api-user-action="reactivate" data-id="${escapeHtml(u.id)}">Reactivate</button>`
|
||
return `<tr>
|
||
<td><span class="api-user-name">${escapeHtml(u.display_name || u.id)}</span><br><span class="badge ${statusClass}">${escapeHtml(u.status || "active")}</span></td>
|
||
<td><code class="key-mask">${maskedKey}</code></td>
|
||
<td>${preBadge}${postBadge}</td>
|
||
<td>${escapeHtml(rate)}<br><span class="cell-sub">${escapeHtml(monthly)}</span></td>
|
||
<td>${escapeHtml(lastUsed)}</td>
|
||
<td>${escapeHtml(expires)}</td>
|
||
<td class="action-cell">
|
||
<button class="btn-mini" data-api-user-action="edit" data-id="${escapeHtml(u.id)}">Edit</button>
|
||
<button class="btn-mini" data-api-user-action="rotate" data-id="${escapeHtml(u.id)}">Rotate Key</button>
|
||
<button class="btn-mini" data-api-user-action="logs" data-id="${escapeHtml(u.id)}">Logs</button>
|
||
${revokeOrReactivate}
|
||
<button class="btn-mini btn-danger" data-api-user-action="delete" data-id="${escapeHtml(u.id)}">Delete</button>
|
||
</td>
|
||
</tr>`
|
||
}).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()
|