commit
58490c2302
4 changed files with 263 additions and 54 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
is a transparent proxy for [Ollama](https://github.com/ollama/ollama) with model deployment aware routing.
|
||||
|
||||
<img width="2490" height="1298" alt="Screenshot_NOMYO_Router_Dashboard" src="https://github.com/user-attachments/assets/ba0518d4-d4e3-4cb0-a7de-377df8e6370c" /><br>
|
||||
<img width="2490" height="1298" alt="Screenshot_NOMYO_Router_0-2-2_Dashboard" src="https://github.com/user-attachments/assets/ddacdf88-e3f3-41dd-8be6-f165b22d9879" /><br>
|
||||
|
||||
It runs between your frontend application and Ollama backend and is transparent for both, the front- and backend.
|
||||
|
||||
|
|
|
|||
16
config.yaml
16
config.yaml
|
|
@ -3,6 +3,18 @@ endpoints:
|
|||
- http://192.168.0.50:11434
|
||||
- http://192.168.0.51:11434
|
||||
- http://192.168.0.52:11434
|
||||
#- https://openrouter.ai/api/v1
|
||||
#- https://api.openai.com/v1
|
||||
|
||||
# Maximum concurrent connections *per endpoint‑model pair*
|
||||
max_concurrent_connections: 2
|
||||
# Maximum concurrent connections *per endpoint‑model pair* (equals to OLLAMA_NUM_PARALLEL)
|
||||
max_concurrent_connections: 2
|
||||
|
||||
# API keys for remote endpoints
|
||||
# Set an environment variable like OPENAI_KEY
|
||||
# Confirm endpoints are exactly as in endpoints block
|
||||
api_keys:
|
||||
"http://192.168.0.50:11434": "ollama"
|
||||
"http://192.168.0.51:11434": "ollama"
|
||||
"http://192.168.0.52:11434": "ollama"
|
||||
#"https://openrouter.ai/api/v1": "${OPENROUTER_KEY}"
|
||||
#"https://api.openai.com/v1": "${OPENAI_KEY}"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.12.15
|
||||
aiosignal==1.4.0
|
||||
annotated-types==0.7.0
|
||||
anyio==4.10.0
|
||||
async-timeout==5.0.1
|
||||
attrs==25.3.0
|
||||
certifi==2025.8.3
|
||||
click==8.2.1
|
||||
distro==1.9.0
|
||||
exceptiongroup==1.3.0
|
||||
fastapi==0.116.1
|
||||
fastapi-sse==1.1.1
|
||||
frozenlist==1.7.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
httpx-aiohttp==0.1.8
|
||||
idna==3.10
|
||||
jiter==0.10.0
|
||||
multidict==6.6.4
|
||||
ollama==0.5.3
|
||||
openai==1.102.0
|
||||
propcache==0.3.2
|
||||
pydantic==2.11.7
|
||||
pydantic-settings==2.10.1
|
||||
pydantic_core==2.33.2
|
||||
|
|
@ -23,3 +33,4 @@ tqdm==4.67.1
|
|||
typing-inspection==0.4.1
|
||||
typing_extensions==4.14.1
|
||||
uvicorn==0.35.0
|
||||
yarl==1.20.1
|
||||
|
|
|
|||
|
|
@ -5,65 +5,75 @@
|
|||
<title>NOMYO Router Dashboard</title>
|
||||
<style>
|
||||
body{font-family:Arial,Helvetica,sans-serif;background:#f7f7f7;color:#333;padding:20px;}
|
||||
h1{margin-top:0;}
|
||||
table{border-collapse:collapse;width:100%;margin-bottom:20px;}
|
||||
th,td{border:1px solid #ddd;padding:8px;}
|
||||
th{background:#333;color:#fff;}
|
||||
tr:nth-child(even){background:#f2f2f2;}
|
||||
.endpoint{font-weight:bold;}
|
||||
.model{font-family:monospace;}
|
||||
.loading{color:#999;}
|
||||
|
||||
/* NEW STYLES */
|
||||
.tables-wrapper{
|
||||
display:flex;
|
||||
gap:1rem;
|
||||
margin-top:1rem;
|
||||
}
|
||||
.table-container{
|
||||
width:50%;
|
||||
}
|
||||
/* Ensure the heading aligns nicely inside each container */
|
||||
.table-container h2{
|
||||
margin:0 0 0.5rem 0;
|
||||
}
|
||||
/* ---- NEW STYLES FOR PORTRAIT (height > width) ---- */
|
||||
@media (orientation: portrait) {
|
||||
/* Stack the two tables vertically */
|
||||
h2 {margin: 0;}
|
||||
.tables-wrapper{display:flex;gap:2rem;}
|
||||
.table-container{flex:1;min-width:350px;background:#fff;padding:1rem;border-radius:6px;}
|
||||
/* ---------- Header + Pull form ---------- */
|
||||
.header-pull-wrapper{display:flex;flex-direction:row;align-items:center;gap:1rem;margin-bottom:0;}
|
||||
#pull-section{display:flex;align-items:center;}
|
||||
#pull-section input{flex:1;padding:0;margin-right:0.5rem;}
|
||||
#pull-section button{padding:0 0;}
|
||||
#pull-status{margin-left:0.5rem; font-weight:bold;}
|
||||
/* ---------- Tables ---------- */
|
||||
table{width:100%;border-collapse:collapse;margin-top:1rem;}
|
||||
th,td{border:1px solid #ddd;padding:0.5rem;text-align:left;}
|
||||
th{background:#e0e0e0;}
|
||||
.loading{color:#777;font-style:italic;}
|
||||
.status-ok{color:#006400;font-weight:bold;}
|
||||
.status-error{color:#8B0000;font-weight:bold;}
|
||||
.copy-link,.delete-link,.show-link{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,.6);align-items:center;justify-content:center;}
|
||||
.modal-content{background:#fff;padding:1rem;max-width:90%;max-height:90%;overflow:auto;border-radius:6px;}
|
||||
.close-btn{float:right;cursor:pointer;font-size:1.5rem;}
|
||||
/* ---------- Usage Chart ---------- */
|
||||
.usage-chart{margin-top:20px;}
|
||||
.endpoint-bar{margin-bottom:12px;}
|
||||
.endpoint-label{font-weight:bold;margin-bottom:4px;}
|
||||
.bar{display:flex;height:16px;background:#e0e0e0;border-radius:4px;overflow:hidden;}
|
||||
.segment{height:100%;color:white;font-size:12px;font-weight: bolder;display:flex;align-items:center;justify-content:center;white-space:nowrap;}
|
||||
.table-container {padding-top:1rem;}
|
||||
/* ---------- Responsive reorder ---------- */
|
||||
@media (max-aspect-ratio: 1/1) {
|
||||
.tables-wrapper {
|
||||
flex-direction: column; /* instead of the default row */
|
||||
flex-direction: column;
|
||||
}
|
||||
.table-container {
|
||||
width: 100%; /* full width when stacked */
|
||||
.tables-wrapper > .table-container:nth-child(1) { /* Tags container */
|
||||
order: 2;
|
||||
}
|
||||
/* Put the “Running Models” table first */
|
||||
.table-container:nth-child(2) { /* the PS table is the 2nd child */
|
||||
order: -1;
|
||||
}
|
||||
/* Keep the other table after it (default order 0) */
|
||||
.table-container:nth-child(1) {
|
||||
order: 0;
|
||||
.tables-wrapper > .table-container:nth-child(2) { /* PS container */
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
/* Add a tiny status‑style section */
|
||||
.status-ok { color: #006400; font-weight: bold; } /* dark green */
|
||||
.status-error{ color: #8B0000; font-weight: bold; } /* dark red */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="https://www.nomyo.ai" target="_blank"><img src="./static/228394408.png" width="100px" height="100px"></a><h1>Router Dashboard</h1>
|
||||
<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">
|
||||
<!-- ---------- Tags ---------- -->
|
||||
<div class="table-container">
|
||||
<h2>Available Models (Tags)</h2>
|
||||
<div class="header-pull-wrapper">
|
||||
<h2><span id="tags-count"></span> Available Models (Tags)</h2>
|
||||
<div id="pull-section">
|
||||
<label for="pull-model-input">Pull a model:</label>
|
||||
<input type="text" id="pull-model-input" placeholder="llama3:latest" />
|
||||
<button id="pull-btn">Pull</button>
|
||||
<span id="pull-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="tags-table">
|
||||
<thead><tr><th>Model</th><th>Digest</th></tr></thead>
|
||||
<tbody id="tags-body">
|
||||
<tr><td colspan="2" class="loading">Loading…</td></tr>
|
||||
</tbody>
|
||||
<tbody id="tags-body"><tr><td colspan="2" class="loading">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ---------- PS + Usage Chart ---------- -->
|
||||
<div class="table-container">
|
||||
<h2>Running Models (PS)</h2>
|
||||
<table id="ps-table">
|
||||
|
|
@ -76,10 +86,11 @@
|
|||
<th>Digest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ps-body">
|
||||
<tr><td colspan="2" class="loading">Loading…</td></tr>
|
||||
</tbody>
|
||||
<tbody id="ps-body"><tr><td colspan="5" class="loading">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<!-- ------------- Usage Chart ------------- -->
|
||||
<div id="usage-chart" class="usage-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,23 +103,22 @@
|
|||
<th>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endpoints-body">
|
||||
<tr><td colspan="3" class="loading">Loading…</td></tr>
|
||||
</tbody>
|
||||
<tbody id="endpoints-body"><tr><td colspan="3" class="loading">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
/* ---------- Utility ---------- */
|
||||
async function fetchJSON(url){
|
||||
const resp = await fetch(url);
|
||||
if(!resp.ok){ throw new Error(`Failed ${url}: ${resp.status}`); }
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
/* ---------- Endpoints ---------- */
|
||||
async function loadEndpoints(){
|
||||
try{
|
||||
const data = await fetchJSON('/api/config');
|
||||
const body = document.getElementById('endpoints-body');
|
||||
// Map each endpoint object to a table row
|
||||
body.innerHTML = data.endpoints.map(e => {
|
||||
const statusClass = e.status === 'ok' ? 'status-ok' : 'status-error';
|
||||
const version = e.version || 'N/A';
|
||||
|
|
@ -126,14 +136,112 @@ async function loadEndpoints(){
|
|||
}
|
||||
}
|
||||
|
||||
/* ---------- Tags ---------- */
|
||||
async function loadTags(){
|
||||
try{
|
||||
const data = await fetchJSON('/api/tags');
|
||||
const body = document.getElementById('tags-body');
|
||||
body.innerHTML = data.models.map(m=>`<tr><td class="model">${m.name}</td><td>${m.digest}</td></tr>`).join('');
|
||||
body.innerHTML = data.models.map(m => {
|
||||
let modelCell = `${m.id || m.name}`;
|
||||
if (m.digest) {
|
||||
modelCell += `<a href="#" class="delete-link" data-model="${m.name}">delete</a>`;
|
||||
modelCell += `<a href="#" class="copy-link" data-source="${m.name}">copy</a>`;
|
||||
modelCell += `<a href="#" class="show-link" data-model="${m.name}">show</a>`;
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="model">${modelCell}</td>
|
||||
<td>${m.digest || ''}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
document.getElementById('tags-count').textContent = `${data.models.length}`;
|
||||
|
||||
/* copy logic */
|
||||
document.querySelectorAll('.copy-link').forEach(link => {
|
||||
link.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const source = link.dataset.source;
|
||||
const dest = prompt(`Enter destination for ${source}:`);
|
||||
if (!dest) return;
|
||||
try{
|
||||
const resp = await fetch(`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,{method:'POST'});
|
||||
if (!resp.ok) throw new Error(`Copy failed: ${resp.status}`);
|
||||
alert(`Copied ${source} to ${dest} successfully.`);
|
||||
loadTags();
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
alert(`Error copying ${source} to ${dest}: ${err}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* delete logic */
|
||||
document.querySelectorAll('.delete-link').forEach(link => {
|
||||
link.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
const model = link.dataset.model;
|
||||
const ok = confirm(`Delete the model “${model}”? This cannot be undone.`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/delete?model=${encodeURIComponent(model)}`,{method:'DELETE'});
|
||||
if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`);
|
||||
alert(`Model “${model}” deleted successfully.`);
|
||||
loadTags();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(`Error deleting ${model}: ${err}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* show logic */
|
||||
document.body.addEventListener('click', async e => {
|
||||
if (!e.target.matches('.show-link')) return;
|
||||
e.preventDefault();
|
||||
const model = e.target.dataset.model;
|
||||
try {
|
||||
const resp = await fetch(`/api/show?model=${encodeURIComponent(model)}`,{method:'POST'});
|
||||
if (!resp.ok) throw new Error(`Status ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
document.getElementById('json-output').textContent = JSON.stringify(data, null, 2).replace(/\\n/g, '\n');
|
||||
document.getElementById('show-modal').style.display = 'flex';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(`Could not load model details: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* pull logic */
|
||||
document.getElementById('pull-btn').addEventListener('click', async () => {
|
||||
const model = document.getElementById('pull-model-input').value.trim();
|
||||
const statusEl = document.getElementById('pull-status');
|
||||
if (!model) { alert('Please enter a model name.'); return; }
|
||||
try {
|
||||
const resp = await fetch(`/api/pull?model=${encodeURIComponent(model)}`,{method:'POST'});
|
||||
if (!resp.ok) throw new Error(`Status ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
statusEl.textContent = `✅ ${JSON.stringify(data, null, 2)}`;
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
}catch(e){ console.error(e); }
|
||||
}
|
||||
|
||||
/* ---------- PS ---------- */
|
||||
async function loadPS(){
|
||||
try{
|
||||
const data = await fetchJSON('/api/ps');
|
||||
|
|
@ -142,11 +250,89 @@ async function loadPS(){
|
|||
}catch(e){ console.error(e); }
|
||||
}
|
||||
|
||||
/* ---------- Usage Chart (stacked‑percentage) ---------- */
|
||||
function getColor(seed){
|
||||
const h = Math.abs(hashString(seed) % 360);
|
||||
return `hsl(${h}, 80%, 30%)`;
|
||||
}
|
||||
function hashString(str){
|
||||
let hash = 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() {
|
||||
// Create the EventSource once and keep it around
|
||||
const source = new EventSource('/api/usage-stream');
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Helper that receives the payload and renders the chart
|
||||
// -----------------------------------------------------------------
|
||||
const renderChart = (data) => {
|
||||
const chart = document.getElementById('usage-chart');
|
||||
const usage = data.usage_counts || {};
|
||||
|
||||
let html = '';
|
||||
for (const [endpoint, models] of Object.entries(usage)) {
|
||||
const total = Object.values(models).reduce((a, b) => a + b, 0);
|
||||
|
||||
html += `<div class="endpoint-bar">
|
||||
<div class="endpoint-label">${endpoint}</div>
|
||||
<div class="bar">`;
|
||||
|
||||
for (const [model, count] of Object.entries(models)) {
|
||||
const pct = total ? (count / total) * 100 : 0;
|
||||
const width = pct.toFixed(2);
|
||||
const color = getColor(model);
|
||||
html += `<div class="segment"
|
||||
style="width:${width}%;background:${color};">
|
||||
${model} (${count})
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
}
|
||||
chart.innerHTML = html;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Event handlers
|
||||
// -----------------------------------------------------------------
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data); // SSE sends plain text
|
||||
renderChart(payload);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE payload', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = (err) => {
|
||||
console.error('SSE connection error. Retrying...', err);
|
||||
// EventSource will automatically try to reconnect.
|
||||
};
|
||||
window.addEventListener('beforeunload', () => source.close());
|
||||
}
|
||||
|
||||
/* ---------- Init ---------- */
|
||||
window.addEventListener('load', ()=>{
|
||||
loadEndpoints();
|
||||
loadTags();
|
||||
loadPS();
|
||||
loadUsage();
|
||||
setInterval(loadPS, 60_000);
|
||||
setInterval(loadEndpoints, 300_000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue