xtom0 commited on
Commit
f72291c
·
1 Parent(s): 0e77268

Initial commit: FastAPI app with auth, API keys, download tracking, WebSocket live progress

Browse files
.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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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>