2025-08-30 00:13:35 +02:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<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;}
|
|
|
|
|
|
|
|
|
|
|
|
.tables-wrapper{
|
|
|
|
|
|
display:flex;
|
|
|
|
|
|
gap:1rem;
|
|
|
|
|
|
margin-top:1rem;
|
|
|
|
|
|
}
|
2025-09-04 15:00:50 +02:00
|
|
|
|
.header-pull-wrapper {
|
|
|
|
|
|
display: flex; /* horizontal layout */
|
|
|
|
|
|
align-items: center; /* vertical centering */
|
|
|
|
|
|
gap: 1rem; /* space between title & form */
|
|
|
|
|
|
flex-wrap: wrap; /* optional – keeps it tidy on very narrow screens */
|
|
|
|
|
|
}
|
2025-08-30 00:13:35 +02:00
|
|
|
|
.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 */
|
|
|
|
|
|
.tables-wrapper {
|
|
|
|
|
|
flex-direction: column; /* instead of the default row */
|
|
|
|
|
|
}
|
|
|
|
|
|
.table-container {
|
|
|
|
|
|
width: 100%; /* full width when stacked */
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-30 12:43:35 +02:00
|
|
|
|
/* Add a tiny status‑style section */
|
2025-09-04 10:39:10 +02:00
|
|
|
|
.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;
|
2025-09-04 15:00:50 +02:00
|
|
|
|
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;
|
2025-09-04 10:39:10 +02:00
|
|
|
|
}
|
2025-09-04 15:00:50 +02:00
|
|
|
|
.delete-link:hover{ text-decoration:none; }
|
2025-09-04 10:39:10 +02:00
|
|
|
|
.copy-link:hover { text-decoration:none; }
|
2025-09-04 15:00:50 +02:00
|
|
|
|
/* 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; }
|
2025-08-30 00:13:35 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="tables-wrapper">
|
|
|
|
|
|
<div class="table-container">
|
2025-09-04 15:00:50 +02:00
|
|
|
|
<div class="header-pull-wrapper">
|
2025-09-03 19:20:01 +02:00
|
|
|
|
<h2><span id="tags-count"></span> Available Models (Tags)</h2>
|
2025-09-04 15:00:50 +02:00
|
|
|
|
<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" style="margin-left:0.5rem; color:green;"></span>
|
|
|
|
|
|
</div></div>
|
2025-08-30 00:13:35 +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>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="table-container">
|
|
|
|
|
|
<h2>Running Models (PS)</h2>
|
|
|
|
|
|
<table id="ps-table">
|
2025-08-30 17:40:39 +02:00
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Model</th>
|
|
|
|
|
|
<th>Params</th>
|
|
|
|
|
|
<th>Quant</th>
|
|
|
|
|
|
<th>Ctx</th>
|
|
|
|
|
|
<th>Digest</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
<tbody id="ps-body">
|
|
|
|
|
|
<tr><td colspan="2" class="loading">Loading…</td></tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<h2>Configured Endpoints</h2>
|
|
|
|
|
|
<table id="endpoints-table">
|
2025-08-30 12:43:35 +02:00
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Endpoint</th>
|
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
|
<th>Version</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
<tbody id="endpoints-body">
|
2025-08-30 12:43:35 +02:00
|
|
|
|
<tr><td colspan="3" class="loading">Loading…</td></tr>
|
2025-08-30 00:13:35 +02:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
async function fetchJSON(url){
|
|
|
|
|
|
const resp = await fetch(url);
|
|
|
|
|
|
if(!resp.ok){ throw new Error(`Failed ${url}: ${resp.status}`); }
|
|
|
|
|
|
return await resp.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadEndpoints(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
const data = await fetchJSON('/api/config');
|
|
|
|
|
|
const body = document.getElementById('endpoints-body');
|
2025-08-30 12:43:35 +02:00
|
|
|
|
// 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';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td class="endpoint">${e.url}</td>
|
|
|
|
|
|
<td class="status ${statusClass}">${e.status}</td>
|
|
|
|
|
|
<td class="version">${version}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}catch(e){
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
const body = document.getElementById('endpoints-body');
|
|
|
|
|
|
body.innerHTML = `<tr><td colspan="3" class="loading">Failed to load endpoints</td></tr>`;
|
|
|
|
|
|
}
|
2025-08-30 00:13:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadTags(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
const data = await fetchJSON('/api/tags');
|
|
|
|
|
|
const body = document.getElementById('tags-body');
|
2025-09-04 10:39:10 +02:00
|
|
|
|
body.innerHTML = data.models.map(m => {
|
|
|
|
|
|
// Build the model cell
|
|
|
|
|
|
let modelCell = `${m.id || m.name}`;
|
2025-09-04 15:00:50 +02:00
|
|
|
|
// Add delete link only when a digest exists
|
|
|
|
|
|
if (m.digest) {
|
|
|
|
|
|
modelCell += `
|
|
|
|
|
|
<a href="#" class="delete-link" data-model="${m.name}">
|
|
|
|
|
|
delete
|
|
|
|
|
|
</a>`;
|
|
|
|
|
|
}
|
2025-09-04 10:39:10 +02:00
|
|
|
|
// Add the copy link *only if a digest exists*
|
|
|
|
|
|
if (m.digest) {
|
|
|
|
|
|
modelCell += `
|
|
|
|
|
|
<a href="#" class="copy-link" data-source="${m.name}">
|
|
|
|
|
|
copy
|
|
|
|
|
|
</a>`;
|
|
|
|
|
|
}
|
2025-09-04 15:00:50 +02:00
|
|
|
|
if (m.digest) {
|
|
|
|
|
|
modelCell += `
|
|
|
|
|
|
<a href="#" class="show-link" data-model="${m.name}">
|
|
|
|
|
|
show
|
|
|
|
|
|
</a>`;
|
|
|
|
|
|
}
|
2025-09-04 10:39:10 +02:00
|
|
|
|
return `
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td class="model">${modelCell}</td>
|
|
|
|
|
|
<td>${m.digest || ''}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join(''); const countSpan = document.getElementById('tags-count');
|
2025-09-03 19:20:01 +02:00
|
|
|
|
countSpan.textContent = `${data.models.length}`;
|
2025-09-04 10:39:10 +02:00
|
|
|
|
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; // cancel if empty
|
|
|
|
|
|
try{
|
2025-09-04 15:00:50 +02:00
|
|
|
|
const resp = await fetch(
|
|
|
|
|
|
`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,
|
|
|
|
|
|
{method: 'POST'}
|
|
|
|
|
|
);
|
2025-09-04 10:39:10 +02:00
|
|
|
|
if (!resp.ok) throw new Error(`Copy failed: ${resp.status}`);
|
|
|
|
|
|
alert(`Copied ${source} to ${dest} successfully.`);
|
2025-09-04 15:00:50 +02:00
|
|
|
|
|
|
|
|
|
|
loadTags();
|
2025-09-04 10:39:10 +02:00
|
|
|
|
}catch(err){
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
alert(`Error copying ${source} to ${dest}: ${err}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-09-04 15:00:50 +02:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-08-30 00:13:35 +02:00
|
|
|
|
}catch(e){ console.error(e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPS(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
const data = await fetchJSON('/api/ps');
|
|
|
|
|
|
const body = document.getElementById('ps-body');
|
2025-08-30 17:40:39 +02:00
|
|
|
|
body.innerHTML = data.models.map(m=>`<tr><td class="model">${m.name}</td><td>${m.details.parameter_size}</td><td>${m.details.quantization_level}</td><td>${m.context_length}</td><td>${m.digest}</td></tr>`).join('');
|
2025-08-30 00:13:35 +02:00
|
|
|
|
}catch(e){ console.error(e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('load', ()=>{
|
|
|
|
|
|
loadEndpoints();
|
|
|
|
|
|
loadTags();
|
|
|
|
|
|
loadPS();
|
|
|
|
|
|
});
|
2025-09-04 15:00:50 +02:00
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
loadTags();
|
|
|
|
|
|
}, 600_000);
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
loadPS();
|
|
|
|
|
|
}, 60_000);
|
2025-08-30 00:13:35 +02:00
|
|
|
|
</script>
|
2025-09-04 15:00:50 +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-08-30 00:13:35 +02:00
|
|
|
|
</body>
|
2025-08-30 12:43:35 +02:00
|
|
|
|
</html>
|