Add files via upload

BREAKING CHANGE:
- new config.yaml config block
- new dependency: httpx-aiohttp for faster endpoint queries in bigger installations
- new dynamic dashboard
This commit is contained in:
Alpha Nerd 2025-09-04 19:07:28 +02:00 committed by GitHub
parent 20790d95ed
commit b3b67fdbf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 191 additions and 203 deletions

View file

@ -1,17 +1,27 @@
aiocache==0.12.3
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiosignal==1.4.0
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.10.0 anyio==4.10.0
async-timeout==5.0.1
attrs==25.3.0
certifi==2025.8.3 certifi==2025.8.3
click==8.2.1 click==8.2.1
distro==1.9.0 distro==1.9.0
exceptiongroup==1.3.0 exceptiongroup==1.3.0
fastapi==0.116.1 fastapi==0.116.1
frozenlist==1.7.0
h11==0.16.0 h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
httpx-aiohttp==0.1.8
idna==3.10 idna==3.10
jiter==0.10.0 jiter==0.10.0
multidict==6.6.4
ollama==0.5.3 ollama==0.5.3
openai==1.102.0 openai==1.102.0
propcache==0.3.2
pydantic==2.11.7 pydantic==2.11.7
pydantic-settings==2.10.1 pydantic-settings==2.10.1
pydantic_core==2.33.2 pydantic_core==2.33.2
@ -23,3 +33,4 @@ tqdm==4.67.1
typing-inspection==0.4.1 typing-inspection==0.4.1
typing_extensions==4.14.1 typing_extensions==4.14.1
uvicorn==0.35.0 uvicorn==0.35.0
yarl==1.20.1

View file

@ -7,6 +7,7 @@ license: AGPL
""" """
# ------------------------------------------------------------- # -------------------------------------------------------------
import json, time, asyncio, yaml, httpx, ollama, openai, os, re import json, time, asyncio, yaml, httpx, ollama, openai, os, re
from httpx_aiohttp import AiohttpTransport
from pathlib import Path from pathlib import Path
from typing import Dict, Set, List, Optional from typing import Dict, Set, List, Optional
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
@ -96,11 +97,12 @@ def get_httpx_client(endpoint: str) -> httpx.AsyncClient:
""" """
return httpx.AsyncClient( return httpx.AsyncClient(
base_url=endpoint, base_url=endpoint,
timeout=httpx.Timeout(5.0, read=5.0, write=5.0, connect=5.0), timeout=httpx.Timeout(5.0, read=5.0, write=None, connect=5.0),
limits=httpx.Limits( #limits=httpx.Limits(
max_keepalive_connections=64, # max_keepalive_connections=64,
max_connections=64 # max_connections=64
) #),
transport=AiohttpTransport()
) )
async def fetch_available_models(endpoint: str, api_key: Optional[str] = None) -> Set[str]: async def fetch_available_models(endpoint: str, api_key: Optional[str] = None) -> Set[str]:
@ -133,8 +135,8 @@ async def fetch_available_models(endpoint: str, api_key: Optional[str] = None) -
# Error expired remove it # Error expired remove it
del _error_cache[endpoint] del _error_cache[endpoint]
client = get_httpx_client(endpoint)
try: try:
client = get_httpx_client(endpoint)
if "/v1" in endpoint: if "/v1" in endpoint:
resp = await client.get(f"/models", headers=headers) resp = await client.get(f"/models", headers=headers)
else: else:
@ -160,6 +162,8 @@ async def fetch_available_models(endpoint: str, api_key: Optional[str] = None) -
print(f"[fetch_available_models] {endpoint} error: {e}") print(f"[fetch_available_models] {endpoint} error: {e}")
_error_cache[endpoint] = time.time() _error_cache[endpoint] = time.time()
return set() return set()
finally:
await client.aclose()
async def fetch_loaded_models(endpoint: str) -> Set[str]: async def fetch_loaded_models(endpoint: str) -> Set[str]:
@ -168,8 +172,8 @@ async def fetch_loaded_models(endpoint: str) -> Set[str]:
loaded on that endpoint. If the request fails (e.g. timeout, 5xx), an empty loaded on that endpoint. If the request fails (e.g. timeout, 5xx), an empty
set is returned. set is returned.
""" """
client = get_httpx_client(endpoint)
try: try:
client = get_httpx_client(endpoint)
resp = await client.get(f"/api/ps") resp = await client.get(f"/api/ps")
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@ -180,6 +184,8 @@ async def fetch_loaded_models(endpoint: str) -> Set[str]:
except Exception: except Exception:
# If anything goes wrong we simply assume the endpoint has no models # If anything goes wrong we simply assume the endpoint has no models
return set() return set()
finally:
await client.aclose()
async def fetch_endpoint_details(endpoint: str, route: str, detail: str, api_key: Optional[str] = None) -> List[dict]: async def fetch_endpoint_details(endpoint: str, route: str, detail: str, api_key: Optional[str] = None) -> List[dict]:
""" """
@ -189,8 +195,9 @@ async def fetch_endpoint_details(endpoint: str, route: str, detail: str, api_key
headers = None headers = None
if api_key is not None: if api_key is not None:
headers = {"Authorization": "Bearer " + api_key} headers = {"Authorization": "Bearer " + api_key}
client = get_httpx_client(endpoint)
try: try:
client = get_httpx_client(endpoint)
resp = await client.get(f"{route}", headers=headers) resp = await client.get(f"{route}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@ -200,6 +207,8 @@ async def fetch_endpoint_details(endpoint: str, route: str, detail: str, api_key
# If anything goes wrong we cannot reply details # If anything goes wrong we cannot reply details
print(e) print(e)
return [] return []
finally:
await client.aclose()
def ep2base(ep): def ep2base(ep):
if "/v1" in ep: if "/v1" in ep:
@ -235,8 +244,8 @@ async def decrement_usage(endpoint: str, model: str) -> None:
# Optionally, clean up zero entries # Optionally, clean up zero entries
if usage_counts[endpoint].get(model, 0) == 0: if usage_counts[endpoint].get(model, 0) == 0:
usage_counts[endpoint].pop(model, None) usage_counts[endpoint].pop(model, None)
if not usage_counts[endpoint]: #if not usage_counts[endpoint]:
usage_counts.pop(endpoint, None) # usage_counts.pop(endpoint, None)
# ------------------------------------------------------------- # -------------------------------------------------------------
# 5. Endpoint selection logic (respecting the configurable limit) # 5. Endpoint selection logic (respecting the configurable limit)
@ -640,7 +649,7 @@ async def show_proxy(request: Request, model: Optional[str] = None):
# 2. Endpoint logic # 2. Endpoint logic
endpoint = await choose_endpoint(model) endpoint = await choose_endpoint(model)
await increment_usage(endpoint, model) #await increment_usage(endpoint, model)
client = ollama.AsyncClient(host=endpoint) client = ollama.AsyncClient(host=endpoint)
# 3. Proxy a simple show request # 3. Proxy a simple show request
@ -907,7 +916,7 @@ async def config_proxy(request: Request):
""" """
async def check_endpoint(url: str): async def check_endpoint(url: str):
try: try:
async with httpx.AsyncClient(timeout=1) as client: async with httpx.AsyncClient(timeout=1, transport=AiohttpTransport()) as client:
if "/v1" in url: if "/v1" in url:
headers = {"Authorization": "Bearer " + config.api_keys[url]} headers = {"Authorization": "Bearer " + config.api_keys[url]}
r = await client.get(f"{url}/models", headers=headers) r = await client.get(f"{url}/models", headers=headers)
@ -921,6 +930,8 @@ async def config_proxy(request: Request):
return {"url": url, "status": "ok", "version": data.get("version")} return {"url": url, "status": "ok", "version": data.get("version")}
except Exception as exc: except Exception as exc:
return {"url": url, "status": "error", "detail": str(exc)} return {"url": url, "status": "error", "detail": str(exc)}
finally:
await client.aclose()
results = await asyncio.gather(*[check_endpoint(ep) for ep in config.endpoints]) results = await asyncio.gather(*[check_endpoint(ep) for ep in config.endpoints])
return {"endpoints": results} return {"endpoints": results}

View file

@ -5,108 +5,75 @@
<title>NOMYO Router Dashboard</title> <title>NOMYO Router Dashboard</title>
<style> <style>
body{font-family:Arial,Helvetica,sans-serif;background:#f7f7f7;color:#333;padding:20px;} body{font-family:Arial,Helvetica,sans-serif;background:#f7f7f7;color:#333;padding:20px;}
h1{margin-top:0;} h2 {margin: 0;}
table{border-collapse:collapse;width:100%;margin-bottom:20px;} .tables-wrapper{display:flex;gap:2rem;}
th,td{border:1px solid #ddd;padding:8px;} .table-container{flex:1;min-width:350px;background:#fff;padding:1rem;border-radius:6px;}
th{background:#333;color:#fff;} /* ---------- Header + Pull form ---------- */
tr:nth-child(even){background:#f2f2f2;} .header-pull-wrapper{display:flex;flex-direction:row;align-items:center;gap:1rem;margin-bottom:0;}
.endpoint{font-weight:bold;} #pull-section{display:flex;align-items:center;}
.model{font-family:monospace;} #pull-section input{flex:1;padding:0;margin-right:0.5rem;}
.loading{color:#999;} #pull-section button{padding:0 0;}
#pull-status{margin-left:0.5rem; font-weight:bold;}
.tables-wrapper{ /* ---------- Tables ---------- */
display:flex; table{width:100%;border-collapse:collapse;margin-top:1rem;}
gap:1rem; th,td{border:1px solid #ddd;padding:0.5rem;text-align:left;}
margin-top:1rem; th{background:#e0e0e0;}
} .loading{color:#777;font-style:italic;}
.header-pull-wrapper { .status-ok{color:#006400;font-weight:bold;}
display: flex; /* horizontal layout */ .status-error{color:#8B0000;font-weight:bold;}
align-items: center; /* vertical centering */ .copy-link,.delete-link,.show-link{font-size:0.9em;margin-left:0.5em;cursor:pointer;text-decoration:underline;float:right;}
gap: 1rem; /* space between title & form */ .delete-link{color:#b22222;}
flex-wrap: wrap; /* optional keeps it tidy on very narrow screens */ .copy-link,.show-link{color:#0066cc;}
} .delete-link:hover,.copy-link:hover,.show-link:hover{text-decoration:none;}
.table-container{ /* ---------- Modal ---------- */
width:50%; .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);align-items:center;justify-content:center;}
} .modal-content{background:#fff;padding:1rem;max-width:90%;max-height:90%;overflow:auto;border-radius:6px;}
/* Ensure the heading aligns nicely inside each container */ .close-btn{float:right;cursor:pointer;font-size:1.5rem;}
.table-container h2{ /* ---------- Usage Chart ---------- */
margin:0 0 0.5rem 0; .usage-chart{margin-top:20px;}
} .endpoint-bar{margin-bottom:12px;}
/* ---- NEW STYLES FOR PORTRAIT (height > width) ---- */ .endpoint-label{font-weight:bold;margin-bottom:4px;}
@media (orientation: portrait) { .bar{display:flex;height:16px;background:#e0e0e0;border-radius:4px;overflow:hidden;}
/* Stack the two tables vertically */ .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 { .tables-wrapper {
flex-direction: column; /* instead of the default row */ flex-direction: column;
} }
.table-container { .tables-wrapper > .table-container:nth-child(1) { /* Tags container */
width: 100%; /* full width when stacked */ order: 2;
} }
/* Put the “Running Models” table first */ .tables-wrapper > .table-container:nth-child(2) { /* PS container */
.table-container:nth-child(2) { /* the PS table is the 2nd child */ order: 1;
order: -1;
}
/* Keep the other table after it (default order 0) */
.table-container:nth-child(1) {
order: 0;
} }
} }
/* Add a tiny statusstyle section */
.status-ok { color: #006400; font-weight: bold; } /* dark green */
.status-error{ color: #8B0000; font-weight: bold; } /* dark red */
.copy-link {
font-size:0.9em;
margin-left:0.5em;
color:#0066cc;
cursor:pointer;
text-decoration:underline;
float: right;
}
.delete-link{
font-size:0.9em;
margin-left:0.5em;
color:#b22222; /* dark red */
cursor:pointer;
text-decoration:underline;
float: right;
}
.show-link {
font-size:0.9em;
margin-left:0.5em;
color:#0066cc;
cursor:pointer;
text-decoration:underline;
float: right;
}
.delete-link:hover{ text-decoration:none; }
.copy-link:hover { text-decoration:none; }
/* modal.css very lightweight feel free to replace with Bootstrap/Material UI */
.modal { display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,.6); align-items:center; justify-content:center; }
.modal-content { background:#fff; padding:1rem; max-width:90%; max-height:90%; overflow:auto; }
.close-btn { float:right; cursor:pointer; font-size:1.5rem; }
</style> </style>
</head> </head>
<body> <body>
<a href="https://www.nomyo.ai" target="_blank"><img src="./static/228394408.png" width="100px" height="100px"></a><h1>Router Dashboard</h1> <a href="https://www.nomyo.ai" target="_blank"><img src="./static/228394408.png" width="100px" height="100px"></a>
<h1>Router Dashboard</h1>
<div class="tables-wrapper"> <div class="tables-wrapper">
<!-- ---------- Tags ---------- -->
<div class="table-container"> <div class="table-container">
<div class="header-pull-wrapper"> <div class="header-pull-wrapper">
<h2><span id="tags-count"></span> Available Models (Tags)</h2> <h2><span id="tags-count"></span> Available Models (Tags)</h2>
<div id="pull-section"> <div id="pull-section">
<label for="pull-model-input">Pull a model:</label> <label for="pull-model-input">Pull a model:</label>
<input type="text" id="pull-model-input" placeholder="llama3:latest" /> <input type="text" id="pull-model-input" placeholder="llama3:latest" />
<button id="pull-btn">Pull</button> <button id="pull-btn">Pull</button>
<span id="pull-status" style="margin-left:0.5rem; color:green;"></span> <span id="pull-status"></span>
</div></div> </div>
</div>
<table id="tags-table"> <table id="tags-table">
<thead><tr><th>Model</th><th>Digest</th></tr></thead> <thead><tr><th>Model</th><th>Digest</th></tr></thead>
<tbody id="tags-body"> <tbody id="tags-body"><tr><td colspan="2" class="loading">Loading…</td></tr></tbody>
<tr><td colspan="2" class="loading">Loading…</td></tr>
</tbody>
</table> </table>
</div> </div>
<!-- ---------- PS + Usage Chart ---------- -->
<div class="table-container"> <div class="table-container">
<h2>Running Models (PS)</h2> <h2>Running Models (PS)</h2>
<table id="ps-table"> <table id="ps-table">
@ -119,10 +86,11 @@
<th>Digest</th> <th>Digest</th>
</tr> </tr>
</thead> </thead>
<tbody id="ps-body"> <tbody id="ps-body"><tr><td colspan="5" class="loading">Loading…</td></tr></tbody>
<tr><td colspan="2" class="loading">Loading…</td></tr>
</tbody>
</table> </table>
<!-- ------------- Usage Chart ------------- -->
<div id="usage-chart" class="usage-chart"></div>
</div> </div>
</div> </div>
@ -135,23 +103,22 @@
<th>Version</th> <th>Version</th>
</tr> </tr>
</thead> </thead>
<tbody id="endpoints-body"> <tbody id="endpoints-body"><tr><td colspan="3" class="loading">Loading…</td></tr></tbody>
<tr><td colspan="3" class="loading">Loading…</td></tr>
</tbody>
</table> </table>
<script> <script>
/* ---------- Utility ---------- */
async function fetchJSON(url){ async function fetchJSON(url){
const resp = await fetch(url); const resp = await fetch(url);
if(!resp.ok){ throw new Error(`Failed ${url}: ${resp.status}`); } if(!resp.ok){ throw new Error(`Failed ${url}: ${resp.status}`); }
return await resp.json(); return await resp.json();
} }
/* ---------- Endpoints ---------- */
async function loadEndpoints(){ async function loadEndpoints(){
try{ try{
const data = await fetchJSON('/api/config'); const data = await fetchJSON('/api/config');
const body = document.getElementById('endpoints-body'); const body = document.getElementById('endpoints-body');
// Map each endpoint object to a table row
body.innerHTML = data.endpoints.map(e => { body.innerHTML = data.endpoints.map(e => {
const statusClass = e.status === 'ok' ? 'status-ok' : 'status-error'; const statusClass = e.status === 'ok' ? 'status-ok' : 'status-error';
const version = e.version || 'N/A'; const version = e.version || 'N/A';
@ -169,54 +136,37 @@ async function loadEndpoints(){
} }
} }
/* ---------- Tags ---------- */
async function loadTags(){ async function loadTags(){
try{ try{
const data = await fetchJSON('/api/tags'); const data = await fetchJSON('/api/tags');
const body = document.getElementById('tags-body'); const body = document.getElementById('tags-body');
body.innerHTML = data.models.map(m => { body.innerHTML = data.models.map(m => {
// Build the model cell
let modelCell = `${m.id || m.name}`; let modelCell = `${m.id || m.name}`;
// Add delete link only when a digest exists
if (m.digest) { if (m.digest) {
modelCell += ` modelCell += `<a href="#" class="delete-link" data-model="${m.name}">delete</a>`;
<a href="#" class="delete-link" data-model="${m.name}"> modelCell += `<a href="#" class="copy-link" data-source="${m.name}">copy</a>`;
delete modelCell += `<a href="#" class="show-link" data-model="${m.name}">show</a>`;
</a>`;
}
// Add the copy link *only if a digest exists*
if (m.digest) {
modelCell += `
<a href="#" class="copy-link" data-source="${m.name}">
copy
</a>`;
}
if (m.digest) {
modelCell += `
<a href="#" class="show-link" data-model="${m.name}">
show
</a>`;
} }
return ` return `
<tr> <tr>
<td class="model">${modelCell}</td> <td class="model">${modelCell}</td>
<td>${m.digest || ''}</td> <td>${m.digest || ''}</td>
</tr>`; </tr>`;
}).join(''); const countSpan = document.getElementById('tags-count'); }).join('');
countSpan.textContent = `${data.models.length}`; document.getElementById('tags-count').textContent = `${data.models.length}`;
/* copy logic */
document.querySelectorAll('.copy-link').forEach(link => { document.querySelectorAll('.copy-link').forEach(link => {
link.addEventListener('click', async (e) => { link.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
const source = link.dataset.source; const source = link.dataset.source;
const dest = prompt(`Enter destination for ${source}:`); const dest = prompt(`Enter destination for ${source}:`);
if (!dest) return; // cancel if empty if (!dest) return;
try{ try{
const resp = await fetch( const resp = await fetch(`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,{method:'POST'});
`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,
{method: 'POST'}
);
if (!resp.ok) throw new Error(`Copy failed: ${resp.status}`); if (!resp.ok) throw new Error(`Copy failed: ${resp.status}`);
alert(`Copied ${source} to ${dest} successfully.`); alert(`Copied ${source} to ${dest} successfully.`);
loadTags(); loadTags();
}catch(err){ }catch(err){
console.error(err); console.error(err);
@ -224,22 +174,18 @@ async function loadTags(){
} }
}); });
}); });
/* delete logic */
document.querySelectorAll('.delete-link').forEach(link => { document.querySelectorAll('.delete-link').forEach(link => {
link.addEventListener('click', async e => { link.addEventListener('click', async e => {
e.preventDefault(); e.preventDefault();
const model = link.dataset.model; const model = link.dataset.model;
const ok = confirm(`Delete the model “${model}”? This cannot be undone.`); const ok = confirm(`Delete the model “${model}”? This cannot be undone.`);
if (!ok) return; if (!ok) return;
try { try {
const resp = await fetch( const resp = await fetch(`/api/delete?model=${encodeURIComponent(model)}`,{method:'DELETE'});
`/api/delete?model=${encodeURIComponent(model)}`,
{method: 'DELETE'}
);
if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`); if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`);
alert(`Model “${model}” deleted successfully.`); alert(`Model “${model}” deleted successfully.`);
loadTags(); loadTags();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -247,71 +193,55 @@ async function loadTags(){
} }
}); });
}); });
/* show logic */
document.body.addEventListener('click', async e => { document.body.addEventListener('click', async e => {
if (!e.target.matches('.show-link')) return; 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);
document.getElementById('show-modal').style.display = 'flex';
} catch (err) {
console.error(err);
alert(`Could not load model details: ${err.message}`);
}
});
e.preventDefault(); /* pull logic */
const model = e.target.dataset.model; 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 = `✅ ${JSON.stringify(data, null, 2)}`;
statusEl.style.color = 'green';
loadTags();
} catch (err) {
console.error(err);
statusEl.textContent = `❌ ${err.message}`;
statusEl.style.color = 'red';
}
});
try { /* modal close */
const resp = await fetch( const modal = document.getElementById('show-modal');
`/api/show?model=${encodeURIComponent(model)}`, modal.addEventListener('click', e => {
{method: 'POST'} if (e.target === modal || e.target.matches('.close-btn')) {
); modal.style.display = 'none';
if (!resp.ok) throw new Error(`Status ${resp.status}`); }
const data = await resp.json(); });
const jsonText = JSON.stringify(data, null, 2)
.replace(/\\n/g, '\n');
document.getElementById('json-output').textContent = jsonText;
document.getElementById('show-modal').style.display = 'flex';
} catch (err) {
console.error(err);
alert(`Could not load model details: ${err.message}`);
}
});
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 = `✅ ${JSON.stringify(data, null, 2)}`;
statusEl.style.color = 'green';
// Optional: refresh the tags list so the new model appears
loadTags();
} catch (err) {
console.error(err);
statusEl.textContent = `❌ ${err.message}`;
statusEl.style.color = 'red';
}
});
const modal = document.getElementById('show-modal');
modal.addEventListener('click', e => {
if (e.target === modal || e.target.matches('.close-btn')) {
modal.style.display = 'none';
}
});
}catch(e){ console.error(e); } }catch(e){ console.error(e); }
} }
/* ---------- PS ---------- */
async function loadPS(){ async function loadPS(){
try{ try{
const data = await fetchJSON('/api/ps'); const data = await fetchJSON('/api/ps');
@ -320,18 +250,54 @@ async function loadPS(){
}catch(e){ console.error(e); } }catch(e){ console.error(e); }
} }
/* ---------- Usage Chart (stackedpercentage) ---------- */
function getColor(seed){
const h = Math.abs(hashString(seed) % 360);
return `hsl(${h}, 70%, 50%)`;
}
function hashString(str){
let hash = 0;
for (let i=0;i<str.length;i++){
hash = ((hash<<5)-hash)+str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
async function loadUsage(){
try{
const data = await fetchJSON('/api/usage');
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;
}catch(e){
console.error('Failed to load usage counts', e);
}
}
/* ---------- Init ---------- */
window.addEventListener('load', ()=>{ window.addEventListener('load', ()=>{
loadEndpoints(); loadEndpoints();
loadTags(); loadTags();
loadPS(); loadPS();
loadUsage();
setInterval(loadPS, 60_000);
setInterval(loadUsage, 1_000);
}); });
setInterval(() => {
loadTags();
}, 600_000);
setInterval(() => {
loadPS();
}, 60_000);
</script> </script>
<div id="show-modal" class="modal"> <div id="show-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<span class="close-btn">&times;</span> <span class="close-btn">&times;</span>