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:
parent
20790d95ed
commit
b3b67fdbf2
3 changed files with 191 additions and 203 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
35
router.py
35
router.py
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 status‑style 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 (stacked‑percentage) ---------- */
|
||||||
|
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">×</span>
|
<span class="close-btn">×</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue