|
|
| import asyncio |
| import time |
| import httpx |
| import subprocess |
| from datetime import datetime, timedelta |
| from typing import Optional |
| from fastapi import FastAPI, Request, HTTPException |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| import uvicorn |
|
|
| app = FastAPI(title="Ollama Gateway + Monitor") |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| OLLAMA_BASE = "http://127.0.0.1:11434" |
|
|
| |
| start_time = time.time() |
| health_log: list[dict] = [] |
| MAX_LOG = 100 |
|
|
| async def check_ollama_health() -> bool: |
| try: |
| async with httpx.AsyncClient(timeout=5) as client: |
| r = await client.get(f"{OLLAMA_BASE}/api/tags") |
| return r.status_code == 200 |
| except Exception: |
| return False |
|
|
| async def health_loop(): |
| while True: |
| ok = await check_ollama_health() |
| health_log.append({"ts": time.time(), "ok": ok}) |
| if len(health_log) > MAX_LOG: |
| health_log.pop(0) |
| await asyncio.sleep(30) |
|
|
| @app.on_event("startup") |
| async def startup(): |
| asyncio.create_task(health_loop()) |
|
|
| |
| @app.get("/health", response_class=JSONResponse) |
| async def health(): |
| ollama_ok = await check_ollama_health() |
| uptime_sec = int(time.time() - start_time) |
| total = len(health_log) |
| good = sum(1 for h in health_log if h["ok"]) |
| return { |
| "status": "ok", |
| "uptime_seconds": uptime_sec, |
| "uptime_human": str(timedelta(seconds=uptime_sec)), |
| "ollama": "up" if ollama_ok else "down", |
| "ollama_uptime_pct": round(good / total * 100, 1) if total else None, |
| "checks_recorded": total, |
| } |
|
|
| @app.get("/monitor", response_class=HTMLResponse) |
| async def monitor_dashboard(): |
| ollama_ok = await check_ollama_health() |
| uptime_sec = int(time.time() - start_time) |
| total = len(health_log) |
| good = sum(1 for h in health_log if h["ok"]) |
| pct = round(good / total * 100, 1) if total else 0 |
|
|
| dots = "" |
| for h in health_log[-50:]: |
| color = "#22c55e" if h["ok"] else "#ef4444" |
| ts = datetime.fromtimestamp(h["ts"]).strftime("%H:%M:%S") |
| dots += f'<span title="{ts}" style="display:inline-block;width:10px;height:20px;background:{color};margin:1px;border-radius:2px"></span>' |
|
|
| ollama_color = "#22c55e" if ollama_ok else "#ef4444" |
| ollama_label = "π’ UP" if ollama_ok else "π΄ DOWN" |
|
|
| html = f"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta http-equiv="refresh" content="30"> |
| <title>Server Monitor</title> |
| <style> |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} |
| body {{ background: #0f172a; color: #e2e8f0; font-family: 'Segoe UI', sans-serif; padding: 2rem; }} |
| h1 {{ font-size: 1.6rem; margin-bottom: 1.5rem; color: #38bdf8; }} |
| .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }} |
| .card {{ background: #1e293b; border-radius: 12px; padding: 1.2rem; border: 1px solid #334155; }} |
| .card .label {{ font-size: .75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; }} |
| .card .value {{ font-size: 1.6rem; font-weight: 700; margin-top: .3rem; }} |
| .section-title {{ color: #94a3b8; font-size: .8rem; text-transform: uppercase; letter-spacing: .1em; margin-bottom: .5rem; }} |
| .sparkline {{ background: #1e293b; border-radius: 12px; padding: 1.2rem; border: 1px solid #334155; }} |
| .links {{ margin-top: 1.5rem; display: flex; gap: 1rem; flex-wrap: wrap; }} |
| a.btn {{ background: #0ea5e9; color: #fff; padding: .5rem 1.2rem; border-radius: 8px; text-decoration: none; font-size: .85rem; }} |
| a.btn:hover {{ background: #38bdf8; }} |
| </style> |
| </head> |
| <body> |
| <h1>π₯οΈ Server Monitor</h1> |
| <div class="grid"> |
| <div class="card"> |
| <div class="label">Server Uptime</div> |
| <div class="value" style="color:#38bdf8">{str(timedelta(seconds=uptime_sec))}</div> |
| </div> |
| <div class="card"> |
| <div class="label">Ollama Status</div> |
| <div class="value" style="color:{ollama_color}">{ollama_label}</div> |
| </div> |
| <div class="card"> |
| <div class="label">Ollama Uptime %</div> |
| <div class="value" style="color:#a78bfa">{pct}%</div> |
| </div> |
| <div class="card"> |
| <div class="label">Checks Logged</div> |
| <div class="value">{total}</div> |
| </div> |
| </div> |
| |
| <div class="sparkline"> |
| <div class="section-title">Last {min(total,50)} checks (green=up, red=down) β auto-refreshes every 30s</div> |
| <div style="margin-top:.6rem;line-height:0">{dots if dots else '<span style="color:#64748b">No data yet β checks run every 30s</span>'}</div> |
| </div> |
| |
| <div class="links"> |
| <a class="btn" href="/monitor">β» Refresh</a> |
| <a class="btn" href="/health">JSON Health</a> |
| <a class="btn" href="/api/tags">Ollama Models</a> |
| <a class="btn" href="/">Terminal</a> |
| </div> |
| </body> |
| </html>""" |
| return html |
|
|
| |
| async def _ensure_ollama(): |
| ok = await check_ollama_health() |
| if not ok: |
| subprocess.Popen( |
| ["ollama", "serve"], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| ) |
| for _ in range(20): |
| await asyncio.sleep(0.5) |
| if await check_ollama_health(): |
| return |
| raise HTTPException(502, "Ollama failed to start") |
|
|
| @app.api_route( |
| "/api/{path:path}", |
| methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], |
| ) |
| async def ollama_proxy(path: str, request: Request): |
| await _ensure_ollama() |
|
|
| url = f"{OLLAMA_BASE}/api/{path}" |
| body = await request.body() |
| headers = { |
| k: v for k, v in request.headers.items() |
| if k.lower() not in ("host", "content-length") |
| } |
|
|
| async def stream_response(): |
| async with httpx.AsyncClient(timeout=None) as client: |
| async with client.stream( |
| request.method, url, |
| content=body, |
| headers=headers, |
| params=dict(request.query_params), |
| ) as r: |
| async for chunk in r.aiter_bytes(): |
| yield chunk |
|
|
| try: |
| import json as _json |
| body_json = _json.loads(body) if body else {} |
| except Exception: |
| body_json = {} |
|
|
| if body_json.get("stream", True) and request.method == "POST": |
| return StreamingResponse(stream_response(), media_type="application/x-ndjson") |
|
|
| async with httpx.AsyncClient(timeout=120) as client: |
| r = await client.request( |
| request.method, url, |
| content=body, |
| headers=headers, |
| params=dict(request.query_params), |
| ) |
| return JSONResponse(status_code=r.status_code, content=r.json()) |
|
|
| |
|
|
| if name == "main": |
| uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False) |
|
|