diff --git a/router.py b/router.py index 6206cbc..595f411 100644 --- a/router.py +++ b/router.py @@ -619,16 +619,17 @@ async def create_proxy(request: Request): # 11. API route – Show # ------------------------------------------------------------- @app.post("/api/show") -async def show_proxy(request: Request): +async def show_proxy(request: Request, model: Optional[str] = None): """ Proxy a model show request to Ollama and reply with ShowResponse. """ try: body_bytes = await request.body() - payload = json.loads(body_bytes.decode("utf-8")) - model = payload.get("model") + if not model: + payload = json.loads(body_bytes.decode("utf-8")) + model = payload.get("model") if not model: raise HTTPException( @@ -652,7 +653,7 @@ async def show_proxy(request: Request): # 12. API route – Copy # ------------------------------------------------------------- @app.post("/api/copy") -async def copy_proxy(request: Request): +async def copy_proxy(request: Request, source: Optional[str] = None, destination: Optional[str] = None): """ Proxy a model copy request to each Ollama endpoint and reply with Status Code. @@ -660,10 +661,14 @@ async def copy_proxy(request: Request): # 1. Parse and validate request try: body_bytes = await request.body() - payload = json.loads(body_bytes.decode("utf-8")) - src = payload.get("source") - dst = payload.get("destination") + if not source and not destination: + payload = json.loads(body_bytes.decode("utf-8")) + src = payload.get("source") + dst = payload.get("destination") + else: + src = source + dst = destination if not src: raise HTTPException( @@ -688,35 +693,11 @@ async def copy_proxy(request: Request): # 4. Return with 200 OK if all went well, 404 if a single endpoint failed return Response(status_code=404 if 404 in status_list else 200) -@app.get("/api/copy") -async def copy_proxy_from_dashboard(source: str, destination: str): - """ - Proxy a model copy request to each Ollama endpoint and reply with a status code. - Accepts `source` and `destination` exclusively as query‑string parameters. - """ - # 1. Validate that both values are non‑empty strings (FastAPI already guarantees presence) - if not source: - raise HTTPException(status_code=400, detail="Missing required query parameter 'source'") - if not destination: - raise HTTPException(status_code=400, detail="Missing required query parameter 'destination'") - - # 2. Iterate over all endpoints to copy the model on each endpoint - status_list = [] - for endpoint in config.endpoints: - if "/v1" not in endpoint: - client = ollama.AsyncClient(host=endpoint) - # 3. Proxy a simple copy request - copy = await client.copy(source=source, destination=destination) - status_list.append(copy.status) - - # 4. Return with 200 OK if all went well, 404 if any endpoint failed - return Response(status_code=404 if 404 in status_list else 200) - # ------------------------------------------------------------- # 13. API route – Delete # ------------------------------------------------------------- @app.delete("/api/delete") -async def delete_proxy(request: Request): +async def delete_proxy(request: Request, model: Optional[str] = None): """ Proxy a model delete request to each Ollama endpoint and reply with Status Code. @@ -724,9 +705,10 @@ async def delete_proxy(request: Request): # 1. Parse and validate request try: body_bytes = await request.body() - payload = json.loads(body_bytes.decode("utf-8")) - model = payload.get("model") + if not model: + payload = json.loads(body_bytes.decode("utf-8")) + model = payload.get("model") if not model: raise HTTPException( @@ -745,30 +727,26 @@ async def delete_proxy(request: Request): status_list.append(copy.status) # 4. Retrun 200 0K, if a single enpoint fails, respond with 404 - if 404 in status_list: - return Response( - status_code=404 - ) - else: - return Response( - status_code=200 - ) + return Response(status_code=404 if 404 in status_list else 200) # ------------------------------------------------------------- # 14. API route – Pull # ------------------------------------------------------------- @app.post("/api/pull") -async def pull_proxy(request: Request): +async def pull_proxy(request: Request, model: Optional[str] = None): """ Proxy a pull request to all Ollama endpoint and report status back. """ # 1. Parse and validate request try: body_bytes = await request.body() - payload = json.loads(body_bytes.decode("utf-8")) - model = payload.get("model") - insecure = payload.get("insecure") + if not model: + payload = json.loads(body_bytes.decode("utf-8")) + model = payload.get("model") + insecure = payload.get("insecure") + else: + insecure = None if not model: raise HTTPException( @@ -780,10 +758,11 @@ async def pull_proxy(request: Request): # 2. Iterate over all endpoints to pull the model status_list = [] for endpoint in config.endpoints: - client = ollama.AsyncClient(host=endpoint) - # 3. Proxy a simple pull request - pull = await client.pull(model=model, insecure=insecure, stream=False) - status_list.append(pull) + if "/v1" not in endpoint: + client = ollama.AsyncClient(host=endpoint) + # 3. Proxy a simple pull request + pull = await client.pull(model=model, insecure=insecure, stream=False) + status_list.append(pull) combined_status = [] for status in status_list: diff --git a/static/index.html b/static/index.html index 975b966..4fd9234 100644 --- a/static/index.html +++ b/static/index.html @@ -14,12 +14,17 @@ .model{font-family:monospace;} .loading{color:#999;} - /* NEW STYLES */ .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%; } @@ -54,8 +59,31 @@ 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; } @@ -63,7 +91,14 @@
+

Available Models (Tags)

+
+ + + + +
@@ -141,7 +176,13 @@ async function loadTags(){ 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 += ` + + delete + `; + } // Add the copy link *only if a digest exists* if (m.digest) { modelCell += ` @@ -149,7 +190,12 @@ async function loadTags(){ copy `; } - + if (m.digest) { + modelCell += ` + + show + `; + } return ` @@ -157,7 +203,6 @@ async function loadTags(){ `; }).join(''); const countSpan = document.getElementById('tags-count'); countSpan.textContent = `${data.models.length}`; - // Attach copy‑link handlers document.querySelectorAll('.copy-link').forEach(link => { link.addEventListener('click', async (e) => { e.preventDefault(); @@ -165,15 +210,105 @@ async function loadTags(){ 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)}`); + 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); } } @@ -190,6 +325,19 @@ window.addEventListener('load', ()=>{ loadTags(); loadPS(); }); +setInterval(() => { + loadTags(); +}, 600_000); +setInterval(() => { + loadPS(); +}, 60_000); + \ No newline at end of file
ModelDigest
${modelCell}