Added endpoint differentiation for models PS board to see where which model is loaded and for how long to ease the viewing of multiple same models deployed for load balancing
1419 lines
57 KiB
HTML
1419 lines
57 KiB
HTML
<!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: 340px;
|
||
white-space: nowrap;
|
||
}
|
||
.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>Instance count</th>
|
||
<th>Params</th>
|
||
<th>Quant</th>
|
||
<th>Ctx</th>
|
||
<th>Size</th>
|
||
<th>Until</th>
|
||
<th>Digest</th>
|
||
<th>Token</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 time‑series 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");
|
||
body.innerHTML = 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("");
|
||
} 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 "Forever";
|
||
}
|
||
if (typeof value === "number") {
|
||
const ms = value > 1e12 ? value : value * 1000;
|
||
const date = new Date(ms);
|
||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||
}
|
||
if (typeof value === "string") {
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||
}
|
||
return String(value);
|
||
};
|
||
|
||
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.length > 24
|
||
? `${digest.slice(0, 12)}...${digest.slice(-12)}`
|
||
: digest;
|
||
const params = modelInstances[0]?.details?.parameter_size ?? "";
|
||
const quant = modelInstances[0]?.details?.quantization_level ?? "";
|
||
const ctx = modelInstances[0]?.context_length ?? "";
|
||
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>${instanceCount}</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 (stacked‑percentage) ---------- */
|
||
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 time‑series 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');
|
||
|
||
// Re‑render 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">×</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">×</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">×</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">×</span>
|
||
<h2>Total Tokens</h2>
|
||
<p id="total-tokens-number"></p>
|
||
<canvas id="total-tokens-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
</html>
|