feat(fleet): profile editor, assignments tab, node management polish
Backend:
- app/nodes.py: fix coordinator response envelope (.get("nodes"/"services"))
- app/nodes.py: add PUT /nodes/{id}/profile (atomic YAML write + reload)
- app/nodes.py: add POST /nodes/{id}/profile/generate (coordinator-seeded skeleton)
- tests/test_nodes.py: fix mock envelopes; add deploy model + profile tests
Frontend:
- NodeManagementView: tab bar switching nodes / assignments panels
- AssignmentsTab: full product.task → model routing UI (add/edit/delete)
- ProfileEditorPanel: full YAML profile editor with GPU + service sections
- CatalogEntryFormModal: add/edit model catalog entries per service
- ServiceFormModal: add/edit service config blocks
- NodeCard, GpuRow, ServiceBadge, OllamaModelPanel, HfNodeModelPanel: polish pass
- ModelsView: model download additions
- nodes.ts: extend types for full profile editing (ServiceManaged, CatalogEntryFull)
This commit is contained in:
parent
e93afec271
commit
79b9ccbd3d
14 changed files with 2630 additions and 88 deletions
180
app/nodes.py
180
app/nodes.py
|
|
@ -120,7 +120,7 @@ def list_nodes() -> list:
|
|||
try:
|
||||
r = httpx.get(f"{coordinator_url}/api/nodes", timeout=5.0)
|
||||
r.raise_for_status()
|
||||
coord_nodes: list[dict] = r.json()
|
||||
coord_nodes: list[dict] = r.json().get("nodes", [])
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Coordinator unreachable: %s", exc)
|
||||
return []
|
||||
|
|
@ -128,7 +128,7 @@ def list_nodes() -> list:
|
|||
try:
|
||||
sr = httpx.get(f"{coordinator_url}/api/services", timeout=5.0)
|
||||
sr.raise_for_status()
|
||||
services_data: list[dict] = sr.json()
|
||||
services_data: list[dict] = sr.json().get("services", [])
|
||||
except httpx.HTTPError:
|
||||
logger.warning("Services API unreachable for %s, skipping", coordinator_url)
|
||||
services_data = []
|
||||
|
|
@ -294,6 +294,99 @@ def update_gpu_services(node_id: str, gpu_id: int, body: UpdateServicesRequest)
|
|||
|
||||
return {"ok": True, "reloaded": reloaded, "warnings": []}
|
||||
|
||||
# ── Profile save / generate ────────────────────────────────────────────────────
|
||||
|
||||
class SaveProfileRequest(BaseModel):
|
||||
profile: dict
|
||||
|
||||
|
||||
@router.put("/nodes/{node_id}/profile", status_code=200)
|
||||
def save_profile(node_id: str, body: SaveProfileRequest) -> dict:
|
||||
"""Write a full profile dict to disk as YAML, then trigger coordinator reload."""
|
||||
p = _profile_path(node_id)
|
||||
if p is None:
|
||||
raise HTTPException(500, "profiles_dir not configured in label_tool.yaml")
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = Path(str(p) + ".tmp")
|
||||
tmp.write_text(
|
||||
yaml.dump(body.profile, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, p)
|
||||
|
||||
cfg = _load_config()
|
||||
coordinator_url = cfg.get("coordinator_url", "") or ""
|
||||
reloaded = False
|
||||
if coordinator_url:
|
||||
try:
|
||||
import httpx
|
||||
rr = httpx.post(f"{coordinator_url}/api/nodes/{node_id}/reload-profile", timeout=5.0)
|
||||
reloaded = rr.status_code < 300
|
||||
except Exception as exc:
|
||||
logger.warning("Coordinator reload failed for %s: %s", node_id, exc)
|
||||
return {"ok": True, "reloaded": reloaded}
|
||||
|
||||
|
||||
@router.post("/nodes/{node_id}/profile/generate")
|
||||
def generate_profile(node_id: str) -> dict:
|
||||
"""Return a profile skeleton seeded from coordinator GPU data.
|
||||
|
||||
If a profile already exists, preserves its services section and only
|
||||
refreshes the nodes hardware section. Never writes to disk — the caller
|
||||
must call PUT /profile to persist.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
cfg = _load_config()
|
||||
coordinator_url = cfg.get("coordinator_url", "") or ""
|
||||
if not coordinator_url:
|
||||
raise HTTPException(503, "coordinator_url not configured")
|
||||
|
||||
try:
|
||||
r = httpx.get(f"{coordinator_url}/api/nodes", timeout=5.0)
|
||||
r.raise_for_status()
|
||||
coord_nodes: list[dict] = r.json().get("nodes", [])
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Coordinator unreachable: {exc}")
|
||||
|
||||
node = next((n for n in coord_nodes if n.get("node_id") == node_id), None)
|
||||
if node is None:
|
||||
raise HTTPException(404, f"Node {node_id!r} not found in coordinator")
|
||||
|
||||
gpus = [
|
||||
{
|
||||
"id": g.get("gpu_id", i),
|
||||
"vram_mb": g.get("vram_total_mb", 0),
|
||||
"compute_cap": g.get("compute_cap", 0.0),
|
||||
"card": g.get("card", g.get("name", "")),
|
||||
"role": "inference",
|
||||
"services": [],
|
||||
}
|
||||
for i, g in enumerate(node.get("gpus", []))
|
||||
]
|
||||
vram_total = max((g["vram_mb"] for g in gpus), default=0)
|
||||
|
||||
existing = _load_profile(node_id) or {}
|
||||
return {
|
||||
"schema_version": existing.get("schema_version", 1),
|
||||
"name": existing.get("name", f"node-{node_id}"),
|
||||
"vram_total_mb": vram_total,
|
||||
"eviction_timeout_s": existing.get("eviction_timeout_s", 10.0),
|
||||
"services": existing.get("services", {}),
|
||||
"nodes": {
|
||||
node_id: {
|
||||
"local_model_root": (
|
||||
(existing.get("nodes", {}) or {})
|
||||
.get(node_id, {})
|
||||
.get("local_model_root", "")
|
||||
),
|
||||
"gpus": gpus,
|
||||
}
|
||||
},
|
||||
"model_size_hints": existing.get("model_size_hints", {}),
|
||||
}
|
||||
|
||||
|
||||
# ── Ollama model management ────────────────────────────────────────────────────
|
||||
|
||||
class PullRequest(BaseModel):
|
||||
|
|
@ -357,3 +450,86 @@ def delete_ollama_model(node_id: str, name: str) -> dict:
|
|||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Ollama unreachable: {exc}")
|
||||
|
||||
|
||||
# ── Model deploy (add catalog entry) ──────────────────────────────────────────
|
||||
|
||||
class DeployModelRequest(BaseModel):
|
||||
model_id: str
|
||||
service_type: str
|
||||
vram_mb: int
|
||||
description: str = ""
|
||||
hf_repo: str = ""
|
||||
path: str = "" # explicit path; if empty, constructed from model_base_path + hf_repo slug
|
||||
|
||||
|
||||
@router.post("/nodes/{node_id}/models/deploy", status_code=200)
|
||||
def deploy_model(node_id: str, body: DeployModelRequest) -> dict:
|
||||
"""Register a model in the node's service catalog.
|
||||
|
||||
Adds (or updates) the catalog entry for body.model_id under the given
|
||||
service_type in the node's profile YAML, then triggers a coordinator reload.
|
||||
Does not download the model — that is the user's responsibility.
|
||||
Returns the resolved path so the caller can see where the model should land.
|
||||
"""
|
||||
p = _profile_path(node_id)
|
||||
if p is None or not p.exists():
|
||||
raise HTTPException(404, f"No profile found for node {node_id!r}")
|
||||
|
||||
try:
|
||||
profile = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise HTTPException(500, f"Malformed profile YAML: {exc}")
|
||||
|
||||
services_def = profile.get("services", {}) or {}
|
||||
svc = services_def.get(body.service_type)
|
||||
if svc is None:
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"Service '{body.service_type}' not defined in node '{node_id}' profile; "
|
||||
"add it first via the profile editor",
|
||||
)
|
||||
|
||||
# Resolve path: explicit > model_base_path + hf slug > model_id slug
|
||||
model_path = body.path.strip()
|
||||
if not model_path:
|
||||
base = (svc.get("model_base_path", "") or "").rstrip("/")
|
||||
if not base:
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"Service '{body.service_type}' has no model_base_path; supply an explicit path",
|
||||
)
|
||||
slug_src = body.hf_repo.strip() if body.hf_repo.strip() else body.model_id
|
||||
hf_slug = slug_src.replace("/", "--")
|
||||
model_path = f"{base}/{hf_slug}"
|
||||
|
||||
# Immutable catalog update — spread, never mutate
|
||||
entry: dict = {"path": model_path, "vram_mb": body.vram_mb}
|
||||
if body.description:
|
||||
entry["description"] = body.description
|
||||
new_catalog = {**(svc.get("catalog") or {}), body.model_id: entry}
|
||||
new_svc = {**svc, "catalog": new_catalog}
|
||||
new_services = {**services_def, body.service_type: new_svc}
|
||||
new_profile = {**profile, "services": new_services}
|
||||
|
||||
# Atomic write
|
||||
tmp = Path(str(p) + ".tmp")
|
||||
tmp.write_text(
|
||||
yaml.dump(new_profile, default_flow_style=False, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, p)
|
||||
|
||||
# Trigger coordinator reload
|
||||
cfg = _load_config()
|
||||
coordinator_url = cfg.get("coordinator_url", "") or ""
|
||||
reloaded = False
|
||||
if coordinator_url:
|
||||
try:
|
||||
import httpx
|
||||
rr = httpx.post(f"{coordinator_url}/api/nodes/{node_id}/reload-profile", timeout=5.0)
|
||||
reloaded = rr.status_code < 300
|
||||
except Exception as exc:
|
||||
logger.warning("Coordinator reload failed for %s: %s", node_id, exc)
|
||||
|
||||
return {"ok": True, "reloaded": reloaded, "path": model_path}
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ def _fake_nodes_response(nodes_json: list, services_json: list | None = None):
|
|||
"""Build side_effect list for two httpx.get calls: nodes then services."""
|
||||
mock_nodes = MagicMock()
|
||||
mock_nodes.raise_for_status = MagicMock()
|
||||
mock_nodes.json.return_value = nodes_json
|
||||
mock_nodes.json.return_value = {"nodes": nodes_json}
|
||||
|
||||
mock_services = MagicMock()
|
||||
mock_services.raise_for_status = MagicMock()
|
||||
mock_services.json.return_value = services_json or []
|
||||
mock_services.json.return_value = {"services": services_json or []}
|
||||
|
||||
return [mock_nodes, mock_services]
|
||||
|
||||
|
|
@ -469,3 +469,107 @@ def test_delete_ollama_model_404_when_not_found(client, tmp_path):
|
|||
r = client.delete("/api/nodes-mgmt/nodes/heimdall/models/ollama/missing-model")
|
||||
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Deploy model endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
_DEPLOY_PROFILE = {
|
||||
"services": {
|
||||
"cf-text": {
|
||||
"max_mb": 20000,
|
||||
"min_compute_cap": 7.0,
|
||||
"model_base_path": "/devl/Assets/LLM/cf-text/models",
|
||||
"catalog": {},
|
||||
},
|
||||
},
|
||||
"nodes": {
|
||||
"heimdall": {
|
||||
"gpus": [],
|
||||
"agent_url": "http://10.1.10.71:7701",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_deploy_model_adds_catalog_entry(client, tmp_path):
|
||||
"""Deploy endpoint should add the model to the service catalog."""
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
_write_config(tmp_path, {
|
||||
"coordinator_url": "http://fake-coord:7700",
|
||||
"profiles_dir": str(profiles_dir),
|
||||
})
|
||||
_write_profile(profiles_dir, "heimdall", _DEPLOY_PROFILE)
|
||||
|
||||
mock_reload = MagicMock()
|
||||
mock_reload.status_code = 200
|
||||
|
||||
with patch("httpx.post", return_value=mock_reload):
|
||||
r = client.post(
|
||||
"/api/nodes-mgmt/nodes/heimdall/models/deploy",
|
||||
json={
|
||||
"model_id": "fdtn-ai--Foundation-Sec-8B-Q4",
|
||||
"service_type": "cf-text",
|
||||
"vram_mb": 5180,
|
||||
"hf_repo": "fdtn-ai/Foundation-Sec-8B-Q4_K_M-GGUF",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["reloaded"] is True
|
||||
assert "fdtn-ai--Foundation-Sec-8B-Q4_K_M-GGUF" in data["path"]
|
||||
|
||||
saved = yaml.safe_load((profiles_dir / "heimdall.yaml").read_text())
|
||||
catalog = saved["services"]["cf-text"]["catalog"]
|
||||
assert "fdtn-ai--Foundation-Sec-8B-Q4" in catalog
|
||||
entry = catalog["fdtn-ai--Foundation-Sec-8B-Q4"]
|
||||
assert entry["vram_mb"] == 5180
|
||||
assert entry["path"].endswith("fdtn-ai--Foundation-Sec-8B-Q4_K_M-GGUF")
|
||||
|
||||
|
||||
def test_deploy_model_explicit_path_overrides_base(client, tmp_path):
|
||||
"""An explicit path in the request body takes precedence over model_base_path."""
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
_write_config(tmp_path, {
|
||||
"coordinator_url": "http://fake-coord:7700",
|
||||
"profiles_dir": str(profiles_dir),
|
||||
})
|
||||
_write_profile(profiles_dir, "heimdall", _DEPLOY_PROFILE)
|
||||
|
||||
with patch("httpx.post", return_value=MagicMock(status_code=200)):
|
||||
r = client.post(
|
||||
"/api/nodes-mgmt/nodes/heimdall/models/deploy",
|
||||
json={
|
||||
"model_id": "my-model",
|
||||
"service_type": "cf-text",
|
||||
"vram_mb": 8000,
|
||||
"path": "/custom/path/to/model",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["path"] == "/custom/path/to/model"
|
||||
|
||||
|
||||
def test_deploy_model_unknown_service_returns_422(client, tmp_path):
|
||||
"""Service type not in profile → 422."""
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
_write_config(tmp_path, {"profiles_dir": str(profiles_dir)})
|
||||
_write_profile(profiles_dir, "heimdall", _DEPLOY_PROFILE)
|
||||
|
||||
r = client.post(
|
||||
"/api/nodes-mgmt/nodes/heimdall/models/deploy",
|
||||
json={"model_id": "x", "service_type": "vllm", "vram_mb": 8000},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "vllm" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_deploy_model_missing_profile_returns_404(client, tmp_path):
|
||||
_write_config(tmp_path, {"profiles_dir": str(tmp_path / "profiles")})
|
||||
r = client.post(
|
||||
"/api/nodes-mgmt/nodes/nonexistent/models/deploy",
|
||||
json={"model_id": "x", "service_type": "cf-text", "vram_mb": 100},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
|
|
|||
170
web/src/components/nodes/CatalogEntryFormModal.vue
Normal file
170
web/src/components/nodes/CatalogEntryFormModal.vue
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { CatalogEntryFull } from '../../types/nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
svcName: string
|
||||
modelName?: string
|
||||
entry?: CatalogEntryFull
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
save: [svcName: string, modelName: string, entry: CatalogEntryFull]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const name = ref(props.modelName ?? '')
|
||||
const path = ref(props.entry?.path ?? '')
|
||||
const vramMb = ref(props.entry?.vram_mb ?? 0)
|
||||
const description = ref(props.entry?.description ?? '')
|
||||
const multiGpu = ref(props.entry?.multi_gpu ?? false)
|
||||
const envPairs = ref<{ k: string; v: string }[]>(
|
||||
Object.entries(props.entry?.env ?? {}).map(([k, v]) => ({ k, v }))
|
||||
)
|
||||
const formError = ref('')
|
||||
|
||||
watch(() => props.entry, (e) => {
|
||||
name.value = props.modelName ?? ''
|
||||
path.value = e?.path ?? ''
|
||||
vramMb.value = e?.vram_mb ?? 0
|
||||
description.value = e?.description ?? ''
|
||||
multiGpu.value = e?.multi_gpu ?? false
|
||||
envPairs.value = Object.entries(e?.env ?? {}).map(([k, v]) => ({ k, v }))
|
||||
})
|
||||
|
||||
function addEnvPair() {
|
||||
envPairs.value = [...envPairs.value, { k: '', v: '' }]
|
||||
}
|
||||
function removeEnvPair(i: number) {
|
||||
envPairs.value = envPairs.value.filter((_, idx) => idx !== i)
|
||||
}
|
||||
|
||||
function submit() {
|
||||
formError.value = ''
|
||||
if (!name.value.trim()) { formError.value = 'Model name is required.'; return }
|
||||
if (!path.value.trim()) { formError.value = 'Path is required.'; return }
|
||||
if (!vramMb.value || vramMb.value < 0) { formError.value = 'vram_mb must be a positive number.'; return }
|
||||
|
||||
const envObj: Record<string, string> = {}
|
||||
for (const { k, v } of envPairs.value) {
|
||||
if (k.trim()) envObj[k.trim()] = v
|
||||
}
|
||||
|
||||
const entry: CatalogEntryFull = { path: path.value.trim(), vram_mb: vramMb.value }
|
||||
if (description.value.trim()) entry.description = description.value.trim()
|
||||
if (multiGpu.value) entry.multi_gpu = true
|
||||
if (Object.keys(envObj).length) entry.env = envObj
|
||||
|
||||
emit('save', props.svcName, name.value.trim(), entry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop" role="dialog" aria-modal="true" :aria-label="`${modelName ? 'Edit' : 'Add'} catalog entry`">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">{{ modelName ? 'Edit' : 'Add' }} Catalog Entry — {{ svcName }}</h3>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="ce-name">Model name</label>
|
||||
<input id="ce-name" v-model="name" class="field-input" :readonly="!!modelName" placeholder="deepseek-r1-7b" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="ce-path">Path</label>
|
||||
<input id="ce-path" v-model="path" class="field-input" placeholder="/devl/Assets/LLM/cf-text/models/..." />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="ce-vram">VRAM (MB)</label>
|
||||
<input id="ce-vram" v-model.number="vramMb" type="number" min="0" class="field-input field-input--sm" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="ce-desc">Description</label>
|
||||
<input id="ce-desc" v-model="description" class="field-input" placeholder="Short description" />
|
||||
</div>
|
||||
<div class="field-row field-row--check">
|
||||
<input id="ce-mgpu" v-model="multiGpu" type="checkbox" />
|
||||
<label for="ce-mgpu">Multi-GPU span</label>
|
||||
</div>
|
||||
|
||||
<div class="env-section">
|
||||
<div class="env-header">
|
||||
<span class="field-label">Env vars</span>
|
||||
<button type="button" class="btn-link" @click="addEnvPair">+ Add</button>
|
||||
</div>
|
||||
<div v-for="(pair, i) in envPairs" :key="i" class="env-row">
|
||||
<input v-model="pair.k" class="field-input field-input--sm" placeholder="CF_TEXT_4BIT" />
|
||||
<span>=</span>
|
||||
<input v-model="pair.v" class="field-input field-input--sm" placeholder="1" />
|
||||
<button type="button" class="btn-icon" @click="removeEnvPair(i)" aria-label="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formError" class="form-error" role="alert">{{ formError }}</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="emit('cancel')">Cancel</button>
|
||||
<button class="btn-primary" @click="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
width: 100%; max-width: 500px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.modal-title { margin: 0 0 0.25rem; font-size: 1rem; font-weight: 600; color: var(--color-text); }
|
||||
.field-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.field-row--check { gap: 0.4rem; color: var(--color-text); }
|
||||
.field-label { min-width: 8rem; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
.field-input {
|
||||
flex: 1;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field-input--sm { flex: 0 0 8rem; }
|
||||
.env-section { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.env-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.env-row { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.btn-link { background: none; border: none; color: var(--app-primary); cursor: pointer; font-size: 0.8rem; padding: 0; }
|
||||
.btn-link:hover { color: var(--app-primary-hover); }
|
||||
.btn-icon { background: none; border: none; color: var(--color-text-muted); cursor: pointer; padding: 0 0.2rem; font-size: 0.85rem; }
|
||||
.btn-icon:hover { color: var(--color-error); }
|
||||
.form-error { color: var(--color-error); font-size: 0.8rem; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.25rem; }
|
||||
.btn-primary {
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary:hover { background: var(--app-primary-hover); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
</style>
|
||||
|
|
@ -106,24 +106,24 @@ async function toggleService(svcName: string) {
|
|||
.gpu-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #111);
|
||||
background: var(--color-surface-alt);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.gpu-info { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; font-size: 0.875rem; }
|
||||
.gpu-label { font-weight: 500; }
|
||||
.gpu-meta { color: var(--text-secondary, #888); font-size: 0.8rem; }
|
||||
.gpu-label { font-weight: 500; color: var(--color-text); }
|
||||
.gpu-meta { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
.vram-wrap { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.vram-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-bar, #2a2a2a);
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vram-fill { height: 100%; background: var(--color-primary, #4080ff); transition: width 0.3s; }
|
||||
.vram-text { font-size: 0.75rem; color: var(--text-secondary, #888); white-space: nowrap; }
|
||||
.vram-fill { height: 100%; background: var(--app-primary); transition: width 0.3s; }
|
||||
.vram-text { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
.services-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.save-msg { color: var(--color-warning, #ed8936); font-size: 0.8rem; }
|
||||
.save-msg { color: var(--color-warning); font-size: 0.8rem; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -99,19 +99,21 @@ onUnmounted(() => { fetchAbort?.abort() })
|
|||
.hf-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.panel-title { margin: 0 0 0.5rem; font-size: 0.9rem; }
|
||||
.hf-hint { font-size: 0.8rem; color: var(--text-secondary, #888); margin: 0 0 0.75rem; }
|
||||
.hf-link { color: var(--color-primary, #4080ff); }
|
||||
.panel-title { margin: 0 0 0.5rem; font-size: 0.9rem; color: var(--color-text); }
|
||||
.hf-hint { font-size: 0.8rem; color: var(--color-text-muted); margin: 0 0 0.75rem; }
|
||||
.hf-link { color: var(--app-primary); }
|
||||
.hf-link:hover { color: var(--app-primary-hover); }
|
||||
.svc-section { margin-bottom: 0.75rem; }
|
||||
.svc-name {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.catalog-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.catalog-item {
|
||||
|
|
@ -119,14 +121,14 @@ onUnmounted(() => { fetchAbort?.abort() })
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary, #111);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.catalog-model { font-family: monospace; flex: 1; }
|
||||
.catalog-vram { color: var(--text-secondary, #888); white-space: nowrap; }
|
||||
.catalog-desc { color: var(--text-secondary, #888); font-size: 0.75rem; flex: 2; }
|
||||
.catalog-empty, .panel-empty { color: var(--text-secondary, #888); font-size: 0.875rem; }
|
||||
.catalog-model { font-family: var(--font-mono, monospace); flex: 1; }
|
||||
.catalog-vram { color: var(--color-text-muted); white-space: nowrap; }
|
||||
.catalog-desc { color: var(--color-text-muted); font-size: 0.75rem; flex: 2; }
|
||||
.catalog-empty, .panel-empty { color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
.sr-announce { min-height: 1.2em; }
|
||||
.panel-error { color: var(--color-error, #fc8181); font-size: 0.8rem; }
|
||||
.panel-error { color: var(--color-error); font-size: 0.8rem; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,43 @@
|
|||
import { ref } from 'vue'
|
||||
import GpuRow from './GpuRow.vue'
|
||||
import OllamaModelPanel from './OllamaModelPanel.vue'
|
||||
import HfNodeModelPanel from './HfNodeModelPanel.vue'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import ProfileEditorPanel from './ProfileEditorPanel.vue'
|
||||
import type { NodeSummary, FullProfile } from '../../types/nodes'
|
||||
|
||||
const props = defineProps<{ node: NodeSummary }>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
|
||||
const showOllama = ref(false)
|
||||
const showHf = ref(false)
|
||||
const showEditor = ref(false)
|
||||
const loadedProfile = ref<FullProfile | null>(null)
|
||||
const profileLoading = ref(false)
|
||||
const profileError = ref('')
|
||||
|
||||
async function openEditor() {
|
||||
if (showEditor.value) { showEditor.value = false; return }
|
||||
profileLoading.value = true
|
||||
profileError.value = ''
|
||||
try {
|
||||
const r = await fetch(`/api/nodes-mgmt/nodes/${props.node.node_id}/profile`)
|
||||
if (r.status === 404) {
|
||||
loadedProfile.value = null
|
||||
} else if (!r.ok) {
|
||||
throw new Error(`HTTP ${r.status}`)
|
||||
} else {
|
||||
loadedProfile.value = await r.json() as FullProfile
|
||||
}
|
||||
showEditor.value = true
|
||||
} catch (e) {
|
||||
profileError.value = e instanceof Error ? e.message : 'Failed to load profile'
|
||||
} finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onProfileSaved() {
|
||||
showEditor.value = false
|
||||
emit('updated')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -25,12 +54,20 @@ const showHf = ref(false)
|
|||
<h2 class="node-name">{{ node.node_id }}</h2>
|
||||
<span class="node-agent">{{ node.agent_url }}</span>
|
||||
</div>
|
||||
<div v-if="node.profile_loaded" class="node-actions">
|
||||
<button class="btn-secondary btn-sm" @click="showOllama = !showOllama">
|
||||
<div class="node-actions">
|
||||
<button
|
||||
v-if="node.profile_loaded"
|
||||
class="btn-secondary btn-sm"
|
||||
@click="showOllama = !showOllama"
|
||||
>
|
||||
{{ showOllama ? 'Hide Ollama' : 'Ollama' }}
|
||||
</button>
|
||||
<button class="btn-secondary btn-sm" @click="showHf = !showHf">
|
||||
{{ showHf ? 'Hide Catalog' : 'Catalog' }}
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
:disabled="profileLoading"
|
||||
@click="openEditor"
|
||||
>
|
||||
{{ profileLoading ? 'Loading…' : node.profile_loaded ? (showEditor ? 'Close Editor' : 'Edit Profile') : 'Create Profile' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -52,16 +89,24 @@ const showHf = ref(false)
|
|||
</div>
|
||||
|
||||
<OllamaModelPanel v-if="showOllama" :node-id="node.node_id" />
|
||||
<HfNodeModelPanel v-if="showHf" :node-id="node.node_id" />
|
||||
<div v-if="profileError" class="profile-load-error" role="alert">{{ profileError }}</div>
|
||||
<ProfileEditorPanel
|
||||
v-if="showEditor"
|
||||
:node-id="node.node_id"
|
||||
:initial-profile="loadedProfile"
|
||||
@saved="onProfileSaved"
|
||||
@close="showEditor = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.node-card {
|
||||
border: 1px solid var(--border, #333);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card, #1a1a1a);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.node-card.offline { opacity: 0.65; }
|
||||
.node-card-header {
|
||||
|
|
@ -72,19 +117,32 @@ const showHf = ref(false)
|
|||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.node-identity { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.node-name { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.node-agent { color: var(--text-secondary, #888); font-size: 0.8rem; font-family: monospace; }
|
||||
.node-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--color-text); }
|
||||
.node-agent { color: var(--color-text-muted); font-size: 0.8rem; font-family: var(--font-mono, monospace); }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.online { background: var(--color-success, #48bb78); }
|
||||
.status-dot.offline { background: var(--color-warning, #ed8936); }
|
||||
.status-dot.online { background: var(--color-success); }
|
||||
.status-dot.offline { background: var(--color-warning); }
|
||||
.node-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-sm { font-size: 0.8rem; padding: 0.25rem 0.6rem; }
|
||||
.no-profile {
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--bg-notice, #1e1e1e);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.gpu-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.profile-load-error { color: var(--color-error); font-size: 0.8rem; margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -198,44 +198,45 @@ onUnmounted(() => {
|
|||
.ollama-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.panel-title { margin: 0 0 0.75rem; font-size: 0.9rem; }
|
||||
.panel-title { margin: 0 0 0.75rem; font-size: 0.9rem; color: var(--color-text); }
|
||||
.pull-form { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.pull-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-input, #111);
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pull-progress { margin-bottom: 0.5rem; }
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-bar, #2a2a2a);
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.progress-fill { height: 100%; background: var(--color-primary, #4080ff); transition: width 0.2s; }
|
||||
.progress-label { font-size: 0.75rem; color: var(--text-secondary, #888); }
|
||||
.pull-error, .panel-error { color: var(--color-error, #fc8181); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||
.progress-fill { height: 100%; background: var(--app-primary); transition: width 0.2s; }
|
||||
.progress-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.pull-error, .panel-error { color: var(--color-error); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||
.sr-announce { min-height: 1.2em; }
|
||||
.panel-loading { color: var(--text-secondary, #888); font-size: 0.875rem; }
|
||||
.panel-loading { color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
.model-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.model-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-secondary, #111);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.model-name { flex: 1; font-family: monospace; }
|
||||
.model-size { color: var(--text-secondary, #888); font-size: 0.8rem; }
|
||||
.model-empty { color: var(--text-secondary, #888); font-size: 0.875rem; padding: 0.25rem 0; }
|
||||
.model-name { flex: 1; font-family: var(--font-mono, monospace); }
|
||||
.model-size { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
.model-empty { color: var(--color-text-muted); font-size: 0.875rem; padding: 0.25rem 0; }
|
||||
</style>
|
||||
|
|
|
|||
597
web/src/components/nodes/ProfileEditorPanel.vue
Normal file
597
web/src/components/nodes/ProfileEditorPanel.vue
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { FullProfile, ServiceDefinition, CatalogEntryFull } from '../../types/nodes'
|
||||
import ServiceFormModal from './ServiceFormModal.vue'
|
||||
import CatalogEntryFormModal from './CatalogEntryFormModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeId: string
|
||||
initialProfile: FullProfile | null
|
||||
}>()
|
||||
const emit = defineEmits<{ saved: []; close: [] }>()
|
||||
|
||||
// Deep-clone initial profile so edits don't mutate the parent's data
|
||||
const profile = ref<FullProfile>(
|
||||
props.initialProfile
|
||||
? JSON.parse(JSON.stringify(props.initialProfile))
|
||||
: { services: {}, nodes: {} }
|
||||
)
|
||||
|
||||
const saving = ref(false)
|
||||
const generating = ref(false)
|
||||
const opError = ref('')
|
||||
const expandedSvcs = ref<Set<string>>(new Set())
|
||||
|
||||
// Service modal
|
||||
const showSvcModal = ref(false)
|
||||
const editingSvcName = ref<string | undefined>()
|
||||
const editingSvcDef = ref<ServiceDefinition | undefined>()
|
||||
|
||||
// Catalog modal
|
||||
const showCatalogModal = ref(false)
|
||||
const catalogTargetSvc = ref('')
|
||||
const editingModelName = ref<string | undefined>()
|
||||
const editingEntry = ref<CatalogEntryFull | undefined>()
|
||||
|
||||
// ── Generate nodes section from coordinator ────────────────────────────────────
|
||||
|
||||
async function generate() {
|
||||
generating.value = true
|
||||
opError.value = ''
|
||||
try {
|
||||
const r = await fetch(`/api/nodes-mgmt/nodes/${props.nodeId}/profile/generate`, { method: 'POST' })
|
||||
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error((d as {detail?: string}).detail ?? `HTTP ${r.status}`) }
|
||||
const generated = await r.json() as FullProfile
|
||||
// Merge: keep current services edits, replace nodes section
|
||||
profile.value = { ...generated, services: profile.value.services }
|
||||
} catch (e) {
|
||||
opError.value = e instanceof Error ? e.message : 'Generate failed'
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save full profile ──────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
opError.value = ''
|
||||
try {
|
||||
const r = await fetch(`/api/nodes-mgmt/nodes/${props.nodeId}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile: profile.value }),
|
||||
})
|
||||
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error((d as {detail?: string}).detail ?? `HTTP ${r.status}`) }
|
||||
emit('saved')
|
||||
} catch (e) {
|
||||
opError.value = e instanceof Error ? e.message : 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
function openAddService() {
|
||||
editingSvcName.value = undefined
|
||||
editingSvcDef.value = undefined
|
||||
showSvcModal.value = true
|
||||
}
|
||||
|
||||
function openEditService(name: string) {
|
||||
editingSvcName.value = name
|
||||
editingSvcDef.value = JSON.parse(JSON.stringify(profile.value.services[name]))
|
||||
showSvcModal.value = true
|
||||
}
|
||||
|
||||
function onServiceSaved(name: string, def: ServiceDefinition) {
|
||||
profile.value = { ...profile.value, services: { ...profile.value.services, [name]: def } }
|
||||
expandedSvcs.value = new Set([...expandedSvcs.value, name])
|
||||
showSvcModal.value = false
|
||||
}
|
||||
|
||||
function deleteService(name: string) {
|
||||
if (!confirm(`Remove service "${name}" from this profile?`)) return
|
||||
const svcs = { ...profile.value.services }
|
||||
delete svcs[name]
|
||||
profile.value = { ...profile.value, services: svcs }
|
||||
expandedSvcs.value = new Set([...expandedSvcs.value].filter(s => s !== name))
|
||||
}
|
||||
|
||||
function toggleSvc(name: string) {
|
||||
const s = new Set(expandedSvcs.value)
|
||||
s.has(name) ? s.delete(name) : s.add(name)
|
||||
expandedSvcs.value = s
|
||||
}
|
||||
|
||||
// ── Catalog CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
function openAddCatalogEntry(svcName: string) {
|
||||
catalogTargetSvc.value = svcName
|
||||
editingModelName.value = undefined
|
||||
editingEntry.value = undefined
|
||||
showCatalogModal.value = true
|
||||
}
|
||||
|
||||
function openEditCatalogEntry(svcName: string, modelName: string) {
|
||||
catalogTargetSvc.value = svcName
|
||||
editingModelName.value = modelName
|
||||
editingEntry.value = JSON.parse(JSON.stringify(profile.value.services[svcName].catalog![modelName]))
|
||||
showCatalogModal.value = true
|
||||
}
|
||||
|
||||
function onCatalogEntrySaved(svcName: string, modelName: string, entry: CatalogEntryFull) {
|
||||
const svcs = { ...profile.value.services }
|
||||
const svc = { ...svcs[svcName], catalog: { ...(svcs[svcName].catalog ?? {}), [modelName]: entry } }
|
||||
svcs[svcName] = svc
|
||||
profile.value = { ...profile.value, services: svcs }
|
||||
showCatalogModal.value = false
|
||||
}
|
||||
|
||||
function deleteCatalogEntry(svcName: string, modelName: string) {
|
||||
if (!confirm(`Remove model "${modelName}" from ${svcName} catalog?`)) return
|
||||
const svcs = { ...profile.value.services }
|
||||
const catalog = { ...(svcs[svcName].catalog ?? {}) }
|
||||
delete catalog[modelName]
|
||||
svcs[svcName] = { ...svcs[svcName], catalog }
|
||||
profile.value = { ...profile.value, services: svcs }
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function gpuList() {
|
||||
return (profile.value.nodes[props.nodeId]?.gpus ?? [])
|
||||
}
|
||||
|
||||
function serviceCount() {
|
||||
return Object.keys(profile.value.services).length
|
||||
}
|
||||
|
||||
// ── Ollama model suggestions ───────────────────────────────────────────────────
|
||||
|
||||
interface OllamaModel { name: string; size: number }
|
||||
const ollamaModels = ref<OllamaModel[]>([])
|
||||
const ollamaLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
ollamaLoading.value = true
|
||||
try {
|
||||
const r = await fetch(`/api/nodes-mgmt/nodes/${props.nodeId}/models/ollama`)
|
||||
if (r.ok) {
|
||||
const d = await r.json() as { models?: OllamaModel[] }
|
||||
ollamaModels.value = d.models ?? []
|
||||
}
|
||||
} catch { /* Ollama offline — silently skip */ }
|
||||
finally { ollamaLoading.value = false }
|
||||
})
|
||||
|
||||
function ollamaNotInCatalog(svcName: string): OllamaModel[] {
|
||||
const catalog = profile.value.services[svcName]?.catalog ?? {}
|
||||
return ollamaModels.value.filter(m => !(m.name in catalog))
|
||||
}
|
||||
|
||||
function openAddFromOllama(svcName: string, modelName: string) {
|
||||
catalogTargetSvc.value = svcName
|
||||
editingModelName.value = modelName
|
||||
editingEntry.value = {
|
||||
path: profile.value.services[svcName]?.model_base_path
|
||||
? `${profile.value.services[svcName].model_base_path}/${modelName}`
|
||||
: '',
|
||||
vram_mb: 0,
|
||||
}
|
||||
showCatalogModal.value = true
|
||||
}
|
||||
|
||||
function formatMb(bytes: number): string {
|
||||
return bytes >= 1_000_000_000
|
||||
? `${(bytes / 1_073_741_824).toFixed(1)} GB`
|
||||
: `${Math.round(bytes / 1_048_576)} MB`
|
||||
}
|
||||
|
||||
// ── Pull model onto node ───────────────────────────────────────────────────────
|
||||
|
||||
const pullName = ref('')
|
||||
const pulling = ref(false)
|
||||
const pullStatus = ref('')
|
||||
const pullPct = ref(0)
|
||||
const pullError = ref('')
|
||||
let pullAbort: AbortController | null = null
|
||||
|
||||
async function doPull() {
|
||||
const name = pullName.value.trim()
|
||||
if (!name || pulling.value) return
|
||||
pulling.value = true
|
||||
pullStatus.value = 'Starting…'
|
||||
pullError.value = ''
|
||||
pullPct.value = 0
|
||||
pullAbort?.abort()
|
||||
pullAbort = new AbortController()
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/nodes-mgmt/nodes/${props.nodeId}/models/ollama/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
signal: pullAbort.signal,
|
||||
})
|
||||
if (!resp.ok || !resp.body) {
|
||||
pullError.value = `HTTP ${resp.status}`
|
||||
return
|
||||
}
|
||||
const reader = resp.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue
|
||||
try {
|
||||
const d = JSON.parse(line.slice(5)) as {
|
||||
status?: string; completed?: number; total?: number; error?: string; done?: boolean
|
||||
}
|
||||
if (d.error) { pullError.value = d.error; return }
|
||||
pullStatus.value = d.status ?? ''
|
||||
if (d.total && d.total > 0) pullPct.value = Math.round((d.completed ?? 0) / d.total * 100)
|
||||
if (d.done) {
|
||||
pullName.value = ''
|
||||
pullPct.value = 100
|
||||
// Refresh Ollama model list so new model appears in suggest chips
|
||||
const r = await fetch(`/api/nodes-mgmt/nodes/${props.nodeId}/models/ollama`)
|
||||
if (r.ok) { const d2 = await r.json() as { models?: OllamaModel[] }; ollamaModels.value = d2.models ?? [] }
|
||||
}
|
||||
} catch { /* skip malformed SSE line */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') pullError.value = e.message
|
||||
} finally {
|
||||
pulling.value = false
|
||||
if (pullPct.value === 100) setTimeout(() => { pullStatus.value = ''; pullPct.value = 0 }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pep" aria-label="Profile editor">
|
||||
<!-- Header -->
|
||||
<div class="pep-header">
|
||||
<div class="pep-title-row">
|
||||
<h3 class="pep-title">Profile — {{ nodeId }}</h3>
|
||||
<span class="pep-svc-count">{{ serviceCount() }} service{{ serviceCount() === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="pep-actions">
|
||||
<button class="btn-secondary btn-sm" :disabled="generating" @click="generate">
|
||||
{{ generating ? 'Refreshing…' : 'Refresh Hardware' }}
|
||||
</button>
|
||||
<button class="btn-primary btn-sm" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save Profile' }}
|
||||
</button>
|
||||
<button class="btn-icon-lg" aria-label="Close editor" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="opError" class="pep-error" role="alert">{{ opError }}</div>
|
||||
|
||||
<!-- Meta fields -->
|
||||
<div class="pep-meta">
|
||||
<label class="meta-label" for="pep-vram">vram_total_mb</label>
|
||||
<input id="pep-vram" v-model.number="profile.vram_total_mb" type="number" min="0" class="meta-input" />
|
||||
<label class="meta-label" for="pep-evict">eviction_timeout_s</label>
|
||||
<input id="pep-evict" v-model.number="profile.eviction_timeout_s" type="number" min="0" step="0.5" class="meta-input" />
|
||||
</div>
|
||||
|
||||
<!-- Hardware summary -->
|
||||
<div v-if="gpuList().length" class="hw-section">
|
||||
<span class="hw-label">Hardware</span>
|
||||
<span v-for="g in gpuList()" :key="g.id" class="hw-gpu">
|
||||
GPU {{ g.id }}: {{ g.card || 'unknown' }} · {{ g.vram_mb }} MB · sm{{ g.compute_cap ?? '?' }}
|
||||
</span>
|
||||
<span v-if="!gpuList().length" class="hw-none">No hardware data — click Refresh Hardware.</span>
|
||||
</div>
|
||||
<div v-else class="hw-section">
|
||||
<span class="hw-none">No hardware data — click Refresh Hardware to seed from coordinator.</span>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="svcs-header">
|
||||
<span class="svcs-title">Services</span>
|
||||
<button class="btn-secondary btn-sm" @click="openAddService">+ Add Service</button>
|
||||
</div>
|
||||
|
||||
<div v-if="serviceCount() === 0" class="svcs-empty">
|
||||
No services defined. Add a service to configure what can run on this node.
|
||||
</div>
|
||||
|
||||
<ul class="svcs-list" role="list">
|
||||
<li
|
||||
v-for="(def, svcName) in profile.services"
|
||||
:key="String(svcName)"
|
||||
class="svc-item"
|
||||
>
|
||||
<!-- Service row header -->
|
||||
<div class="svc-row">
|
||||
<button
|
||||
class="svc-toggle"
|
||||
:aria-expanded="expandedSvcs.has(String(svcName))"
|
||||
@click="toggleSvc(String(svcName))"
|
||||
>
|
||||
<span class="svc-arrow">{{ expandedSvcs.has(String(svcName)) ? '▾' : '▸' }}</span>
|
||||
<span class="svc-name">{{ svcName }}</span>
|
||||
</button>
|
||||
<span class="svc-badges">
|
||||
<span class="badge">{{ def.max_mb }} MB</span>
|
||||
<span class="badge">p{{ def.priority }}</span>
|
||||
<span v-if="def.shared" class="badge badge--blue">shared</span>
|
||||
<span v-if="def.managed" class="badge badge--dim">managed</span>
|
||||
<span v-if="def.catalog" class="badge badge--dim">{{ Object.keys(def.catalog).length }} models</span>
|
||||
</span>
|
||||
<div class="svc-btns">
|
||||
<button class="btn-secondary btn-xs" @click="openEditService(String(svcName))">Edit</button>
|
||||
<button class="btn-danger btn-xs" @click="deleteService(String(svcName))">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded catalog -->
|
||||
<div v-if="expandedSvcs.has(String(svcName))" class="svc-detail">
|
||||
<div class="svc-detail-meta">
|
||||
<span v-if="def.min_compute_cap">min sm{{ def.min_compute_cap }}</span>
|
||||
<span v-if="def.max_concurrent">max_concurrent: {{ def.max_concurrent }}</span>
|
||||
<span v-if="def.idle_stop_after_s">idle_stop: {{ def.idle_stop_after_s }}s</span>
|
||||
<span v-if="def.always_on" class="badge badge--blue">always_on</span>
|
||||
</div>
|
||||
|
||||
<!-- Ollama model suggestions + pull -->
|
||||
<div class="ollama-suggest">
|
||||
<div class="suggest-row">
|
||||
<span class="suggest-label">On node (Ollama):</span>
|
||||
<span v-if="ollamaLoading" class="suggest-loading">loading…</span>
|
||||
<template v-else-if="ollamaNotInCatalog(String(svcName)).length">
|
||||
<button
|
||||
v-for="m in ollamaNotInCatalog(String(svcName))"
|
||||
:key="m.name"
|
||||
class="suggest-chip"
|
||||
:title="`Add ${m.name} (${formatMb(m.size)}) to this service catalog`"
|
||||
@click="openAddFromOllama(String(svcName), m.name)"
|
||||
>
|
||||
+ {{ m.name }} <span class="chip-size">{{ formatMb(m.size) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<span v-else-if="!ollamaLoading" class="suggest-none">All Ollama models already in catalog.</span>
|
||||
</div>
|
||||
|
||||
<!-- Pull model onto this node -->
|
||||
<div class="pull-row">
|
||||
<input
|
||||
v-model="pullName"
|
||||
class="pull-input"
|
||||
placeholder="Pull model on node (e.g. llama3:8b)"
|
||||
:disabled="pulling"
|
||||
@keyup.enter="doPull"
|
||||
/>
|
||||
<button class="btn-pull" :disabled="pulling || !pullName.trim()" @click="doPull">
|
||||
{{ pulling ? 'Pulling…' : 'Pull' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="pulling || pullPct > 0" class="pull-progress">
|
||||
<div class="pull-bar"><div class="pull-fill" :style="{ width: pullPct + '%' }" /></div>
|
||||
<span class="pull-status">{{ pullStatus }}</span>
|
||||
</div>
|
||||
<div v-if="pullError" class="pull-err" role="alert">{{ pullError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-header">
|
||||
<span class="catalog-title">Catalog</span>
|
||||
<button class="btn-link" @click="openAddCatalogEntry(String(svcName))">+ Add Model</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!def.catalog || !Object.keys(def.catalog).length" class="catalog-empty">
|
||||
No catalog entries. Only services like cf-text need a catalog.
|
||||
</div>
|
||||
<ul v-else class="catalog-list" role="list">
|
||||
<li
|
||||
v-for="(entry, modelName) in def.catalog"
|
||||
:key="String(modelName)"
|
||||
class="catalog-item"
|
||||
>
|
||||
<span class="catalog-model">{{ modelName }}</span>
|
||||
<span class="catalog-vram">{{ entry.vram_mb }} MB</span>
|
||||
<span v-if="entry.multi_gpu" class="badge badge--dim">multi-gpu</span>
|
||||
<span v-if="entry.description" class="catalog-desc">{{ entry.description }}</span>
|
||||
<div class="catalog-btns">
|
||||
<button class="btn-secondary btn-xs" @click="openEditCatalogEntry(String(svcName), String(modelName))">Edit</button>
|
||||
<button class="btn-danger btn-xs" @click="deleteCatalogEntry(String(svcName), String(modelName))">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Service form modal -->
|
||||
<ServiceFormModal
|
||||
v-if="showSvcModal"
|
||||
:service-name="editingSvcName"
|
||||
:definition="editingSvcDef"
|
||||
@save="onServiceSaved"
|
||||
@cancel="showSvcModal = false"
|
||||
/>
|
||||
|
||||
<!-- Catalog entry form modal -->
|
||||
<CatalogEntryFormModal
|
||||
v-if="showCatalogModal"
|
||||
:svc-name="catalogTargetSvc"
|
||||
:model-name="editingModelName"
|
||||
:entry="editingEntry"
|
||||
@save="onCatalogEntrySaved"
|
||||
@cancel="showCatalogModal = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pep {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pep-header {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||
margin-bottom: 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.pep-title-row { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.pep-title { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--color-text); }
|
||||
.pep-svc-count { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.pep-actions { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.pep-error { color: var(--color-error); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||
.pep-meta {
|
||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
||||
padding: 0.5rem; background: var(--color-surface-alt); border-radius: 4px; margin-bottom: 0.75rem;
|
||||
}
|
||||
.meta-label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.meta-input {
|
||||
width: 7rem; background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: 4px; padding: 0.2rem 0.4rem; color: var(--color-text); font-size: 0.8rem;
|
||||
}
|
||||
.hw-section {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.8rem; color: var(--color-text-muted);
|
||||
padding: 0.4rem 0.5rem; border-radius: 4px; background: var(--color-surface-alt);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hw-label { font-weight: 600; color: var(--color-text); }
|
||||
.hw-gpu { font-family: monospace; color: var(--color-text); }
|
||||
.hw-none { font-style: italic; }
|
||||
.svcs-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.svcs-title { font-size: 0.85rem; font-weight: 600; color: var(--color-text); }
|
||||
.svcs-empty { color: var(--color-text-muted); font-size: 0.85rem; padding: 0.5rem 0; }
|
||||
.svcs-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.svc-item { border: 1px solid var(--color-border); border-radius: 4px; overflow: hidden; }
|
||||
.svc-row {
|
||||
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem;
|
||||
background: var(--color-surface-alt); flex-wrap: wrap;
|
||||
}
|
||||
.svc-toggle {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
background: none; border: none; cursor: pointer; color: var(--color-text); padding: 0; flex: 1; min-width: 0;
|
||||
}
|
||||
.svc-arrow { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||
.svc-name { font-size: 0.875rem; font-weight: 500; font-family: monospace; }
|
||||
.svc-badges { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.svc-btns { display: flex; gap: 0.3rem; margin-left: auto; }
|
||||
.svc-detail { padding: 0.5rem 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; background: var(--color-surface-raised); }
|
||||
.svc-detail-meta {
|
||||
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
||||
font-size: 0.78rem; color: var(--color-text-muted);
|
||||
}
|
||||
.ollama-suggest {
|
||||
display: flex; flex-direction: column; gap: 0.35rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--color-primary-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.suggest-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.suggest-label { color: var(--color-text-muted); font-weight: 500; white-space: nowrap; }
|
||||
.suggest-loading { color: var(--color-text-muted); font-style: italic; }
|
||||
.suggest-none { color: var(--color-text-muted); font-style: italic; }
|
||||
.suggest-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.suggest-chip:hover { border-color: var(--app-primary); background: var(--color-surface-alt); }
|
||||
.chip-size { color: var(--color-text-muted); font-size: 0.72rem; }
|
||||
.pull-row { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.pull-input {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.pull-input:disabled { opacity: 0.5; }
|
||||
.btn-pull {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-pull:hover:not(:disabled) { background: var(--app-primary-hover); }
|
||||
.btn-pull:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pull-progress { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.pull-bar {
|
||||
flex: 1; height: 6px;
|
||||
background: var(--color-border);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.pull-fill { height: 100%; background: var(--app-primary); transition: width 0.2s; }
|
||||
.pull-status { color: var(--color-text-muted); font-size: 0.72rem; white-space: nowrap; max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pull-err { color: var(--color-error); font-size: 0.75rem; }
|
||||
.catalog-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.catalog-title { font-size: 0.8rem; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.catalog-empty { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; }
|
||||
.catalog-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.catalog-item {
|
||||
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
|
||||
padding: 0.25rem 0.5rem; background: var(--color-surface-alt); border-radius: 3px; font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.catalog-model { font-family: monospace; flex: 1; min-width: 12rem; }
|
||||
.catalog-vram { color: var(--color-text-muted); white-space: nowrap; }
|
||||
.catalog-desc { color: var(--color-text-muted); flex: 2; font-size: 0.75rem; }
|
||||
.catalog-btns { display: flex; gap: 0.25rem; margin-left: auto; }
|
||||
.badge {
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.72rem;
|
||||
background: var(--color-surface); border: 1px solid var(--color-border); color: var(--color-text);
|
||||
}
|
||||
.badge--blue { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-primary-light); }
|
||||
.badge--dim { opacity: 0.75; }
|
||||
.btn-link { background: none; border: none; color: var(--color-accent); cursor: pointer; font-size: 0.8rem; padding: 0; }
|
||||
.btn-link:hover { color: var(--color-accent-hover); }
|
||||
.btn-primary {
|
||||
background: var(--color-primary); color: var(--color-text-inverse); border: none;
|
||||
border-radius: 4px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
background: transparent; border: 1px solid var(--color-border); color: var(--color-text);
|
||||
border-radius: 4px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-danger {
|
||||
background: transparent; border: 1px solid var(--color-error); color: var(--color-error);
|
||||
border-radius: 4px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.btn-danger:hover { background: var(--color-surface-alt); }
|
||||
.btn-sm { padding: 0.3rem 0.6rem; }
|
||||
.btn-xs { padding: 0.15rem 0.4rem; }
|
||||
.btn-icon-lg { background: none; border: none; color: var(--color-text-muted); cursor: pointer; font-size: 1rem; padding: 0.2rem 0.3rem; }
|
||||
.btn-icon-lg:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
|
@ -64,18 +64,19 @@ function handleToggle() {
|
|||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--bg-badge, #1e1e1e);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s, border-color 0.1s;
|
||||
}
|
||||
.service-badge:hover:not(.is-disabled) { opacity: 0.8; }
|
||||
.service-badge.is-disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
.service-badge.state-running { border-color: var(--color-success, #48bb78); }
|
||||
.service-badge.state-stopped { border-color: var(--color-warning, #ed8936); }
|
||||
.service-badge.state-assigned-only { border-color: var(--color-info, #4299e1); }
|
||||
.service-badge.state-incompatible { border-color: var(--color-error, #fc8181); }
|
||||
.service-badge.state-vram-tight { border-color: var(--color-warning, #ed8936); }
|
||||
.badge-state { color: var(--text-secondary, #888); }
|
||||
.service-badge.state-running { border-color: var(--color-success); }
|
||||
.service-badge.state-stopped { border-color: var(--color-warning); }
|
||||
.service-badge.state-assigned-only { border-color: var(--color-info); }
|
||||
.service-badge.state-incompatible { border-color: var(--color-error); }
|
||||
.service-badge.state-vram-tight { border-color: var(--color-warning); }
|
||||
.badge-state { color: var(--color-text-muted); }
|
||||
</style>
|
||||
|
|
|
|||
231
web/src/components/nodes/ServiceFormModal.vue
Normal file
231
web/src/components/nodes/ServiceFormModal.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { ServiceDefinition } from '../../types/nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
serviceName?: string
|
||||
definition?: ServiceDefinition
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
save: [name: string, def: ServiceDefinition]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const name = ref(props.serviceName ?? '')
|
||||
const maxMb = ref(props.definition?.max_mb ?? 0)
|
||||
const priority = ref(props.definition?.priority ?? 1)
|
||||
const minCap = ref(props.definition?.min_compute_cap ?? 0)
|
||||
const prefCap = ref<number | ''>(props.definition?.preferred_compute_cap ?? '')
|
||||
const shared = ref(props.definition?.shared ?? false)
|
||||
const maxConcurrent = ref<number | ''>(props.definition?.max_concurrent ?? '')
|
||||
const idleStop = ref<number | ''>(props.definition?.idle_stop_after_s ?? '')
|
||||
const alwaysOn = ref(props.definition?.always_on ?? false)
|
||||
const modelBasePath = ref(props.definition?.model_base_path ?? '')
|
||||
const hasManaged = ref(!!props.definition?.managed)
|
||||
const managedJson = ref(
|
||||
props.definition?.managed ? JSON.stringify(props.definition.managed, null, 2) : ''
|
||||
)
|
||||
const formError = ref('')
|
||||
|
||||
watch(() => props.definition, (d) => {
|
||||
name.value = props.serviceName ?? ''
|
||||
maxMb.value = d?.max_mb ?? 0
|
||||
priority.value = d?.priority ?? 1
|
||||
minCap.value = d?.min_compute_cap ?? 0
|
||||
prefCap.value = d?.preferred_compute_cap ?? ''
|
||||
shared.value = d?.shared ?? false
|
||||
maxConcurrent.value = d?.max_concurrent ?? ''
|
||||
idleStop.value = d?.idle_stop_after_s ?? ''
|
||||
alwaysOn.value = d?.always_on ?? false
|
||||
modelBasePath.value = d?.model_base_path ?? ''
|
||||
hasManaged.value = !!d?.managed
|
||||
managedJson.value = d?.managed ? JSON.stringify(d.managed, null, 2) : ''
|
||||
})
|
||||
|
||||
const managedJsonError = computed(() => {
|
||||
if (!hasManaged.value || !managedJson.value.trim()) return ''
|
||||
try { JSON.parse(managedJson.value); return '' }
|
||||
catch { return 'Invalid JSON' }
|
||||
})
|
||||
|
||||
function submit() {
|
||||
formError.value = ''
|
||||
if (!name.value.trim()) { formError.value = 'Service name is required.'; return }
|
||||
if (!maxMb.value || maxMb.value <= 0) { formError.value = 'max_mb must be > 0.'; return }
|
||||
if (managedJsonError.value) { formError.value = 'Fix the managed JSON before saving.'; return }
|
||||
|
||||
const def: ServiceDefinition = { max_mb: maxMb.value, priority: priority.value }
|
||||
if (minCap.value) def.min_compute_cap = minCap.value
|
||||
if (prefCap.value !== '') def.preferred_compute_cap = Number(prefCap.value)
|
||||
if (shared.value) def.shared = true
|
||||
if (maxConcurrent.value !== '') def.max_concurrent = Number(maxConcurrent.value)
|
||||
if (idleStop.value !== '') def.idle_stop_after_s = Number(idleStop.value)
|
||||
if (alwaysOn.value) def.always_on = true
|
||||
if (modelBasePath.value.trim()) def.model_base_path = modelBasePath.value.trim()
|
||||
if (hasManaged.value && managedJson.value.trim()) {
|
||||
def.managed = JSON.parse(managedJson.value)
|
||||
}
|
||||
// Preserve existing catalog when editing
|
||||
if (props.definition?.catalog) def.catalog = props.definition.catalog
|
||||
|
||||
emit('save', name.value.trim(), def)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop" role="dialog" aria-modal="true" :aria-label="`${serviceName ? 'Edit' : 'Add'} service`">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">{{ serviceName ? 'Edit' : 'Add' }} Service</h3>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-name">Service name</label>
|
||||
<input id="sf-name" v-model="name" class="field-input" :readonly="!!serviceName" placeholder="cf-text" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-maxmb">max_mb</label>
|
||||
<input id="sf-maxmb" v-model.number="maxMb" type="number" min="0" class="field-input field-input--sm" />
|
||||
<span class="field-hint">VRAM ceiling</span>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-prio">priority</label>
|
||||
<input id="sf-prio" v-model.number="priority" type="number" min="1" max="10" class="field-input field-input--sm" />
|
||||
<span class="field-hint">1 = highest</span>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-mincap">min_compute_cap</label>
|
||||
<input id="sf-mincap" v-model.number="minCap" type="number" step="0.1" min="0" class="field-input field-input--sm" placeholder="0.0" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-prefcap">preferred_cap</label>
|
||||
<input id="sf-prefcap" v-model="prefCap" type="number" step="0.1" min="0" class="field-input field-input--sm" placeholder="optional" />
|
||||
</div>
|
||||
|
||||
<div class="field-row field-row--check">
|
||||
<input id="sf-shared" v-model="shared" type="checkbox" />
|
||||
<label for="sf-shared">shared (multiple concurrent users)</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-maxcon">max_concurrent</label>
|
||||
<input id="sf-maxcon" v-model="maxConcurrent" type="number" min="1" class="field-input field-input--sm" placeholder="optional" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-idle">idle_stop_after_s</label>
|
||||
<input id="sf-idle" v-model="idleStop" type="number" min="0" class="field-input field-input--sm" placeholder="optional" />
|
||||
<span class="field-hint">seconds</span>
|
||||
</div>
|
||||
|
||||
<div class="field-row field-row--check">
|
||||
<input id="sf-always" v-model="alwaysOn" type="checkbox" />
|
||||
<label for="sf-always">always_on (never evict)</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="sf-base">model_base_path</label>
|
||||
<input id="sf-base" v-model="modelBasePath" class="field-input" placeholder="/devl/Assets/LLM/cf-text/models (optional)" />
|
||||
</div>
|
||||
|
||||
<div class="managed-section">
|
||||
<div class="field-row field-row--check">
|
||||
<input id="sf-has-managed" v-model="hasManaged" type="checkbox" />
|
||||
<label for="sf-has-managed">Has managed process config</label>
|
||||
</div>
|
||||
<div v-if="hasManaged" class="managed-body">
|
||||
<label class="field-label" for="sf-managed">managed (JSON)</label>
|
||||
<textarea
|
||||
id="sf-managed"
|
||||
v-model="managedJson"
|
||||
class="field-textarea"
|
||||
rows="6"
|
||||
spellcheck="false"
|
||||
placeholder='{"type": "process", "exec_path": "...", "args_template": "...", "port": 8008, "host_port": 8008}'
|
||||
/>
|
||||
<span v-if="managedJsonError" class="json-error" role="alert">{{ managedJsonError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formError" class="form-error" role="alert">{{ formError }}</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="emit('cancel')">Cancel</button>
|
||||
<button class="btn-primary" @click="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
width: 100%; max-width: 540px;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 0.65rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.modal-title { margin: 0 0 0.25rem; font-size: 1rem; font-weight: 600; color: var(--color-text); }
|
||||
.field-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.field-row--check { gap: 0.4rem; font-size: 0.875rem; color: var(--color-text); }
|
||||
.field-label { min-width: 9rem; font-size: 0.85rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.field-hint { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.field-input {
|
||||
flex: 1;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field-input--sm { flex: 0 0 8rem; }
|
||||
.managed-section { display: flex; flex-direction: column; gap: 0.4rem; border-top: 1px solid var(--color-border); padding-top: 0.5rem; }
|
||||
.managed-body { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.json-error { color: var(--color-error); font-size: 0.78rem; }
|
||||
.form-error { color: var(--color-error); font-size: 0.8rem; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.25rem; }
|
||||
.btn-primary {
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary:hover { background: var(--app-primary-hover); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
</style>
|
||||
|
|
@ -25,3 +25,65 @@ export interface NodeSummary {
|
|||
profile_loaded: boolean
|
||||
services_catalog: Record<string, ServiceInfo>
|
||||
}
|
||||
|
||||
// ── Full profile types (for profile editor) ────────────────────────────────────
|
||||
|
||||
export interface ServiceManaged {
|
||||
type: string
|
||||
exec_path?: string
|
||||
args_template?: string
|
||||
port?: number
|
||||
host_port?: number
|
||||
base_port?: number
|
||||
health_path?: string
|
||||
cwd?: string
|
||||
adopt?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CatalogEntryFull {
|
||||
path: string
|
||||
vram_mb: number
|
||||
description?: string
|
||||
multi_gpu?: boolean
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ServiceDefinition {
|
||||
max_mb: number
|
||||
priority: number
|
||||
min_compute_cap?: number
|
||||
preferred_compute_cap?: number
|
||||
shared?: boolean
|
||||
max_concurrent?: number
|
||||
idle_stop_after_s?: number
|
||||
always_on?: boolean
|
||||
model_base_path?: string
|
||||
managed?: ServiceManaged
|
||||
catalog?: Record<string, CatalogEntryFull>
|
||||
}
|
||||
|
||||
export interface NodeHardwareGpu {
|
||||
id: number
|
||||
vram_mb: number
|
||||
compute_cap?: number
|
||||
card?: string
|
||||
role?: string
|
||||
services?: string[]
|
||||
}
|
||||
|
||||
export interface NodeHardwareEntry {
|
||||
local_model_root?: string
|
||||
agent_url?: string
|
||||
gpus: NodeHardwareGpu[]
|
||||
}
|
||||
|
||||
export interface FullProfile {
|
||||
schema_version?: number
|
||||
name?: string
|
||||
vram_total_mb?: number
|
||||
eviction_timeout_s?: number
|
||||
services: Record<string, ServiceDefinition>
|
||||
nodes: Record<string, NodeHardwareEntry>
|
||||
model_size_hints?: Record<string, string>
|
||||
}
|
||||
|
|
|
|||
987
web/src/views/AssignmentsTab.vue
Normal file
987
web/src/views/AssignmentsTab.vue
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
<template>
|
||||
<div class="assignments-tab">
|
||||
|
||||
<!-- ── Toast ───────────────────────────────────────────── -->
|
||||
<div v-if="toast" class="toast" :class="toast.type" role="status" aria-live="polite">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
|
||||
<!-- ── Assignments section ─────────────────────────────── -->
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Task Assignments</h2>
|
||||
<button class="btn-primary btn-sm" @click="openNewAssignment">+ New Assignment</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label for="product-filter" class="filter-label">Product</label>
|
||||
<select id="product-filter" v-model="productFilter" class="filter-select">
|
||||
<option value="">All products</option>
|
||||
<option v-for="p in allProducts" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="assignmentsLoading" class="empty-state">Loading assignments…</div>
|
||||
<div v-else-if="assignmentsError" class="error-notice" role="alert">{{ assignmentsError }}</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">No assignments yet. Add one above.</div>
|
||||
<div v-else class="product-groups">
|
||||
<div v-for="group in filteredGroups" :key="group.product" class="product-group">
|
||||
<h3 class="product-name">{{ group.product.toUpperCase() }}</h3>
|
||||
<div class="assignment-list">
|
||||
<div v-for="a in group.assignments" :key="`${a.product}/${a.task}`" class="assignment-row">
|
||||
<div class="assignment-main">
|
||||
<span class="task-id">{{ a.task }}</span>
|
||||
<span
|
||||
class="model-name"
|
||||
:title="a.model_id"
|
||||
>{{ displayModelId(a) }}</span>
|
||||
<span v-if="a.vram_mb" class="chip chip-vram">{{ formatVram(a.vram_mb) }}</span>
|
||||
<span v-if="a.service_type" class="chip" :class="serviceChipClass(a.service_type)">{{ a.service_type }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Node deployment status -->
|
||||
<div v-if="deploymentMap[`${a.product}/${a.task}`]" class="node-statuses">
|
||||
<span
|
||||
v-for="ns in deploymentMap[`${a.product}/${a.task}`]"
|
||||
:key="ns.node_id"
|
||||
class="node-badge-wrap"
|
||||
>
|
||||
<span
|
||||
class="node-badge"
|
||||
:class="ns.status"
|
||||
:title="`${ns.node_id}: ${ns.status}`"
|
||||
>
|
||||
<span class="node-icon">{{ nodeIcon(ns.status) }}</span>
|
||||
{{ ns.node_id }}
|
||||
</span>
|
||||
<button
|
||||
v-if="ns.status === 'absent'"
|
||||
class="btn-deploy"
|
||||
:disabled="deploying.has(`${a.product}/${a.task}/${ns.node_id}`)"
|
||||
:title="`Register ${a.model_id} in ${ns.node_id} catalog`"
|
||||
@click="deployModel(a, ns.node_id)"
|
||||
>{{ deploying.has(`${a.product}/${a.task}/${ns.node_id}`) ? '…' : 'Register' }}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="assignment-actions">
|
||||
<button
|
||||
v-if="editingKey !== `${a.product}/${a.task}`"
|
||||
class="btn-ghost btn-sm"
|
||||
@click="startEdit(a)"
|
||||
>Edit</button>
|
||||
<button
|
||||
class="btn-ghost btn-sm btn-danger"
|
||||
@click="deleteAssignment(a.product, a.task)"
|
||||
>Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline edit form -->
|
||||
<div v-if="editingKey === `${a.product}/${a.task}`" class="inline-edit">
|
||||
<select v-model="editDraft.model_id" class="edit-select" aria-label="Model">
|
||||
<option value="" disabled>Select model…</option>
|
||||
<option v-for="m in registryModels" :key="m.model_id" :value="m.model_id">
|
||||
{{ m.alias || truncate(m.model_id, 40) }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="editDraft.description"
|
||||
type="text"
|
||||
class="edit-input"
|
||||
placeholder="Description (optional)"
|
||||
/>
|
||||
<div class="inline-edit-btns">
|
||||
<button class="btn-primary btn-sm" :disabled="!editDraft.model_id" @click="saveEdit(a)">Save</button>
|
||||
<button class="btn-ghost btn-sm" @click="editingKey = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Model Registry section ───────────────────────────── -->
|
||||
<div class="section-header section-header-mt">
|
||||
<h2 class="section-title">Model Registry</h2>
|
||||
<button class="btn-primary btn-sm" @click="showRegisterModal = true">Register Model</button>
|
||||
</div>
|
||||
|
||||
<div v-if="registryLoading" class="empty-state">Loading model registry…</div>
|
||||
<div v-else-if="registryError" class="error-notice" role="alert">{{ registryError }}</div>
|
||||
<div v-else-if="registryModels.length === 0" class="empty-state">No models registered yet.</div>
|
||||
<div v-else class="registry-table-wrap">
|
||||
<table class="registry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Model ID</th>
|
||||
<th>VRAM</th>
|
||||
<th>Service</th>
|
||||
<th class="col-hf">HF Repo</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in registryModels" :key="m.model_id">
|
||||
<td>{{ m.alias || '—' }}</td>
|
||||
<td>
|
||||
<span class="truncated" :title="m.model_id">{{ truncate(m.model_id, 36) }}</span>
|
||||
</td>
|
||||
<td>{{ formatVram(m.vram_mb) }}</td>
|
||||
<td><span class="chip" :class="serviceChipClass(m.service_type)">{{ m.service_type }}</span></td>
|
||||
<td class="col-hf">
|
||||
<a
|
||||
v-if="m.hf_repo"
|
||||
:href="`https://huggingface.co/${m.hf_repo}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hf-link"
|
||||
>{{ truncate(m.hf_repo, 30) }}</a>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn-ghost btn-sm btn-danger" @click="deleteModel(m.model_id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── New Assignment modal ─────────────────────────────── -->
|
||||
<div v-if="showNewAssignmentModal" class="modal-backdrop" @click.self="showNewAssignmentModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-new-assignment-title">
|
||||
<h3 id="modal-new-assignment-title" class="modal-title">New Assignment</h3>
|
||||
<label class="form-label">Product</label>
|
||||
<input
|
||||
v-model="newAssignment.product"
|
||||
list="product-list"
|
||||
class="form-input"
|
||||
placeholder="e.g. peregrine"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<datalist id="product-list">
|
||||
<option v-for="p in allProducts" :key="p" :value="p" />
|
||||
</datalist>
|
||||
|
||||
<label class="form-label">Task ID</label>
|
||||
<input
|
||||
v-model="newAssignment.task"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="e.g. cover_letter"
|
||||
/>
|
||||
|
||||
<label class="form-label">Model</label>
|
||||
<select v-model="newAssignment.model_id" class="form-select">
|
||||
<option value="" disabled>Select from registry…</option>
|
||||
<option v-for="m in registryModels" :key="m.model_id" :value="m.model_id">
|
||||
{{ m.alias || truncate(m.model_id, 50) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label">Description <span class="optional">(optional)</span></label>
|
||||
<input
|
||||
v-model="newAssignment.description"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="Human-readable note for operators"
|
||||
/>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="!newAssignment.product || !newAssignment.task || !newAssignment.model_id || saving"
|
||||
@click="saveNewAssignment"
|
||||
>{{ saving ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-ghost" @click="showNewAssignmentModal = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Register Model modal ─────────────────────────────── -->
|
||||
<div v-if="showRegisterModal" class="modal-backdrop" @click.self="showRegisterModal = false">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-register-title">
|
||||
<h3 id="modal-register-title" class="modal-title">Register Model</h3>
|
||||
|
||||
<label class="form-label">Model ID <span class="hint">(HuggingFace slug, e.g. ibm-granite/granite-4.1-8b)</span></label>
|
||||
<input v-model="newModel.model_id" type="text" class="form-input" placeholder="org/model-name" />
|
||||
|
||||
<label class="form-label">Alias <span class="optional">(optional, short name for assignments)</span></label>
|
||||
<input v-model="newModel.alias" type="text" class="form-input" placeholder="e.g. granite-8b" />
|
||||
|
||||
<label class="form-label">Service type</label>
|
||||
<select v-model="newModel.service_type" class="form-select">
|
||||
<option value="" disabled>Select service…</option>
|
||||
<option value="cf-text">cf-text — Language Models</option>
|
||||
<option value="cf-stt">cf-stt — Speech Recognition</option>
|
||||
<option value="cf-tts">cf-tts — Text to Speech</option>
|
||||
<option value="cf-vision">cf-vision — Vision / VLM</option>
|
||||
<option value="cf-image">cf-image — Image Generation</option>
|
||||
<option value="cf-voice">cf-voice — Audio Classification</option>
|
||||
<option value="vllm">vllm — vLLM inference</option>
|
||||
<option value="ollama">ollama — Ollama inference</option>
|
||||
</select>
|
||||
|
||||
<label class="form-label">VRAM required (MB)</label>
|
||||
<input v-model.number="newModel.vram_mb" type="number" min="0" class="form-input" placeholder="e.g. 16384" />
|
||||
|
||||
<label class="form-label">HF Repo <span class="optional">(optional)</span></label>
|
||||
<input v-model="newModel.hf_repo" type="text" class="form-input" placeholder="org/repo-name" />
|
||||
|
||||
<label class="form-label">Description <span class="optional">(optional)</span></label>
|
||||
<input v-model="newModel.description" type="text" class="form-input" placeholder="Human-readable note" />
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="!newModel.model_id || !newModel.service_type || !newModel.vram_mb || saving"
|
||||
@click="saveNewModel"
|
||||
>{{ saving ? 'Saving…' : 'Register' }}</button>
|
||||
<button class="btn-ghost" @click="showRegisterModal = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
|
||||
interface AssignmentNode {
|
||||
node_id: string
|
||||
status: 'present' | 'absent' | 'vram_tight'
|
||||
}
|
||||
|
||||
interface DeployingKey {
|
||||
nodeId: string
|
||||
assignmentKey: string
|
||||
}
|
||||
|
||||
interface Assignment {
|
||||
product: string
|
||||
task: string
|
||||
model_id: string
|
||||
description: string
|
||||
alias?: string
|
||||
service_type?: string
|
||||
vram_mb?: number
|
||||
nodes?: AssignmentNode[]
|
||||
}
|
||||
|
||||
interface RegistryModel {
|
||||
model_id: string
|
||||
alias: string
|
||||
service_type: string
|
||||
vram_mb: number
|
||||
hf_repo: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface ProductGroup {
|
||||
product: string
|
||||
assignments: Assignment[]
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
|
||||
const assignments = ref<Assignment[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignmentsError = ref<string | null>(null)
|
||||
|
||||
const registryModels = ref<RegistryModel[]>([])
|
||||
const registryLoading = ref(false)
|
||||
const registryError = ref<string | null>(null)
|
||||
|
||||
const productFilter = ref('')
|
||||
const editingKey = ref<string | null>(null)
|
||||
const editDraft = ref({ model_id: '', description: '' })
|
||||
|
||||
const showNewAssignmentModal = ref(false)
|
||||
const newAssignment = ref({ product: '', task: '', model_id: '', description: '' })
|
||||
|
||||
const showRegisterModal = ref(false)
|
||||
const newModel = ref({ model_id: '', alias: '', service_type: '', vram_mb: 0, hf_repo: '', description: '' })
|
||||
|
||||
const saving = ref(false)
|
||||
const toast = ref<Toast | null>(null)
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const deploying = ref<Set<string>>(new Set())
|
||||
|
||||
// ── Derived ────────────────────────────────────────────
|
||||
|
||||
const allProducts = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const a of assignments.value) seen.add(a.product)
|
||||
return [...seen].sort()
|
||||
})
|
||||
|
||||
const deploymentMap = computed(() => {
|
||||
const map: Record<string, AssignmentNode[]> = {}
|
||||
for (const a of assignments.value) {
|
||||
if (a.nodes) map[`${a.product}/${a.task}`] = a.nodes
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const filteredGroups = computed((): ProductGroup[] => {
|
||||
const filtered = productFilter.value
|
||||
? assignments.value.filter(a => a.product === productFilter.value)
|
||||
: assignments.value
|
||||
|
||||
const byProduct: Record<string, Assignment[]> = {}
|
||||
for (const a of filtered) {
|
||||
if (!byProduct[a.product]) byProduct[a.product] = []
|
||||
byProduct[a.product].push(a)
|
||||
}
|
||||
return Object.keys(byProduct)
|
||||
.sort()
|
||||
.map(product => ({ product, assignments: byProduct[product] }))
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
||||
}
|
||||
|
||||
function displayModelId(a: Assignment): string {
|
||||
if (a.alias) return a.alias
|
||||
const id = a.model_id
|
||||
// Show only the model name part (after /) and truncate long slugs
|
||||
const short = id.includes('/') ? id.split('/').slice(1).join('/') : id
|
||||
return truncate(short, 36)
|
||||
}
|
||||
|
||||
function formatVram(mb: number | undefined): string {
|
||||
if (!mb) return ''
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb} MB`
|
||||
}
|
||||
|
||||
function serviceChipClass(service: string): string {
|
||||
return `chip-service-${service.replace(/[^a-z0-9]/g, '-')}`
|
||||
}
|
||||
|
||||
function nodeIcon(status: string): string {
|
||||
if (status === 'present') return '✓'
|
||||
if (status === 'vram_tight') return '~'
|
||||
return '✗'
|
||||
}
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toast.value = { message, type }
|
||||
toastTimer = setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
function openNewAssignment() {
|
||||
newAssignment.value = { product: '', task: '', model_id: '', description: '' }
|
||||
showNewAssignmentModal.value = true
|
||||
}
|
||||
|
||||
function startEdit(a: Assignment) {
|
||||
editingKey.value = `${a.product}/${a.task}`
|
||||
editDraft.value = { model_id: a.model_id, description: a.description }
|
||||
}
|
||||
|
||||
// ── API ────────────────────────────────────────────────
|
||||
|
||||
async function loadAssignments() {
|
||||
assignmentsLoading.value = true
|
||||
assignmentsError.value = null
|
||||
try {
|
||||
// Fetch both list and deployment status in parallel
|
||||
const [listRes, statusRes] = await Promise.all([
|
||||
fetch('/api/cforch/assignments'),
|
||||
fetch('/api/cforch/assignments/deployment-status'),
|
||||
])
|
||||
if (!listRes.ok) throw new Error(`HTTP ${listRes.status}`)
|
||||
const list: Assignment[] = (await listRes.json()).assignments ?? []
|
||||
|
||||
// Merge deployment status into assignments if available
|
||||
if (statusRes.ok) {
|
||||
const statusList: Assignment[] = (await statusRes.json()).deployment_status ?? []
|
||||
const statusMap: Record<string, AssignmentNode[]> = {}
|
||||
for (const s of statusList) {
|
||||
statusMap[`${s.product}/${s.task}`] = s.nodes ?? []
|
||||
}
|
||||
for (const a of list) {
|
||||
a.nodes = statusMap[`${a.product}/${a.task}`] ?? []
|
||||
// Enrich with service_type/vram_mb from status payload
|
||||
const s = statusList.find(x => x.product === a.product && x.task === a.task)
|
||||
if (s) {
|
||||
a.service_type = s.service_type
|
||||
a.vram_mb = s.vram_mb
|
||||
a.alias = s.alias
|
||||
}
|
||||
}
|
||||
}
|
||||
assignments.value = list
|
||||
} catch (e) {
|
||||
assignmentsError.value = `Could not load assignments: ${e}`
|
||||
} finally {
|
||||
assignmentsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
registryLoading.value = true
|
||||
registryError.value = null
|
||||
try {
|
||||
const res = await fetch('/api/cforch/model-registry')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
registryModels.value = (await res.json()).models ?? []
|
||||
} catch (e) {
|
||||
registryError.value = `Could not load model registry: ${e}`
|
||||
} finally {
|
||||
registryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNewAssignment() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/cforch/assignments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAssignment.value),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
showNewAssignmentModal.value = false
|
||||
showToast('Assignment saved')
|
||||
await loadAssignments()
|
||||
} catch (e) {
|
||||
showToast(`Save failed: ${e}`, 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(a: Assignment) {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/cforch/assignments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
product: a.product,
|
||||
task: a.task,
|
||||
model_id: editDraft.value.model_id,
|
||||
description: editDraft.value.description,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
editingKey.value = null
|
||||
showToast('Assignment updated')
|
||||
await loadAssignments()
|
||||
} catch (e) {
|
||||
showToast(`Update failed: ${e}`, 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAssignment(product: string, task: string) {
|
||||
if (!confirm(`Delete assignment ${product}.${task}?`)) return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/cforch/assignments/${encodeURIComponent(product)}/${encodeURIComponent(task)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
showToast('Assignment deleted')
|
||||
await loadAssignments()
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNewModel() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/cforch/model-registry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newModel.value),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
showRegisterModal.value = false
|
||||
showToast('Model registered')
|
||||
await loadRegistry()
|
||||
} catch (e) {
|
||||
showToast(`Register failed: ${e}`, 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModel(model_id: string) {
|
||||
if (!confirm(`Remove ${model_id} from the registry?`)) return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/cforch/model-registry/${encodeURIComponent(model_id)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
showToast('Model removed')
|
||||
await loadRegistry()
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function deployModel(a: Assignment, nodeId: string) {
|
||||
const key = `${a.product}/${a.task}/${nodeId}`
|
||||
if (deploying.value.has(key)) return
|
||||
|
||||
// Look up hf_repo from registry for cleaner path construction
|
||||
const regEntry = registryModels.value.find(m => m.model_id === a.model_id)
|
||||
const hf_repo = regEntry?.hf_repo ?? ''
|
||||
const service_type = a.service_type ?? regEntry?.service_type ?? ''
|
||||
const vram_mb = a.vram_mb ?? regEntry?.vram_mb ?? 0
|
||||
const description = regEntry?.alias ? `${regEntry.alias} (via assignments)` : ''
|
||||
|
||||
if (!service_type) {
|
||||
showToast(`No service type for model ${a.model_id}`, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
deploying.value = new Set([...deploying.value, key])
|
||||
try {
|
||||
const res = await fetch(`/api/nodes-mgmt/nodes/${encodeURIComponent(nodeId)}/models/deploy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: a.model_id, service_type, vram_mb, hf_repo, description }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
showToast(`Registered ${a.model_id} on ${nodeId} at ${data.path}`)
|
||||
|
||||
// Optimistic update: flip node to 'present' immediately so the Register button
|
||||
// disappears before the coordinator reload confirms. loadAssignments() reconciles
|
||||
// with real server state on the next round-trip.
|
||||
assignments.value = assignments.value.map(asgn => {
|
||||
if (asgn.product !== a.product || asgn.task !== a.task) return asgn
|
||||
return {
|
||||
...asgn,
|
||||
nodes: (asgn.nodes ?? []).map(ns =>
|
||||
ns.node_id === nodeId ? { ...ns, status: 'present' as const } : ns
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
await loadAssignments()
|
||||
} catch (e) {
|
||||
showToast(`Deploy failed: ${e}`, 'error')
|
||||
} finally {
|
||||
deploying.value = new Set([...deploying.value].filter(k => k !== key))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAssignments()
|
||||
loadRegistry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assignments-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
z-index: 200;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.toast.success {
|
||||
background: var(--color-success, #2a8050);
|
||||
color: #fff;
|
||||
}
|
||||
.toast.error {
|
||||
background: var(--color-danger, #b03030);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Section headers ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.section-header-mt {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--app-primary, #2A6080);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Filter row ── */
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
}
|
||||
.filter-select {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2030);
|
||||
}
|
||||
|
||||
/* ── Product groups ── */
|
||||
.product-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.product-group {}
|
||||
.product-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
.assignment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Assignment rows ── */
|
||||
.assignment-row {
|
||||
background: var(--color-surface-raised, #f0f4fa);
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.assignment-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.task-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1a2030);
|
||||
min-width: 0;
|
||||
}
|
||||
.model-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
cursor: default;
|
||||
}
|
||||
.assignment-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Node status badges ── */
|
||||
.node-statuses {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.node-badge-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.node-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.node-badge.present {
|
||||
background: color-mix(in srgb, var(--color-success, #2a8050) 15%, transparent);
|
||||
color: var(--color-success, #2a8050);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success, #2a8050) 30%, transparent);
|
||||
}
|
||||
.node-badge.absent {
|
||||
background: color-mix(in srgb, var(--color-danger, #b03030) 12%, transparent);
|
||||
color: var(--color-danger, #b03030);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger, #b03030) 25%, transparent);
|
||||
}
|
||||
.node-badge.vram_tight {
|
||||
background: color-mix(in srgb, #c08030 15%, transparent);
|
||||
color: #8a5500;
|
||||
border: 1px solid color-mix(in srgb, #c08030 30%, transparent);
|
||||
}
|
||||
.node-icon {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.btn-deploy {
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--app-primary, #2A6080) 12%, transparent);
|
||||
color: var(--app-primary, #2A6080);
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary, #2A6080) 30%, transparent);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-deploy:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--app-primary, #2A6080) 22%, transparent);
|
||||
}
|
||||
.btn-deploy:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* ── Inline edit ── */
|
||||
.inline-edit {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid var(--color-border, #d0d7e8);
|
||||
}
|
||||
.edit-select,
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2030);
|
||||
}
|
||||
.inline-edit-btns {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Registry table ── */
|
||||
.registry-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
}
|
||||
.registry-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.registry-table th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
background: var(--color-surface-raised, #f0f4fa);
|
||||
border-bottom: 1px solid var(--color-border, #d0d7e8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.registry-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border, #d0d7e8);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.registry-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.truncated {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
cursor: default;
|
||||
}
|
||||
.hf-link {
|
||||
color: var(--app-primary, #2A6080);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.hf-link:hover { text-decoration: underline; }
|
||||
.text-muted { color: var(--color-text-muted, #6b7a99); }
|
||||
|
||||
/* ── Chips ── */
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-vram {
|
||||
background: color-mix(in srgb, var(--app-primary, #2A6080) 12%, transparent);
|
||||
color: var(--app-primary, #2A6080);
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary, #2A6080) 25%, transparent);
|
||||
}
|
||||
/* service chips — match ModelsView convention */
|
||||
.chip-service-cf-text { background: #e8f0fe; color: #1a5276; border: 1px solid #a9c4e8; }
|
||||
.chip-service-cf-stt { background: #eaf6ea; color: #1e6b3a; border: 1px solid #a2d9b1; }
|
||||
.chip-service-cf-tts { background: #fdf3e3; color: #7d4e00; border: 1px solid #e8c98a; }
|
||||
.chip-service-cf-vision { background: #f3e8fd; color: #5b2d8e; border: 1px solid #c8a0e8; }
|
||||
.chip-service-cf-image { background: #fce8f0; color: #8e1a4f; border: 1px solid #e8a0c0; }
|
||||
.chip-service-cf-voice { background: #e8f8fc; color: #0a5c6e; border: 1px solid #88d0e0; }
|
||||
.chip-service-vllm { background: #f5ece0; color: #7a3800; border: 1px solid #d4a87a; }
|
||||
.chip-service-ollama { background: #eeeeee; color: #444; border: 1px solid #ccc; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--app-primary, #2A6080);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-primary:not(:disabled):hover { opacity: 0.88; }
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--color-surface-raised, #e4ebf5); }
|
||||
.btn-ghost.btn-danger { color: var(--color-danger, #b03030); border-color: color-mix(in srgb, var(--color-danger, #b03030) 30%, transparent); }
|
||||
.btn-ghost.btn-danger:hover { background: color-mix(in srgb, var(--color-danger, #b03030) 10%, transparent); }
|
||||
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
|
||||
/* ── Empty / error states ── */
|
||||
.empty-state {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
font-size: 0.9rem;
|
||||
background: var(--color-surface-raised, #f0f4fa);
|
||||
border: 1px dashed var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.error-notice {
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in srgb, var(--color-danger, #b03030) 10%, transparent);
|
||||
color: var(--color-danger, #b03030);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger, #b03030) 25%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.87rem;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 0.65rem;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary, #2A6080);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
}
|
||||
.form-input,
|
||||
.form-select {
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2030);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-input:focus, .form-select:focus {
|
||||
outline: 2px solid var(--app-primary, #2A6080);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.optional, .hint {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted, #6b7a99);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.assignment-main { flex-direction: column; align-items: flex-start; }
|
||||
.col-hf { display: none; }
|
||||
.model-name { max-width: 100%; }
|
||||
.modal { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,6 +2,24 @@
|
|||
<div class="models-view">
|
||||
<h1 class="page-title">🤗 Models</h1>
|
||||
|
||||
<!-- ── Fleet tab bar ─────────────────────────────── -->
|
||||
<div class="mode-toggle" role="group" aria-label="Fleet view">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: fleetTab === 'models' }"
|
||||
@click="fleetTab = 'models'"
|
||||
>Models</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: fleetTab === 'assignments' }"
|
||||
@click="fleetTab = 'assignments'"
|
||||
>Assignments</button>
|
||||
</div>
|
||||
|
||||
<AssignmentsTab v-if="fleetTab === 'assignments'" />
|
||||
|
||||
<template v-if="fleetTab === 'models'">
|
||||
|
||||
<!-- ── 1. HF Lookup ───────────────────────────────── -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">HuggingFace Lookup</h2>
|
||||
|
|
@ -297,11 +315,17 @@
|
|||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
</template><!-- end fleetTab === 'models' -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import AssignmentsTab from './AssignmentsTab.vue'
|
||||
|
||||
type FleetTab = 'models' | 'assignments'
|
||||
const fleetTab = ref<FleetTab>('models')
|
||||
|
||||
// ── Type definitions ──────────────────────────────────
|
||||
|
||||
|
|
@ -738,6 +762,39 @@ onUnmounted(() => {
|
|||
color: var(--color-primary, #2d5a27);
|
||||
}
|
||||
|
||||
/* ── Fleet tab bar (mode-toggle pattern from BenchmarkView) ── */
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.mode-btn {
|
||||
padding: 0.4rem 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.mode-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--color-border, #d0d7e8);
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: var(--app-primary, #2A6080);
|
||||
color: #fff;
|
||||
}
|
||||
.mode-btn:not(.active):hover {
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.mode-btn { padding: 0.4rem 0.65rem; font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import NodeCard from '../components/nodes/NodeCard.vue'
|
||||
import AssignmentsTab from './AssignmentsTab.vue'
|
||||
import type { NodeSummary } from '../types/nodes'
|
||||
|
||||
type Tab = 'nodes' | 'assignments'
|
||||
|
||||
const activeTab = ref<Tab>('nodes')
|
||||
const nodes = ref<NodeSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
|
@ -25,12 +29,39 @@ onMounted(fetchNodes)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main class="nodes-page">
|
||||
<header class="nodes-header">
|
||||
<h1>Nodes</h1>
|
||||
<button class="btn-secondary" @click="fetchNodes" :disabled="loading">Refresh</button>
|
||||
<main class="fleet-page">
|
||||
<header class="fleet-header">
|
||||
<h1 class="fleet-title">Fleet</h1>
|
||||
</header>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<nav class="tab-bar" role="tablist" aria-label="Fleet sections">
|
||||
<button
|
||||
id="tab-nodes"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'nodes'"
|
||||
:class="['tab', { active: activeTab === 'nodes' }]"
|
||||
@click="activeTab = 'nodes'"
|
||||
>Nodes</button>
|
||||
<button
|
||||
id="tab-assignments"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'assignments'"
|
||||
:class="['tab', { active: activeTab === 'assignments' }]"
|
||||
@click="activeTab = 'assignments'"
|
||||
>Assignments</button>
|
||||
</nav>
|
||||
|
||||
<!-- Nodes tab -->
|
||||
<section
|
||||
v-if="activeTab === 'nodes'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-nodes"
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="nodes-toolbar">
|
||||
<button class="btn-secondary btn-sm" @click="fetchNodes" :disabled="loading">Refresh</button>
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-announce">
|
||||
<span v-if="loading">Loading nodes...</span>
|
||||
</div>
|
||||
|
|
@ -46,24 +77,89 @@ onMounted(fetchNodes)
|
|||
@updated="fetchNodes"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Assignments tab -->
|
||||
<section
|
||||
v-else-if="activeTab === 'assignments'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-assignments"
|
||||
class="tab-panel"
|
||||
>
|
||||
<AssignmentsTab />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nodes-page { padding: 1.5rem; }
|
||||
.nodes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
.fleet-page { padding: 1.5rem; }
|
||||
|
||||
.fleet-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.nodes-header h1 { margin: 0; font-size: 1.5rem; }
|
||||
.fleet-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Tab bar ── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--color-text); }
|
||||
.tab.active {
|
||||
color: var(--app-primary);
|
||||
border-bottom-color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* ── Tab panel ── */
|
||||
.tab-panel { min-height: 200px; }
|
||||
|
||||
/* ── Nodes toolbar ── */
|
||||
.nodes-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Nodes grid / status ── */
|
||||
.nodes-grid { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.nodes-status {
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.nodes-error { color: var(--color-error, #fc8181); }
|
||||
.nodes-error { color: var(--color-error); }
|
||||
.sr-announce { min-height: 1.2em; }
|
||||
|
||||
/* ── Shared button ── */
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-surface-raised); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue