const state = { summary: null, detail: null, groupDetail: null, newKey: null, loading: false, usageData: null }; const content = document.getElementById("content"); const notice = document.getElementById("notice"); const pageTitle = document.getElementById("pageTitle"); const pageSubtitle = document.getElementById("pageSubtitle"); const sidebarMeta = document.getElementById("sidebarMeta"); document.getElementById("refreshButton").addEventListener("click", () => refresh(true)); window.addEventListener("hashchange", () => renderRoute()); content.addEventListener("click", async (event) => { const button = event.target.closest("button[data-action]"); if (!button) { return; } const { action, clientId, model, keyId, groupId, memberId } = button.dataset; try { setBusy(button, true); if (action === "disable-hour") { await api(`/clients/${encodeURIComponent(clientId)}/disable`, { method: "POST", body: { mode: "duration", durationMinutes: 60 } }); setNotice(`Disabled ${clientId} for one hour.`); await refresh(); } if (action === "disable-manual") { await api(`/clients/${encodeURIComponent(clientId)}/disable`, { method: "POST", body: { mode: "manual" } }); setNotice(`Disabled ${clientId}.`); await refresh(); } if (action === "enable-client") { await api(`/clients/${encodeURIComponent(clientId)}/enable`, { method: "POST" }); setNotice(`Enabled ${clientId}.`); await refresh(); } if (action === "model-command") { await runModelCommand(clientId, model, button.dataset.modelAction); } if (action === "delete-key") { if (!confirm("Delete this API key?")) { return; } await api(`/api-keys/${encodeURIComponent(keyId)}`, { method: "DELETE" }); setNotice("API key deleted."); await refresh(); } if (action === "copy-key") { await navigator.clipboard.writeText(button.dataset.key); setNotice("API key copied."); } if (action === "delete-group") { if (!confirm("Delete this group and all its clients and key assignments?")) { return; } await api(`/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" }); setNotice("Group deleted."); window.location.hash = "#groups"; await refresh(); } if (action === "remove-client") { if (!confirm("Remove this client from the group?")) { return; } await api(`/groups/${encodeURIComponent(groupId)}/clients/${memberId}`, { method: "DELETE" }); setNotice("Client removed."); await loadGroupDetail(groupId); } if (action === "toggle-key-assignment") { await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true"); } if (action === "delete-billing-rule") { const ruleId = button.dataset.ruleId; if (!confirm("Delete this billing rule?")) { return; } await api(`/groups/${encodeURIComponent(groupId)}/billing/rules/${ruleId}`, { method: "DELETE" }); setNotice("Billing rule deleted."); await loadGroupDetail(groupId); } if (action === "delete-payment") { const paymentId = button.dataset.paymentId; if (!confirm("Delete this payment record?")) { return; } await api(`/groups/${encodeURIComponent(groupId)}/billing/payments/${paymentId}`, { method: "DELETE" }); setNotice("Payment deleted."); await loadGroupDetail(groupId); } } catch (error) { setNotice(error.message, true); } finally { setBusy(button, false); } }); content.addEventListener("submit", async (event) => { const form = event.target; if (!(form instanceof HTMLFormElement)) { return; } event.preventDefault(); const data = Object.fromEntries(new FormData(form).entries()); const submit = form.querySelector("button[type=submit]"); try { setBusy(submit, true); if (form.dataset.form === "model-action") { await runModelCommand(data.clientId, data.model, data.action); form.reset(); } if (form.dataset.form === "model-detail") { await loadModelDetail(data.model, data.clientId); } if (form.dataset.form === "api-key") { state.newKey = await api("/api-keys", { method: "POST", body: { name: data.name } }); setNotice("API key created."); await refresh(); } if (form.dataset.form === "create-group") { const result = await api("/groups", { method: "POST", body: { name: data.name } }); setNotice("Group created."); form.reset(); await refresh(); window.location.hash = `#groups/${encodeURIComponent(result.id)}`; } if (form.dataset.form === "edit-group-name") { const groupId = form.dataset.groupId; await api(`/groups/${encodeURIComponent(groupId)}`, { method: "PUT", body: { name: data.name } }); setNotice("Group name updated."); await refresh(); await loadGroupDetail(groupId); } if (form.dataset.form === "add-client") { const groupId = form.dataset.groupId; const body = {}; if (data.clientId && data.clientId.trim()) { body.clientId = data.clientId.trim(); } if (data.model && data.model.trim()) { body.model = data.model.trim(); } if (data.clientPattern && data.clientPattern.trim()) { body.clientPattern = data.clientPattern.trim(); } if (!body.clientId && !body.clientPattern) { throw new Error("Either Client ID or Client pattern is required."); } await api(`/groups/${encodeURIComponent(groupId)}/clients`, { method: "POST", body }); setNotice("Client added."); form.reset(); await loadGroupDetail(groupId); } if (form.dataset.form === "billing-config") { const groupId = form.dataset.groupId; await api(`/groups/${encodeURIComponent(groupId)}/billing`, { method: "PUT", body: { currency: data.currency, defaultRatePer1k: parseFloat(data.defaultRatePer1k) || 0, refuseBelowBalance: parseFloat(data.refuseBelowBalance) || 0, enabled: data.enabled === "on" } }); setNotice("Billing configuration saved."); await loadGroupDetail(groupId); } if (form.dataset.form === "add-billing-rule") { const groupId = form.dataset.groupId; await api(`/groups/${encodeURIComponent(groupId)}/billing/rules`, { method: "POST", body: { modelRegex: data.modelRegex, ratePer1k: parseFloat(data.ratePer1k) || 0 } }); setNotice("Billing rule added."); form.reset(); await loadGroupDetail(groupId); } if (form.dataset.form === "add-payment") { const groupId = form.dataset.groupId; await api(`/groups/${encodeURIComponent(groupId)}/billing/payments`, { method: "POST", body: { amount: parseFloat(data.amount) || 0, description: data.description || null } }); setNotice("Payment recorded."); form.reset(); await loadGroupDetail(groupId); } } catch (error) { setNotice(error.message, true); } finally { setBusy(submit, false); } }); async function boot() { await refresh(); setInterval(() => refresh(), 15000); } async function refresh(showNotice = false, render = true) { state.loading = true; try { state.summary = await api("/summary"); updateShell(); if (render) { await renderRoute(); } if (showNotice) { setNotice("Refreshed."); } } catch (error) { setNotice(error.message, true); content.innerHTML = `
${escapeHtml(error.message)}
`; } finally { state.loading = false; } } async function api(path, options = {}) { const headers = options.headers ? { ...options.headers } : {}; const init = { method: options.method || "GET", credentials: "same-origin", headers }; if (options.body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(options.body); } const response = await fetch(`/api/admin${path}`, init); const contentType = response.headers.get("content-type") || ""; const body = contentType.includes("application/json") ? await response.json() : await response.text(); if (!response.ok) { const message = body?.detail || body?.error || body?.title || response.statusText; throw new Error(message); } return body; } async function renderRoute() { const hash = (window.location.hash || "#clients").slice(1); const parts = hash.split("/"); const view = parts[0]; const encodedParam = parts[1]; document.querySelectorAll("[data-nav]").forEach((link) => { link.classList.toggle("active", link.dataset.nav === view); }); if (view === "models" && encodedParam) { const model = decodeURIComponent(encodedParam); await loadModelDetail(model); return; } if (view === "groups" && encodedParam) { const groupId = decodeURIComponent(encodedParam); await loadGroupDetail(groupId); return; } state.detail = null; state.groupDetail = null; if (view === "models") { renderModels(); return; } if (view === "api-keys") { renderApiKeys(); return; } if (view === "groups") { renderGroups(); return; } if (view === "usage") { await loadUsage(); return; } renderClients(); } function updateShell() { const summary = state.summary; if (!summary) { return; } sidebarMeta.innerHTML = `
${escapeHtml(summary.user?.name || "Signed in")}
${summary.clients.length} clients
${summary.models.length} models
${(summary.groups || []).length} groups
${formatDate(summary.generatedAtUtc)}
`; } function renderClients() { const clients = state.summary?.clients || []; pageTitle.textContent = "Clients"; pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls."; content.innerHTML = `

Clients

${clients.length} total
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
`; } function clientsTable(clients) { const clientGroups = state.summary?.clientGroups || {}; const rows = clients.map((client) => { const groups = clientGroups[client.id] || []; return `
${escapeHtml(client.id)}
${client.connected ? "Connected" : "Offline"}${client.modelsUpdatedAt ? `, models ${formatDate(client.modelsUpdatedAt)}` : ""}
${client.connected ? badge("Connected", "good") : badge("Offline", "")} ${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
${client.disabled ? `
${escapeHtml(disabledText(client))}
` : ""} ${number(client.pendingRequests)}
${number(client.requestStats.total)}
${number(client.requestStats.last10Minutes)} in 10m, ${number(client.requestStats.lastHour)} in 1h
${modelBadges(client.models)} ${modelBadges(client.activeModels)}
${groups.length ? groups.map((g) => `${escapeHtml(g)}`).join("") : `None`}
`}).join(""); return ` ${rows}
Client Status Pending Requests Listed models Active models Groups Actions
`; } function renderModels() { const models = state.summary?.models || []; const clients = connectedClients(); pageTitle.textContent = "Models"; pageSubtitle.textContent = "Listed and active models, recent request volume, and model operations."; content.innerHTML = `

Run model action

${modelActionForm(clients)}

Models

${models.length} total
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
`; } function modelActionForm(clients, selectedModel = "", selectedClient = "") { return `
`; } function modelsTable(models) { const rows = models.map((model) => `
${escapeHtml(model.name)}
${number(model.metrics.totalRequests)} total requests
${modelBadges(model.listedClients)} ${modelBadges(model.activeClients)}
${number(model.metrics.requestsLast10Minutes)}
${number(model.metrics.requestsLastHour)} in last hour
${number(model.metrics.tokensLast10Minutes)}
${number(model.metrics.tokensLastHour)} in last hour
Details `).join(""); return ` ${rows}
Model Listed on Active on Requests 10m Tokens 10m
`; } async function loadModelDetail(model, clientId) { pageTitle.textContent = "Model Detail"; pageSubtitle.textContent = model; content.innerHTML = `
Loading model detail...
`; const query = new URLSearchParams({ model }); if (clientId) { query.set("clientId", clientId); } state.detail = await api(`/models/detail?${query.toString()}`); renderModelDetail(); } function renderModelDetail() { const detail = state.detail; const clients = connectedClients(); const model = detail.model; const selectedClient = detail.selectedClientId || clients[0]?.id || ""; const metrics = detail.metrics; const showBody = detail.show?.body === undefined ? null : detail.show.body; pageTitle.textContent = "Model Detail"; pageSubtitle.textContent = model; content.innerHTML = `
Back to models
${metric(number(metrics.requestsLast10Minutes), "Requests in 10 minutes")} ${metric(number(metrics.requestsLastHour), "Requests in 1 hour")} ${metric(number(metrics.tokensLast10Minutes), "Tokens in 10 minutes")} ${metric(number(metrics.tokensLastHour), "Tokens in 1 hour")}

Placement

${badge("Listed", detail.listedClients.length ? "good" : "")} ${modelBadges(detail.listedClients)}
${badge("Active", detail.activeClients.length ? "good" : "warn")} ${modelBadges(detail.activeClients)}

Client action

Ollama show response

${detail.show ? badge(detail.show.ok ? "OK" : `HTTP ${detail.show.statusCode}`, detail.show.ok ? "good" : "bad") : ""}
${detail.show ? `
${escapeHtml(formatJson(showBody))}
` : emptyState("No connected client was available for details.")}
`; } async function runModelCommand(clientId, model, action) { if (!clientId || !model || !action) { throw new Error("Client, model, and action are required."); } if (action === "remove" && !confirm(`Remove ${model} from ${clientId}?`)) { return; } const result = await api("/models/actions", { method: "POST", body: { clientId, model, action } }); if (!result.ok) { throw new Error(modelActionError(result)); } const detail = modelActionResultDetail(result); const completed = `${capitalize(action)} completed for ${model} on ${clientId}${detail ? ` (${detail})` : ""}.`; if (action === "load" || action === "unload") { const shouldBeActive = action === "load"; setNotice(`${completed} Waiting for active model snapshot.`); if (await waitForModelActiveState(clientId, model, shouldBeActive)) { setNotice(completed); return; } setNotice(`${completed} The active model snapshot has not reflected the change yet.`); return; } setNotice(completed); await refreshAfterModelCommand(model, clientId); } function renderApiKeys() { const keys = state.summary?.apiKeys || []; pageTitle.textContent = "API Keys"; pageSubtitle.textContent = "Keys accepted by the proxy token header, bearer auth, query token, and token path."; content.innerHTML = ` ${state.newKey ? newKeyPanel(state.newKey) : ""}

Create API key

API keys

${keys.length} total
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
`; } function newKeyPanel(key) { return `
New API key ${escapeHtml(key.key)}
`; } function apiKeysTable(keys) { const rows = keys.map((key) => `
${escapeHtml(key.name)}
${escapeHtml(key.keyPrefix)}...
${formatDate(key.createdAtUtc)} ${key.lastUsedUtc ? formatDate(key.lastUsedUtc) : "Never"} `).join(""); return ` ${rows}
Name Created Last used
`; } function renderGroups() { const groups = state.summary?.groups || []; pageTitle.textContent = "Groups"; pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach."; content.innerHTML = `

Create group

Groups

${groups.length} total
${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
`; } function groupsTable(groups) { const rows = groups.map((group) => ` ${escapeHtml(group.name)} ${formatDate(group.createdAtUtc)}
Edit
`).join(""); return ` ${rows}
Name Created
`; } async function loadGroupDetail(groupId) { pageTitle.textContent = "Group Detail"; pageSubtitle.textContent = groupId; content.innerHTML = `
Loading group...
`; try { const [group, clients, apiKeyGroups, billing, rules, payments, balance] = await Promise.all([ api(`/groups/${encodeURIComponent(groupId)}`), api(`/groups/${encodeURIComponent(groupId)}/clients`), api("/api-keys/groups"), api(`/groups/${encodeURIComponent(groupId)}/billing`), api(`/groups/${encodeURIComponent(groupId)}/billing/rules`), api(`/groups/${encodeURIComponent(groupId)}/billing/payments`), api(`/groups/${encodeURIComponent(groupId)}/billing/balance`) ]); state.groupDetail = { group, clients, apiKeyGroups, billing, rules, payments, balance }; renderGroupDetail(); } catch (error) { content.innerHTML = `
${escapeHtml(error.message)}
`; } } function renderGroupDetail() { const { group, clients, apiKeyGroups, billing, rules, payments, balance } = state.groupDetail; const allApiKeys = state.summary?.apiKeys || []; const assignedKeyIds = new Set( apiKeyGroups .filter((akg) => akg.apiKeyId && (akg.groupIds || []).includes(group.id)) .map((akg) => akg.apiKeyId) ); pageTitle.textContent = "Group Detail"; pageSubtitle.textContent = group.name; content.innerHTML = `
Back to groups

Group name

Add client

Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model.

Clients

${clients.length} total
${clients.length ? groupClientsTable(clients, group.id) : emptyState("No clients in this group.")}

API key assignments

${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")}

Billing

${billing.enabled ? badge("Enabled", "good") : badge("Disabled", "")}

Balance

${formatCurrency(balance.balance, balance.currency)} Current balance
${formatCurrency(balance.totalPayments, balance.currency)} payments
${formatCurrency(balance.totalCosts, balance.currency)} costs

Billing rules

${rules.length} total
${rules.length ? billingRulesTable(rules, group.id) : emptyState("No billing rules. The default rate applies to all models.")}

Payments

${payments.length} total
${payments.length ? paymentsTable(payments, group.id) : emptyState("No payments recorded.")}
`; } function groupClientsTable(clients, groupId) { const rows = clients.map((client) => ` ${client.clientId ? `
${escapeHtml(client.clientId)}
` : `
-
` } ${client.model ? `
${escapeHtml(client.model)}
` : `
All models
` } ${client.clientPattern ? `
${escapeHtml(client.clientPattern)}
` : `
-
` } `).join(""); return ` ${rows}
Client ID Model Client pattern
`; } function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { const rows = apiKeys.map((key) => { const isAssigned = assignedKeyIds.has(key.id); return `
${escapeHtml(key.name)}
${escapeHtml(key.keyPrefix)}...
`; }).join(""); return ` ${rows}
API Key
`; } async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { const apiKeyGroups = state.groupDetail?.apiKeyGroups || []; const allApiKeys = state.summary?.apiKeys || []; const keyGroups = apiKeyGroups.find((akg) => akg.apiKeyId === keyId); const currentGroupIds = keyGroups ? [...keyGroups.groupIds] : []; let newGroupIds; if (currentlyAssigned) { newGroupIds = currentGroupIds.filter((id) => id !== groupId); } else { newGroupIds = [...currentGroupIds, groupId]; } await api(`/api-keys/${encodeURIComponent(keyId)}/groups`, { method: "PUT", body: { groupIds: newGroupIds } }); setNotice(currentlyAssigned ? "API key unassigned from group." : "API key assigned to group."); await loadGroupDetail(groupId); } function billingRulesTable(rules, groupId) { const rows = rules.map((rule) => `
${escapeHtml(rule.modelRegex)}
${rule.ratePer1k} `).join(""); return ` ${rows}
Model regex Rate / 1k tokens
`; } function paymentsTable(payments, groupId) { const rows = payments.map((payment) => ` ${formatCurrency(payment.amount, "")} ${payment.description ? escapeHtml(payment.description) : `-`} ${payment.createdBy ? escapeHtml(payment.createdBy) : `-`} ${formatDate(payment.createdAtUtc)} `).join(""); return ` ${rows}
Amount Description Created by Date
`; } function formatCurrency(value, currency) { const num = typeof value === "number" ? value : parseFloat(value) || 0; const formatted = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(num); if (currency && typeof currency === "string" && currency.length > 0) { return `${formatted} ${currency}`; } return formatted; } async function loadUsage() { pageTitle.textContent = "Usage"; pageSubtitle.textContent = "Token usage and revenue statistics."; content.innerHTML = `
Loading usage data...
`; try { const [usage, revenue] = await Promise.all([ api("/usage/tokens"), api("/usage/revenue") ]); state.usageData = { usage, revenue }; renderUsage(); } catch (error) { content.innerHTML = `
${escapeHtml(error.message)}
`; } } function renderUsage() { const { usage, revenue } = state.usageData; const hasAnyBilling = (state.summary?.groups || []).some((g) => { const billing = state.groupDetail?.billing; return billing?.enabled; }); const byModel = usage.byModel || []; const byClient = usage.byClient || []; const byApiKey = usage.byApiKey || []; const byGroup = usage.byGroup || []; const clientRevenue = revenue || []; const revenueMap = {}; for (const r of clientRevenue) { revenueMap[r.clientId] = r; } content.innerHTML = `

Tokens by model

${byModel.length} models
${byModel.length ? tokenStatsModelTable(byModel) : emptyState("No token data yet.")}

Tokens by machine

${byClient.length} machines
${byClient.length ? tokenStatsClientTable(byClient, revenueMap) : emptyState("No token data yet.")}

Tokens by API key

${byApiKey.length} keys
${byApiKey.length ? tokenStatsApiKeyTable(byApiKey) : emptyState("No token data yet.")}

Tokens by group

${byGroup.length} groups
${byGroup.length ? tokenStatsGroupTable(byGroup) : emptyState("No token data yet.")}
`; } function tokenStatsModelTable(stats) { const rows = stats.map((s) => `
${escapeHtml(s.model)}
${number(s.promptTokens)} ${number(s.completionTokens)} ${number(s.totalTokens)} ${number(s.requests)} `).join(""); return ` ${rows}
Model Prompt tokens Completion tokens Total tokens Requests
`; } function tokenStatsClientTable(stats, revenueMap) { const rows = stats.map((s) => { const rev = revenueMap[s.clientId]; return `
${escapeHtml(s.clientId)}
${number(s.promptTokens)} ${number(s.completionTokens)} ${number(s.totalTokens)} ${number(s.requests)} ${rev ? `${formatCurrency(rev.revenue, rev.currency)}` : `-`} `}).join(""); return ` ${rows}
Machine Prompt tokens Completion tokens Total tokens Requests Revenue
`; } function tokenStatsApiKeyTable(stats) { const rows = stats.map((s) => `
${escapeHtml(s.apiKeyName)}
${escapeHtml(s.apiKeyPrefix)}...
${number(s.promptTokens)} ${number(s.completionTokens)} ${number(s.totalTokens)} ${number(s.requests)} `).join(""); return ` ${rows}
API key Prompt tokens Completion tokens Total tokens Requests
`; } function tokenStatsGroupTable(stats) { const rows = stats.map((s) => `
${escapeHtml(s.groupName)}
${number(s.promptTokens)} ${number(s.completionTokens)} ${number(s.totalTokens)} ${number(s.requests)} `).join(""); return ` ${rows}
Group Prompt tokens Completion tokens Total tokens Requests
`; } function connectedClients() { return (state.summary?.clients || []).filter((client) => client.connected); } async function waitForModelActiveState(clientId, model, shouldBeActive) { const deadline = Date.now() + 15000; while (Date.now() <= deadline) { await refreshAfterModelCommand(model, clientId); const client = (state.summary?.clients || []) .find((item) => sameText(item.id, clientId)); if (client && modelListContains(client.activeModels, model) === shouldBeActive) { return true; } await delay(1000); } return false; } async function refreshAfterModelCommand(model, clientId) { await refresh(false, false); const hash = window.location.hash || ""; if (hash.startsWith(`#models/${encodeURIComponent(model)}`)) { await loadModelDetail(model, clientId); return; } await renderRoute(); } function modelActionResultDetail(result) { const body = result?.body; if (!body || typeof body === "string") { return body || ""; } if (body.status) { return body.status; } if (body.done_reason) { return `done: ${body.done_reason}`; } if (body.done) { return "done"; } return ""; } function modelActionError(result) { const body = result?.body; if (typeof body === "string" && body) { return body; } if (body?.error) { return body.error; } if (body?.message) { return body.message; } return `Model action failed with HTTP ${result.statusCode}.`; } function modelListContains(models, model) { return (models || []).some((item) => sameModelName(item, model)); } function sameModelName(left, right) { return stripLatestTag(left).toLowerCase() === stripLatestTag(right).toLowerCase(); } function sameText(left, right) { return String(left || "").trim().toLowerCase() === String(right || "").trim().toLowerCase(); } function stripLatestTag(model) { const value = String(model || "").trim(); return value.toLowerCase().endsWith(":latest") ? value.slice(0, -":latest".length) : value; } function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function clientOptions(clients, selectedClient) { if (!clients.length) { return ``; } return clients.map((client) => ` `).join(""); } function modelBadges(items) { if (!items || !items.length) { return `None`; } return `
${items.map((item) => badge(item, "")).join("")}
`; } function badge(text, kind) { return `${escapeHtml(text)}`; } function metric(value, label) { return `
${escapeHtml(value)} ${escapeHtml(label)}
`; } function emptyState(text) { return `
${escapeHtml(text)}
`; } function disabledText(client) { if (client.disabledManually) { return "Until enabled manually"; } return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled"; } function formatDate(value) { if (!value) { return ""; } return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "medium" }).format(new Date(value)); } function formatJson(value) { if (typeof value === "string") { return value; } return JSON.stringify(value, null, 2); } function number(value) { return new Intl.NumberFormat().format(value || 0); } function capitalize(value) { return value ? value[0].toUpperCase() + value.slice(1) : value; } function setNotice(message, isError = false) { notice.hidden = false; notice.textContent = message; notice.classList.toggle("error", isError); clearTimeout(setNotice.timer); setNotice.timer = setTimeout(() => { notice.hidden = true; }, isError ? 7000 : 3500); } function setBusy(element, busy) { if (!element) { return; } element.disabled = busy; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttr(value) { return escapeHtml(value); } boot();