nomyo-router/static/index.html

1032 lines
39 KiB
HTML
Raw Normal View History

<!doctype html>
2025-08-30 00:13:35 +02:00
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>NOMYO Router Dashboard</title>
2025-11-19 17:05:25 +01:00
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
background: #e0e0e0;
color: #333;
padding: 20px;
}
.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;
}
.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%;
2025-11-19 17:05:25 +01:00
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;
}
.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;
}
2025-11-19 17:05:25 +01:00
.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;
}
/* ---------- 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;
}
}
2025-11-19 17:05:25 +01:00
/* ---------- 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;
2025-11-20 09:53:28 +01:00
height: 600px;
2025-11-19 17:05:25 +01:00
margin-top: 1rem;
}
2025-11-19 17:28:31 +01:00
.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;
}
</style>
</head>
<body>
<a href="https://www.nomyo.ai" target="_blank"
><img src="./static/228394408.png" width="100px" height="100px"
/></a>
<h1>Router Dashboard</h1>
2025-08-30 00:13:35 +02:00
<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>
2025-08-30 00:13:35 +02:00
<!-- ---------- PS + Usage Chart ---------- -->
<div class="table-container">
<h2>Running Models (PS)</h2>
<table id="ps-table">
<thead>
<tr>
<th>Model</th>
<th>Params</th>
<th>Quant</th>
<th>Ctx</th>
<th>Digest</th>
2025-11-19 17:05:25 +01:00
<th>Token</th>
</tr>
</thead>
<tbody id="ps-body">
<tr>
<td colspan="6" class="loading">Loading…</td>
</tr>
</tbody>
</table>
2025-08-30 00:13:35 +02:00
<!-- ------------- 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>
2025-08-30 00:13:35 +02:00
<script>
2025-11-19 17:05:25 +01:00
let psRows = new Map();
// Global placeholders for stats modal handling
let statsModal = null; // stats modal element
let statsChart = null; // Chart.js instance inside the modal
let rawTimeSeries = null; // raw timeseries data for the current model
/* 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) {
2025-11-20 09:53:28 +01:00
// Guard clause
if (!Array.isArray(timeSeriesData) || !timeSeriesData.length) {
chart.data.labels = [];
chart.data.datasets[0].data = [];
chart.data.datasets[1].data = [];
chart.update();
return;
}
2025-11-19 17:05:25 +01:00
2025-11-20 12:53:18 +01:00
/* ── 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;
2025-11-19 17:05:25 +01:00
2025-11-20 12:53:18 +01:00
/* ── 3⃣ Build ordered bucket slots aligned to local time boundaries ───── */
2025-11-20 09:53:28 +01:00
const slots = [];
2025-11-20 12:53:18 +01:00
// 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;
2025-11-20 09:53:28 +01:00
}
2025-11-19 17:05:25 +01:00
2025-11-20 12:53:18 +01:00
/* ── 4⃣ Aggregate raw rows into local time buckets ───────────────────── */
const bucketMap = {};
2025-11-20 09:53:28 +01:00
timeSeriesData.forEach(row => {
2025-11-20 12:53:18 +01:00
// 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;
}
2025-11-20 09:53:28 +01:00
});
2025-11-19 17:05:25 +01:00
2025-11-20 12:53:18 +01:00
/* ── 5⃣ Build labels in local timezone ───────────────────────────────── */
const labels = slots.map(ts => {
const d = new Date(ts);
return d.toLocaleString(undefined, {
...timeFormat,
2025-11-20 09:53:28 +01:00
timeZoneName: 'short'
2025-11-20 12:53:18 +01:00
});
2025-11-20 09:53:28 +01:00
});
2025-11-19 17:05:25 +01:00
2025-11-20 09:53:28 +01:00
const inputData = slots.map(ts => (bucketMap[ts]?.input ?? 0));
const outputData = slots.map(ts => (bucketMap[ts]?.output ?? 0));
2025-11-20 12:53:18 +01:00
/* ── 6⃣ Push into the Chart.js instance ─────────────────────────────── */
2025-11-20 09:53:28 +01:00
chart.data.labels = labels;
chart.data.datasets[0].data = inputData;
chart.data.datasets[1].data = outputData;
chart.update();
2025-11-19 17:05:25 +01:00
}
/* ---------- Utility ---------- */
async function fetchJSON(url) {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed ${url}: ${resp.status}`);
}
return await resp.json();
}
function toggleDarkMode() {
document.documentElement.classList.toggle("dark-mode");
}
2025-08-30 00:13:35 +02:00
/* ---------- Endpoints ---------- */
async function loadEndpoints() {
try {
const data = await fetchJSON("/api/config");
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>`;
}
}
2025-08-30 00:13:35 +02:00
/* ---------- 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 fetch(
`/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) return;
try {
const resp = await fetch(
`/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);
}
}
2025-08-30 00:13:35 +02:00
/* ---------- PS ---------- */
async function loadPS() {
try {
const data = await fetchJSON("/api/ps");
const body = document.getElementById("ps-body");
body.innerHTML = data.models
.map(m => {
const existingRow = psRows.get(m.name);
const tokenValue = existingRow
? existingRow.querySelector(".token-usage")?.textContent ?? 0
: 0;
const digest = m.digest || "";
const shortDigest = digest.length > 24
? `${digest.slice(0, 12)}...${digest.slice(-12)}`
: digest;
return `<tr data-model="${m.name}">
<td class="model">${m.name} <a href="#" class="stats-link" data-model="${m.name}">stats</a></td>
<td>${m.details.parameter_size}</td>
<td>${m.details.quantization_level}</td>
<td>${m.context_length}</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);
}
}
2025-08-30 00:13:35 +02:00
/* ---------- 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() {
// Create the EventSource once and keep it around
const source = new EventSource("/api/usage-stream");
// -----------------------------------------------------------------
// 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) => {
/* regular usage count optional if you want to keep it */
let total = 0;
for (const ep in usage) {
total += usage[ep][model] || 0;
}
const usageCell = row.querySelector(".usage");
if (usageCell) usageCell.textContent = total;
/* token usage */
let tokenTotal = 0;
for (const ep in tokens) {
tokenTotal += tokens[ep][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 = (err) => {
console.error("SSE connection error. Retrying...", err);
// EventSource will automatically try to reconnect.
};
window.addEventListener("beforeunload", () => source.close());
}
/* ---------- Init ---------- */
window.addEventListener("load", () => {
loadEndpoints();
loadTags();
loadPS();
loadUsage();
setInterval(loadPS, 60_000);
setInterval(loadEndpoints, 300_000);
2025-11-19 17:05:25 +01:00
/* (renderTimeSeriesChart removed will be defined globally) */
/* 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 fetch(
`/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 fetch(
`/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 fetch(
`/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";
2025-11-19 17:05:25 +01:00
2025-11-19 17:28:31 +01:00
// 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}`);
}
});
2025-11-19 17:05:25 +01:00
/* ---------- Helper to initialise or refresh the stats chart ---------- */
2025-11-19 17:28:31 +01:00
function initStatsChart(timeSeriesData, endpointDistribution) {
2025-11-19 17:05:25 +01:00
console.log('initStatsChart called with payload:', timeSeriesData);
2025-11-19 17:28:31 +01:00
console.log('Endpoint distribution:', endpointDistribution);
2025-11-19 17:05:25 +01:00
// 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
console.log('initStatsChart raw data:', timeSeriesData);
rawTimeSeries = timeSeriesData || [];
// Render the initial view (default to 60minutes)
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);
});
});
2025-11-19 17:28:31 +01:00
// 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}%)`;
}
}
}
}
}
});
}
2025-11-19 17:05:25 +01:00
}
/* stats modal close */
2025-11-19 17:05:25 +01:00
// The close handler is already attached during initial page load.
// No additional listener is needed here.
});
</script>
<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>
</body>
</html>