@@ -0,0 +1,496 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f9;
|
||||
--panel: #ffffff;
|
||||
--panel-alt: #eef2f6;
|
||||
--text: #18212f;
|
||||
--muted: #667085;
|
||||
--line: #d9e0e7;
|
||||
--line-strong: #b8c3cf;
|
||||
--blue: #2f6fed;
|
||||
--blue-dark: #1f4fb7;
|
||||
--green: #117a55;
|
||||
--red: #b42318;
|
||||
--amber: #9a6500;
|
||||
--shadow: 0 12px 30px rgba(24, 33, 47, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px 16px;
|
||||
background: #151b24;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #4c5d74;
|
||||
border-radius: 6px;
|
||||
background: #223044;
|
||||
color: #c8f2df;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand small {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
color: #aeb8c6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: #d8dee8;
|
||||
text-decoration: none;
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav a.active,
|
||||
.nav a:hover {
|
||||
background: #263449;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #344154;
|
||||
color: #b8c3cf;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
outline: 2px solid rgba(47, 111, 237, 0.25);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--blue-dark);
|
||||
border-color: var(--blue-dark);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #ffffff;
|
||||
border-color: var(--line-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: #ffffff;
|
||||
border-color: #e5aaa4;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.button.warning {
|
||||
background: #ffffff;
|
||||
border-color: #e4c37c;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.button.warning:hover {
|
||||
background: #fff8e5;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.cell-main {
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
border-color: #9bd7bd;
|
||||
background: #eaf8f1;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badge.bad {
|
||||
border-color: #efb2ad;
|
||||
background: #fff1f0;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
border-color: #ead09a;
|
||||
background: #fff8e5;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #bad2ff;
|
||||
border-radius: 8px;
|
||||
background: #edf4ff;
|
||||
color: #163d85;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
border-color: #efb2ad;
|
||||
background: #fff1f0;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pre {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #101820;
|
||||
color: #e9eef5;
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.new-key {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #9bd7bd;
|
||||
border-radius: 8px;
|
||||
background: #eaf8f1;
|
||||
}
|
||||
|
||||
.new-key code {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
border: 1px solid #9bd7bd;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button,
|
||||
.input,
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
const state = {
|
||||
summary: null,
|
||||
detail: null,
|
||||
newKey: null,
|
||||
loading: false
|
||||
};
|
||||
|
||||
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 } = 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.");
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
} 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 = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||
} 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 [view, encodedModel] = hash.split("/");
|
||||
|
||||
document.querySelectorAll("[data-nav]").forEach((link) => {
|
||||
link.classList.toggle("active", link.dataset.nav === view);
|
||||
});
|
||||
|
||||
if (view === "models" && encodedModel) {
|
||||
const model = decodeURIComponent(encodedModel);
|
||||
await loadModelDetail(model);
|
||||
return;
|
||||
}
|
||||
|
||||
state.detail = null;
|
||||
|
||||
if (view === "models") {
|
||||
renderModels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "api-keys") {
|
||||
renderApiKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
renderClients();
|
||||
}
|
||||
|
||||
function updateShell() {
|
||||
const summary = state.summary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarMeta.innerHTML = `
|
||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
||||
<div>${summary.clients.length} clients</div>
|
||||
<div>${summary.models.length} models</div>
|
||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClients() {
|
||||
const clients = state.summary?.clients || [];
|
||||
pageTitle.textContent = "Clients";
|
||||
pageSubtitle.textContent = "Connected tunnel clients, request counts, and forwarding controls.";
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Clients</h2>
|
||||
<span class="badge">${clients.length} total</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${clients.length ? clientsTable(clients) : emptyState("No clients have connected yet.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function clientsTable(clients) {
|
||||
const rows = clients.map((client) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-main">${escapeHtml(client.id)}</div>
|
||||
<div class="cell-sub">${client.connected ? "Connected" : "Offline"}${client.modelsUpdatedAt ? `, models ${formatDate(client.modelsUpdatedAt)}` : ""}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge-row">
|
||||
${client.connected ? badge("Connected", "good") : badge("Offline", "")}
|
||||
${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
|
||||
</div>
|
||||
${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
||||
</td>
|
||||
<td>${number(client.pendingRequests)}</td>
|
||||
<td>
|
||||
<div class="cell-main">${number(client.requestStats.total)}</div>
|
||||
<div class="cell-sub">${number(client.requestStats.last10Minutes)} in 10m, ${number(client.requestStats.lastHour)} in 1h</div>
|
||||
</td>
|
||||
<td>${modelBadges(client.models)}</td>
|
||||
<td>${modelBadges(client.activeModels)}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button>
|
||||
<button class="button warning" data-action="disable-manual" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable</button>
|
||||
<button class="button secondary" data-action="enable-client" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "" : "disabled"}>Enable</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Status</th>
|
||||
<th>Pending</th>
|
||||
<th>Requests</th>
|
||||
<th>Listed models</th>
|
||||
<th>Active models</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Run model action</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
${modelActionForm(clients)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Models</h2>
|
||||
<span class="badge">${models.length} total</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${models.length ? modelsTable(models) : emptyState("No models have been reported yet.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function modelActionForm(clients, selectedModel = "", selectedClient = "") {
|
||||
return `
|
||||
<form class="form-row" data-form="model-action">
|
||||
<div class="field">
|
||||
<label for="modelActionClient">Client</label>
|
||||
<select class="select" id="modelActionClient" name="clientId" required>
|
||||
${clientOptions(clients, selectedClient)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="modelActionModel">Model</label>
|
||||
<input class="input" id="modelActionModel" name="model" value="${escapeAttr(selectedModel)}" placeholder="llama3.1" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="modelActionAction">Action</label>
|
||||
<select class="select" id="modelActionAction" name="action" required>
|
||||
<option value="add">Add</option>
|
||||
<option value="load">Load</option>
|
||||
<option value="unload">Unload</option>
|
||||
<option value="remove">Remove</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="button" type="submit" ${clients.length ? "" : "disabled"}>Run</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function modelsTable(models) {
|
||||
const rows = models.map((model) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-main">${escapeHtml(model.name)}</div>
|
||||
<div class="cell-sub">${number(model.metrics.totalRequests)} total requests</div>
|
||||
</td>
|
||||
<td>${modelBadges(model.listedClients)}</td>
|
||||
<td>${modelBadges(model.activeClients)}</td>
|
||||
<td>
|
||||
<div class="cell-main">${number(model.metrics.requestsLast10Minutes)}</div>
|
||||
<div class="cell-sub">${number(model.metrics.requestsLastHour)} in last hour</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell-main">${number(model.metrics.tokensLast10Minutes)}</div>
|
||||
<div class="cell-sub">${number(model.metrics.tokensLastHour)} in last hour</div>
|
||||
</td>
|
||||
<td>
|
||||
<a class="button secondary" href="#models/${encodeURIComponent(model.name)}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Listed on</th>
|
||||
<th>Active on</th>
|
||||
<th>Requests 10m</th>
|
||||
<th>Tokens 10m</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadModelDetail(model, clientId) {
|
||||
pageTitle.textContent = "Model Detail";
|
||||
pageSubtitle.textContent = model;
|
||||
content.innerHTML = `<div class="panel"><div class="empty">Loading model detail...</div></div>`;
|
||||
|
||||
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 = `
|
||||
<div class="toolbar">
|
||||
<a class="button secondary" href="#models">Back to models</a>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
${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")}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Placement</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="badge-row">
|
||||
${badge("Listed", detail.listedClients.length ? "good" : "")}
|
||||
${modelBadges(detail.listedClients)}
|
||||
</div>
|
||||
<div class="badge-row" style="margin-top:8px">
|
||||
${badge("Active", detail.activeClients.length ? "good" : "warn")}
|
||||
${modelBadges(detail.activeClients)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Client action</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form-row" data-form="model-detail">
|
||||
<input type="hidden" name="model" value="${escapeAttr(model)}">
|
||||
<div class="field">
|
||||
<label for="detailClient">Client</label>
|
||||
<select class="select" id="detailClient" name="clientId" required>
|
||||
${clientOptions(clients, selectedClient)}
|
||||
</select>
|
||||
</div>
|
||||
<button class="button secondary" type="submit" ${clients.length ? "" : "disabled"}>Refresh detail</button>
|
||||
</form>
|
||||
<div class="actions" style="margin-top:10px">
|
||||
<button class="button secondary" data-action="model-command" data-model-action="load" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Load</button>
|
||||
<button class="button secondary" data-action="model-command" data-model-action="unload" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Unload</button>
|
||||
<button class="button danger" data-action="model-command" data-model-action="remove" data-client-id="${escapeAttr(selectedClient)}" data-model="${escapeAttr(model)}" ${selectedClient ? "" : "disabled"}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Ollama show response</h2>
|
||||
${detail.show ? badge(detail.show.ok ? "OK" : `HTTP ${detail.show.statusCode}`, detail.show.ok ? "good" : "bad") : ""}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
${detail.show ? `<pre class="pre">${escapeHtml(formatJson(showBody))}</pre>` : emptyState("No connected client was available for details.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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) : ""}
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Create API key</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form-row" data-form="api-key">
|
||||
<div class="field">
|
||||
<label for="apiKeyName">Name</label>
|
||||
<input class="input" id="apiKeyName" name="name" placeholder="e.g. openwebui_prod" required>
|
||||
</div>
|
||||
<button class="button" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>API keys</h2>
|
||||
<span class="badge">${keys.length} total</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
${keys.length ? apiKeysTable(keys) : emptyState("No API keys have been created.")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function newKeyPanel(key) {
|
||||
return `
|
||||
<div class="new-key">
|
||||
<strong>New API key</strong>
|
||||
<code>${escapeHtml(key.key)}</code>
|
||||
<div class="actions">
|
||||
<button class="button secondary" type="button" data-action="copy-key" data-key="${escapeAttr(key.key)}">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function apiKeysTable(keys) {
|
||||
const rows = keys.map((key) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-main">${escapeHtml(key.name)}</div>
|
||||
<div class="cell-sub">${escapeHtml(key.keyPrefix)}...</div>
|
||||
</td>
|
||||
<td>${formatDate(key.createdAtUtc)}</td>
|
||||
<td>${key.lastUsedUtc ? formatDate(key.lastUsedUtc) : "Never"}</td>
|
||||
<td>
|
||||
<button class="button danger" data-action="delete-key" data-key-id="${escapeAttr(key.id)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<option value="">No connected clients</option>`;
|
||||
}
|
||||
|
||||
return clients.map((client) => `
|
||||
<option value="${escapeAttr(client.id)}" ${client.id === selectedClient ? "selected" : ""}>${escapeHtml(client.id)}</option>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function modelBadges(items) {
|
||||
if (!items || !items.length) {
|
||||
return `<span class="cell-sub">None</span>`;
|
||||
}
|
||||
|
||||
return `<div class="badge-row">${items.map((item) => badge(item, "")).join("")}</div>`;
|
||||
}
|
||||
|
||||
function badge(text, kind) {
|
||||
return `<span class="badge ${kind || ""}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function metric(value, label) {
|
||||
return `
|
||||
<div class="metric">
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function emptyState(text) {
|
||||
return `<div class="empty">${escapeHtml(text)}</div>`;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ReverseLlama Admin</title>
|
||||
<link rel="stylesheet" href="/admin/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">RL</span>
|
||||
<span>
|
||||
<strong>ReverseLlama</strong>
|
||||
<small>Admin</small>
|
||||
</span>
|
||||
</div>
|
||||
<nav class="nav" aria-label="Admin sections">
|
||||
<a href="#clients" data-nav="clients">Clients</a>
|
||||
<a href="#models" data-nav="models">Models</a>
|
||||
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
||||
</nav>
|
||||
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1 id="pageTitle">Clients</h1>
|
||||
<p id="pageSubtitle">Connected tunnel clients and forwarding controls.</p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="button secondary" id="refreshButton" type="button">Refresh</button>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button secondary" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="notice" class="notice" hidden></section>
|
||||
<section id="content" class="content" aria-live="polite"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/admin/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user