2026-01-27 13:29:54 +01:00
|
|
|
|
<!doctype html>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
<html lang="en">
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<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>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<style>
|
|
|
|
|
|
body {
|
|
|
|
|
|
font-family: Arial, Helvetica, sans-serif;
|
|
|
|
|
|
background: #e0e0e0;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
}
|
2026-01-14 09:28:02 +01:00
|
|
|
|
body.auth-locked {
|
|
|
|
|
|
background: #bfbfbf;
|
|
|
|
|
|
}
|
|
|
|
|
|
body.auth-locked > *:not(#api-key-modal) {
|
|
|
|
|
|
display: none !important;
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
.dark-mode {
|
|
|
|
|
|
filter: invert(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
#dark-mode-button {
|
2025-09-06 16:07:45 +02:00
|
|
|
|
position: fixed; /* stays relative to the viewport */
|
|
|
|
|
|
top: 1rem; /* distance from top edge */
|
|
|
|
|
|
right: 1rem; /* distance from right edge */
|
2025-09-06 15:37:36 +02:00
|
|
|
|
cursor: pointer;
|
2025-09-10 10:21:49 +02:00
|
|
|
|
min-width: 1rem;
|
|
|
|
|
|
min-height: 1rem;
|
|
|
|
|
|
font-size: 1rem;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
}
|
|
|
|
|
|
.tables-wrapper {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.table-container {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 350px;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
border-radius: 6px;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
overflow-x: auto;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2025-09-06 16:07:45 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
#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
|
|
|
|
|
2025-09-06 15:37:36 +02: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;
|
|
|
|
|
|
}
|
2026-01-27 13:29:54 +01:00
|
|
|
|
.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 {
|
2026-01-29 10:54:43 +01:00
|
|
|
|
min-width: 200px;
|
|
|
|
|
|
max-width: 300px;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
2026-01-29 10:54:43 +01:00
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
.loading {
|
|
|
|
|
|
color: #777;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
.status-ok {
|
|
|
|
|
|
color: #006400;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}
|
|
|
|
|
|
.status-error {
|
|
|
|
|
|
color: #8b0000;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}
|
|
|
|
|
|
.copy-link,
|
|
|
|
|
|
.delete-link,
|
2025-11-20 15:37:04 +01:00
|
|
|
|
.show-link,
|
|
|
|
|
|
.stats-link {
|
2025-09-06 15:37:36 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
.close-btn {
|
|
|
|
|
|
float: right;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
|
}
|
2026-01-14 09:28:02 +01:00
|
|
|
|
#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;
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- 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;
|
|
|
|
|
|
}
|
2025-11-20 09:22:45 +01:00
|
|
|
|
/* ---------- 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;
|
|
|
|
|
|
}
|
2025-11-28 14:59:29 +01:00
|
|
|
|
.header-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center; /* vertically center the button with the headline */
|
|
|
|
|
|
gap: 1rem;
|
2026-03-27 16:24:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
.logo-chart-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
#header-tps-container {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 0.25rem 0.75rem;
|
|
|
|
|
|
height: 100px;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-03-27 16:24:57 +01:00
|
|
|
|
<div class="logo-chart-row">
|
|
|
|
|
|
<a href="https://www.nomyo.ai" target="_blank"
|
|
|
|
|
|
><img src="./static/228394408.png" width="100px" height="100px"
|
|
|
|
|
|
/></a>
|
|
|
|
|
|
<div id="header-tps-container">
|
|
|
|
|
|
<canvas id="header-tps-canvas"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 14:59:29 +01:00
|
|
|
|
<div class="header-row">
|
|
|
|
|
|
<h1>Router Dashboard</h1>
|
2026-04-10 17:29:43 +02:00
|
|
|
|
<span id="hostname" style="color:#777; font-size:0.85em;"></span>
|
2026-01-14 09:28:02 +01:00
|
|
|
|
<button id="total-tokens-btn">Stats Total</button>
|
|
|
|
|
|
<span id="aggregation-status" class="loading" style="margin-left:8px;"></span>
|
2025-11-28 14:59:29 +01:00
|
|
|
|
</div>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2025-09-06 16:07:45 +02:00
|
|
|
|
<button onclick="toggleDarkMode()" id="dark-mode-button">
|
|
|
|
|
|
🌗
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<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>
|
2025-09-04 19:07:28 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<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
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<!-- ---------- PS + Usage Chart ---------- -->
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<h2>Running Models (PS)</h2>
|
|
|
|
|
|
<table id="ps-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-01-27 13:29:54 +01:00
|
|
|
|
<th class="model-col">Model</th>
|
|
|
|
|
|
<th>Endpoint</th>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<th>Params</th>
|
|
|
|
|
|
<th>Quant</th>
|
|
|
|
|
|
<th>Ctx</th>
|
2026-01-27 13:29:54 +01:00
|
|
|
|
<th>Size</th>
|
2026-02-01 10:05:46 +01:00
|
|
|
|
<th>Unload</th>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<th>Digest</th>
|
2026-01-29 10:54:43 +01:00
|
|
|
|
<th>Tokens</th>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody id="ps-body">
|
|
|
|
|
|
<tr>
|
2025-11-04 17:55:19 +01:00
|
|
|
|
<td colspan="6" class="loading">Loading…</td>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<!-- ------------- Usage Chart ------------- -->
|
|
|
|
|
|
<div id="usage-chart" class="usage-chart"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="endpoints-container">
|
2025-09-06 16:07:45 +02:00
|
|
|
|
<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>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
</div>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2026-01-14 09:28:02 +01:00
|
|
|
|
<script>
|
2025-11-19 17:05:25 +01:00
|
|
|
|
let psRows = new Map();
|
2026-01-14 09:28:02 +01:00
|
|
|
|
let statsModal = null;
|
|
|
|
|
|
let statsChart = null;
|
|
|
|
|
|
let rawTimeSeries = null;
|
|
|
|
|
|
let totalTokensChart = null;
|
2026-03-27 16:24:57 +01:00
|
|
|
|
let headerTpsChart = null;
|
|
|
|
|
|
const TPS_HISTORY_SIZE = 60;
|
|
|
|
|
|
const tpsHistory = [];
|
|
|
|
|
|
let latestPerModelTokens = {};
|
|
|
|
|
|
const modelFirstSeen = {};
|
2026-01-14 09:28:02 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-19 17:05:25 +01:00
|
|
|
|
|
2026-01-14 09:28:02 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-11-19 17:05:25 +01:00
|
|
|
|
|
|
|
|
|
|
/* 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) {
|
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;
|
2026-01-14 09:28:02 +01:00
|
|
|
|
chart.update();
|
2025-11-19 17:05:25 +01:00
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- Utility ---------- */
|
2026-01-14 09:28:02 +01:00
|
|
|
|
async function fetchJSON(url, options = {}) {
|
|
|
|
|
|
const resp = await authedFetch(url, options);
|
2025-09-06 15:37:36 +02:00
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
throw new Error(`Failed ${url}: ${resp.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return await resp.json();
|
|
|
|
|
|
}
|
2025-09-06 16:07:45 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
function toggleDarkMode() {
|
2025-09-06 16:07:45 +02:00
|
|
|
|
document.documentElement.classList.toggle("dark-mode");
|
2025-09-06 15:37:36 +02:00
|
|
|
|
}
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- Endpoints ---------- */
|
|
|
|
|
|
async function loadEndpoints() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchJSON("/api/config");
|
2026-01-14 09:28:02 +01:00
|
|
|
|
if (data.require_router_api_key && !getStoredApiKey()) {
|
|
|
|
|
|
showApiKeyModal("Enter the NOMYO Router API key to load the dashboard.");
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
const body = document.getElementById("endpoints-body");
|
2026-02-10 16:46:51 +01:00
|
|
|
|
|
|
|
|
|
|
// Build HTML for both endpoints and llama_server_endpoints
|
|
|
|
|
|
let html = "";
|
|
|
|
|
|
|
|
|
|
|
|
// Add Ollama endpoints
|
|
|
|
|
|
html += data.endpoints
|
2025-09-06 15:37:36 +02:00
|
|
|
|
.map((e) => {
|
|
|
|
|
|
const statusClass =
|
|
|
|
|
|
e.status === "ok"
|
|
|
|
|
|
? "status-ok"
|
|
|
|
|
|
: "status-error";
|
|
|
|
|
|
const version = e.version || "N/A";
|
|
|
|
|
|
return `
|
2025-08-30 12:43:35 +02:00
|
|
|
|
<tr>
|
|
|
|
|
|
<td class="endpoint">${e.url}</td>
|
|
|
|
|
|
<td class="status ${statusClass}">${e.status}</td>
|
|
|
|
|
|
<td class="version">${version}</td>
|
|
|
|
|
|
</tr>`;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
})
|
|
|
|
|
|
.join("");
|
2026-02-10 16:46:51 +01:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
} 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
|
|
|
|
|
2025-09-06 15:37:36 +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) => {
|
2025-09-21 16:20:36 +02:00
|
|
|
|
let modelCell = `${m.model}`;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
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 `
|
2025-09-04 10:39:10 +02:00
|
|
|
|
<tr>
|
|
|
|
|
|
<td class="model">${modelCell}</td>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<td>${m.digest || ""}</td>
|
2025-09-04 10:39:10 +02:00
|
|
|
|
</tr>`;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
})
|
|
|
|
|
|
.join("");
|
|
|
|
|
|
document.getElementById("tags-count").textContent =
|
|
|
|
|
|
`${data.models.length}`;
|
2025-09-04 19:07:28 +02:00
|
|
|
|
|
2026-01-27 13:29:54 +01:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* 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 {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch(
|
2025-09-06 15:37:36 +02:00
|
|
|
|
`/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}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-09-04 15:00:50 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* delete logic */
|
|
|
|
|
|
document
|
|
|
|
|
|
.querySelectorAll(".delete-link")
|
|
|
|
|
|
.forEach((link) => {
|
|
|
|
|
|
link.addEventListener("click", async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const model = link.dataset.model;
|
|
|
|
|
|
const ok = confirm(
|
2025-11-28 14:59:29 +01:00
|
|
|
|
`Delete the model "${model}"? This cannot be undone.`,
|
2025-09-06 15:37:36 +02:00
|
|
|
|
);
|
2025-11-28 14:59:29 +01:00
|
|
|
|
if (ok) {
|
|
|
|
|
|
try {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch(
|
2025-11-28 14:59:29 +01:00
|
|
|
|
`/api/delete?model=${encodeURIComponent(model)}`,
|
|
|
|
|
|
{ method: "DELETE" },
|
2025-09-06 15:37:36 +02:00
|
|
|
|
);
|
2025-11-28 14:59:29 +01:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-09-04 15:00:50 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- PS ---------- */
|
|
|
|
|
|
async function loadPS() {
|
|
|
|
|
|
try {
|
2026-01-27 13:29:54 +01:00
|
|
|
|
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",
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
const body = document.getElementById("ps-body");
|
2026-01-27 13:29:54 +01:00
|
|
|
|
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 === "") {
|
2026-02-10 20:21:46 +01:00
|
|
|
|
return "∞";
|
2026-01-27 13:29:54 +01:00
|
|
|
|
}
|
2026-01-29 10:54:43 +01:00
|
|
|
|
|
|
|
|
|
|
let targetTime;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
if (typeof value === "number") {
|
|
|
|
|
|
const ms = value > 1e12 ? value : value * 1000;
|
2026-01-29 10:54:43 +01:00
|
|
|
|
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);
|
2026-01-27 13:29:54 +01:00
|
|
|
|
}
|
2026-01-29 10:54:43 +01:00
|
|
|
|
|
|
|
|
|
|
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`;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2025-11-04 17:55:19 +01:00
|
|
|
|
const tokenValue = existingRow
|
|
|
|
|
|
? existingRow.querySelector(".token-usage")?.textContent ?? 0
|
|
|
|
|
|
: 0;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
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 || "";
|
2026-01-29 10:54:43 +01:00
|
|
|
|
const shortDigest = digest ? digest.slice(-6) : "";
|
2026-01-27 13:29:54 +01:00
|
|
|
|
const params = modelInstances[0]?.details?.parameter_size ?? "";
|
|
|
|
|
|
const quant = modelInstances[0]?.details?.quantization_level ?? "";
|
|
|
|
|
|
const ctx = modelInstances[0]?.context_length ?? "";
|
2026-02-10 16:46:51 +01:00
|
|
|
|
const originalName = modelInstances[0]?.original_name || modelName;
|
2026-01-27 13:29:54 +01:00
|
|
|
|
const uniqueEndpoints = Array.from(new Set(endpoints));
|
|
|
|
|
|
const endpointsData = encodeURIComponent(JSON.stringify(uniqueEndpoints));
|
|
|
|
|
|
return `<tr data-model="${modelName}" data-endpoints="${endpointsData}">
|
2026-03-27 16:24:57 +01:00
|
|
|
|
<td class="model"><span style="color:${getColor(modelName)}">${modelName}</span> <a href="#" class="stats-link" data-model="${modelName}">stats</a></td>
|
2026-01-27 13:29:54 +01:00
|
|
|
|
<td>${renderInstanceList(endpoints)}</td>
|
|
|
|
|
|
<td>${params}</td>
|
|
|
|
|
|
<td>${quant}</td>
|
|
|
|
|
|
<td>${ctx}</td>
|
|
|
|
|
|
<td>${renderInstanceList(sizes)}</td>
|
|
|
|
|
|
<td>${renderInstanceList(untils)}</td>
|
2025-11-18 11:16:21 +01:00
|
|
|
|
<td>${shortDigest}</td>
|
2025-11-04 17:55:19 +01:00
|
|
|
|
<td class="token-usage">${tokenValue}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
})
|
2025-09-06 15:37:36 +02:00
|
|
|
|
.join("");
|
2025-11-28 14:59:29 +01:00
|
|
|
|
psRows.clear();
|
|
|
|
|
|
document
|
2025-11-04 17:55:19 +01:00
|
|
|
|
.querySelectorAll("#ps-body tr[data-model]")
|
|
|
|
|
|
.forEach((row) => {
|
|
|
|
|
|
const model = row.dataset.model;
|
|
|
|
|
|
if (model) psRows.set(model, row);
|
|
|
|
|
|
});
|
2025-09-06 15:37:36 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-30 00:13:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- Usage Chart (stacked‑percentage) ---------- */
|
|
|
|
|
|
function getColor(seed) {
|
|
|
|
|
|
const h = Math.abs(hashString(seed) % 360);
|
|
|
|
|
|
return `hsl(${h}, 80%, 30%)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function hashString(str) {
|
2025-11-20 09:22:45 +01:00
|
|
|
|
let hash = 42;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
for (let i = 0; i < str.length; i++) {
|
2025-11-20 09:22:45 +01:00
|
|
|
|
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
2025-09-06 15:37:36 +02:00
|
|
|
|
hash |= 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.abs(hash);
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadUsage() {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
if (usageSource) {
|
|
|
|
|
|
usageSource.close();
|
|
|
|
|
|
usageSource = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const source = new EventSource(buildAuthedUrl("/api/usage-stream"));
|
|
|
|
|
|
usageSource = source;
|
2025-09-05 09:44:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
// Helper that receives the payload and renders the chart
|
|
|
|
|
|
const renderChart = (data) => {
|
|
|
|
|
|
const chart = document.getElementById("usage-chart");
|
|
|
|
|
|
const usage = data.usage_counts || {};
|
2025-09-04 19:07:28 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
let html = "";
|
|
|
|
|
|
for (const [endpoint, models] of Object.entries(usage)) {
|
|
|
|
|
|
const total = Object.values(models).reduce(
|
|
|
|
|
|
(a, b) => a + b,
|
|
|
|
|
|
0,
|
|
|
|
|
|
);
|
2025-09-05 09:44:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
html += `<div class="endpoint-bar">
|
2025-09-05 09:44:35 +02:00
|
|
|
|
<div class="endpoint-label">${endpoint}</div>
|
|
|
|
|
|
<div class="bar">`;
|
|
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
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"
|
2025-09-05 09:44:35 +02:00
|
|
|
|
style="width:${width}%;background:${color};">
|
|
|
|
|
|
${model} (${count})
|
|
|
|
|
|
</div>`;
|
2025-09-06 15:37:36 +02:00
|
|
|
|
}
|
2025-09-05 09:44:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
html += `</div></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
chart.innerHTML = html;
|
|
|
|
|
|
};
|
2025-09-05 09:44:35 +02:00
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
// Event handlers
|
|
|
|
|
|
source.onmessage = (e) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = JSON.parse(e.data); // SSE sends plain text
|
|
|
|
|
|
renderChart(payload);
|
2026-03-27 16:24:57 +01:00
|
|
|
|
updateTpsChart(payload);
|
2025-11-04 17:55:19 +01:00
|
|
|
|
const usage = payload.usage_counts || {};
|
|
|
|
|
|
const tokens = payload.token_usage_counts || {};
|
|
|
|
|
|
|
|
|
|
|
|
psRows.forEach((row, model) => {
|
|
|
|
|
|
let tokenTotal = 0;
|
|
|
|
|
|
for (const ep in tokens) {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const endpointTokens = tokens[ep] || {};
|
|
|
|
|
|
tokenTotal += endpointTokens[model] || 0;
|
2025-11-04 17:55:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
const tokenCell = row.querySelector(".token-usage");
|
|
|
|
|
|
if (tokenCell) tokenCell.textContent = tokenTotal;
|
|
|
|
|
|
});
|
2025-09-06 15:37:36 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("Failed to parse SSE payload", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-09-05 09:44:35 +02:00
|
|
|
|
|
2026-01-14 09:28:02 +01:00
|
|
|
|
source.onerror = async (err) => {
|
2025-09-06 15:37:36 +02:00
|
|
|
|
console.error("SSE connection error. Retrying...", err);
|
2026-01-14 09:28:02 +01:00
|
|
|
|
source.close();
|
|
|
|
|
|
await showApiKeyModal("Enter the NOMYO Router API key to view live usage.");
|
|
|
|
|
|
loadUsage();
|
2025-09-06 15:37:36 +02:00
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("beforeunload", () => source.close());
|
|
|
|
|
|
}
|
2025-09-04 19:07:28 +02:00
|
|
|
|
|
2026-03-27 16:24:57 +01:00
|
|
|
|
/* ---------- Header TPS Chart ---------- */
|
|
|
|
|
|
function initHeaderChart() {
|
|
|
|
|
|
const canvas = document.getElementById('header-tps-canvas');
|
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
|
headerTpsChart = new Chart(canvas.getContext('2d'), {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: { labels: [], datasets: [] },
|
|
|
|
|
|
options: {
|
|
|
|
|
|
responsive: true,
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
animation: false,
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: { display: false },
|
|
|
|
|
|
y: { display: true, min: 0, ticks: { font: { size: 10 } } }
|
|
|
|
|
|
},
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: { display: false },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
callbacks: {
|
|
|
|
|
|
title: (items) => items[0]?.dataset?.label || '',
|
|
|
|
|
|
label: (item) => `${item.parsed.y.toFixed(1)} tok/s`
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
elements: { point: { radius: 0 } }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateTpsChart(payload) {
|
|
|
|
|
|
const tokens = payload.token_usage_counts || {};
|
|
|
|
|
|
const perModelTokens = {};
|
2026-04-01 18:10:48 +02:00
|
|
|
|
const allModels = new Set();
|
|
|
|
|
|
for (const ep in tokens) for (const model in tokens[ep]) allModels.add(model);
|
|
|
|
|
|
allModels.forEach(model => {
|
2026-03-27 16:24:57 +01:00
|
|
|
|
let total = 0;
|
|
|
|
|
|
for (const ep in tokens) total += tokens[ep]?.[model] || 0;
|
|
|
|
|
|
// Normalise against the first-seen cumulative total so history
|
|
|
|
|
|
// entries start at 0 and the || 0 fallback never causes a spike.
|
|
|
|
|
|
if (!(model in modelFirstSeen)) modelFirstSeen[model] = total;
|
|
|
|
|
|
perModelTokens[model] = total - modelFirstSeen[model];
|
|
|
|
|
|
});
|
|
|
|
|
|
latestPerModelTokens = perModelTokens;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tickTpsChart() {
|
|
|
|
|
|
if (!headerTpsChart) return;
|
|
|
|
|
|
tpsHistory.push({ time: Date.now(), perModelTokens: { ...latestPerModelTokens } });
|
|
|
|
|
|
if (tpsHistory.length > TPS_HISTORY_SIZE) tpsHistory.shift();
|
|
|
|
|
|
if (tpsHistory.length < 2) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Only chart models present in the latest snapshot — never accumulate
|
|
|
|
|
|
// stale names from old history entries.
|
|
|
|
|
|
const allModels = Object.keys(tpsHistory[tpsHistory.length - 1].perModelTokens);
|
|
|
|
|
|
|
|
|
|
|
|
const labels = tpsHistory.map(h => new Date(h.time).toLocaleTimeString());
|
|
|
|
|
|
const datasets = Array.from(allModels).map(model => {
|
|
|
|
|
|
const data = tpsHistory.map((h, i) => {
|
|
|
|
|
|
if (i === 0) return 0;
|
|
|
|
|
|
const prev = tpsHistory[i - 1];
|
|
|
|
|
|
const dt = (h.time - prev.time) / 1000;
|
|
|
|
|
|
const dTokens = (h.perModelTokens[model] || 0) - (prev.perModelTokens[model] || 0);
|
|
|
|
|
|
return dt > 0 ? Math.max(0, dTokens / dt) : 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: model,
|
|
|
|
|
|
data,
|
|
|
|
|
|
borderColor: getColor(model),
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
tension: 0.3,
|
|
|
|
|
|
pointRadius: 0
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
headerTpsChart.data.labels = labels;
|
|
|
|
|
|
headerTpsChart.data.datasets = datasets;
|
|
|
|
|
|
headerTpsChart.update('none');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
/* ---------- Init ---------- */
|
|
|
|
|
|
window.addEventListener("load", () => {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
loadEndpoints();
|
|
|
|
|
|
loadTags();
|
|
|
|
|
|
loadPS();
|
|
|
|
|
|
loadUsage();
|
2026-03-27 16:24:57 +01:00
|
|
|
|
initHeaderChart();
|
|
|
|
|
|
setInterval(tickTpsChart, 1000);
|
2025-09-06 15:37:36 +02:00
|
|
|
|
setInterval(loadPS, 60_000);
|
|
|
|
|
|
setInterval(loadEndpoints, 300_000);
|
2025-11-19 17:05:25 +01:00
|
|
|
|
|
2025-10-30 09:06:21 +01:00
|
|
|
|
/* show logic */
|
|
|
|
|
|
document.body.addEventListener("click", async (e) => {
|
|
|
|
|
|
if (!e.target.matches(".show-link")) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const model = e.target.dataset.model;
|
|
|
|
|
|
try {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch(
|
2025-10-30 09:06:21 +01:00
|
|
|
|
`/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 {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch(
|
2025-10-30 09:06:21 +01:00
|
|
|
|
`/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";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-11-18 19:02:36 +01:00
|
|
|
|
|
|
|
|
|
|
/* stats logic */
|
|
|
|
|
|
document.body.addEventListener("click", async (e) => {
|
|
|
|
|
|
if (!e.target.matches(".stats-link")) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const model = e.target.dataset.model;
|
|
|
|
|
|
try {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch(
|
2025-11-18 19:02:36 +01:00
|
|
|
|
`/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 = `
|
2025-11-20 09:22:45 +01:00
|
|
|
|
<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>
|
2025-11-18 19:02:36 +01:00
|
|
|
|
</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);
|
2025-11-18 19:02:36 +01:00
|
|
|
|
} 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
|
|
|
|
// 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 || [];
|
|
|
|
|
|
|
2025-11-28 14:59:29 +01:00
|
|
|
|
// Render the initial view (default to 60 minutes)
|
2025-11-19 17:05:25 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
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
|
|
|
|
}
|
2025-09-06 15:37:36 +02:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
2025-11-28 14:59:29 +01:00
|
|
|
|
<script>
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
2026-04-10 17:29:43 +02:00
|
|
|
|
authedFetch('/api/hostname').then(r => r.json()).then(data => {
|
|
|
|
|
|
const el = document.getElementById('hostname');
|
|
|
|
|
|
if (el && data.hostname) el.textContent = data.hostname;
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
|
2025-11-28 14:59:29 +01:00
|
|
|
|
const totalBtn = document.getElementById('total-tokens-btn');
|
|
|
|
|
|
if (totalBtn) {
|
|
|
|
|
|
totalBtn.addEventListener('click', async () => {
|
|
|
|
|
|
try {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const resp = await authedFetch('/api/token_counts');
|
2025-11-28 14:59:29 +01:00
|
|
|
|
const data = await resp.json();
|
|
|
|
|
|
const modal = document.getElementById('total-tokens-modal');
|
|
|
|
|
|
const numberEl = document.getElementById('total-tokens-number');
|
|
|
|
|
|
numberEl.textContent = data.total_tokens;
|
2025-12-02 12:18:23 +01:00
|
|
|
|
document.getElementById('aggregation-status').textContent = 'Aggregating...';
|
|
|
|
|
|
try {
|
2026-01-14 09:28:02 +01:00
|
|
|
|
const aggResp = await authedFetch('/api/aggregate_time_series_days', {
|
2025-12-02 12:18:23 +01:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
2025-11-28 14:59:29 +01:00
|
|
|
|
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,
|
2025-12-02 12:18:23 +01:00
|
|
|
|
text: 'Token Distribution by Model per Endpoint'
|
2025-11-28 14:59:29 +01:00
|
|
|
|
},
|
|
|
|
|
|
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>
|
2025-09-04 19:07:28 +02:00
|
|
|
|
|
2026-01-14 09:28:02 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-09-06 15:37:36 +02:00
|
|
|
|
<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>
|
2025-11-18 19:02:36 +01:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-11-28 14:59:29 +01:00
|
|
|
|
<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>
|
2025-09-06 15:37:36 +02:00
|
|
|
|
</html>
|