Initial commit: FastAPI app with auth, API keys, download tracking, WebSocket live progress
Browse files- .gitignore +6 -0
- Dockerfile +12 -0
- README.md +12 -1
- app.py +235 -0
- config.py +30 -0
- database.py +156 -0
- requirements.txt +11 -0
- static/style.css +200 -0
- templates/dashboard.html +145 -0
- templates/index.html +42 -0
- templates/login.html +80 -0
- templates/progress.html +79 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
*.db
|
| 6 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -7,4 +7,15 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# FreeAPI
|
| 11 |
+
|
| 12 |
+
Ollama Model Manager - Manage model downloads, generate API keys, and track progress for 16GB systems.
|
| 13 |
+
|
| 14 |
+
## API Endpoints
|
| 15 |
+
|
| 16 |
+
- `POST /api/register` - Register user
|
| 17 |
+
- `POST /api/login` - Login (returns JWT)
|
| 18 |
+
- `GET /api/models` - Recommended models
|
| 19 |
+
- `POST /api/api-keys` - Create API key
|
| 20 |
+
- `POST /api/download-sessions` - Start download
|
| 21 |
+
- `WS /ws/{session_id}` - Live progress
|
app.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import asyncio
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request, Form
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
| 11 |
+
from fastapi.templating import Jinja2Templates
|
| 12 |
+
from jose import JWTError, jwt
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
from config import settings
|
| 16 |
+
from database import (
|
| 17 |
+
init_db, create_user, get_user_by_username, verify_password,
|
| 18 |
+
get_user_by_api_key, create_download_session, update_download_progress,
|
| 19 |
+
get_download_session, get_user_sessions, create_api_key,
|
| 20 |
+
get_user_api_keys, revoke_api_key, generate_api_key
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
app = FastAPI(title=settings.app_name)
|
| 24 |
+
|
| 25 |
+
security = HTTPBearer()
|
| 26 |
+
templates = Jinja2Templates(directory="templates")
|
| 27 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 28 |
+
|
| 29 |
+
init_db()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# --- Models ---
|
| 33 |
+
|
| 34 |
+
class RegisterRequest(BaseModel):
|
| 35 |
+
username: str
|
| 36 |
+
email: str
|
| 37 |
+
password: str
|
| 38 |
+
|
| 39 |
+
class LoginRequest(BaseModel):
|
| 40 |
+
username: str
|
| 41 |
+
password: str
|
| 42 |
+
|
| 43 |
+
class CreateApiKeyRequest(BaseModel):
|
| 44 |
+
key_name: str
|
| 45 |
+
|
| 46 |
+
class CreateSessionRequest(BaseModel):
|
| 47 |
+
model_name: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# --- Auth Helpers ---
|
| 51 |
+
|
| 52 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 53 |
+
to_encode = data.copy()
|
| 54 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
|
| 55 |
+
to_encode.update({"exp": expire})
|
| 56 |
+
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
| 57 |
+
|
| 58 |
+
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 59 |
+
token = credentials.credentials
|
| 60 |
+
try:
|
| 61 |
+
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 62 |
+
username = payload.get("sub")
|
| 63 |
+
if username is None:
|
| 64 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 65 |
+
except JWTError:
|
| 66 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 67 |
+
user = get_user_by_username(username)
|
| 68 |
+
if not user:
|
| 69 |
+
raise HTTPException(status_code=401, detail="User not found")
|
| 70 |
+
return user
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# --- WebSocket Progress Manager ---
|
| 74 |
+
|
| 75 |
+
class ConnectionManager:
|
| 76 |
+
def __init__(self):
|
| 77 |
+
self.active_connections: dict[str, list[WebSocket]] = {}
|
| 78 |
+
|
| 79 |
+
async def connect(self, session_id: str, websocket: WebSocket):
|
| 80 |
+
await websocket.accept()
|
| 81 |
+
if session_id not in self.active_connections:
|
| 82 |
+
self.active_connections[session_id] = []
|
| 83 |
+
self.active_connections[session_id].append(websocket)
|
| 84 |
+
|
| 85 |
+
def disconnect(self, session_id: str, websocket: WebSocket):
|
| 86 |
+
if session_id in self.active_connections:
|
| 87 |
+
self.active_connections[session_id].remove(websocket)
|
| 88 |
+
if not self.active_connections[session_id]:
|
| 89 |
+
del self.active_connections[session_id]
|
| 90 |
+
|
| 91 |
+
async def broadcast(self, session_id: str, data: dict):
|
| 92 |
+
if session_id in self.active_connections:
|
| 93 |
+
for ws in self.active_connections[session_id]:
|
| 94 |
+
try:
|
| 95 |
+
await ws.send_text(json.dumps(data))
|
| 96 |
+
except Exception:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
manager = ConnectionManager()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --- Web Pages ---
|
| 103 |
+
|
| 104 |
+
@app.get("/", response_class=HTMLResponse)
|
| 105 |
+
async def index(request: Request):
|
| 106 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 107 |
+
|
| 108 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 109 |
+
async def login_page(request: Request):
|
| 110 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 111 |
+
|
| 112 |
+
@app.get("/dashboard", response_class=HTMLResponse)
|
| 113 |
+
async def dashboard_page(request: Request):
|
| 114 |
+
return templates.TemplateResponse("dashboard.html", {"request": request})
|
| 115 |
+
|
| 116 |
+
@app.get("/progress/{session_id}", response_class=HTMLResponse)
|
| 117 |
+
async def progress_page(request: Request, session_id: str):
|
| 118 |
+
return templates.TemplateResponse("progress.html", {"request": request, "session_id": session_id})
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# --- Auth API ---
|
| 122 |
+
|
| 123 |
+
@app.post("/api/register")
|
| 124 |
+
async def register(req: RegisterRequest):
|
| 125 |
+
user_id = create_user(req.username, req.email, req.password)
|
| 126 |
+
if not user_id:
|
| 127 |
+
raise HTTPException(status_code=400, detail="Username or email already taken")
|
| 128 |
+
return {"message": "User created", "user_id": user_id}
|
| 129 |
+
|
| 130 |
+
@app.post("/api/login")
|
| 131 |
+
async def login(req: LoginRequest):
|
| 132 |
+
user = get_user_by_username(req.username)
|
| 133 |
+
if not user or not verify_password(req.password, user["password_hash"]):
|
| 134 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 135 |
+
token = create_access_token({"sub": user["username"]})
|
| 136 |
+
return {"access_token": token, "token_type": "bearer", "username": user["username"]}
|
| 137 |
+
|
| 138 |
+
@app.get("/api/me")
|
| 139 |
+
async def me(user: dict = Depends(get_current_user)):
|
| 140 |
+
return {
|
| 141 |
+
"username": user["username"],
|
| 142 |
+
"email": user["email"],
|
| 143 |
+
"api_key": user["api_key"],
|
| 144 |
+
"created_at": user["created_at"]
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# --- Model Recommendations ---
|
| 149 |
+
|
| 150 |
+
@app.get("/api/models")
|
| 151 |
+
async def get_models():
|
| 152 |
+
return {"models": settings.recommended_models}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# --- API Key Management ---
|
| 156 |
+
|
| 157 |
+
@app.post("/api/api-keys")
|
| 158 |
+
async def create_new_api_key(req: CreateApiKeyRequest, user: dict = Depends(get_current_user)):
|
| 159 |
+
key = create_api_key(user["id"], req.key_name)
|
| 160 |
+
return {"api_key": key, "key_name": req.key_name}
|
| 161 |
+
|
| 162 |
+
@app.get("/api/api-keys")
|
| 163 |
+
async def list_api_keys(user: dict = Depends(get_current_user)):
|
| 164 |
+
return {"api_keys": get_user_api_keys(user["id"])}
|
| 165 |
+
|
| 166 |
+
@app.delete("/api/api-keys/{key_id}")
|
| 167 |
+
async def delete_api_key(key_id: int, user: dict = Depends(get_current_user)):
|
| 168 |
+
revoke_api_key(key_id, user["id"])
|
| 169 |
+
return {"message": "API key revoked"}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# --- Download Sessions ---
|
| 173 |
+
|
| 174 |
+
@app.post("/api/download-sessions")
|
| 175 |
+
async def start_download_session(req: CreateSessionRequest, user: dict = Depends(get_current_user)):
|
| 176 |
+
session_id = str(uuid.uuid4())
|
| 177 |
+
create_download_session(user["id"], session_id, req.model_name)
|
| 178 |
+
asyncio.create_task(simulate_download(session_id, req.model_name))
|
| 179 |
+
return {"session_id": session_id, "model_name": req.model_name, "status": "started"}
|
| 180 |
+
|
| 181 |
+
@app.get("/api/download-sessions")
|
| 182 |
+
async def list_sessions(user: dict = Depends(get_current_user)):
|
| 183 |
+
return {"sessions": get_user_sessions(user["id"])}
|
| 184 |
+
|
| 185 |
+
@app.get("/api/download-sessions/{session_id}")
|
| 186 |
+
async def get_session(session_id: str, user: dict = Depends(get_current_user)):
|
| 187 |
+
session = get_download_session(session_id)
|
| 188 |
+
if not session:
|
| 189 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 190 |
+
if session["user_id"] != user["id"]:
|
| 191 |
+
raise HTTPException(status_code=403, detail="Not your session")
|
| 192 |
+
return session
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# --- WebSocket ---
|
| 196 |
+
|
| 197 |
+
@app.websocket("/ws/{session_id}")
|
| 198 |
+
async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
| 199 |
+
await manager.connect(session_id, websocket)
|
| 200 |
+
try:
|
| 201 |
+
while True:
|
| 202 |
+
await websocket.receive_text()
|
| 203 |
+
except WebSocketDisconnect:
|
| 204 |
+
manager.disconnect(session_id, websocket)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# --- Simulated Download (mocked until Ollama works) ---
|
| 208 |
+
|
| 209 |
+
async def simulate_download(session_id: str, model_name: str):
|
| 210 |
+
total_mb = 4500
|
| 211 |
+
downloaded = 0
|
| 212 |
+
speed = 5000
|
| 213 |
+
|
| 214 |
+
update_download_progress(session_id, status="downloading", progress=0, total_size_mb=total_mb, downloaded_mb=0, speed_kbps=speed, current_step="Connecting...", eta_seconds=total_mb * 8 // speed)
|
| 215 |
+
await manager.broadcast(session_id, {"status": "downloading", "progress": 0, "current_step": "Connecting...", "speed_kbps": speed, "eta_seconds": total_mb * 8 // speed})
|
| 216 |
+
|
| 217 |
+
await asyncio.sleep(2)
|
| 218 |
+
|
| 219 |
+
for i in range(1, 101):
|
| 220 |
+
await asyncio.sleep(0.5)
|
| 221 |
+
downloaded = int(total_mb * i / 100)
|
| 222 |
+
eta = int((total_mb - downloaded) * 8 / speed)
|
| 223 |
+
step = "Downloading manifest" if i < 10 else ("Downloading layers" if i < 90 else "Verifying")
|
| 224 |
+
update_download_progress(session_id, progress=i, downloaded_mb=downloaded, current_step=step, speed_kbps=speed, eta_seconds=eta)
|
| 225 |
+
await manager.broadcast(session_id, {"status": "downloading", "progress": i, "downloaded_mb": downloaded, "total_size_mb": total_mb, "current_step": step, "speed_kbps": speed, "eta_seconds": eta})
|
| 226 |
+
|
| 227 |
+
update_download_progress(session_id, status="completed", progress=100, downloaded_mb=total_mb, current_step="Done", speed_kbps=0, eta_seconds=0, completed_at=datetime.utcnow().isoformat())
|
| 228 |
+
await manager.broadcast(session_id, {"status": "completed", "progress": 100, "current_step": "Done"})
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# --- Startup ---
|
| 232 |
+
|
| 233 |
+
if __name__ == "__main__":
|
| 234 |
+
import uvicorn
|
| 235 |
+
uvicorn.run(app, host=settings.host, port=settings.port)
|
config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pydantic_settings import BaseSettings
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
app_name: str = "Ollama Model Manager"
|
| 8 |
+
secret_key: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
| 9 |
+
algorithm: str = "HS256"
|
| 10 |
+
access_token_expire_minutes: int = 30
|
| 11 |
+
database_url: str = "sqlite:///./ollama_tool.db"
|
| 12 |
+
host: str = "0.0.0.0"
|
| 13 |
+
port: int = 7860
|
| 14 |
+
|
| 15 |
+
# Model recommendations for 16GB RAM
|
| 16 |
+
recommended_models: List[dict] = [
|
| 17 |
+
{"name": "llama3.1:8b", "size": "4.7GB", "ram": "8GB", "description": "Best all-around model for 16GB"},
|
| 18 |
+
{"name": "qwen2.5:7b", "size": "4.4GB", "ram": "8GB", "description": "Excellent multilingual support"},
|
| 19 |
+
{"name": "mistral:7b", "size": "4.1GB", "ram": "8GB", "description": "Fast and capable"},
|
| 20 |
+
{"name": "phi3:mini", "size": "2.3GB", "ram": "4GB", "description": "Lightweight, good for coding"},
|
| 21 |
+
{"name": "gemma2:9b", "size": "5.4GB", "ram": "10GB", "description": "Google's strong model"},
|
| 22 |
+
{"name": "qwen2.5:14b", "size": "8.2GB", "ram": "16GB", "description": "High quality, fits 16GB"},
|
| 23 |
+
{"name": "llama3.1:70b-q4", "size": "39GB", "ram": "48GB", "description": "Too large for 16GB"},
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
class Config:
|
| 27 |
+
env_file = ".env"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
settings = Settings()
|
database.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import hashlib
|
| 3 |
+
import secrets
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import Optional, List, Dict
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@contextmanager
|
| 11 |
+
def get_db():
|
| 12 |
+
conn = sqlite3.connect(settings.database_url.replace("sqlite:///", ""))
|
| 13 |
+
conn.row_factory = sqlite3.Row
|
| 14 |
+
try:
|
| 15 |
+
yield conn
|
| 16 |
+
conn.commit()
|
| 17 |
+
except Exception:
|
| 18 |
+
conn.rollback()
|
| 19 |
+
raise
|
| 20 |
+
finally:
|
| 21 |
+
conn.close()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def init_db():
|
| 25 |
+
with get_db() as conn:
|
| 26 |
+
conn.execute("""
|
| 27 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 28 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 29 |
+
username TEXT UNIQUE NOT NULL,
|
| 30 |
+
email TEXT UNIQUE NOT NULL,
|
| 31 |
+
password_hash TEXT NOT NULL,
|
| 32 |
+
api_key TEXT UNIQUE NOT NULL,
|
| 33 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 34 |
+
is_active BOOLEAN DEFAULT 1
|
| 35 |
+
)
|
| 36 |
+
""")
|
| 37 |
+
conn.execute("""
|
| 38 |
+
CREATE TABLE IF NOT EXISTS download_sessions (
|
| 39 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 40 |
+
user_id INTEGER NOT NULL,
|
| 41 |
+
session_id TEXT UNIQUE NOT NULL,
|
| 42 |
+
status TEXT DEFAULT 'pending',
|
| 43 |
+
ollama_version TEXT,
|
| 44 |
+
model_name TEXT,
|
| 45 |
+
progress INTEGER DEFAULT 0,
|
| 46 |
+
current_step TEXT,
|
| 47 |
+
total_size_mb INTEGER DEFAULT 0,
|
| 48 |
+
downloaded_mb INTEGER DEFAULT 0,
|
| 49 |
+
speed_kbps INTEGER DEFAULT 0,
|
| 50 |
+
eta_seconds INTEGER DEFAULT 0,
|
| 51 |
+
error_message TEXT,
|
| 52 |
+
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 53 |
+
completed_at TIMESTAMP,
|
| 54 |
+
FOREIGN KEY (user_id) REFERENCES users (id)
|
| 55 |
+
)
|
| 56 |
+
""")
|
| 57 |
+
conn.execute("""
|
| 58 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 59 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 60 |
+
user_id INTEGER NOT NULL,
|
| 61 |
+
key_name TEXT NOT NULL,
|
| 62 |
+
api_key TEXT UNIQUE NOT NULL,
|
| 63 |
+
is_active BOOLEAN DEFAULT 1,
|
| 64 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 65 |
+
last_used_at TIMESTAMP,
|
| 66 |
+
FOREIGN KEY (user_id) REFERENCES users (id)
|
| 67 |
+
)
|
| 68 |
+
""")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def hash_password(password: str) -> str:
|
| 72 |
+
return hashlib.sha256(password.encode()).hexdigest()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def verify_password(password: str, password_hash: str) -> bool:
|
| 76 |
+
return hash_password(password) == password_hash
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def generate_api_key() -> str:
|
| 80 |
+
return f"ollama_{secrets.token_urlsafe(32)}"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def create_user(username: str, email: str, password: str) -> Optional[int]:
|
| 84 |
+
with get_db() as conn:
|
| 85 |
+
try:
|
| 86 |
+
cursor = conn.execute(
|
| 87 |
+
"INSERT INTO users (username, email, password_hash, api_key) VALUES (?, ?, ?, ?)",
|
| 88 |
+
(username, email, hash_password(password), generate_api_key())
|
| 89 |
+
)
|
| 90 |
+
return cursor.lastrowid
|
| 91 |
+
except sqlite3.IntegrityError:
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def get_user_by_username(username: str) -> Optional[Dict]:
|
| 96 |
+
with get_db() as conn:
|
| 97 |
+
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
| 98 |
+
return dict(row) if row else None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_user_by_api_key(api_key: str) -> Optional[Dict]:
|
| 102 |
+
with get_db() as conn:
|
| 103 |
+
row = conn.execute("SELECT * FROM users WHERE api_key = ? AND is_active = 1", (api_key,)).fetchone()
|
| 104 |
+
return dict(row) if row else None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def create_download_session(user_id: int, session_id: str, model_name: str) -> int:
|
| 108 |
+
with get_db() as conn:
|
| 109 |
+
cursor = conn.execute(
|
| 110 |
+
"INSERT INTO download_sessions (user_id, session_id, model_name, status) VALUES (?, ?, ?, 'pending')",
|
| 111 |
+
(user_id, session_id, model_name)
|
| 112 |
+
)
|
| 113 |
+
return cursor.lastrowid
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def update_download_progress(session_id: str, **kwargs):
|
| 117 |
+
with get_db() as conn:
|
| 118 |
+
fields = ", ".join([f"{k} = ?" for k in kwargs.keys()])
|
| 119 |
+
values = list(kwargs.values()) + [session_id]
|
| 120 |
+
conn.execute(f"UPDATE download_sessions SET {fields} WHERE session_id = ?", values)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_download_session(session_id: str) -> Optional[Dict]:
|
| 124 |
+
with get_db() as conn:
|
| 125 |
+
row = conn.execute("SELECT * FROM download_sessions WHERE session_id = ?", (session_id,)).fetchone()
|
| 126 |
+
return dict(row) if row else None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def get_user_sessions(user_id: int) -> List[Dict]:
|
| 130 |
+
with get_db() as conn:
|
| 131 |
+
rows = conn.execute(
|
| 132 |
+
"SELECT * FROM download_sessions WHERE user_id = ? ORDER BY started_at DESC",
|
| 133 |
+
(user_id,)
|
| 134 |
+
).fetchall()
|
| 135 |
+
return [dict(row) for row in rows]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def create_api_key(user_id: int, key_name: str) -> str:
|
| 139 |
+
api_key = generate_api_key()
|
| 140 |
+
with get_db() as conn:
|
| 141 |
+
conn.execute(
|
| 142 |
+
"INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)",
|
| 143 |
+
(user_id, key_name, api_key)
|
| 144 |
+
)
|
| 145 |
+
return api_key
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_user_api_keys(user_id: int) -> List[Dict]:
|
| 149 |
+
with get_db() as conn:
|
| 150 |
+
rows = conn.execute("SELECT * FROM api_keys WHERE user_id = ?", (user_id,)).fetchall()
|
| 151 |
+
return [dict(row) for row in rows]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def revoke_api_key(key_id: int, user_id: int):
|
| 155 |
+
with get_db() as conn:
|
| 156 |
+
conn.execute("UPDATE api_keys SET is_active = 0 WHERE id = ? AND user_id = ?", (key_id, user_id))
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.0
|
| 3 |
+
httpx==0.27.0
|
| 4 |
+
pydantic==2.7.0
|
| 5 |
+
pydantic-settings==2.3.0
|
| 6 |
+
python-jose[cryptography]==3.3.0
|
| 7 |
+
passlib[bcrypt]==1.7.4
|
| 8 |
+
python-multipart==0.0.9
|
| 9 |
+
jinja2==3.1.3
|
| 10 |
+
websockets==12.0
|
| 11 |
+
aiofiles==23.2.1
|
static/style.css
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 2 |
+
|
| 3 |
+
body {
|
| 4 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 5 |
+
background: #f0f2f5;
|
| 6 |
+
color: #333;
|
| 7 |
+
line-height: 1.6;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.container { max-width: 960px; margin: 0 auto; padding: 20px; }
|
| 11 |
+
|
| 12 |
+
header {
|
| 13 |
+
display: flex;
|
| 14 |
+
justify-content: space-between;
|
| 15 |
+
align-items: center;
|
| 16 |
+
margin-bottom: 30px;
|
| 17 |
+
padding-bottom: 20px;
|
| 18 |
+
border-bottom: 2px solid #e0e0e0;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
header h1 { font-size: 1.8rem; color: #1a1a2e; }
|
| 22 |
+
|
| 23 |
+
a { color: #4361ee; text-decoration: none; }
|
| 24 |
+
a:hover { text-decoration: underline; }
|
| 25 |
+
|
| 26 |
+
.card {
|
| 27 |
+
background: white;
|
| 28 |
+
border-radius: 12px;
|
| 29 |
+
padding: 24px;
|
| 30 |
+
margin-bottom: 20px;
|
| 31 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.card h2 { margin-bottom: 16px; font-size: 1.2rem; color: #1a1a2e; }
|
| 35 |
+
|
| 36 |
+
.hero {
|
| 37 |
+
display: grid;
|
| 38 |
+
grid-template-columns: 1fr 1fr;
|
| 39 |
+
gap: 20px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.buttons { display: flex; gap: 12px; margin-top: 16px; }
|
| 43 |
+
|
| 44 |
+
.btn {
|
| 45 |
+
display: inline-block;
|
| 46 |
+
padding: 10px 24px;
|
| 47 |
+
border-radius: 8px;
|
| 48 |
+
border: none;
|
| 49 |
+
font-size: 1rem;
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
text-decoration: none;
|
| 52 |
+
transition: background 0.2s;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.btn-primary { background: #4361ee; color: white; }
|
| 56 |
+
.btn-primary:hover { background: #3a56d4; }
|
| 57 |
+
|
| 58 |
+
.btn-secondary { background: #e0e0e0; color: #333; }
|
| 59 |
+
.btn-secondary:hover { background: #ccc; }
|
| 60 |
+
|
| 61 |
+
.btn-danger { background: #e63946; color: white; }
|
| 62 |
+
.btn-danger:hover { background: #c1121f; }
|
| 63 |
+
|
| 64 |
+
.btn-small { padding: 4px 12px; font-size: 0.85rem; }
|
| 65 |
+
|
| 66 |
+
.auth-box {
|
| 67 |
+
max-width: 400px;
|
| 68 |
+
margin: 40px auto;
|
| 69 |
+
background: white;
|
| 70 |
+
border-radius: 12px;
|
| 71 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
| 72 |
+
overflow: hidden;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.tabs { display: flex; }
|
| 76 |
+
.tab {
|
| 77 |
+
flex: 1;
|
| 78 |
+
padding: 14px;
|
| 79 |
+
border: none;
|
| 80 |
+
background: #f0f2f5;
|
| 81 |
+
cursor: pointer;
|
| 82 |
+
font-size: 1rem;
|
| 83 |
+
transition: background 0.2s;
|
| 84 |
+
}
|
| 85 |
+
.tab.active { background: white; font-weight: 600; }
|
| 86 |
+
|
| 87 |
+
.auth-form { padding: 24px; }
|
| 88 |
+
.auth-form.hidden { display: none; }
|
| 89 |
+
|
| 90 |
+
.auth-form input {
|
| 91 |
+
width: 100%;
|
| 92 |
+
padding: 10px 14px;
|
| 93 |
+
margin-bottom: 12px;
|
| 94 |
+
border: 1px solid #ddd;
|
| 95 |
+
border-radius: 8px;
|
| 96 |
+
font-size: 1rem;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.error { color: #e63946; font-size: 0.9rem; margin-top: 8px; }
|
| 100 |
+
.hidden { display: none; }
|
| 101 |
+
|
| 102 |
+
.dashboard-grid {
|
| 103 |
+
display: grid;
|
| 104 |
+
grid-template-columns: 1fr 1fr;
|
| 105 |
+
gap: 20px;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.full-width { grid-column: 1 / -1; }
|
| 109 |
+
|
| 110 |
+
.model-list { list-style: none; }
|
| 111 |
+
.model-list li {
|
| 112 |
+
padding: 8px 0;
|
| 113 |
+
border-bottom: 1px solid #e0e0e0;
|
| 114 |
+
font-size: 0.9rem;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.key-list { list-style: none; margin-top: 12px; }
|
| 118 |
+
.key-list li {
|
| 119 |
+
display: flex;
|
| 120 |
+
align-items: center;
|
| 121 |
+
gap: 8px;
|
| 122 |
+
padding: 8px 0;
|
| 123 |
+
border-bottom: 1px solid #eee;
|
| 124 |
+
font-size: 0.9rem;
|
| 125 |
+
}
|
| 126 |
+
.key-list code {
|
| 127 |
+
background: #f0f2f5;
|
| 128 |
+
padding: 2px 6px;
|
| 129 |
+
border-radius: 4px;
|
| 130 |
+
font-size: 0.8rem;
|
| 131 |
+
word-break: break-all;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.progress-container { max-width: 600px; margin: 0 auto; }
|
| 135 |
+
|
| 136 |
+
.progress-bar-wrapper {
|
| 137 |
+
width: 100%;
|
| 138 |
+
height: 30px;
|
| 139 |
+
background: #e0e0e0;
|
| 140 |
+
border-radius: 15px;
|
| 141 |
+
overflow: hidden;
|
| 142 |
+
margin: 16px 0;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.progress-bar {
|
| 146 |
+
height: 100%;
|
| 147 |
+
background: linear-gradient(90deg, #4361ee, #7209b7);
|
| 148 |
+
transition: width 0.3s ease;
|
| 149 |
+
width: 0%;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.progress-text {
|
| 153 |
+
text-align: center;
|
| 154 |
+
font-size: 1.5rem;
|
| 155 |
+
font-weight: 700;
|
| 156 |
+
color: #1a1a2e;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.progress-details {
|
| 160 |
+
margin-top: 20px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.progress-details p {
|
| 164 |
+
margin: 8px 0;
|
| 165 |
+
font-size: 0.95rem;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.session-item {
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: center;
|
| 171 |
+
gap: 16px;
|
| 172 |
+
padding: 12px;
|
| 173 |
+
border-bottom: 1px solid #eee;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.status {
|
| 177 |
+
padding: 2px 10px;
|
| 178 |
+
border-radius: 12px;
|
| 179 |
+
font-size: 0.85rem;
|
| 180 |
+
font-weight: 600;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.status-pending { background: #fff3cd; color: #856404; }
|
| 184 |
+
.status-downloading { background: #cce5ff; color: #004085; }
|
| 185 |
+
.status-completed { background: #d4edda; color: #155724; }
|
| 186 |
+
.status-error { background: #f8d7da; color: #721c24; }
|
| 187 |
+
|
| 188 |
+
select {
|
| 189 |
+
width: 100%;
|
| 190 |
+
padding: 10px 14px;
|
| 191 |
+
margin-bottom: 12px;
|
| 192 |
+
border: 1px solid #ddd;
|
| 193 |
+
border-radius: 8px;
|
| 194 |
+
font-size: 1rem;
|
| 195 |
+
background: white;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@media (max-width: 600px) {
|
| 199 |
+
.hero, .dashboard-grid { grid-template-columns: 1fr; }
|
| 200 |
+
}
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Dashboard - Ollama Manager</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container">
|
| 11 |
+
<header>
|
| 12 |
+
<h1>Dashboard</h1>
|
| 13 |
+
<button class="btn btn-secondary" onclick="logout()">Logout</button>
|
| 14 |
+
</header>
|
| 15 |
+
|
| 16 |
+
<div class="dashboard-grid">
|
| 17 |
+
<div class="card">
|
| 18 |
+
<h2>Your Profile</h2>
|
| 19 |
+
<div id="profile"></div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="card">
|
| 23 |
+
<h2>API Keys</h2>
|
| 24 |
+
<form onsubmit="createApiKey(event)">
|
| 25 |
+
<input type="text" id="key-name" placeholder="Key name" required>
|
| 26 |
+
<button type="submit" class="btn btn-primary">Generate</button>
|
| 27 |
+
</form>
|
| 28 |
+
<ul id="api-keys-list" class="key-list"></ul>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="card">
|
| 32 |
+
<h2>Start Download</h2>
|
| 33 |
+
<form onsubmit="startDownload(event)">
|
| 34 |
+
<select id="model-select" required></select>
|
| 35 |
+
<button type="submit" class="btn btn-primary">Download</button>
|
| 36 |
+
</form>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div class="card full-width">
|
| 40 |
+
<h2>Download Sessions</h2>
|
| 41 |
+
<div id="sessions-list"></div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<script>
|
| 47 |
+
const token = localStorage.getItem('token');
|
| 48 |
+
if (!token) window.location.href = '/login';
|
| 49 |
+
|
| 50 |
+
async function api(url, opts = {}) {
|
| 51 |
+
opts.headers = {...opts.headers, 'Authorization': `Bearer ${token}`};
|
| 52 |
+
if (opts.body && typeof opts.body === 'object') {
|
| 53 |
+
opts.headers['Content-Type'] = 'application/json';
|
| 54 |
+
opts.body = JSON.stringify(opts.body);
|
| 55 |
+
}
|
| 56 |
+
const res = await fetch(url, opts);
|
| 57 |
+
if (res.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; }
|
| 58 |
+
return res;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function logout() {
|
| 62 |
+
localStorage.removeItem('token');
|
| 63 |
+
window.location.href = '/login';
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Load profile
|
| 67 |
+
api('/api/me').then(r => r.json()).then(data => {
|
| 68 |
+
document.getElementById('profile').innerHTML = `
|
| 69 |
+
<p><strong>Username:</strong> ${data.username}</p>
|
| 70 |
+
<p><strong>Email:</strong> ${data.email}</p>
|
| 71 |
+
<p><strong>Default API Key:</strong> <code>${data.api_key}</code></p>
|
| 72 |
+
`;
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// Load models
|
| 76 |
+
fetch('/api/models').then(r => r.json()).then(data => {
|
| 77 |
+
const select = document.getElementById('model-select');
|
| 78 |
+
data.models.filter(m => !m.name.includes('70b')).forEach(m => {
|
| 79 |
+
const opt = document.createElement('option');
|
| 80 |
+
opt.value = m.name;
|
| 81 |
+
opt.textContent = `${m.name} (${m.size})`;
|
| 82 |
+
select.appendChild(opt);
|
| 83 |
+
});
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Load API keys
|
| 87 |
+
async function loadApiKeys() {
|
| 88 |
+
const res = await api('/api/api-keys');
|
| 89 |
+
const data = await res.json();
|
| 90 |
+
const ul = document.getElementById('api-keys-list');
|
| 91 |
+
ul.innerHTML = data.api_keys.map(k => `
|
| 92 |
+
<li>
|
| 93 |
+
<strong>${k.key_name}</strong>: <code>${k.api_key}</code>
|
| 94 |
+
<button onclick="revokeKey(${k.id})" class="btn-small btn-danger">Revoke</button>
|
| 95 |
+
</li>
|
| 96 |
+
`).join('');
|
| 97 |
+
}
|
| 98 |
+
loadApiKeys();
|
| 99 |
+
|
| 100 |
+
async function createApiKey(e) {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
const name = document.getElementById('key-name').value;
|
| 103 |
+
await api('/api/api-keys', {method: 'POST', body: {key_name: name}});
|
| 104 |
+
document.getElementById('key-name').value = '';
|
| 105 |
+
loadApiKeys();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function revokeKey(id) {
|
| 109 |
+
if (confirm('Revoke this API key?')) {
|
| 110 |
+
await api(`/api/api-keys/${id}`, {method: 'DELETE'});
|
| 111 |
+
loadApiKeys();
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// Start download
|
| 116 |
+
async function startDownload(e) {
|
| 117 |
+
e.preventDefault();
|
| 118 |
+
const model = document.getElementById('model-select').value;
|
| 119 |
+
const res = await api('/api/download-sessions', {method: 'POST', body: {model_name: model}});
|
| 120 |
+
const data = await res.json();
|
| 121 |
+
window.location.href = `/progress/${data.session_id}`;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Load sessions
|
| 125 |
+
async function loadSessions() {
|
| 126 |
+
const res = await api('/api/download-sessions');
|
| 127 |
+
const data = await res.json();
|
| 128 |
+
const div = document.getElementById('sessions-list');
|
| 129 |
+
if (data.sessions.length === 0) {
|
| 130 |
+
div.innerHTML = '<p>No download sessions yet.</p>';
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
div.innerHTML = data.sessions.map(s => `
|
| 134 |
+
<div class="session-item">
|
| 135 |
+
<a href="/progress/${s.session_id}"><strong>${s.model_name}</strong></a>
|
| 136 |
+
<span class="status status-${s.status}">${s.status}</span>
|
| 137 |
+
<span>${s.progress}%</span>
|
| 138 |
+
<small>${s.started_at}</small>
|
| 139 |
+
</div>
|
| 140 |
+
`).join('');
|
| 141 |
+
}
|
| 142 |
+
loadSessions();
|
| 143 |
+
</script>
|
| 144 |
+
</body>
|
| 145 |
+
</html>
|
templates/index.html
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Ollama Model Manager</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container">
|
| 11 |
+
<header>
|
| 12 |
+
<h1>Ollama Model Manager</h1>
|
| 13 |
+
<p>Manage Ollama model downloads for 16GB systems</p>
|
| 14 |
+
</header>
|
| 15 |
+
<div class="hero">
|
| 16 |
+
<div class="card">
|
| 17 |
+
<h2>Get Started</h2>
|
| 18 |
+
<p>Register to generate API keys and track model download progress.</p>
|
| 19 |
+
<div class="buttons">
|
| 20 |
+
<a href="/login" class="btn btn-primary">Login / Register</a>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="card">
|
| 24 |
+
<h2>Recommended Models</h2>
|
| 25 |
+
<ul id="model-list" class="model-list"></ul>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<script>
|
| 30 |
+
fetch('/api/models')
|
| 31 |
+
.then(r => r.json())
|
| 32 |
+
.then(data => {
|
| 33 |
+
const ul = document.getElementById('model-list');
|
| 34 |
+
data.models.filter(m => !m.name.includes('70b')).forEach(m => {
|
| 35 |
+
const li = document.createElement('li');
|
| 36 |
+
li.innerHTML = `<strong>${m.name}</strong> (${m.size}, ${m.ram} RAM) - ${m.description}`;
|
| 37 |
+
ul.appendChild(li);
|
| 38 |
+
});
|
| 39 |
+
});
|
| 40 |
+
</script>
|
| 41 |
+
</body>
|
| 42 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Login - Ollama Manager</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container">
|
| 11 |
+
<header>
|
| 12 |
+
<h1>Ollama Model Manager</h1>
|
| 13 |
+
</header>
|
| 14 |
+
<div class="auth-box">
|
| 15 |
+
<div class="tabs">
|
| 16 |
+
<button class="tab active" onclick="showTab('login')">Login</button>
|
| 17 |
+
<button class="tab" onclick="showTab('register')">Register</button>
|
| 18 |
+
</div>
|
| 19 |
+
<form id="login-form" class="auth-form" onsubmit="handleLogin(event)">
|
| 20 |
+
<h2>Login</h2>
|
| 21 |
+
<input type="text" name="username" placeholder="Username" required>
|
| 22 |
+
<input type="password" name="password" placeholder="Password" required>
|
| 23 |
+
<button type="submit" class="btn btn-primary">Login</button>
|
| 24 |
+
<p id="login-error" class="error"></p>
|
| 25 |
+
</form>
|
| 26 |
+
<form id="register-form" class="auth-form hidden" onsubmit="handleRegister(event)">
|
| 27 |
+
<h2>Register</h2>
|
| 28 |
+
<input type="text" name="username" placeholder="Username" required>
|
| 29 |
+
<input type="email" name="email" placeholder="Email" required>
|
| 30 |
+
<input type="password" name="password" placeholder="Password" required>
|
| 31 |
+
<button type="submit" class="btn btn-primary">Register</button>
|
| 32 |
+
<p id="register-error" class="error"></p>
|
| 33 |
+
</form>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
<script>
|
| 37 |
+
function showTab(name) {
|
| 38 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 39 |
+
document.querySelectorAll('.auth-form').forEach(f => f.classList.add('hidden'));
|
| 40 |
+
document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
|
| 41 |
+
document.getElementById(`${name}-form`).classList.remove('hidden');
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async function handleLogin(e) {
|
| 45 |
+
e.preventDefault();
|
| 46 |
+
const fd = new FormData(e.target);
|
| 47 |
+
const res = await fetch('/api/login', {
|
| 48 |
+
method: 'POST',
|
| 49 |
+
headers: {'Content-Type': 'application/json'},
|
| 50 |
+
body: JSON.stringify({username: fd.get('username'), password: fd.get('password')})
|
| 51 |
+
});
|
| 52 |
+
if (res.ok) {
|
| 53 |
+
const data = await res.json();
|
| 54 |
+
localStorage.setItem('token', data.access_token);
|
| 55 |
+
window.location.href = '/dashboard';
|
| 56 |
+
} else {
|
| 57 |
+
const err = await res.json();
|
| 58 |
+
document.getElementById('login-error').textContent = err.detail || 'Login failed';
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async function handleRegister(e) {
|
| 63 |
+
e.preventDefault();
|
| 64 |
+
const fd = new FormData(e.target);
|
| 65 |
+
const res = await fetch('/api/register', {
|
| 66 |
+
method: 'POST',
|
| 67 |
+
headers: {'Content-Type': 'application/json'},
|
| 68 |
+
body: JSON.stringify({username: fd.get('username'), email: fd.get('email'), password: fd.get('password')})
|
| 69 |
+
});
|
| 70 |
+
if (res.ok) {
|
| 71 |
+
document.getElementById('register-error').textContent = 'Registered! You can now login.';
|
| 72 |
+
document.getElementById('register-error').style.color = 'green';
|
| 73 |
+
} else {
|
| 74 |
+
const err = await res.json();
|
| 75 |
+
document.getElementById('register-error').textContent = err.detail || 'Registration failed';
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
</script>
|
| 79 |
+
</body>
|
| 80 |
+
</html>
|
templates/progress.html
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Download Progress - Ollama Manager</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="container">
|
| 11 |
+
<header>
|
| 12 |
+
<h1>Download Progress</h1>
|
| 13 |
+
<a href="/dashboard" class="btn btn-secondary">Back to Dashboard</a>
|
| 14 |
+
</header>
|
| 15 |
+
|
| 16 |
+
<div class="progress-container">
|
| 17 |
+
<div class="card">
|
| 18 |
+
<h2 id="model-name">Model: <span id="model-label"></span></h2>
|
| 19 |
+
<div class="progress-bar-wrapper">
|
| 20 |
+
<div class="progress-bar" id="progress-bar"></div>
|
| 21 |
+
</div>
|
| 22 |
+
<p id="progress-percent" class="progress-text">0%</p>
|
| 23 |
+
<div class="progress-details">
|
| 24 |
+
<p><strong>Status:</strong> <span id="status-text">Pending</span></p>
|
| 25 |
+
<p><strong>Step:</strong> <span id="step-text">Waiting...</span></p>
|
| 26 |
+
<p><strong>Downloaded:</strong> <span id="downloaded-text">0 MB</span> / <span id="total-text">0 MB</span></p>
|
| 27 |
+
<p><strong>Speed:</strong> <span id="speed-text">0</span> KB/s</p>
|
| 28 |
+
<p><strong>ETA:</strong> <span id="eta-text">--</span></p>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<script>
|
| 35 |
+
const sessionId = '{{ session_id }}';
|
| 36 |
+
const token = localStorage.getItem('token');
|
| 37 |
+
if (!token) window.location.href = '/login';
|
| 38 |
+
|
| 39 |
+
// Load session info
|
| 40 |
+
fetch(`/api/download-sessions/${sessionId}`, {
|
| 41 |
+
headers: {'Authorization': `Bearer ${token}`}
|
| 42 |
+
}).then(r => r.json()).then(data => {
|
| 43 |
+
document.getElementById('model-label').textContent = data.model_name || 'Unknown';
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
// WebSocket
|
| 47 |
+
const ws = new WebSocket(`ws://${window.location.host}/ws/${sessionId}`);
|
| 48 |
+
|
| 49 |
+
ws.onmessage = function(event) {
|
| 50 |
+
const data = JSON.parse(event.data);
|
| 51 |
+
|
| 52 |
+
const progress = data.progress || 0;
|
| 53 |
+
document.getElementById('progress-bar').style.width = progress + '%';
|
| 54 |
+
document.getElementById('progress-percent').textContent = progress + '%';
|
| 55 |
+
|
| 56 |
+
if (data.status) document.getElementById('status-text').textContent = data.status;
|
| 57 |
+
if (data.current_step) document.getElementById('step-text').textContent = data.current_step;
|
| 58 |
+
if (data.downloaded_mb) document.getElementById('downloaded-text').textContent = data.downloaded_mb + ' MB';
|
| 59 |
+
if (data.total_size_mb) document.getElementById('total-text').textContent = data.total_size_mb + ' MB';
|
| 60 |
+
if (data.speed_kbps) document.getElementById('speed-text').textContent = data.speed_kbps;
|
| 61 |
+
if (data.eta_seconds !== undefined) {
|
| 62 |
+
const eta = data.eta_seconds;
|
| 63 |
+
if (eta <= 0) document.getElementById('eta-text').textContent = 'Done';
|
| 64 |
+
else if (eta < 60) document.getElementById('eta-text').textContent = eta + 's';
|
| 65 |
+
else document.getElementById('eta-text').textContent = Math.floor(eta / 60) + 'm ' + (eta % 60) + 's';
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (data.status === 'completed') {
|
| 69 |
+
document.getElementById('status-text').textContent = 'Completed';
|
| 70 |
+
document.getElementById('step-text').textContent = 'Done';
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
ws.onerror = function() {
|
| 75 |
+
document.getElementById('status-text').textContent = 'Connection lost';
|
| 76 |
+
};
|
| 77 |
+
</script>
|
| 78 |
+
</body>
|
| 79 |
+
</html>
|