From 79b9ccbd3d099ad2ecafea264eacc356373757dc Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 17 May 2026 11:23:47 -0700 Subject: [PATCH] feat(fleet): profile editor, assignments tab, node management polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/nodes.py | 180 +++- tests/test_nodes.py | 108 +- .../nodes/CatalogEntryFormModal.vue | 170 +++ web/src/components/nodes/GpuRow.vue | 14 +- web/src/components/nodes/HfNodeModelPanel.vue | 24 +- web/src/components/nodes/NodeCard.vue | 90 +- web/src/components/nodes/OllamaModelPanel.vue | 29 +- .../components/nodes/ProfileEditorPanel.vue | 597 +++++++++++ web/src/components/nodes/ServiceBadge.vue | 17 +- web/src/components/nodes/ServiceFormModal.vue | 231 ++++ web/src/types/nodes.ts | 62 ++ web/src/views/AssignmentsTab.vue | 987 ++++++++++++++++++ web/src/views/ModelsView.vue | 57 + web/src/views/NodeManagementView.vue | 152 ++- 14 files changed, 2630 insertions(+), 88 deletions(-) create mode 100644 web/src/components/nodes/CatalogEntryFormModal.vue create mode 100644 web/src/components/nodes/ProfileEditorPanel.vue create mode 100644 web/src/components/nodes/ServiceFormModal.vue create mode 100644 web/src/views/AssignmentsTab.vue diff --git a/app/nodes.py b/app/nodes.py index 51114ad..b7f9b98 100644 --- a/app/nodes.py +++ b/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} diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 0579f6d..3ecc0c1 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -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 diff --git a/web/src/components/nodes/CatalogEntryFormModal.vue b/web/src/components/nodes/CatalogEntryFormModal.vue new file mode 100644 index 0000000..aa0432d --- /dev/null +++ b/web/src/components/nodes/CatalogEntryFormModal.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/web/src/components/nodes/GpuRow.vue b/web/src/components/nodes/GpuRow.vue index 724248a..985fa4f 100644 --- a/web/src/components/nodes/GpuRow.vue +++ b/web/src/components/nodes/GpuRow.vue @@ -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; } diff --git a/web/src/components/nodes/HfNodeModelPanel.vue b/web/src/components/nodes/HfNodeModelPanel.vue index a39dee8..fad2696 100644 --- a/web/src/components/nodes/HfNodeModelPanel.vue +++ b/web/src/components/nodes/HfNodeModelPanel.vue @@ -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; } diff --git a/web/src/components/nodes/NodeCard.vue b/web/src/components/nodes/NodeCard.vue index 7d46fbf..0c6ee35 100644 --- a/web/src/components/nodes/NodeCard.vue +++ b/web/src/components/nodes/NodeCard.vue @@ -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(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') +} diff --git a/web/src/components/nodes/OllamaModelPanel.vue b/web/src/components/nodes/OllamaModelPanel.vue index d30b0e4..15321a4 100644 --- a/web/src/components/nodes/OllamaModelPanel.vue +++ b/web/src/components/nodes/OllamaModelPanel.vue @@ -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; } diff --git a/web/src/components/nodes/ProfileEditorPanel.vue b/web/src/components/nodes/ProfileEditorPanel.vue new file mode 100644 index 0000000..d4c9914 --- /dev/null +++ b/web/src/components/nodes/ProfileEditorPanel.vue @@ -0,0 +1,597 @@ + + + + + diff --git a/web/src/components/nodes/ServiceBadge.vue b/web/src/components/nodes/ServiceBadge.vue index d1f1f44..def5a46 100644 --- a/web/src/components/nodes/ServiceBadge.vue +++ b/web/src/components/nodes/ServiceBadge.vue @@ -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); } diff --git a/web/src/components/nodes/ServiceFormModal.vue b/web/src/components/nodes/ServiceFormModal.vue new file mode 100644 index 0000000..48eb3cb --- /dev/null +++ b/web/src/components/nodes/ServiceFormModal.vue @@ -0,0 +1,231 @@ + + +