Compare commits
No commits in common. "478a47f6e0a40727beae2b74042b27b50a0544ee" and "a7cb3ae62a29926f33238f72b63644ab7cd6e681" have entirely different histories.
478a47f6e0
...
a7cb3ae62a
6 changed files with 0 additions and 1659 deletions
|
|
@ -145,9 +145,6 @@ 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
428
app/models.py
|
|
@ -1,428 +0,0 @@
|
||||||
"""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}
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
"""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,7 +66,6 @@ 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,7 +7,6 @@ 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(),
|
||||||
|
|
@ -16,7 +15,6 @@ 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' } },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,826 +0,0 @@
|
||||||
<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