feat: HuggingFace model management tab #19
6 changed files with 1659 additions and 0 deletions
|
|
@ -145,6 +145,9 @@ app = FastAPI(title="Avocet API")
|
||||||
from app.sft import router as sft_router
|
from app.sft import router as sft_router
|
||||||
app.include_router(sft_router, prefix="/api/sft")
|
app.include_router(sft_router, prefix="/api/sft")
|
||||||
|
|
||||||
|
from app.models import router as models_router
|
||||||
|
app.include_router(models_router, prefix="/api/models")
|
||||||
|
|
||||||
# In-memory last-action store (single user, local tool — in-memory is fine)
|
# In-memory last-action store (single user, local tool — in-memory is fine)
|
||||||
_last_action: dict | None = None
|
_last_action: dict | None = None
|
||||||
|
|
||||||
|
|
|
||||||
428
app/models.py
Normal file
428
app/models.py
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
"""Avocet — HF model lifecycle API.
|
||||||
|
|
||||||
|
Handles model metadata lookup, approval queue, download with progress,
|
||||||
|
and installed model management.
|
||||||
|
|
||||||
|
All endpoints are registered on `router` (a FastAPI APIRouter).
|
||||||
|
api.py includes this router with prefix="/api/models".
|
||||||
|
|
||||||
|
Module-level globals (_MODELS_DIR, _QUEUE_DIR) follow the same
|
||||||
|
testability pattern as sft.py — override them via set_models_dir() and
|
||||||
|
set_queue_dir() in test fixtures.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.utils import read_jsonl, write_jsonl
|
||||||
|
|
||||||
|
try:
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
snapshot_download = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).parent.parent
|
||||||
|
_MODELS_DIR: Path = _ROOT / "models"
|
||||||
|
_QUEUE_DIR: Path = _ROOT / "data"
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Download progress shared state ────────────────────────────────────────────
|
||||||
|
# Updated by the background download thread; read by GET /download/stream.
|
||||||
|
_download_progress: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# ── HF pipeline_tag → adapter recommendation ──────────────────────────────────
|
||||||
|
_TAG_TO_ADAPTER: dict[str, str] = {
|
||||||
|
"zero-shot-classification": "ZeroShotAdapter",
|
||||||
|
"text-classification": "ZeroShotAdapter",
|
||||||
|
"natural-language-inference": "ZeroShotAdapter",
|
||||||
|
"sentence-similarity": "RerankerAdapter",
|
||||||
|
"text-ranking": "RerankerAdapter",
|
||||||
|
"text-generation": "GenerationAdapter",
|
||||||
|
"text2text-generation": "GenerationAdapter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Testability seams ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_models_dir(path: Path) -> None:
|
||||||
|
global _MODELS_DIR
|
||||||
|
_MODELS_DIR = path
|
||||||
|
|
||||||
|
|
||||||
|
def set_queue_dir(path: Path) -> None:
|
||||||
|
global _QUEUE_DIR
|
||||||
|
_QUEUE_DIR = path
|
||||||
|
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _queue_file() -> Path:
|
||||||
|
return _QUEUE_DIR / "model_queue.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_queue() -> list[dict]:
|
||||||
|
return read_jsonl(_queue_file())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_queue(records: list[dict]) -> None:
|
||||||
|
write_jsonl(_queue_file(), records)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_model_name(repo_id: str) -> str:
|
||||||
|
"""Convert repo_id to a filesystem-safe directory name (HF convention)."""
|
||||||
|
return repo_id.replace("/", "--")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_installed(repo_id: str) -> bool:
|
||||||
|
"""Check if a model is already downloaded in _MODELS_DIR."""
|
||||||
|
safe_name = _safe_model_name(repo_id)
|
||||||
|
model_dir = _MODELS_DIR / safe_name
|
||||||
|
return model_dir.exists() and (
|
||||||
|
(model_dir / "config.json").exists()
|
||||||
|
or (model_dir / "training_info.json").exists()
|
||||||
|
or (model_dir / "model_info.json").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_queued(repo_id: str) -> bool:
|
||||||
|
"""Check if repo_id is already in the queue (non-dismissed)."""
|
||||||
|
for entry in _read_queue():
|
||||||
|
if entry.get("repo_id") == repo_id and entry.get("status") != "dismissed":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _update_queue_entry(entry_id: str, updates: dict) -> dict | None:
|
||||||
|
"""Update a queue entry by id. Returns updated entry or None if not found."""
|
||||||
|
records = _read_queue()
|
||||||
|
for i, r in enumerate(records):
|
||||||
|
if r.get("id") == entry_id:
|
||||||
|
records[i] = {**r, **updates}
|
||||||
|
_write_queue(records)
|
||||||
|
return records[i]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_queue_entry(entry_id: str) -> dict | None:
|
||||||
|
for r in _read_queue():
|
||||||
|
if r.get("id") == entry_id:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Background download ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_download(entry_id: str, repo_id: str, pipeline_tag: str | None, adapter_recommendation: str | None) -> None:
|
||||||
|
"""Background thread: download model via huggingface_hub.snapshot_download."""
|
||||||
|
global _download_progress
|
||||||
|
safe_name = _safe_model_name(repo_id)
|
||||||
|
local_dir = _MODELS_DIR / safe_name
|
||||||
|
|
||||||
|
_download_progress = {
|
||||||
|
"active": True,
|
||||||
|
"repo_id": repo_id,
|
||||||
|
"downloaded_bytes": 0,
|
||||||
|
"total_bytes": 0,
|
||||||
|
"pct": 0.0,
|
||||||
|
"done": False,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if snapshot_download is None:
|
||||||
|
raise RuntimeError("huggingface_hub is not installed")
|
||||||
|
|
||||||
|
snapshot_download(
|
||||||
|
repo_id=repo_id,
|
||||||
|
local_dir=str(local_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write model_info.json alongside downloaded files
|
||||||
|
model_info = {
|
||||||
|
"repo_id": repo_id,
|
||||||
|
"pipeline_tag": pipeline_tag,
|
||||||
|
"adapter_recommendation": adapter_recommendation,
|
||||||
|
"downloaded_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
local_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(local_dir / "model_info.json").write_text(
|
||||||
|
json.dumps(model_info, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
_download_progress["done"] = True
|
||||||
|
_download_progress["pct"] = 100.0
|
||||||
|
_update_queue_entry(entry_id, {"status": "ready"})
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Download failed for %s: %s", repo_id, exc)
|
||||||
|
_download_progress["error"] = str(exc)
|
||||||
|
_download_progress["done"] = True
|
||||||
|
_update_queue_entry(entry_id, {"status": "failed", "error": str(exc)})
|
||||||
|
finally:
|
||||||
|
_download_progress["active"] = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /lookup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/lookup")
|
||||||
|
def lookup_model(repo_id: str) -> dict:
|
||||||
|
"""Validate repo_id and fetch metadata from the HF API."""
|
||||||
|
# Validate: must contain exactly one '/', no whitespace
|
||||||
|
if "/" not in repo_id or any(c.isspace() for c in repo_id):
|
||||||
|
raise HTTPException(422, f"Invalid repo_id {repo_id!r}: must be 'owner/model-name' with no whitespace")
|
||||||
|
|
||||||
|
hf_url = f"https://huggingface.co/api/models/{repo_id}"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(hf_url, timeout=10.0)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise HTTPException(502, f"Network error reaching HuggingFace API: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise HTTPException(404, f"Model {repo_id!r} not found on HuggingFace")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, f"HuggingFace API returned status {resp.status_code}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
pipeline_tag = data.get("pipeline_tag")
|
||||||
|
adapter_recommendation = _TAG_TO_ADAPTER.get(pipeline_tag) if pipeline_tag else None
|
||||||
|
if pipeline_tag and adapter_recommendation is None:
|
||||||
|
logger.warning("Unknown pipeline_tag %r for %s — no adapter recommendation", pipeline_tag, repo_id)
|
||||||
|
|
||||||
|
# Estimate model size from siblings list
|
||||||
|
siblings = data.get("siblings") or []
|
||||||
|
model_size_bytes: int = sum(s.get("size", 0) for s in siblings if isinstance(s, dict))
|
||||||
|
|
||||||
|
# Description: first 300 chars of card data (modelId field used as fallback)
|
||||||
|
card_data = data.get("cardData") or {}
|
||||||
|
description_raw = card_data.get("description") or data.get("modelId") or ""
|
||||||
|
description = description_raw[:300] if description_raw else ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"repo_id": repo_id,
|
||||||
|
"pipeline_tag": pipeline_tag,
|
||||||
|
"adapter_recommendation": adapter_recommendation,
|
||||||
|
"model_size_bytes": model_size_bytes,
|
||||||
|
"description": description,
|
||||||
|
"tags": data.get("tags") or [],
|
||||||
|
"downloads": data.get("downloads") or 0,
|
||||||
|
"already_installed": _is_installed(repo_id),
|
||||||
|
"already_queued": _is_queued(repo_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /queue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/queue")
|
||||||
|
def get_queue() -> list[dict]:
|
||||||
|
"""Return all non-dismissed queue entries sorted newest-first."""
|
||||||
|
records = _read_queue()
|
||||||
|
active = [r for r in records if r.get("status") != "dismissed"]
|
||||||
|
return sorted(active, key=lambda r: r.get("queued_at", ""), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /queue ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class QueueAddRequest(BaseModel):
|
||||||
|
repo_id: str
|
||||||
|
pipeline_tag: str | None = None
|
||||||
|
adapter_recommendation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/queue", status_code=201)
|
||||||
|
def add_to_queue(req: QueueAddRequest) -> dict:
|
||||||
|
"""Add a model to the approval queue with status 'pending'."""
|
||||||
|
if _is_installed(req.repo_id):
|
||||||
|
raise HTTPException(409, f"{req.repo_id!r} is already installed")
|
||||||
|
if _is_queued(req.repo_id):
|
||||||
|
raise HTTPException(409, f"{req.repo_id!r} is already in the queue")
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"repo_id": req.repo_id,
|
||||||
|
"pipeline_tag": req.pipeline_tag,
|
||||||
|
"adapter_recommendation": req.adapter_recommendation,
|
||||||
|
"status": "pending",
|
||||||
|
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
records = _read_queue()
|
||||||
|
records.append(entry)
|
||||||
|
_write_queue(records)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /queue/{id}/approve ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/queue/{entry_id}/approve")
|
||||||
|
def approve_queue_entry(entry_id: str) -> dict:
|
||||||
|
"""Approve a pending queue entry and start background download."""
|
||||||
|
entry = _get_queue_entry(entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(404, f"Queue entry {entry_id!r} not found")
|
||||||
|
if entry.get("status") != "pending":
|
||||||
|
raise HTTPException(409, f"Entry is not in pending state (current: {entry.get('status')!r})")
|
||||||
|
|
||||||
|
updated = _update_queue_entry(entry_id, {"status": "downloading"})
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_download,
|
||||||
|
args=(entry_id, entry["repo_id"], entry.get("pipeline_tag"), entry.get("adapter_recommendation")),
|
||||||
|
daemon=True,
|
||||||
|
name=f"model-download-{entry_id}",
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /queue/{id} ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.delete("/queue/{entry_id}")
|
||||||
|
def dismiss_queue_entry(entry_id: str) -> dict:
|
||||||
|
"""Dismiss (soft-delete) a queue entry."""
|
||||||
|
entry = _get_queue_entry(entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(404, f"Queue entry {entry_id!r} not found")
|
||||||
|
|
||||||
|
_update_queue_entry(entry_id, {"status": "dismissed"})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /download/stream ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/download/stream")
|
||||||
|
def download_stream() -> StreamingResponse:
|
||||||
|
"""SSE stream of download progress. Yields one idle event if no download active."""
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
prog = _download_progress
|
||||||
|
if not prog.get("active") and not (prog.get("done") and not prog.get("error")):
|
||||||
|
yield f"data: {json.dumps({'type': 'idle'})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
if prog.get("done"):
|
||||||
|
if prog.get("error"):
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'error': prog['error']})}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'repo_id': prog.get('repo_id')})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stream live progress
|
||||||
|
import time
|
||||||
|
while True:
|
||||||
|
p = dict(_download_progress)
|
||||||
|
if p.get("done"):
|
||||||
|
if p.get("error"):
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'error': p['error']})}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"data: {json.dumps({'type': 'done', 'repo_id': p.get('repo_id')})}\n\n"
|
||||||
|
break
|
||||||
|
event = json.dumps({
|
||||||
|
"type": "progress",
|
||||||
|
"repo_id": p.get("repo_id"),
|
||||||
|
"downloaded_bytes": p.get("downloaded_bytes", 0),
|
||||||
|
"total_bytes": p.get("total_bytes", 0),
|
||||||
|
"pct": p.get("pct", 0.0),
|
||||||
|
})
|
||||||
|
yield f"data: {event}\n\n"
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /installed ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/installed")
|
||||||
|
def list_installed() -> list[dict]:
|
||||||
|
"""Scan _MODELS_DIR and return info on each installed model."""
|
||||||
|
if not _MODELS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for sub in _MODELS_DIR.iterdir():
|
||||||
|
if not sub.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
has_training_info = (sub / "training_info.json").exists()
|
||||||
|
has_config = (sub / "config.json").exists()
|
||||||
|
has_model_info = (sub / "model_info.json").exists()
|
||||||
|
|
||||||
|
if not (has_training_info or has_config or has_model_info):
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_type = "finetuned" if has_training_info else "downloaded"
|
||||||
|
|
||||||
|
# Compute directory size
|
||||||
|
size_bytes = sum(f.stat().st_size for f in sub.rglob("*") if f.is_file())
|
||||||
|
|
||||||
|
# Load adapter/model_id from model_info.json or training_info.json
|
||||||
|
adapter: str | None = None
|
||||||
|
model_id: str | None = None
|
||||||
|
|
||||||
|
if has_model_info:
|
||||||
|
try:
|
||||||
|
info = json.loads((sub / "model_info.json").read_text(encoding="utf-8"))
|
||||||
|
adapter = info.get("adapter_recommendation")
|
||||||
|
model_id = info.get("repo_id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif has_training_info:
|
||||||
|
try:
|
||||||
|
info = json.loads((sub / "training_info.json").read_text(encoding="utf-8"))
|
||||||
|
adapter = info.get("adapter")
|
||||||
|
model_id = info.get("base_model") or info.get("model_id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"name": sub.name,
|
||||||
|
"path": str(sub),
|
||||||
|
"type": model_type,
|
||||||
|
"adapter": adapter,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"model_id": model_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /installed/{name} ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.delete("/installed/{name}")
|
||||||
|
def delete_installed(name: str) -> dict:
|
||||||
|
"""Remove an installed model directory by name. Blocks path traversal."""
|
||||||
|
# Validate: single path component, no slashes or '..'
|
||||||
|
if "/" in name or "\\" in name or ".." in name or not name or name.startswith("."):
|
||||||
|
raise HTTPException(400, f"Invalid model name {name!r}: must be a single directory name with no path separators or '..'")
|
||||||
|
|
||||||
|
model_path = _MODELS_DIR / name
|
||||||
|
|
||||||
|
# Extra safety: confirm resolved path is inside _MODELS_DIR
|
||||||
|
try:
|
||||||
|
model_path.resolve().relative_to(_MODELS_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"Path traversal detected for name {name!r}")
|
||||||
|
|
||||||
|
if not model_path.exists():
|
||||||
|
raise HTTPException(404, f"Installed model {name!r} not found")
|
||||||
|
|
||||||
|
shutil.rmtree(model_path)
|
||||||
|
return {"ok": True}
|
||||||
399
tests/test_models.py
Normal file
399
tests/test_models.py
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
"""Tests for app/models.py — /api/models/* endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_models_globals(tmp_path):
|
||||||
|
"""Redirect module-level dirs to tmp_path and reset download progress."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
prev_models = models_module._MODELS_DIR
|
||||||
|
prev_queue = models_module._QUEUE_DIR
|
||||||
|
prev_progress = dict(models_module._download_progress)
|
||||||
|
|
||||||
|
models_dir = tmp_path / "models"
|
||||||
|
queue_dir = tmp_path / "data"
|
||||||
|
models_dir.mkdir()
|
||||||
|
queue_dir.mkdir()
|
||||||
|
|
||||||
|
models_module.set_models_dir(models_dir)
|
||||||
|
models_module.set_queue_dir(queue_dir)
|
||||||
|
models_module._download_progress = {}
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
models_module.set_models_dir(prev_models)
|
||||||
|
models_module.set_queue_dir(prev_queue)
|
||||||
|
models_module._download_progress = prev_progress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from app.api import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hf_response(repo_id: str = "org/model", pipeline_tag: str = "text-classification") -> dict:
|
||||||
|
"""Minimal HF API response payload."""
|
||||||
|
return {
|
||||||
|
"modelId": repo_id,
|
||||||
|
"pipeline_tag": pipeline_tag,
|
||||||
|
"tags": ["pytorch", pipeline_tag],
|
||||||
|
"downloads": 42000,
|
||||||
|
"siblings": [
|
||||||
|
{"rfilename": "pytorch_model.bin", "size": 500_000_000},
|
||||||
|
],
|
||||||
|
"cardData": {"description": "A test model description."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_one(client, repo_id: str = "org/model") -> dict:
|
||||||
|
"""Helper: POST to /queue and return the created entry."""
|
||||||
|
r = client.post("/api/models/queue", json={
|
||||||
|
"repo_id": repo_id,
|
||||||
|
"pipeline_tag": "text-classification",
|
||||||
|
"adapter_recommendation": "ZeroShotAdapter",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /lookup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_lookup_invalid_repo_id_returns_422_no_slash(client):
|
||||||
|
"""repo_id without a '/' should be rejected with 422."""
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "noslash"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_invalid_repo_id_returns_422_whitespace(client):
|
||||||
|
"""repo_id containing whitespace should be rejected with 422."""
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/model name"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_hf_404_returns_404(client):
|
||||||
|
"""HF API returning 404 should surface as HTTP 404."""
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 404
|
||||||
|
|
||||||
|
with patch("app.models.httpx.get", return_value=mock_resp):
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/nonexistent"})
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_hf_network_error_returns_502(client):
|
||||||
|
"""Network error reaching HF API should return 502."""
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
with patch("app.models.httpx.get", side_effect=_httpx.RequestError("timeout")):
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/model"})
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_returns_correct_shape(client):
|
||||||
|
"""Successful lookup returns all required fields."""
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = _make_hf_response("org/mymodel", "text-classification")
|
||||||
|
|
||||||
|
with patch("app.models.httpx.get", return_value=mock_resp):
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/mymodel"})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["repo_id"] == "org/mymodel"
|
||||||
|
assert data["pipeline_tag"] == "text-classification"
|
||||||
|
assert data["adapter_recommendation"] == "ZeroShotAdapter"
|
||||||
|
assert data["model_size_bytes"] == 500_000_000
|
||||||
|
assert data["downloads"] == 42000
|
||||||
|
assert data["already_installed"] is False
|
||||||
|
assert data["already_queued"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_unknown_pipeline_tag_returns_null_adapter(client):
|
||||||
|
"""An unrecognised pipeline_tag yields adapter_recommendation=null."""
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = _make_hf_response("org/m", "audio-classification")
|
||||||
|
|
||||||
|
with patch("app.models.httpx.get", return_value=mock_resp):
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/m"})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["adapter_recommendation"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_already_queued_flag(client):
|
||||||
|
"""already_queued is True when repo_id is in the pending queue."""
|
||||||
|
_queue_one(client, "org/queued-model")
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = _make_hf_response("org/queued-model")
|
||||||
|
|
||||||
|
with patch("app.models.httpx.get", return_value=mock_resp):
|
||||||
|
r = client.get("/api/models/lookup", params={"repo_id": "org/queued-model"})
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["already_queued"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /queue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_queue_empty_initially(client):
|
||||||
|
r = client.get("/api/models/queue")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_add_and_list(client):
|
||||||
|
"""POST then GET /queue should return the entry."""
|
||||||
|
entry = _queue_one(client, "org/my-model")
|
||||||
|
|
||||||
|
r = client.get("/api/models/queue")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["repo_id"] == "org/my-model"
|
||||||
|
assert items[0]["status"] == "pending"
|
||||||
|
assert items[0]["id"] == entry["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_add_returns_entry_fields(client):
|
||||||
|
"""POST /queue returns an entry with all expected fields."""
|
||||||
|
entry = _queue_one(client)
|
||||||
|
assert "id" in entry
|
||||||
|
assert "queued_at" in entry
|
||||||
|
assert entry["status"] == "pending"
|
||||||
|
assert entry["pipeline_tag"] == "text-classification"
|
||||||
|
assert entry["adapter_recommendation"] == "ZeroShotAdapter"
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /queue — 409 duplicate ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_queue_duplicate_returns_409(client):
|
||||||
|
"""Posting the same repo_id twice should return 409."""
|
||||||
|
_queue_one(client, "org/dup-model")
|
||||||
|
|
||||||
|
r = client.post("/api/models/queue", json={
|
||||||
|
"repo_id": "org/dup-model",
|
||||||
|
"pipeline_tag": "text-classification",
|
||||||
|
"adapter_recommendation": "ZeroShotAdapter",
|
||||||
|
})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_multiple_different_models(client):
|
||||||
|
"""Multiple distinct repo_ids should all be accepted."""
|
||||||
|
_queue_one(client, "org/model-a")
|
||||||
|
_queue_one(client, "org/model-b")
|
||||||
|
_queue_one(client, "org/model-c")
|
||||||
|
|
||||||
|
r = client.get("/api/models/queue")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /queue/{id} — dismiss ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_queue_dismiss(client):
|
||||||
|
"""DELETE /queue/{id} sets status=dismissed; entry not returned by GET /queue."""
|
||||||
|
entry = _queue_one(client)
|
||||||
|
entry_id = entry["id"]
|
||||||
|
|
||||||
|
r = client.delete(f"/api/models/queue/{entry_id}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True}
|
||||||
|
|
||||||
|
r2 = client.get("/api/models/queue")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_dismiss_nonexistent_returns_404(client):
|
||||||
|
"""DELETE /queue/{id} with unknown id returns 404."""
|
||||||
|
r = client.delete("/api/models/queue/does-not-exist")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_dismiss_allows_re_queue(client):
|
||||||
|
"""After dismissal the same repo_id can be queued again."""
|
||||||
|
entry = _queue_one(client, "org/requeue-model")
|
||||||
|
client.delete(f"/api/models/queue/{entry['id']}")
|
||||||
|
|
||||||
|
r = client.post("/api/models/queue", json={
|
||||||
|
"repo_id": "org/requeue-model",
|
||||||
|
"pipeline_tag": None,
|
||||||
|
"adapter_recommendation": None,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /queue/{id}/approve ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_approve_nonexistent_returns_404(client):
|
||||||
|
"""Approving an unknown id returns 404."""
|
||||||
|
r = client.post("/api/models/queue/ghost-id/approve")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_non_pending_returns_409(client):
|
||||||
|
"""Approving an entry that is not in 'pending' state returns 409."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
entry = _queue_one(client)
|
||||||
|
# Manually flip status to 'failed'
|
||||||
|
models_module._update_queue_entry(entry["id"], {"status": "failed"})
|
||||||
|
|
||||||
|
r = client.post(f"/api/models/queue/{entry['id']}/approve")
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_starts_download_and_returns_ok(client):
|
||||||
|
"""Approving a pending entry returns {ok: true} and starts a background thread."""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
entry = _queue_one(client)
|
||||||
|
|
||||||
|
# Patch snapshot_download so the thread doesn't actually hit the network.
|
||||||
|
# Use an Event so we can wait for the thread to finish before asserting.
|
||||||
|
thread_done = threading.Event()
|
||||||
|
original_run = None
|
||||||
|
|
||||||
|
def _fake_snapshot_download(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.models.snapshot_download", side_effect=_fake_snapshot_download):
|
||||||
|
r = client.post(f"/api/models/queue/{entry['id']}/approve")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True}
|
||||||
|
# Give the background thread a moment to complete while snapshot_download is patched
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Queue entry status should have moved to 'downloading' (or 'ready' if fast)
|
||||||
|
from app import models as models_module
|
||||||
|
updated = models_module._get_queue_entry(entry["id"])
|
||||||
|
assert updated is not None, "Queue entry not found — thread may have run after fixture teardown"
|
||||||
|
assert updated["status"] in ("downloading", "ready", "failed")
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /download/stream ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_download_stream_idle_when_no_download(client):
|
||||||
|
"""GET /download/stream returns a single idle event when nothing is downloading."""
|
||||||
|
r = client.get("/api/models/download/stream")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# SSE body should contain the idle event
|
||||||
|
assert "idle" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /installed ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_installed_empty(client):
|
||||||
|
"""GET /installed returns [] when models dir is empty."""
|
||||||
|
r = client.get("/api/models/installed")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_detects_downloaded_model(client, tmp_path):
|
||||||
|
"""A subdir with config.json is surfaced as type='downloaded'."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
model_dir = models_module._MODELS_DIR / "org--mymodel"
|
||||||
|
model_dir.mkdir()
|
||||||
|
(model_dir / "config.json").write_text(json.dumps({"model_type": "bert"}), encoding="utf-8")
|
||||||
|
(model_dir / "model_info.json").write_text(
|
||||||
|
json.dumps({"repo_id": "org/mymodel", "adapter_recommendation": "ZeroShotAdapter"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/models/installed")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["type"] == "downloaded"
|
||||||
|
assert items[0]["name"] == "org--mymodel"
|
||||||
|
assert items[0]["adapter"] == "ZeroShotAdapter"
|
||||||
|
assert items[0]["model_id"] == "org/mymodel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_detects_finetuned_model(client):
|
||||||
|
"""A subdir with training_info.json is surfaced as type='finetuned'."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
model_dir = models_module._MODELS_DIR / "my-finetuned"
|
||||||
|
model_dir.mkdir()
|
||||||
|
(model_dir / "training_info.json").write_text(
|
||||||
|
json.dumps({"base_model": "org/base", "epochs": 5}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/models/installed")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["type"] == "finetuned"
|
||||||
|
assert items[0]["name"] == "my-finetuned"
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /installed/{name} ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delete_installed_removes_directory(client):
|
||||||
|
"""DELETE /installed/{name} removes the directory and returns {ok: true}."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
model_dir = models_module._MODELS_DIR / "org--removeme"
|
||||||
|
model_dir.mkdir()
|
||||||
|
(model_dir / "config.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
r = client.delete("/api/models/installed/org--removeme")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True}
|
||||||
|
assert not model_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_installed_not_found_returns_404(client):
|
||||||
|
r = client.delete("/api/models/installed/does-not-exist")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_installed_path_traversal_blocked(client):
|
||||||
|
"""DELETE /installed/../../etc must be blocked (400 or 422)."""
|
||||||
|
r = client.delete("/api/models/installed/../../etc")
|
||||||
|
assert r.status_code in (400, 404, 422)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_installed_dotdot_name_blocked(client):
|
||||||
|
"""A name containing '..' in any form must be rejected."""
|
||||||
|
r = client.delete("/api/models/installed/..%2F..%2Fetc")
|
||||||
|
assert r.status_code in (400, 404, 422)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_installed_name_with_slash_blocked(client):
|
||||||
|
"""A name containing a literal '/' after URL decoding must be rejected."""
|
||||||
|
from app import models as models_module
|
||||||
|
|
||||||
|
# The router will see the path segment after /installed/ — a second '/' would
|
||||||
|
# be parsed as a new path segment, so we test via the validation helper directly.
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
# Simulate calling delete logic with a slash-containing name directly
|
||||||
|
from fastapi import HTTPException as _HTTPException
|
||||||
|
from app.models import delete_installed
|
||||||
|
try:
|
||||||
|
delete_installed("org/traversal")
|
||||||
|
except _HTTPException as exc:
|
||||||
|
assert exc.status_code in (400, 404)
|
||||||
|
raise
|
||||||
|
|
@ -66,6 +66,7 @@ const navItems = [
|
||||||
{ path: '/fetch', icon: '📥', label: 'Fetch' },
|
{ path: '/fetch', icon: '📥', label: 'Fetch' },
|
||||||
{ path: '/stats', icon: '📊', label: 'Stats' },
|
{ path: '/stats', icon: '📊', label: 'Stats' },
|
||||||
{ path: '/benchmark', icon: '🏁', label: 'Benchmark' },
|
{ path: '/benchmark', icon: '🏁', label: 'Benchmark' },
|
||||||
|
{ path: '/models', icon: '🤗', label: 'Models' },
|
||||||
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
|
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
|
||||||
{ path: '/settings', icon: '⚙️', label: 'Settings' },
|
{ path: '/settings', icon: '⚙️', label: 'Settings' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const StatsView = () => import('../views/StatsView.vue')
|
||||||
const BenchmarkView = () => import('../views/BenchmarkView.vue')
|
const BenchmarkView = () => import('../views/BenchmarkView.vue')
|
||||||
const SettingsView = () => import('../views/SettingsView.vue')
|
const SettingsView = () => import('../views/SettingsView.vue')
|
||||||
const CorrectionsView = () => import('../views/CorrectionsView.vue')
|
const CorrectionsView = () => import('../views/CorrectionsView.vue')
|
||||||
|
const ModelsView = () => import('../views/ModelsView.vue')
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
|
|
@ -15,6 +16,7 @@ export const router = createRouter({
|
||||||
{ path: '/fetch', component: FetchView, meta: { title: 'Fetch' } },
|
{ path: '/fetch', component: FetchView, meta: { title: 'Fetch' } },
|
||||||
{ path: '/stats', component: StatsView, meta: { title: 'Stats' } },
|
{ path: '/stats', component: StatsView, meta: { title: 'Stats' } },
|
||||||
{ path: '/benchmark', component: BenchmarkView, meta: { title: 'Benchmark' } },
|
{ path: '/benchmark', component: BenchmarkView, meta: { title: 'Benchmark' } },
|
||||||
|
{ path: '/models', component: ModelsView, meta: { title: 'Models' } },
|
||||||
{ path: '/corrections', component: CorrectionsView, meta: { title: 'Corrections' } },
|
{ path: '/corrections', component: CorrectionsView, meta: { title: 'Corrections' } },
|
||||||
{ path: '/settings', component: SettingsView, meta: { title: 'Settings' } },
|
{ path: '/settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
826
web/src/views/ModelsView.vue
Normal file
826
web/src/views/ModelsView.vue
Normal file
|
|
@ -0,0 +1,826 @@
|
||||||
|
<template>
|
||||||
|
<div class="models-view">
|
||||||
|
<h1 class="page-title">🤗 Models</h1>
|
||||||
|
|
||||||
|
<!-- ── 1. HF Lookup ───────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">HuggingFace Lookup</h2>
|
||||||
|
|
||||||
|
<div class="lookup-row">
|
||||||
|
<input
|
||||||
|
v-model="lookupInput"
|
||||||
|
type="text"
|
||||||
|
class="lookup-input"
|
||||||
|
placeholder="org/model or huggingface.co/org/model"
|
||||||
|
:disabled="lookupLoading"
|
||||||
|
@keydown.enter="doLookup"
|
||||||
|
aria-label="HuggingFace model ID"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="lookupLoading || !lookupInput.trim()"
|
||||||
|
@click="doLookup"
|
||||||
|
>
|
||||||
|
{{ lookupLoading ? 'Looking up…' : 'Lookup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lookupError" class="error-notice" role="alert">
|
||||||
|
{{ lookupError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lookupResult" class="preview-card">
|
||||||
|
<div class="preview-header">
|
||||||
|
<span class="preview-repo-id">{{ lookupResult.repo_id }}</span>
|
||||||
|
<div class="badge-group">
|
||||||
|
<span v-if="lookupResult.already_installed" class="badge badge-success">Installed</span>
|
||||||
|
<span v-if="lookupResult.already_queued" class="badge badge-info">In queue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span v-if="lookupResult.pipeline_tag" class="chip chip-pipeline">
|
||||||
|
{{ lookupResult.pipeline_tag }}
|
||||||
|
</span>
|
||||||
|
<span v-if="lookupResult.adapter_recommendation" class="chip chip-adapter">
|
||||||
|
{{ lookupResult.adapter_recommendation }}
|
||||||
|
</span>
|
||||||
|
<span v-if="lookupResult.size != null" class="preview-size">
|
||||||
|
{{ humanBytes(lookupResult.size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="lookupResult.description" class="preview-desc">
|
||||||
|
{{ lookupResult.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-primary btn-add-queue"
|
||||||
|
:disabled="lookupResult.already_installed || lookupResult.already_queued || addingToQueue"
|
||||||
|
@click="addToQueue"
|
||||||
|
>
|
||||||
|
{{ addingToQueue ? 'Adding…' : 'Add to queue' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 2. Approval Queue ──────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Approval Queue</h2>
|
||||||
|
|
||||||
|
<div v-if="pendingModels.length === 0" class="empty-notice">
|
||||||
|
No models waiting for approval.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="model in pendingModels" :key="model.id" class="model-card">
|
||||||
|
<div class="model-card-header">
|
||||||
|
<span class="model-repo-id">{{ model.repo_id }}</span>
|
||||||
|
<button
|
||||||
|
class="btn-dismiss"
|
||||||
|
:aria-label="`Dismiss ${model.repo_id}`"
|
||||||
|
@click="dismissModel(model.id)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="model-meta">
|
||||||
|
<span v-if="model.pipeline_tag" class="chip chip-pipeline">{{ model.pipeline_tag }}</span>
|
||||||
|
<span v-if="model.adapter_recommendation" class="chip chip-adapter">{{ model.adapter_recommendation }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="model-card-actions">
|
||||||
|
<button class="btn-primary btn-sm" @click="approveModel(model.id)">
|
||||||
|
Approve download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 3. Active Downloads ────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Active Downloads</h2>
|
||||||
|
|
||||||
|
<div v-if="downloadingModels.length === 0" class="empty-notice">
|
||||||
|
No active downloads.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="model in downloadingModels" :key="model.id" class="model-card">
|
||||||
|
<div class="model-card-header">
|
||||||
|
<span class="model-repo-id">{{ model.repo_id }}</span>
|
||||||
|
<span v-if="downloadErrors[model.id]" class="badge badge-error">Error</span>
|
||||||
|
</div>
|
||||||
|
<div class="model-meta">
|
||||||
|
<span v-if="model.pipeline_tag" class="chip chip-pipeline">{{ model.pipeline_tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="downloadErrors[model.id]" class="download-error" role="alert">
|
||||||
|
{{ downloadErrors[model.id] }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="progress-wrap" :aria-label="`Download progress for ${model.repo_id}`">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:style="{ width: `${downloadProgress[model.id] ?? 0}%` }"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="downloadProgress[model.id] ?? 0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
/>
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ downloadProgress[model.id] == null ? 'Preparing…' : `${downloadProgress[model.id]}%` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 4. Installed Models ────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Installed Models</h2>
|
||||||
|
|
||||||
|
<div v-if="installedModels.length === 0" class="empty-notice">
|
||||||
|
No models installed yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="installed-table-wrap">
|
||||||
|
<table class="installed-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Adapter</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="model in installedModels" :key="model.name">
|
||||||
|
<td class="td-name">{{ model.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="model.type === 'finetuned' ? 'badge-accent' : 'badge-info'"
|
||||||
|
>
|
||||||
|
{{ model.type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ model.adapter ?? '—' }}</td>
|
||||||
|
<td>{{ humanBytes(model.size) }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn-danger btn-sm"
|
||||||
|
@click="deleteInstalled(model.name)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
// ── Type definitions ──────────────────────────────────
|
||||||
|
|
||||||
|
interface LookupResult {
|
||||||
|
repo_id: string
|
||||||
|
pipeline_tag: string | null
|
||||||
|
adapter_recommendation: string | null
|
||||||
|
size: number | null
|
||||||
|
description: string | null
|
||||||
|
already_installed: boolean
|
||||||
|
already_queued: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedModel {
|
||||||
|
id: string
|
||||||
|
repo_id: string
|
||||||
|
status: 'pending' | 'downloading' | 'done' | 'error'
|
||||||
|
pipeline_tag: string | null
|
||||||
|
adapter_recommendation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstalledModel {
|
||||||
|
name: string
|
||||||
|
type: 'finetuned' | 'downloaded'
|
||||||
|
adapter: string | null
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SseProgressEvent {
|
||||||
|
model_id: string
|
||||||
|
pct: number | null
|
||||||
|
status: 'progress' | 'done' | 'error'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lookupInput = ref('')
|
||||||
|
const lookupLoading = ref(false)
|
||||||
|
const lookupError = ref<string | null>(null)
|
||||||
|
const lookupResult = ref<LookupResult | null>(null)
|
||||||
|
const addingToQueue = ref(false)
|
||||||
|
|
||||||
|
const queuedModels = ref<QueuedModel[]>([])
|
||||||
|
const installedModels = ref<InstalledModel[]>([])
|
||||||
|
|
||||||
|
const downloadProgress = ref<Record<string, number>>({})
|
||||||
|
const downloadErrors = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let sseSource: EventSource | null = null
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const pendingModels = computed(() =>
|
||||||
|
queuedModels.value.filter(m => m.status === 'pending')
|
||||||
|
)
|
||||||
|
|
||||||
|
const downloadingModels = computed(() =>
|
||||||
|
queuedModels.value.filter(m => m.status === 'downloading')
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function humanBytes(bytes: number | null): string {
|
||||||
|
if (bytes == null) return '—'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let value = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepoId(raw: string): string {
|
||||||
|
return raw.trim().replace(/^https?:\/\/huggingface\.co\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API calls ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async function doLookup() {
|
||||||
|
const repoId = normalizeRepoId(lookupInput.value)
|
||||||
|
if (!repoId) return
|
||||||
|
|
||||||
|
lookupLoading.value = true
|
||||||
|
lookupError.value = null
|
||||||
|
lookupResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/lookup?repo_id=${encodeURIComponent(repoId)}`)
|
||||||
|
if (res.status === 404) {
|
||||||
|
lookupError.value = 'Model not found on HuggingFace.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 502) {
|
||||||
|
lookupError.value = 'HuggingFace unreachable. Check your connection and try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
lookupError.value = `Lookup failed (HTTP ${res.status}).`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lookupResult.value = await res.json() as LookupResult
|
||||||
|
} catch {
|
||||||
|
lookupError.value = 'Network error. Is the Avocet API running?'
|
||||||
|
} finally {
|
||||||
|
lookupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToQueue() {
|
||||||
|
if (!lookupResult.value) return
|
||||||
|
addingToQueue.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/models/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ repo_id: lookupResult.value.repo_id }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
lookupResult.value = { ...lookupResult.value, already_queued: true }
|
||||||
|
await loadQueue()
|
||||||
|
}
|
||||||
|
} catch { /* ignore — already_queued badge won't flip, user can retry */ }
|
||||||
|
finally {
|
||||||
|
addingToQueue.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveModel(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/queue/${encodeURIComponent(id)}/approve`, { method: 'POST' })
|
||||||
|
if (res.ok) {
|
||||||
|
await loadQueue()
|
||||||
|
startSse()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissModel(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/queue/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
queuedModels.value = queuedModels.value.filter(m => m.id !== id)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInstalled(name: string) {
|
||||||
|
if (!window.confirm(`Delete installed model "${name}"? This cannot be undone.`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/installed/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
installedModels.value = installedModels.value.filter(m => m.name !== name)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQueue() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/models/queue')
|
||||||
|
if (res.ok) queuedModels.value = await res.json() as QueuedModel[]
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstalled() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/models/installed')
|
||||||
|
if (res.ok) installedModels.value = await res.json() as InstalledModel[]
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE for download progress ─────────────────────────
|
||||||
|
|
||||||
|
function startSse() {
|
||||||
|
if (sseSource) return // already connected
|
||||||
|
|
||||||
|
sseSource = new EventSource('/api/models/download/stream')
|
||||||
|
|
||||||
|
sseSource.addEventListener('message', (e: MessageEvent) => {
|
||||||
|
let event: SseProgressEvent
|
||||||
|
try {
|
||||||
|
event = JSON.parse(e.data as string) as SseProgressEvent
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { model_id, pct, status, message } = event
|
||||||
|
|
||||||
|
if (status === 'progress' && pct != null) {
|
||||||
|
downloadProgress.value = { ...downloadProgress.value, [model_id]: pct }
|
||||||
|
} else if (status === 'done') {
|
||||||
|
const updated = { ...downloadProgress.value }
|
||||||
|
delete updated[model_id]
|
||||||
|
downloadProgress.value = updated
|
||||||
|
|
||||||
|
queuedModels.value = queuedModels.value.filter(m => m.id !== model_id)
|
||||||
|
loadInstalled()
|
||||||
|
} else if (status === 'error') {
|
||||||
|
downloadErrors.value = {
|
||||||
|
...downloadErrors.value,
|
||||||
|
[model_id]: message ?? 'Download failed.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sseSource.onerror = () => {
|
||||||
|
sseSource?.close()
|
||||||
|
sseSource = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSse() {
|
||||||
|
sseSource?.close()
|
||||||
|
sseSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function startPollingIfDownloading() {
|
||||||
|
if (pollInterval) return
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
await loadQueue()
|
||||||
|
if (downloadingModels.value.length === 0) {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadQueue(), loadInstalled()])
|
||||||
|
|
||||||
|
if (downloadingModels.value.length > 0) {
|
||||||
|
startSse()
|
||||||
|
startPollingIfDownloading()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
stopSse()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.models-view {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary, #2d5a27);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ── */
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lookup row ── */
|
||||||
|
.lookup-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--color-surface-raised, #f5f7fc);
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font-body, sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-input::placeholder {
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notices ── */
|
||||||
|
.error-notice {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error, #c0392b) 30%, transparent);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notice {
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed var(--color-border, #a8b8d0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview card ── */
|
||||||
|
.preview-card {
|
||||||
|
border: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
border-radius: var(--radius-lg, 1rem);
|
||||||
|
background: var(--color-surface-raised, #f5f7fc);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-repo-id {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-size {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-queue {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model cards (queue + downloads) ── */
|
||||||
|
.model-card {
|
||||||
|
border: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
background: var(--color-surface-raised, #f5f7fc);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-repo-id {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress bar ── */
|
||||||
|
.progress-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 1.5rem;
|
||||||
|
background: var(--color-surface-alt, #dde4f0);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-accent, #c4732a);
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
transition: width 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 10%, transparent);
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Installed table ── */
|
||||||
|
.installed-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.installed-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.installed-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.installed-table td {
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light, #ccd5e6);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-name {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ── */
|
||||||
|
.badge-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: color-mix(in srgb, var(--color-success, #3a7a32) 15%, transparent);
|
||||||
|
color: var(--color-success, #3a7a32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background: color-mix(in srgb, var(--color-info, #1e6091) 15%, transparent);
|
||||||
|
color: var(--color-info, #1e6091);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-accent {
|
||||||
|
background: color-mix(in srgb, var(--color-accent, #c4732a) 15%, transparent);
|
||||||
|
color: var(--color-accent, #c4732a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 15%, transparent);
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chips ── */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: var(--radius-full, 9999px);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-surface-alt, #dde4f0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pipeline {
|
||||||
|
color: var(--color-primary, #2d5a27);
|
||||||
|
background: color-mix(in srgb, var(--color-primary, #2d5a27) 12%, var(--color-surface-alt, #dde4f0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-adapter {
|
||||||
|
color: var(--color-accent, #c4732a);
|
||||||
|
background: color-mix(in srgb, var(--color-accent, #c4732a) 12%, var(--color-surface-alt, #dde4f0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn-primary, .btn-danger {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid;
|
||||||
|
font-family: var(--font-body, sans-serif);
|
||||||
|
transition: background var(--transition, 200ms ease), color var(--transition, 200ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: var(--color-primary, #2d5a27);
|
||||||
|
background: var(--color-primary, #2d5a27);
|
||||||
|
color: var(--color-text-inverse, #eaeff8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover, #234820);
|
||||||
|
border-color: var(--color-primary-hover, #234820);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: var(--color-error, #c0392b);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dismiss {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--transition, 200ms ease), background var(--transition, 200ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dismiss:hover {
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.lookup-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:not(.btn-sm) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.installed-table th:nth-child(3),
|
||||||
|
.installed-table td:nth-child(3) {
|
||||||
|
display: none; /* hide Adapter column on very narrow screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue