nomyo-router/static/index.html
alpha-nerd-nomyo fbce181a81
Add files via upload
herding ollamas
- added management functions to dashboard and updated routes in backend
2025-09-04 15:00:50 +02:00

343 lines
No EOL
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
}
.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 */
}
.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;
}
}
/* 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>
</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">
<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" style="margin-left:0.5rem; color:green;"></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>
</table>
</div>
<div class="table-container">
<h2>Running Models (PS)</h2>
<table id="ps-table">
<thead>
<tr>
<th>Model</th>
<th>Params</th>
<th>Quant</th>
<th>Ctx</th>
<th>Digest</th>
</tr>
</thead>
<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">
<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>
<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');
// 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>`;
}
}
async function loadTags(){
try{
const data = await fetchJSON('/api/tags');
const body = document.getElementById('tags-body');
body.innerHTML = data.models.map(m => {
// Build the model cell
let modelCell = `${m.id || m.name}`;
// Add delete link only when a digest exists
if (m.digest) {
modelCell += `
<a href="#" class="delete-link" data-model="${m.name}">
delete
</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 `
<tr>
<td class="model">${modelCell}</td>
<td>${m.digest || ''}</td>
</tr>`;
}).join(''); const countSpan = document.getElementById('tags-count');
countSpan.textContent = `${data.models.length}`;
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{
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}`);
}
});
});
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';
}
});
}catch(e){ console.error(e); }
}
async function loadPS(){
try{
const data = await fetchJSON('/api/ps');
const body = document.getElementById('ps-body');
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('');
}catch(e){ console.error(e); }
}
window.addEventListener('load', ()=>{
loadEndpoints();
loadTags();
loadPS();
});
setInterval(() => {
loadTags();
}, 600_000);
setInterval(() => {
loadPS();
}, 60_000);
</script>
<div id="show-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Model details</h2>
<pre id="json-output"></pre>
</div>
</div>
</body>
</html>