nomyo-router/static/index.html

1478 lines
59 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>NOMYO Router Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
background: #e0e0e0;
color: #333;
padding: 20px;
}
body.auth-locked {
background: #bfbfbf;
}
body.auth-locked > *:not(#api-key-modal) {
display: none !important;
}
.dark-mode {
filter: invert(100%);
}
h2 {
margin: 0;
}
#dark-mode-button {
position: fixed; /* stays relative to the viewport */
top: 1rem; /* distance from top edge */
right: 1rem; /* distance from right edge */
cursor: pointer;
min-width: 1rem;
min-height: 1rem;
font-size: 1rem;
}
.tables-wrapper {
display: flex;
gap: 2rem;
}
.table-container {
flex: 1;
min-width: 350px;
background: white;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
.endpoints-container {
flex: 1;
min-width: 350px;
background: white;
padding: 1rem;
margin-top: 1rem;
border-radius: 6px;
}
/* ---------- Header + Pull form ---------- */
.header-pull-wrapper {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
margin-bottom: 0;
}
#pull-section {
display: flex;
align-items: center;
}
#pull-section label {
min-width: 30%;
}
#pull-section input {
flex: 1;
padding: 0;
margin-right: 0.5rem;
margin-left: 0.5rem;
min-width: 50%;
min-height: 1.5rem;
text-align: left;
text-indent: 0.25rem;
outline: 0.1rem solid;
}
#pull-section button {
padding: 0 0;
cursor: pointer;
background-color: #e0e0e0;
color: black;
border: none;
outline: none;
margin-left: 0.5rem;
margin-right: 0.5rem;
min-width: 30%;
font-size: 1rem;
transition: 0.3s;
}
#pull-section button:hover {
background-color: #d1d1d1;
}
#pull-status {
margin-left: 0.5rem;
font-weight: bold;
}
/* ---------- Tables ---------- */
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th,
td {
border: 1px solid #ddd;
padding: 0.5rem;
text-align: left;
}
th {
background: #e0e0e0;
}
.ps-subrow {
display: block;
}
.ps-subrow + .ps-subrow {
margin-top: 2px;
}
#ps-table {
width: max-content;
min-width: 100%;
}
#ps-table th.model-col,
#ps-table td.model {
min-width: 200px;
max-width: 300px;
white-space: nowrap;
}
/* Optimize narrow columns */
#ps-table th:nth-child(3),
#ps-table td:nth-child(3),
#ps-table th:nth-child(4),
#ps-table td:nth-child(4),
#ps-table th:nth-child(5),
#ps-table td:nth-child(5) {
width: 80px;
text-align: center;
}
.loading {
color: #777;
font-style: italic;
}
.status-ok {
color: #006400;
font-weight: bold;
}
.status-error {
color: #8b0000;
font-weight: bold;
}
.copy-link,
.delete-link,
.show-link,
.stats-link {
font-size: 0.9em;
margin-left: 0.5em;
cursor: pointer;
text-decoration: underline;
float: right;
}
.delete-link {
color: #b22222;
}
.copy-link,
.show-link {
color: #0066cc;
}
.delete-link:hover,
.copy-link:hover,
.show-link:hover {
text-decoration: none;
}
/* ---------- Modal ---------- */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
align-items: center;
justify-content: center;
}
.modal-content {
background: #fff;
padding: 1rem;
width: 95%;
height: 95%;
overflow: auto;
border-radius: 6px;
}
.close-btn {
float: right;
cursor: pointer;
font-size: 1.5rem;
}
#api-key-modal .modal-content {
width: 420px;
height: auto;
max-width: 90%;
}
#api-key-modal .modal-message {
margin: 0.5rem 0;
}
#api-key-modal input[type="password"] {
width: 100%;
padding: 0.6rem;
box-sizing: border-box;
}
#api-key-modal .modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
#api-key-indicator {
font-size: 0.9rem;
color: #555;
}
/* ---------- Usage Chart ---------- */
.usage-chart {
margin-top: 20px;
}
.endpoint-bar {
margin-bottom: 12px;
}
.endpoint-label {
font-weight: bold;
margin-bottom: 4px;
}
.bar {
display: flex;
height: 16px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.segment {
height: 100%;
color: white;
font-size: 12px;
font-weight: bolder;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.table-container {
padding-top: 1rem;
}
/* ---------- Responsive reorder ---------- */
@media (max-aspect-ratio: 1/1) {
.tables-wrapper {
flex-direction: column;
}
.tables-wrapper > .table-container:nth-child(1) {
/* Tags container */
order: 2;
}
.tables-wrapper > .table-container:nth-child(2) {
/* PS container */
order: 1;
}
}
/* ---------- Chart Timeframe Controls ---------- */
.timeframe-controls {
margin: 1rem 0;
}
.timeframe-controls button {
margin-right: 0.5rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
background-color: #e0e0e0;
border: none;
border-radius: 4px;
}
.timeframe-controls button.active {
background-color: #0066cc;
color: white;
}
.chart-container {
position: relative;
height: 600px;
margin-top: 1rem;
}
.pie-chart-container {
position: relative;
height: 250px;
margin-top: 1rem;
max-width: 400px;
}
/* ---------- Stats Modal Layout ---------- */
.stats-content-wrapper {
display: flex;
flex-direction: row;
gap: 20px;
}
.main-stats-content {
flex: 1;
}
.endpoint-distribution-container {
flex: 0 0 auto;
width: 400px;
position: relative;
}
.endpoint-distribution-container h3 {
margin-top: 0;
}
.header-row {
display: flex;
align-items: center; /* vertically center the button with the headline */
gap: 1rem;
}
</style>
</head>
<body>
<a href="https://www.nomyo.ai" target="_blank"
><img src="./static/228394408.png" width="100px" height="100px"
/></a>
<div class="header-row">
<h1>Router Dashboard</h1>
<button id="total-tokens-btn">Stats Total</button>
<span id="aggregation-status" class="loading" style="margin-left:8px;"></span>
</div>
<button onclick="toggleDarkMode()" id="dark-mode-button">
🌗
</button>
<div class="tables-wrapper">
<!-- ---------- Tags ---------- -->
<div class="table-container">
<div class="header-pull-wrapper">
<h2>
<span id="tags-count"></span> Available Models (Tags)
</h2>
<div id="pull-section">
<label for="pull-model-input">Pull a model: </label>
<input
type="text"
id="pull-model-input"
placeholder="llama3:latest"
/>
<button id="pull-btn">Pull</button>
<span id="pull-status"></span>
</div>
</div>
<table id="tags-table">
<thead>
<tr>
<th>Model</th>
<th>Digest</th>
</tr>
</thead>
<tbody id="tags-body">
<tr>
<td colspan="2" class="loading">Loading…</td>
</tr>
</tbody>
</table>
</div>
<!-- ---------- PS + Usage Chart ---------- -->
<div class="table-container">
<h2>Running Models (PS)</h2>
<table id="ps-table">
<thead>
<tr>
<th class="model-col">Model</th>
<th>Endpoint</th>
<th>Params</th>
<th>Quant</th>
<th>Ctx</th>
<th>Size</th>
<th>Unload</th>
<th>Digest</th>
<th>Tokens</th>
</tr>
</thead>
<tbody id="ps-body">
<tr>
<td colspan="6" class="loading">Loading…</td>
</tr>
</tbody>
</table>
<!-- ------------- Usage Chart ------------- -->
<div id="usage-chart" class="usage-chart"></div>
</div>
</div>
<div class="endpoints-container">
<h2>Configured Endpoints</h2>
<table id="endpoints-table">
<thead>
<tr>
<th>Endpoint</th>
<th>Status</th>
<th>Version</th>
</tr>
</thead>
<tbody id="endpoints-body">
<tr>
<td colspan="3" class="loading">Loading…</td>
</tr>
</tbody>
</table>
</div>
<script>
let psRows = new Map();
let statsModal = null;
let statsChart = null;
let rawTimeSeries = null;
let totalTokensChart = null;
let usageSource = null;
const API_KEY_STORAGE_KEY = "nomyo-router-api-key";
let apiKeyWaiters = [];
function getStoredApiKey() {
return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
}
function setStoredApiKey(key) {
if (key) {
localStorage.setItem(API_KEY_STORAGE_KEY, key);
} else {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
updateApiKeyIndicator();
}
function updateApiKeyIndicator() {
const indicator = document.getElementById("api-key-indicator");
if (!indicator) return;
const hasKey = !!getStoredApiKey();
indicator.textContent = hasKey ? "API key set" : "API key not set";
indicator.style.color = hasKey ? "green" : "#b22222";
}
function buildAuthedUrl(url) {
const key = getStoredApiKey();
if (!key) return url;
try {
const u = new URL(url, window.location.origin);
if (!u.searchParams.has("api_key")) {
u.searchParams.set("api_key", key);
}
return u.toString();
} catch (err) {
return url;
}
}
function showApiKeyModal(reasonText) {
const overlay = document.getElementById("api-key-modal");
if (!overlay) return Promise.resolve();
document.body.classList.add("auth-locked");
const reason = document.getElementById("api-key-reason");
if (reason) {
reason.textContent =
reasonText ||
"Enter the NOMYO Router API key to continue.";
}
const status = document.getElementById("api-key-status");
if (status) {
status.textContent = "";
}
const input = document.getElementById("api-key-input");
if (input) {
input.value = getStoredApiKey();
setTimeout(() => input.focus(), 10);
}
overlay.style.display = "flex";
return new Promise((resolve) => apiKeyWaiters.push(resolve));
}
function closeApiKeyModal(statusMessage) {
const overlay = document.getElementById("api-key-modal");
const status = document.getElementById("api-key-status");
if (status) {
status.textContent = statusMessage || "";
}
if (overlay) {
overlay.style.display = "none";
}
if (getStoredApiKey()) {
document.body.classList.remove("auth-locked");
}
while (apiKeyWaiters.length) {
const resolve = apiKeyWaiters.pop();
resolve();
}
}
async function authedFetch(url, options = {}, allowRetry = true) {
const headers = new Headers(options.headers || {});
const key = getStoredApiKey();
if (key) {
headers.set("Authorization", `Bearer ${key}`);
}
const response = await fetch(url, { ...options, headers });
if ((response.status === 401 || response.status === 403) && allowRetry) {
await showApiKeyModal("Enter the NOMYO Router API key to continue.");
return authedFetch(url, options, false);
}
return response;
}
/* Integrated modal initialization and close handling into the main load block */
// Assign the stats modal element and attach the close handler once the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Get the modal element (it now exists in the DOM)
statsModal = document.getElementById('stats-modal');
// Attach a single close handler (prevents multiple duplicate listeners)
if (statsModal) {
statsModal.addEventListener('click', (e) => {
if (e.target === statsModal || e.target.matches('.close-btn')) {
// Hide the modal
statsModal.style.display = 'none';
// Clean up the chart instance to avoid caching stale data
if (statsChart) {
statsChart.destroy();
statsChart = null;
}
// Remove the canvas element so a fresh one is created on next open
const oldCanvas = document.getElementById('time-series-chart');
if (oldCanvas) {
oldCanvas.remove();
}
// Reset stored timeseries data to avoid reuse of stale data
rawTimeSeries = null;
}
});
}
});
/* ---------- Global renderTimeSeriesChart ---------- */
function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
// Guard clause
if (!Array.isArray(timeSeriesData) || !timeSeriesData.length) {
chart.data.labels = [];
chart.data.datasets[0].data = [];
chart.data.datasets[1].data = [];
chart.update();
return;
}
/* ── 1⃣ Determine bucket interval based on timeframe ──────────────────── */
let intervalMs;
let timeFormat;
if (minutes <= 60) {
// 1 hour: 5-minute buckets
intervalMs = 5 * 60 * 1000;
timeFormat = { hour: '2-digit', minute: '2-digit' };
} else if (minutes <= 1440) {
// 1 day: 1-hour buckets
intervalMs = 60 * 60 * 1000;
timeFormat = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
} else if (minutes <= 10080) {
// 7 days: 6-hour buckets
intervalMs = 6 * 60 * 60 * 1000;
timeFormat = { month: 'short', day: 'numeric', hour: '2-digit' };
} else {
// 30 days: 1-day buckets
intervalMs = 24 * 60 * 60 * 1000;
timeFormat = { month: 'short', day: 'numeric' };
}
/* ── 2⃣ Get current time in local timezone ──────────────────────────── */
const now = new Date();
const nowMs = now.getTime();
const cutoffMs = nowMs - minutes * 60 * 1000;
/* ── 3⃣ Build ordered bucket slots aligned to local time boundaries ───── */
const slots = [];
// Round cutoff down to nearest bucket interval in local time
const cutoffDate = new Date(cutoffMs);
let startDate = new Date(cutoffDate);
if (minutes <= 60) {
// Align to 5-minute boundaries
startDate.setMinutes(Math.floor(startDate.getMinutes() / 5) * 5, 0, 0);
} else if (minutes <= 1440) {
// Align to hour boundaries
startDate.setMinutes(0, 0, 0);
} else if (minutes <= 10080) {
// Align to 6-hour boundaries (00:00, 06:00, 12:00, 18:00)
startDate.setHours(Math.floor(startDate.getHours() / 6) * 6, 0, 0, 0);
} else {
// Align to day boundaries
startDate.setHours(0, 0, 0, 0);
}
let slotTime = startDate.getTime();
while (slotTime <= nowMs) {
slots.push(slotTime);
slotTime += intervalMs;
}
/* ── 4⃣ Aggregate raw rows into local time buckets ───────────────────── */
const bucketMap = {};
timeSeriesData.forEach(row => {
// Database stores UTC timestamps in seconds, convert to local time milliseconds
const utcTimestampMs = row.timestamp * 1000;
// Check if within our time window
if (utcTimestampMs < cutoffMs || utcTimestampMs > nowMs) return;
// Find which bucket this timestamp belongs to
let closestSlot = null;
let minDiff = Infinity;
for (const slot of slots) {
const diff = Math.abs(utcTimestampMs - slot);
if (diff < minDiff && diff < intervalMs) {
minDiff = diff;
closestSlot = slot;
}
}
if (closestSlot !== null) {
if (!bucketMap[closestSlot]) bucketMap[closestSlot] = { input: 0, output: 0 };
bucketMap[closestSlot].input += row.input_tokens || 0;
bucketMap[closestSlot].output += row.output_tokens || 0;
}
});
/* ── 5⃣ Build labels in local timezone ───────────────────────────────── */
const labels = slots.map(ts => {
const d = new Date(ts);
return d.toLocaleString(undefined, {
...timeFormat,
timeZoneName: 'short'
});
});
const inputData = slots.map(ts => (bucketMap[ts]?.input ?? 0));
const outputData = slots.map(ts => (bucketMap[ts]?.output ?? 0));
/* ── 6⃣ Push into the Chart.js instance ─────────────────────────────── */
chart.data.labels = labels;
chart.data.datasets[0].data = inputData;
chart.data.datasets[1].data = outputData;
chart.update();
}
/* ---------- Utility ---------- */
async function fetchJSON(url, options = {}) {
const resp = await authedFetch(url, options);
if (!resp.ok) {
throw new Error(`Failed ${url}: ${resp.status}`);
}
return await resp.json();
}
function toggleDarkMode() {
document.documentElement.classList.toggle("dark-mode");
}
/* ---------- Endpoints ---------- */
async function loadEndpoints() {
try {
const data = await fetchJSON("/api/config");
if (data.require_router_api_key && !getStoredApiKey()) {
showApiKeyModal("Enter the NOMYO Router API key to load the dashboard.");
}
const body = document.getElementById("endpoints-body");
// Build HTML for both endpoints and llama_server_endpoints
let html = "";
// Add Ollama endpoints
html += data.endpoints
.map((e) => {
const statusClass =
e.status === "ok"
? "status-ok"
: "status-error";
const version = e.version || "N/A";
return `
<tr>
<td class="endpoint">${e.url}</td>
<td class="status ${statusClass}">${e.status}</td>
<td class="version">${version}</td>
</tr>`;
})
.join("");
// Add llama-server endpoints
if (data.llama_server_endpoints && data.llama_server_endpoints.length > 0) {
html += data.llama_server_endpoints
.map((e) => {
const statusClass =
e.status === "ok"
? "status-ok"
: "status-error";
const version = e.version || "N/A";
return `
<tr>
<td class="endpoint">${e.url}</td>
<td class="status ${statusClass}">${e.status}</td>
<td class="version">${version}</td>
</tr>`;
})
.join("");
}
body.innerHTML = html;
} catch (e) {
console.error(e);
const body = document.getElementById("endpoints-body");
body.innerHTML = `<tr><td colspan="3" class="loading">Failed to load endpoints</td></tr>`;
}
}
/* ---------- Tags ---------- */
async function loadTags() {
try {
const data = await fetchJSON("/api/tags");
const body = document.getElementById("tags-body");
body.innerHTML = data.models
.map((m) => {
let modelCell = `${m.model}`;
if (m.digest) {
modelCell += `<a href="#" class="delete-link" data-model="${m.name}">delete</a>`;
modelCell += `<a href="#" class="copy-link" data-source="${m.name}">copy</a>`;
modelCell += `<a href="#" class="show-link" data-model="${m.name}">show</a>`;
}
return `
<tr>
<td class="model">${modelCell}</td>
<td>${m.digest || ""}</td>
</tr>`;
})
.join("");
document.getElementById("tags-count").textContent =
`${data.models.length}`;
/* copy logic */
document.querySelectorAll(".copy-link").forEach((link) => {
link.addEventListener("click", async (e) => {
e.preventDefault();
const source = link.dataset.source;
const dest = prompt(
`Enter destination for ${source}:`,
);
if (!dest) return;
try {
const resp = await authedFetch(
`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,
{ method: "POST" },
);
if (!resp.ok)
throw new Error(
`Copy failed: ${resp.status}`,
);
alert(
`Copied ${source} to ${dest} successfully.`,
);
loadTags();
} catch (err) {
console.error(err);
alert(
`Error copying ${source} to ${dest}: ${err}`,
);
}
});
});
/* delete logic */
document
.querySelectorAll(".delete-link")
.forEach((link) => {
link.addEventListener("click", async (e) => {
e.preventDefault();
const model = link.dataset.model;
const ok = confirm(
`Delete the model "${model}"? This cannot be undone.`,
);
if (ok) {
try {
const resp = await authedFetch(
`/api/delete?model=${encodeURIComponent(model)}`,
{ method: "DELETE" },
);
if (!resp.ok)
throw new Error(
`Delete failed: ${resp.status}`,
);
alert(
`Model "${model}" deleted successfully.`,
);
loadTags();
} catch (err) {
console.error(err);
alert(`Error deleting ${model}: ${err}`);
}
}
});
});
} catch (e) {
console.error(e);
}
}
/* ---------- PS ---------- */
async function loadPS() {
try {
let instances = [];
try {
const detailed = await fetchJSON("/api/ps_details");
instances = Array.isArray(detailed.models) ? detailed.models : [];
} catch (err) {
console.error("Failed to load ps_details, falling back to /api/ps", err);
const fallback = await fetchJSON("/api/ps");
instances = (fallback.models || []).map((m) => ({
...m,
endpoint: "unknown",
}));
}
const body = document.getElementById("ps-body");
const grouped = new Map();
for (const instance of instances) {
if (!instance || !instance.name) continue;
if (!grouped.has(instance.name)) grouped.set(instance.name, []);
grouped.get(instance.name).push(instance);
}
const formatBytes = (value) => {
if (value === null || value === undefined || value === "") return "";
if (typeof value === "string") return value;
if (typeof value !== "number" || Number.isNaN(value)) return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 || unitIndex == 0 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unitIndex]}`;
};
const formatUntil = (value) => {
if (value === null || value === undefined || value === "") {
return "∞";
}
let targetTime;
if (typeof value === "number") {
const ms = value > 1e12 ? value : value * 1000;
targetTime = new Date(ms);
} else if (typeof value === "string") {
targetTime = new Date(value);
} else {
return String(value);
}
if (Number.isNaN(targetTime.getTime())) {
return String(value);
}
const now = new Date();
const diffMs = targetTime - now;
const diffSec = Math.floor(Math.abs(diffMs) / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMs < 0) {
return "expired";
}
if (diffMin < 1) {
return `in ${diffSec} sec`;
} else if (diffMin < 60) {
return `in ${diffMin} min`;
} else if (diffHours < 24) {
return `in ${diffHours} hr`;
} else {
return `in ${diffDays} days`;
}
};
const renderInstanceList = (items) => {
if (!items.length) return "";
return items.map((item) => `<div class="ps-subrow">${item || ""}</div>`).join("");
};
body.innerHTML = Array.from(grouped.entries())
.map(([modelName, modelInstances]) => {
const existingRow = psRows.get(modelName);
const tokenValue = existingRow
? existingRow.querySelector(".token-usage")?.textContent ?? 0
: 0;
const instanceCount = modelInstances.length;
const endpoints = modelInstances.map((m) => m.endpoint || "unknown");
const sizes = modelInstances.map((m) => formatBytes(m.size ?? m.size_vram ?? m.details?.size));
const untils = modelInstances.map((m) =>
formatUntil(m.until ?? m.expires_at ?? m.expiresAt ?? m.expire_at),
);
const digest = modelInstances[0]?.digest || "";
const shortDigest = digest ? digest.slice(-6) : "";
const params = modelInstances[0]?.details?.parameter_size ?? "";
const quant = modelInstances[0]?.details?.quantization_level ?? "";
const ctx = modelInstances[0]?.context_length ?? "";
const originalName = modelInstances[0]?.original_name || modelName;
const uniqueEndpoints = Array.from(new Set(endpoints));
const endpointsData = encodeURIComponent(JSON.stringify(uniqueEndpoints));
return `<tr data-model="${modelName}" data-endpoints="${endpointsData}">
<td class="model">${modelName} <a href="#" class="stats-link" data-model="${modelName}">stats</a></td>
<td>${renderInstanceList(endpoints)}</td>
<td>${params}</td>
<td>${quant}</td>
<td>${ctx}</td>
<td>${renderInstanceList(sizes)}</td>
<td>${renderInstanceList(untils)}</td>
<td>${shortDigest}</td>
<td class="token-usage">${tokenValue}</td>
</tr>`;
})
.join("");
psRows.clear();
document
.querySelectorAll("#ps-body tr[data-model]")
.forEach((row) => {
const model = row.dataset.model;
if (model) psRows.set(model, row);
});
} catch (e) {
console.error(e);
}
}
/* ---------- Usage Chart (stackedpercentage) ---------- */
function getColor(seed) {
const h = Math.abs(hashString(seed) % 360);
return `hsl(${h}, 80%, 30%)`;
}
function hashString(str) {
let hash = 42;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
async function loadUsage() {
if (usageSource) {
usageSource.close();
usageSource = null;
}
const source = new EventSource(buildAuthedUrl("/api/usage-stream"));
usageSource = source;
// Helper that receives the payload and renders the chart
const renderChart = (data) => {
const chart = document.getElementById("usage-chart");
const usage = data.usage_counts || {};
let html = "";
for (const [endpoint, models] of Object.entries(usage)) {
const total = Object.values(models).reduce(
(a, b) => a + b,
0,
);
html += `<div class="endpoint-bar">
<div class="endpoint-label">${endpoint}</div>
<div class="bar">`;
for (const [model, count] of Object.entries(models)) {
const pct = total ? (count / total) * 100 : 0;
const width = pct.toFixed(2);
const color = getColor(model);
html += `<div class="segment"
style="width:${width}%;background:${color};">
${model} (${count})
</div>`;
}
html += `</div></div>`;
}
chart.innerHTML = html;
};
// Event handlers
source.onmessage = (e) => {
try {
const payload = JSON.parse(e.data); // SSE sends plain text
renderChart(payload);
const usage = payload.usage_counts || {};
const tokens = payload.token_usage_counts || {};
psRows.forEach((row, model) => {
let tokenTotal = 0;
for (const ep in tokens) {
const endpointTokens = tokens[ep] || {};
tokenTotal += endpointTokens[model] || 0;
}
const tokenCell = row.querySelector(".token-usage");
if (tokenCell) tokenCell.textContent = tokenTotal;
});
} catch (err) {
console.error("Failed to parse SSE payload", err);
}
};
source.onerror = async (err) => {
console.error("SSE connection error. Retrying...", err);
source.close();
await showApiKeyModal("Enter the NOMYO Router API key to view live usage.");
loadUsage();
};
window.addEventListener("beforeunload", () => source.close());
}
/* ---------- Init ---------- */
window.addEventListener("load", () => {
updateApiKeyIndicator();
const apiKeyModal = document.getElementById("api-key-modal");
if (apiKeyModal) {
apiKeyModal.addEventListener("click", (e) => {
if (e.target === apiKeyModal || e.target.matches(".close-btn")) {
closeApiKeyModal();
}
});
}
const saveKeyBtn = document.getElementById("api-key-save");
if (saveKeyBtn) {
saveKeyBtn.addEventListener("click", () => {
const key = document.getElementById("api-key-input")?.value.trim();
setStoredApiKey(key);
closeApiKeyModal(key ? "API key saved." : "API key cleared.");
loadUsage();
});
}
const clearKeyBtn = document.getElementById("api-key-clear");
if (clearKeyBtn) {
clearKeyBtn.addEventListener("click", () => {
setStoredApiKey("");
closeApiKeyModal("API key cleared.");
loadUsage();
});
}
loadEndpoints();
loadTags();
loadPS();
loadUsage();
setInterval(loadPS, 60_000);
setInterval(loadEndpoints, 300_000);
/* show logic */
document.body.addEventListener("click", async (e) => {
if (!e.target.matches(".show-link")) return;
e.preventDefault();
const model = e.target.dataset.model;
try {
const resp = await authedFetch(
`/api/show?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
if (!resp.ok)
throw new Error(`Status ${resp.status}`);
const data = await resp.json();
document.getElementById("json-output").textContent =
JSON.stringify(data, null, 2).replace(
/\\n/g,
"\n",
);
document.getElementById(
"show-modal",
).style.display = "flex";
} catch (err) {
console.error(err);
alert(
`Could not load model details: ${err.message}`,
);
}
});
/* pull logic */
document
.getElementById("pull-btn")
.addEventListener("click", async () => {
const model = document
.getElementById("pull-model-input")
.value.trim();
const statusEl =
document.getElementById("pull-status");
if (!model) {
alert("Please enter a model name.");
return;
}
try {
const resp = await authedFetch(
`/api/pull?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
if (!resp.ok)
throw new Error(`Status ${resp.status}`);
const data = await resp.json();
statusEl.textContent = `${data.status}`;
statusEl.style.color = "green";
loadTags();
} catch (err) {
console.error(err);
statusEl.textContent = `${err.message}`;
statusEl.style.color = "red";
}
});
/* modal close */
const modal = document.getElementById("show-modal");
modal.addEventListener("click", (e) => {
if (
e.target === modal ||
e.target.matches(".close-btn")
) {
modal.style.display = "none";
}
});
/* stats logic */
document.body.addEventListener("click", async (e) => {
if (!e.target.matches(".stats-link")) return;
e.preventDefault();
const model = e.target.dataset.model;
try {
const resp = await authedFetch(
`/api/stats?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
if (!resp.ok)
throw new Error(`Status ${resp.status}`);
const data = await resp.json();
const content = document.getElementById("stats-content");
content.innerHTML = `
<div class="stats-content-wrapper">
<div class="main-stats-content">
<h3>Token Usage</h3>
<p>Input tokens: ${data.input_tokens}</p>
<p>Output tokens: ${data.output_tokens}</p>
<p>Total tokens: ${data.total_tokens}</p>
<h3>Usage Over Time</h3>
<div class="timeframe-controls">
<button class="timeframe-btn active" data-minutes="60">Last 1 hour</button>
<button class="timeframe-btn" data-minutes="1440">Last 1 day</button>
<button class="timeframe-btn" data-minutes="10080">Last 7 days</button>
<button class="timeframe-btn" data-minutes="43200">Last 30 days</button>
</div>
<div class="chart-container">
<canvas id="time-series-chart"></canvas>
</div>
</div>
<div class="endpoint-distribution-container">
<h3>Endpoint Distribution</h3>
<div class="pie-chart-container">
<canvas id="endpoint-pie-chart"></canvas>
</div>
</div>
</div>
`;
document.getElementById("stats-modal").style.display = "flex";
// Initialise the charts (time-series + pie chart)
initStatsChart(data.time_series, data.endpoint_distribution);
} catch (err) {
console.error(err);
alert(`Could not load model stats: ${err.message}`);
}
});
/* ---------- Helper to initialise or refresh the stats chart ---------- */
function initStatsChart(timeSeriesData, endpointDistribution) {
// Destroy any existing chart instance
if (statsChart) {
statsChart.destroy();
statsChart = null;
}
// Remove any existing canvas and create a fresh one
const oldCanvas = document.getElementById('time-series-chart');
if (oldCanvas) {
oldCanvas.remove();
}
const canvas = document.createElement('canvas');
canvas.id = 'time-series-chart';
document.querySelector('.chart-container').appendChild(canvas);
// Create a new Chart.js instance
const ctx = canvas.getContext('2d');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: [],
datasets: [
{ label: 'Input Tokens', data: [], backgroundColor: '#4CAF50' },
{ label: 'Output Tokens', data: [], backgroundColor: '#2196F3' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true },
y: { stacked: true }
},
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Token Usage Over Time' }
}
}
});
// Store the chart globally for later updates
statsChart = chart;
// Store the raw timeseries data globally
rawTimeSeries = timeSeriesData || [];
// Render the initial view (default to 60 minutes)
renderTimeSeriesChart(rawTimeSeries, statsChart, 60);
// Attach timeframe button handlers (once)
document.querySelectorAll('.timeframe-btn').forEach(button => {
button.addEventListener('click', function () {
// Update active button styling
document.querySelectorAll('.timeframe-btn').forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
// Rerender chart with the selected timeframe
const minutes = parseInt(this.dataset.minutes);
renderTimeSeriesChart(rawTimeSeries, statsChart, minutes);
});
});
// Create endpoint distribution pie chart
if (endpointDistribution && Object.keys(endpointDistribution).length > 0) {
const pieCanvas = document.getElementById('endpoint-pie-chart');
const pieCtx = pieCanvas.getContext('2d');
const endpoints = Object.keys(endpointDistribution);
const tokenCounts = Object.values(endpointDistribution);
const colors = endpoints.map(ep => getColor(ep));
new Chart(pieCtx, {
type: 'pie',
data: {
labels: endpoints,
datasets: [{
data: tokenCounts,
backgroundColor: colors,
borderWidth: 1,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
font: { size: 11 }
}
},
title: {
display: true,
text: 'Total Tokens per Endpoint'
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value.toLocaleString()} tokens (${percentage}%)`;
}
}
}
}
}
});
}
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const totalBtn = document.getElementById('total-tokens-btn');
if (totalBtn) {
totalBtn.addEventListener('click', async () => {
try {
const resp = await authedFetch('/api/token_counts');
const data = await resp.json();
const modal = document.getElementById('total-tokens-modal');
const numberEl = document.getElementById('total-tokens-number');
numberEl.textContent = data.total_tokens;
document.getElementById('aggregation-status').textContent = 'Aggregating...';
try {
const aggResp = await authedFetch('/api/aggregate_time_series_days', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: 30 , trim_old: true})
});
if (aggResp.ok) {
const aggData = await aggResp.json();
const aggr = aggData.aggregated_groups ?? 0;
document.getElementById('aggregation-status').textContent = `Aggregated ${aggr} groups`;
} else {
document.getElementById('aggregation-status').textContent = 'Aggregation failed';
}
} catch (err) {
document.getElementById('aggregation-status').textContent = 'Aggregation error';
}
const chartCanvas = document.getElementById('total-tokens-chart');
if (chartCanvas) {
// Destroy existing chart if it exists
if (totalTokensChart) {
totalTokensChart.destroy();
totalTokensChart = null;
}
const ctx = chartCanvas.getContext('2d');
const tokenCounts = data.breakdown || [];
/* NEW LOGIC: concentric rings per model */
const modelTotals = {};
const modelEndpointTotals = {};
tokenCounts.forEach(entry => {
const { model, endpoint, total_tokens } = entry;
modelTotals[model] = (modelTotals[model] || 0) + total_tokens;
if (!modelEndpointTotals[model]) modelEndpointTotals[model] = {};
modelEndpointTotals[model][endpoint] = (modelEndpointTotals[model][endpoint] || 0) + total_tokens;
});
const endpointsSet = new Set();
tokenCounts.forEach(entry => endpointsSet.add(entry.endpoint));
const endpoints = Array.from(endpointsSet);
const endpointColors = {};
endpoints.forEach(ep => {
endpointColors[ep] = getColor(ep);
});
const sortedModels = Object.keys(modelTotals).sort((a, b) => modelTotals[b] - modelTotals[a]);
const datasets = sortedModels.map(model => {
const data = endpoints.map(ep => (modelEndpointTotals[model][ep] || 0));
const backgroundColor = endpoints.map(ep => endpointColors[ep]);
return {
label: model,
data,
backgroundColor,
borderWidth: 1,
borderColor: '#fff'
};
});
totalTokensChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: endpoints,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '15%',
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
font: { size: 11 }
}
},
title: {
display: true,
text: 'Token Distribution by Model per Endpoint'
},
tooltip: {
callbacks: {
label: function(context) {
const endpointName = context.chart.data.labels[context.dataIndex];
const modelName = context.dataset.label;
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${modelName} - ${endpointName}: ${value.toLocaleString()} tokens (${percentage}%)`;
}
}
}
}
}
});
}
modal.style.display = 'flex';
} catch (err) {
console.error(err);
alert('Failed to load token counts');
}
});
}
const totalTokensModal = document.getElementById('total-tokens-modal');
if (totalTokensModal) {
totalTokensModal.addEventListener('click', (e) => {
if (e.target === totalTokensModal || e.target.matches('.close-btn')) {
totalTokensModal.style.display = 'none';
}
});
}
});
</script>
<div id="api-key-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Router API Key</h2>
<p id="api-key-reason" class="modal-message">Enter the NOMYO Router API key to continue.</p>
<input
id="api-key-input"
type="password"
placeholder="NOMYO Router API key"
autocomplete="off"
/>
<div class="modal-actions">
<button id="api-key-clear">Clear</button>
<button id="api-key-save">Save</button>
</div>
<p id="api-key-status" class="loading"></p>
</div>
</div>
<div id="show-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Model details</h2>
<pre id="json-output"></pre>
</div>
</div>
<div id="stats-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Model Stats</h2>
<div id="stats-content">
<p>Loading stats...</p>
</div>
</div>
</div>
<div id="total-tokens-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Total Tokens</h2>
<p id="total-tokens-number"></p>
<canvas id="total-tokens-chart"></canvas>
</div>
</div>
</html>