diff --git a/.trivyignore b/.trivyignore index a197602f..e88b0d3e 100644 --- a/.trivyignore +++ b/.trivyignore @@ -20,6 +20,12 @@ CVE-2025-68121 exp:2026-11-30 # Added: 2026-02-23 CVE-2024-45337 exp:2026-11-30 +# CVE-2026-56854: golang.org/x/crypto/ssh - Authentication bypass due to unenforced source-address restrictions +# Present in pre-compiled helm, oras, and tofu binaries +# Not exploitable in our context (we don't run an SSH server via helm/oras/tofu) +# Added: 2026-09-04 +CVE-2026-56854 exp:2026-11-30 + # CVE-2026-33186: gRPC authorization bypass (google.golang.org/grpc < 1.79.3) # Affects: helm and tofu binaries in Docker image (grpc v1.76.0) # Status: Waiting for upstream helm/tofu releases with fixed grpc diff --git a/backend/models/benchmark.py b/backend/models/benchmark.py index 91a4c9e2..cbbef200 100644 --- a/backend/models/benchmark.py +++ b/backend/models/benchmark.py @@ -118,6 +118,11 @@ class BenchmarkRun(Base): proxy_deployment = relationship("ProxyDeployment", back_populates="runs") run_group = relationship("BenchmarkRunGroup", back_populates="runs") + @property + def cluster_name(self) -> str | None: + """Name of the associated Kubernetes cluster (via target).""" + return self.target.cluster.name if (self.target and self.target.cluster) else None + __table_args__ = ( Index("idx_benchmark_run_proxy_created", "proxy", "created_at"), Index("idx_benchmark_run_tool_proxy", "tool", "proxy"), @@ -183,6 +188,11 @@ class BenchmarkRunGroup(Base): runs = relationship("BenchmarkRun", back_populates="run_group", order_by="BenchmarkRun.id") target = relationship("BenchmarkTarget") + @property + def cluster_name(self) -> str | None: + """Name of the associated Kubernetes cluster (via target).""" + return self.target.cluster.name if (self.target and self.target.cluster) else None + __table_args__ = ( Index("idx_benchmark_run_group_scenario", "scenario_key"), Index("idx_benchmark_run_group_status_created", "status", "created_at"), @@ -330,6 +340,11 @@ class BenchmarkTarget(Base): proxy_deployments = relationship("ProxyDeployment", back_populates="target", cascade="all, delete-orphan") runs = relationship("BenchmarkRun", back_populates="target") + @property + def cluster_name(self) -> str | None: + """Name of the associated Kubernetes cluster.""" + return self.cluster.name if self.cluster else None + @property def proxy_count(self) -> int: """Number of proxy deployments for this target (used in list view).""" diff --git a/backend/openapi.json b/backend/openapi.json index 8ebd6722..2185f1b9 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "BNK-Forge API", - "version": "3.1.6" + "version": "4.0.0" }, "paths": { "/": { @@ -27009,6 +27009,22 @@ "title": "Status" } }, + { + "name": "cluster_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cluster Id" + } + }, { "name": "limit", "in": "query", @@ -34359,6 +34375,17 @@ ], "title": "Target Id" }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, "proxy_deployment_id": { "anyOf": [ { @@ -34767,6 +34794,17 @@ ], "title": "Target Id" }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, "proxy_deployment_id": { "anyOf": [ { @@ -35224,6 +35262,17 @@ "type": "integer", "title": "Cluster Id" }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, "llm_base_url": { "type": "string", "title": "Llm Base Url" @@ -35375,6 +35424,17 @@ "type": "integer", "title": "Cluster Id" }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, "llm_base_url": { "type": "string", "title": "Llm Base Url" @@ -56610,6 +56670,17 @@ ], "title": "Target Id" }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, "proxy": { "anyOf": [ { diff --git a/backend/requirements.txt b/backend/requirements.txt index a9e21447..691c3443 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -49,6 +49,7 @@ kr8s==0.20.15 # HTTP httpx==0.28.1 requests==2.33.0 +urllib3==2.7.0 # Templating (DPU bf.conf renderer — Phase 4) # 3.1.6 fixes CVE-2025-27516 (sandbox bypass via |attr filter). diff --git a/backend/routes/benchmarks.py b/backend/routes/benchmarks.py index 167ccd2e..2220bcd2 100644 --- a/backend/routes/benchmarks.py +++ b/backend/routes/benchmarks.py @@ -310,13 +310,22 @@ def list_benchmark_runs( tool: str | None = Query(None), model: str | None = Query(None), status: str | None = Query(None), + cluster_id: int | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: Session = Depends(get_db), ): """List benchmark runs with optional filters.""" svc = BenchmarkService(db) - runs, total = svc.list_runs(proxy=proxy, tool=tool, model=model, status=status, limit=limit, offset=offset) + runs, total = svc.list_runs( + proxy=proxy, + tool=tool, + model=model, + status=status, + cluster_id=cluster_id, + limit=limit, + offset=offset, + ) return {"runs": runs, "total": total, "limit": limit, "offset": offset} @@ -1107,7 +1116,7 @@ def trigger_benchmark_run( # 3. Build RunConfig — keys map directly to aiperf CLI flags # See: https://github.com/ai-dynamo/aiperf/blob/main/docs/cli-options.md - base_url = deploy.proxy_url or target.llm_base_url + base_url = deploy.external_url or deploy.proxy_url or target.llm_base_url config_json: dict = { "url": base_url, @@ -1272,7 +1281,7 @@ def run_benchmark_scenario( code="AGENT_NOT_CONNECTED", ) - base_url = deploy.proxy_url or target.llm_base_url + base_url = deploy.external_url or deploy.proxy_url or target.llm_base_url # 3. Expand scenario into a run-group + child runs group, runs = bench_svc.create_run_group_from_scenario( @@ -1470,6 +1479,10 @@ def _agent_ws_authorized(websocket: WebSocket, agent_id: int) -> int | None: # claim is mandatory, not merely honoured when present. token_agent_id = payload.get("agent_id") if token_agent_id is None: + # The built-in agent container connects using the bootstrap token minted + # before registration (carrying sub=forge-builtin-agent, role=agent, and no agent_id). + if payload.get("sub") == "forge-builtin-agent": + return None logger.warning( "Agent %d WS rejected: token carries no agent_id claim (agent auth required)", agent_id, @@ -1569,14 +1582,46 @@ async def agent_websocket(websocket: WebSocket, agent_id: int): _agent_ws_connections[agent_id] = websocket logger.info("Agent %d connected via WebSocket", agent_id) - # Mark agent as connected + # Mark agent as connected and check for pending runs to dispatch db = next(get_db()) try: svc = BenchmarkService(db) svc.update_agent_status(agent_id, "connected") db.commit() - except Exception: - pass + + pending_run = svc.get_first_pending_run_for_agent(agent_id) + if pending_run: + group_id = pending_run.run_group_id + if group_id: + # Route grouped runs through the SAME gated dispatcher the terminal + # WS handlers use (MAJOR-2). Its group-guarded atomic claim is the + # single serialization point, so a connect-drain racing a + # run_completed/run_failed handler can never leave two children of + # one group RUNNING — the loser's claim fails the NOT-EXISTS guard. + # It claims, sends, and reverts the claim on send failure. + await _dispatch_next_group_child(svc, agent_id, group_id) + # Reflect the group as RUNNING only once a child is actually running (NIT-D atomic flip). + if svc.mark_run_group_running_if_pending(group_id): + db.commit() + elif svc.claim_pending_run(pending_run.id): + # Standalone (group-less) run: no siblings, single-row atomic claim. + db.commit() + sent = await send_command_to_agent( + agent_id, {"type": "run", "run_id": pending_run.id, "config": pending_run.config_snapshot} + ) + if sent: + logger.info("Agent %d connect: dispatched pending run #%d", agent_id, pending_run.id) + if pending_run.run_group_id: + group = svc.get_run_group(pending_run.run_group_id) + if group and group.status == BenchmarkRunStatus.PENDING: + group.status = BenchmarkRunStatus.RUNNING + group.started_at = datetime.now(UTC) + db.commit() + else: + svc.release_claimed_run(pending_run.id) + db.commit() + except Exception as e: + logger.warning("Error checking pending runs on agent connect: %s", e) finally: db.close() @@ -1620,12 +1665,14 @@ async def agent_websocket(websocket: WebSocket, agent_id: int): if run_id and result_data and _agent_owns_run(svc, agent_id, int(run_id)): svc.complete_run_with_aiperf_result(int(run_id), result_data) logger.info("Run #%d completed by agent %d — result ingested", run_id, agent_id) + db.commit() # Gated dispatch: now that this child is done, send the next pending # child of its run-group (one aiperf at a time, strictly sequential). done = svc.get_run(int(run_id)) - if done.run_group_id: + if done and done.run_group_id: await _dispatch_next_group_child(svc, agent_id, done.run_group_id) - db.commit() + else: + db.commit() except Exception as e: logger.error("Error ingesting run_completed for run #%s: %s", run_id, e) db.rollback() @@ -1653,9 +1700,12 @@ async def agent_websocket(websocket: WebSocket, agent_id: int): # Roll up the parent run-group when a child fails. if run.run_group_id: svc.maybe_finalize_run_group(run.run_group_id) - # Gated dispatch: continue the sweep with the next child. + db.commit() + # Gated dispatch: continue the sweep with the next child. + if run.run_group_id: await _dispatch_next_group_child(svc, agent_id, run.run_group_id) - db.commit() + else: + db.commit() except Exception as e: logger.error("Error handling run_failed for run #%s: %s", run_id, e) db.rollback() @@ -1704,9 +1754,17 @@ async def _dispatch_next_group_child(svc: "BenchmarkService", agent_id: int, gro return nxt_id = nxt.id nxt_config = nxt.config_snapshot - if not svc.claim_pending_run(nxt_id): - # Lost the race — another handler already claimed and dispatched this child. + # Group-guarded atomic claim (MAJOR-2 / MAJOR-A): only claim if NO sibling of this group + # is already RUNNING. This is the single serialization point shared with the + # connect-drain (which routes through here for grouped runs), so two children + # of one group can never both be RUNNING — even if the two paths pick different + # sibling rows, the second claim fails the NOT-EXISTS guard and skips. + if not svc.claim_pending_run(nxt_id, group_id=group_id): + # Lost the race — a sibling is already claimed/RUNNING, or another handler + # already claimed and dispatched this child. return + # MINOR-B: Persist the claim BEFORE the awaited network send, freeing the connection/row lock. + svc.db.commit() sent = await send_command_to_agent( agent_id, {"type": "run", "run_id": nxt_id, "config": nxt_config} ) @@ -1715,6 +1773,7 @@ async def _dispatch_next_group_child(svc: "BenchmarkService", agent_id: int, gro else: # Undo the claim so the child can be re-dispatched later. svc.release_claimed_run(nxt_id) + svc.db.commit() logger.warning("Gated dispatch: send failed for run #%d, reverted to pending", nxt_id) diff --git a/backend/schemas/benchmarks.py b/backend/schemas/benchmarks.py index e5e65c99..c367145b 100644 --- a/backend/schemas/benchmarks.py +++ b/backend/schemas/benchmarks.py @@ -157,6 +157,7 @@ class BenchmarkRunResponse(BaseModel): config_id: int | None agent_id: int | None target_id: int | None + cluster_name: str | None = None proxy_deployment_id: int | None scenario_key: str | None = None status: str @@ -389,6 +390,7 @@ class BenchmarkTargetResponse(BaseModel): name: str description: str | None cluster_id: int + cluster_name: str | None = None llm_base_url: str llm_model: str llm_namespace: str @@ -549,6 +551,7 @@ class RunGroupSummary(BaseModel): run_label: str | None status: str target_id: int | None + cluster_name: str | None = None proxy: str | None model: str | None total_runs: int diff --git a/backend/services/benchmark_service.py b/backend/services/benchmark_service.py index 1452f849..a4a62ed7 100644 --- a/backend/services/benchmark_service.py +++ b/backend/services/benchmark_service.py @@ -11,7 +11,7 @@ from datetime import UTC, datetime, timedelta from sqlalchemy import desc, func -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import aliased, joinedload from core.errors import BadRequestError, ConflictError, NotFoundError from models.benchmark import ( @@ -495,11 +495,14 @@ def list_runs( tool: str | None = None, model: str | None = None, status: str | None = None, + cluster_id: int | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[BenchmarkRun], int]: """List benchmark runs with optional filters.""" - query = self.db.query(BenchmarkRun) + query = self.db.query(BenchmarkRun).options( + joinedload(BenchmarkRun.target).joinedload(BenchmarkTarget.cluster), + ) if proxy: query = query.filter(BenchmarkRun.proxy == proxy) if tool: @@ -508,6 +511,10 @@ def list_runs( query = query.filter(BenchmarkRun.model == model) if status: query = query.filter(BenchmarkRun.status == status) + if cluster_id: + query = query.join(BenchmarkTarget, BenchmarkRun.target_id == BenchmarkTarget.id).filter( + BenchmarkTarget.cluster_id == cluster_id + ) total = query.count() runs = query.order_by(desc(BenchmarkRun.created_at)).limit(limit).offset(offset).all() @@ -516,7 +523,9 @@ def list_runs( def get_run(self, run_id: int, with_details: bool = False) -> BenchmarkRun: """Get a benchmark run by ID.""" - query = self.db.query(BenchmarkRun) + query = self.db.query(BenchmarkRun).options( + joinedload(BenchmarkRun.target).joinedload(BenchmarkTarget.cluster), + ) if with_details: query = query.options( joinedload(BenchmarkRun.config), @@ -889,7 +898,29 @@ def get_next_pending_group_run(self, group_id: int) -> BenchmarkRun | None: .first() ) - def claim_pending_run(self, run_id: int) -> bool: + def get_first_pending_run_for_agent(self, agent_id: int) -> BenchmarkRun | None: + """Find the earliest pending run assigned to an agent that has no sibling currently running.""" + running = ( + self.db.query(BenchmarkRun) + .filter( + BenchmarkRun.agent_id == agent_id, + BenchmarkRun.status == BenchmarkRunStatus.RUNNING, + ) + .first() + ) + if running: + return None + return ( + self.db.query(BenchmarkRun) + .filter( + BenchmarkRun.agent_id == agent_id, + BenchmarkRun.status == BenchmarkRunStatus.PENDING, + ) + .order_by(BenchmarkRun.id) + .first() + ) + + def claim_pending_run(self, run_id: int, group_id: int | None = None) -> bool: """Atomically transition a run PENDING→RUNNING. Returns True iff this call won the claim (rowcount == 1). @@ -899,14 +930,39 @@ def claim_pending_run(self, run_id: int) -> bool: UPDATE (WHERE status='pending') means exactly one caller flips it to RUNNING and dispatches; the loser sees rowcount 0 and skips, so aiperf is invoked once. Caller commits the surrounding transaction. + ``group_id`` adds the group-sequential guard (MAJOR-2 / MAJOR-A): + Under PostgreSQL READ COMMITTED, evaluating NOT EXISTS without a lock can + suffer write-skew if concurrent transactions claim different sibling rows. + To guarantee mutual exclusion across transactions, we acquire an exclusive row + lock on the group (``with_for_update()``) before evaluating the conditional + UPDATE requiring that NO sibling of that group is currently RUNNING. + This serializes all sibling claims within a group so two children can never + both be claimed/RUNNING simultaneously. Standalone (group-less) runs omit + ``group_id`` and rely on the single-row atomic guard. """ now = datetime.now(UTC) + filters = [ + BenchmarkRun.id == run_id, + BenchmarkRun.status == BenchmarkRunStatus.PENDING, + ] + if group_id is not None: + # Lock the group row to serialize sibling claims across concurrent transactions + # under PostgreSQL READ COMMITTED (MAJOR-A / INV-8). + # SQLite (test env) ignores with_for_update() and serializes via its database write lock. + self.db.query(BenchmarkRunGroup).filter(BenchmarkRunGroup.id == group_id).with_for_update().first() + sibling = aliased(BenchmarkRun) + running_sibling = ( + self.db.query(sibling.id) + .filter( + sibling.run_group_id == group_id, + sibling.status == BenchmarkRunStatus.RUNNING, + ) + .exists() + ) + filters.append(~running_sibling) result = ( self.db.query(BenchmarkRun) - .filter( - BenchmarkRun.id == run_id, - BenchmarkRun.status == BenchmarkRunStatus.PENDING, - ) + .filter(*filters) .update( { BenchmarkRun.status: BenchmarkRunStatus.RUNNING, @@ -938,6 +994,31 @@ def release_claimed_run(self, run_id: int) -> None: synchronize_session=False, ) + def mark_run_group_running_if_pending(self, group_id: int) -> bool: + """Atomically transition a run-group PENDING→RUNNING if it has a running child. + + Returns True iff this call transitioned the row (rowcount == 1). + """ + if not self.find_running_group_child(group_id): + return False + now = datetime.now(UTC) + result = ( + self.db.query(BenchmarkRunGroup) + .filter( + BenchmarkRunGroup.id == group_id, + BenchmarkRunGroup.status == BenchmarkRunStatus.PENDING, + ) + .update( + { + BenchmarkRunGroup.status: BenchmarkRunStatus.RUNNING, + BenchmarkRunGroup.started_at: now, + BenchmarkRunGroup.updated_at: now, + }, + synchronize_session=False, + ) + ) + return result == 1 + def maybe_finalize_run_group(self, group_id: int) -> BenchmarkRunGroup | None: """Recompute group counts; roll up aggregate metrics when all children terminal. diff --git a/backend/services/benchmark_target_service.py b/backend/services/benchmark_target_service.py index 85c9478e..059b21df 100644 --- a/backend/services/benchmark_target_service.py +++ b/backend/services/benchmark_target_service.py @@ -103,6 +103,7 @@ def list_targets( ) -> tuple[list[BenchmarkTarget], int]: """List all benchmark targets with optional filters.""" query = self.db.query(BenchmarkTarget).options( + joinedload(BenchmarkTarget.cluster), joinedload(BenchmarkTarget.proxy_deployments), ) if status: @@ -118,7 +119,9 @@ def list_targets( def get_target(self, target_id: int, with_details: bool = False) -> BenchmarkTarget: """Get a benchmark target by ID.""" - query = self.db.query(BenchmarkTarget) + query = self.db.query(BenchmarkTarget).options( + joinedload(BenchmarkTarget.cluster), + ) if with_details: query = query.options( joinedload(BenchmarkTarget.proxy_deployments), @@ -242,6 +245,41 @@ def validate_target(self, target_id: int) -> BenchmarkTarget: except (TimeoutError, OSError) as e: http_msg = f"Unreachable — TCP connect to {host}:{port} failed: {e}" + # --- Layer 3: Kubernetes Service / Pod check fallback --- + if not http_ok and target.cluster_id: + try: + from kubernetes import client as k8s_client + + from services.kubernetes import KubernetesService + from services.proxy_discovery_service import _extract_svc_name + + svc_name = _extract_svc_name(target.llm_base_url) + svc_ns = target.llm_namespace or "default" + + k8s = KubernetesService(self.db) + api_client = k8s.load_kubeconfig(target.cluster) + core = k8s_client.CoreV1Api(api_client) + + svc = core.read_namespaced_service(name=svc_name, namespace=svc_ns, _request_timeout=10) + selector = svc.spec.selector + if selector: + label_selector = ",".join(f"{k}={v}" for k, v in selector.items()) + pods = core.list_namespaced_pod(svc_ns, label_selector=label_selector, _request_timeout=10) + ready_pods = [ + p for p in (pods.items or []) + if any(c.type == "Ready" and c.status == "True" for c in (p.status.conditions or [])) + ] + if ready_pods: + http_ok = True + http_msg = f"K8s Service '{svc_name}.{svc_ns}' healthy ({len(ready_pods)} ready pod(s))" + else: + http_msg = f"K8s Service '{svc_name}.{svc_ns}' found but 0 ready pods" + else: + http_ok = True + http_msg = f"K8s Service '{svc_name}.{svc_ns}' found" + except Exception as k8s_err: + logger.warning("K8s validation fallback failed for target %d: %s", target_id, k8s_err) + if http_ok: target.status = BenchmarkTargetStatus.ACTIVE target.validation_msg = f"Validated: {http_msg}" diff --git a/backend/services/proxy_deploy_service.py b/backend/services/proxy_deploy_service.py index 8bf075ff..653f72e7 100644 --- a/backend/services/proxy_deploy_service.py +++ b/backend/services/proxy_deploy_service.py @@ -23,6 +23,8 @@ from urllib.parse import urlparse import yaml +from kubernetes import client as k8s_client +from kubernetes.client.rest import ApiException from sqlalchemy.orm import Session from core.errors import BadRequestError, NotFoundError, ReleaseNotFoundError @@ -31,6 +33,8 @@ from services.cluster_utils import kubeconfig_for_cluster from services.entity_lock import EntityLock, set_locked_entity_fields from services.helm_service import HelmService +from services.kubernetes_service import KubernetesService +from services.proxy_discovery_service import _resolve_external_url from utils.security import validate_cli_arg logger = logging.getLogger(__name__) @@ -326,7 +330,9 @@ def deploy( ) else: proxy_url = f"http://{release}.{namespace}:{PROXY_LISTEN_PORT}" - external_url = None + external_url = self._resolve_service_external_url( + cluster, release, namespace, on_status, + ) self._write( deploy, @@ -440,6 +446,60 @@ def undeploy( self._emit(on_status, f"Uninstall FAILED: {exc}") raise + def _resolve_service_external_url( + self, + cluster: Any, + release: str, + namespace: str, + on_status: Any = None, + ) -> str | None: + """Resolve a routable external URL (NodePort or LoadBalancer) for a deployed proxy service.""" + try: + k8s_svc = KubernetesService(self.db) + api_client = k8s_svc.load_kubeconfig(cluster) + core_v1 = k8s_client.CoreV1Api(api_client) + + # Look up service by exact name or matching pattern in namespace + svc = None + try: + svc = core_v1.read_namespaced_service(name=release, namespace=namespace, _request_timeout=10) + except ApiException: + pass + + if not svc: + svcs = core_v1.list_namespaced_service(namespace=namespace, _request_timeout=10) + for item in svcs.items: + item_name = item.metadata.name or "" + if release in item_name or item_name in release: + svc = item + break + + if not svc: + self._emit(on_status, f"Could not find K8s Service for release '{release}' to resolve external URL") + return None + + ports = svc.spec.ports or [] + if not ports: + return None + + # Prefer http/proxy/web named ports or standard ports 80/10080 or first with node_port + chosen_port = ports[0] + for p in ports: + p_name = (p.name or "").lower() + if p_name in ("http", "proxy", "web") or p.port in (80, 10080): + chosen_port = p + break + + ext_url = _resolve_external_url(core_v1, svc, chosen_port) + if ext_url: + self._emit(on_status, f"Resolved external URL: {ext_url}") + return ext_url + + except Exception as exc: + logger.warning("Failed to resolve external URL for service %s/%s: %s", namespace, release, exc) + self._emit(on_status, f"Warning: failed to resolve external URL: {exc}") + return None + # ------------------------------------------------------------------ # Envoy Gateway data-plane resources (Helm chart only ships controller) # ------------------------------------------------------------------ @@ -1186,6 +1246,12 @@ def _values_haproxy(self, deploy: ProxyDeployment, target: BenchmarkTarget) -> d return { "service": { "type": "NodePort", + "ports": { + "http": PROXY_LISTEN_PORT, + }, + }, + "containerPorts": { + "http": PROXY_LISTEN_PORT, }, "config": ( f"frontend llm_proxy\n" diff --git a/backend/services/target_discovery_service.py b/backend/services/target_discovery_service.py index f957875b..fa00a2d8 100644 --- a/backend/services/target_discovery_service.py +++ b/backend/services/target_discovery_service.py @@ -595,7 +595,7 @@ def _create_configs( if config_name in existing_names: continue - base_url = proxy.proxy_url or target.llm_base_url + base_url = proxy.external_url or proxy.proxy_url or target.llm_base_url # Config keys map 1:1 to aiperf CLI flags: # url → --url diff --git a/backend/tests/unit/test_benchmark_agent_auth.py b/backend/tests/unit/test_benchmark_agent_auth.py index e4a993b6..6f4c45d7 100644 --- a/backend/tests/unit/test_benchmark_agent_auth.py +++ b/backend/tests/unit/test_benchmark_agent_auth.py @@ -327,12 +327,23 @@ def test_no_agent_id_claim_is_rejected(self): from routes.benchmarks import _agent_ws_authorized from services.auth_service import create_access_token - token = create_access_token(data={"sub": "agent", "role": "admin"}) - ws = MagicMock() - ws.query_params = {"token": token} + token_viewer = create_access_token(data={"sub": "viewer-user", "role": "viewer"}) + token_admin = create_access_token(data={"sub": "admin-user", "role": "admin"}) + token_builtin = create_access_token(data={"sub": "forge-builtin-agent", "role": "agent"}) + + ws_viewer = MagicMock() + ws_viewer.query_params = {"token": token_viewer} + + ws_admin = MagicMock() + ws_admin.query_params = {"token": token_admin} + + ws_builtin = MagicMock() + ws_builtin.query_params = {"token": token_builtin} with patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", True): - assert _agent_ws_authorized(ws, 5) == 4401 + assert _agent_ws_authorized(ws_viewer, 5) == 4401 + assert _agent_ws_authorized(ws_admin, 5) == 4401 + assert _agent_ws_authorized(ws_builtin, 5) is None def test_matching_agent_id_claim_authorizes_through_helper(self): """The bound case still connects — the gate must not be a blanket deny.""" diff --git a/backend/tests/unit/test_benchmark_cluster_info.py b/backend/tests/unit/test_benchmark_cluster_info.py new file mode 100644 index 00000000..6d12838e --- /dev/null +++ b/backend/tests/unit/test_benchmark_cluster_info.py @@ -0,0 +1,93 @@ +""" +Unit tests for benchmark cluster_name serialization and cluster_id filtering. +""" +from datetime import UTC, datetime, timezone +from unittest.mock import MagicMock + +from models.benchmark import BenchmarkRun, BenchmarkRunGroup, BenchmarkTarget +from schemas.benchmarks import BenchmarkRunResponse, BenchmarkTargetResponse, RunGroupSummary + + +class TestBenchmarkClusterInfo: + def test_target_cluster_name_property_and_serialization(self): + mock_cluster = MagicMock() + mock_cluster.name = "bnk-singapore" + + now = datetime.now(UTC) + target = BenchmarkTarget( + id=1, + name="vllm-awsbnkctl", + description="Test target", + cluster_id=42, + llm_base_url="http://vllm.default:8000", + llm_model="meta-llama/Llama-3-8b", + llm_namespace="default", + llm_endpoint="/v1/chat/completions", + proxy_namespace="perf-proxies", + status="active", + ) + target.cluster = mock_cluster + target.proxy_deployments = [] + target.created_at = now + target.updated_at = now + target.last_validated = None + target.validation_msg = None + target.tags = None + + assert target.cluster_name == "bnk-singapore" + + # Test Pydantic schema serialization + response = BenchmarkTargetResponse.model_validate(target) + assert response.cluster_id == 42 + assert response.cluster_name == "bnk-singapore" + + def test_target_without_cluster_returns_none(self): + target = BenchmarkTarget( + id=2, + name="orphan-target", + cluster_id=99, + llm_base_url="http://vllm.default:8000", + llm_model="test-model", + llm_namespace="default", + llm_endpoint="/v1/chat/completions", + proxy_namespace="perf-proxies", + status="active", + ) + target.cluster = None + assert target.cluster_name is None + + def test_run_cluster_name_property(self): + mock_cluster = MagicMock() + mock_cluster.name = "bnk-tokyo" + + mock_target = MagicMock() + mock_target.cluster = mock_cluster + + run = BenchmarkRun( + id=10, + tool="aiperf", + proxy="haproxy", + model="llama-3", + base_url="http://10.10.1.229:30267", + status="completed", + ) + run.target = mock_target + + assert run.cluster_name == "bnk-tokyo" + + def test_run_group_cluster_name_property(self): + mock_cluster = MagicMock() + mock_cluster.name = "bnk-us-east" + + mock_target = MagicMock() + mock_target.cluster = mock_cluster + + group = BenchmarkRunGroup( + id=5, + scenario_key="prefix-cache", + scenario_name="Prefix Cache", + status="completed", + ) + group.target = mock_target + + assert group.cluster_name == "bnk-us-east" diff --git a/backend/tests/unit/test_benchmark_service_run_groups.py b/backend/tests/unit/test_benchmark_service_run_groups.py index 40548c9e..95c2e9d6 100644 --- a/backend/tests/unit/test_benchmark_service_run_groups.py +++ b/backend/tests/unit/test_benchmark_service_run_groups.py @@ -11,7 +11,7 @@ exercise the race-closing behavior the fixes depend on. """ -from models.benchmark import BenchmarkRun, BenchmarkRunGroup +from models.benchmark import BenchmarkAgent, BenchmarkRun, BenchmarkRunGroup from models.enums import BenchmarkRunStatus from services.benchmark_service import BenchmarkService @@ -19,6 +19,18 @@ # Helpers # --------------------------------------------------------------------------- +def _agent(db, name="test-agent"): + agent = BenchmarkAgent( + name=name, + status="connected", + managed=False, + ) + db.add(agent) + db.commit() + db.refresh(agent) + return agent + + def _group(db, **overrides): group = BenchmarkRunGroup( scenario_key=overrides.get("scenario_key", "baseline"), @@ -271,3 +283,295 @@ def test_release_claimedRun_revertsToPending(self, db): db.refresh(child) assert child.status == BenchmarkRunStatus.PENDING assert child.started_at is None + + +class TestGetFirstPendingRunForAgent: + def test_returns_none_if_no_pending_runs(self, db): + agent = _agent(db, name="agent-no-runs") + svc = BenchmarkService(db) + assert svc.get_first_pending_run_for_agent(agent.id) is None + + def test_returns_first_pending_run(self, db): + agent = _agent(db, name="agent-first-pending") + group = _group(db, status="running", total_runs=2) + child1 = _child(db, group.id, "pending", agent_id=agent.id, variant_label="c1") + _child(db, group.id, "pending", agent_id=agent.id, variant_label="c2") + + svc = BenchmarkService(db) + found = svc.get_first_pending_run_for_agent(agent.id) + assert found is not None + assert found.id == child1.id + + def test_returns_none_if_another_run_already_running_for_agent(self, db): + agent = _agent(db, name="agent-already-running") + group = _group(db, status="running", total_runs=2) + _child(db, group.id, "running", agent_id=agent.id, variant_label="c1") + _child(db, group.id, "pending", agent_id=agent.id, variant_label="c2") + + svc = BenchmarkService(db) + assert svc.get_first_pending_run_for_agent(agent.id) is None + + +# --------------------------------------------------------------------------- +# MAJOR-2 — group-sequential guard on the atomic claim. +# +# claim_pending_run(run_id, group_id=...) must refuse to claim a child while ANY +# sibling of that group is already RUNNING, so two children of one group can +# never both be RUNNING even when the connect-drain and _dispatch_next_group_child +# pick different sibling rows. +# --------------------------------------------------------------------------- + +class TestGroupGuardedClaim: + def test_claim_withGroupGuard_failsWhenSiblingRunning(self, db): + group = _group(db, status="running", total_runs=2) + _child(db, group.id, "running", variant_label="s0") + s1 = _child(db, group.id, "pending", variant_label="s1") + + svc = BenchmarkService(db) + won = svc.claim_pending_run(s1.id, group_id=group.id) + db.commit() + assert won is False + db.refresh(s1) + assert s1.status == BenchmarkRunStatus.PENDING # untouched + + def test_claim_withGroupGuard_succeedsWhenNoSiblingRunning(self, db): + group = _group(db, status="running", total_runs=2) + s1 = _child(db, group.id, "pending", variant_label="s1") + + svc = BenchmarkService(db) + assert svc.claim_pending_run(s1.id, group_id=group.id) is True + db.commit() + db.refresh(s1) + assert s1.status == BenchmarkRunStatus.RUNNING + + def test_claim_twoSiblings_cannotBothBeRunning(self, db): + # The MAJOR-2 invariant, proved at the state level: once one sibling wins + # the claim, a claim of the OTHER sibling (a different row — as the drain + # vs _dispatch_next_group_child race would pick) fails the NOT-EXISTS guard. + group = _group(db, status="running", total_runs=2) + s1 = _child(db, group.id, "pending", variant_label="s1") + s2 = _child(db, group.id, "pending", variant_label="s2") + + svc = BenchmarkService(db) + first = svc.claim_pending_run(s1.id, group_id=group.id) + second = svc.claim_pending_run(s2.id, group_id=group.id) + db.commit() + + assert first is True + assert second is False + db.refresh(s1) + db.refresh(s2) + running = [c for c in (s1, s2) if c.status == BenchmarkRunStatus.RUNNING] + assert len(running) == 1 # never two siblings RUNNING at once + + def test_claim_groupGuard_onlyGuardsSameGroup(self, db): + # A RUNNING child in group A must not block claiming a child of group B. + group_a = _group(db, status="running", total_runs=1) + _child(db, group_a.id, "running", variant_label="a0") + group_b = _group(db, status="running", total_runs=1) + b0 = _child(db, group_b.id, "pending", variant_label="b0") + + svc = BenchmarkService(db) + assert svc.claim_pending_run(b0.id, group_id=group_b.id) is True + db.commit() + db.refresh(b0) + assert b0.status == BenchmarkRunStatus.RUNNING + + +# --------------------------------------------------------------------------- +# MAJOR-1 — the initial POST dispatch claims the first child ATOMICALLY (and +# persists the claim) BEFORE the blocking dispatch round-trip, so a concurrent +# connect-drain cannot win a second claim of the same row and double-dispatch. +# --------------------------------------------------------------------------- + +class TestInitialDispatchVsDrainRace: + def test_initialClaim_blocksConcurrentDrainClaim_sameRow(self, db): + group = _group(db, status="pending", total_runs=1) + first = _child(db, group.id, "pending", variant_label="first") + + svc = BenchmarkService(db) + # Initial POST dispatch claims + persists BEFORE its send round-trip. + initial = svc.claim_pending_run(first.id, group_id=group.id) + db.commit() + # A WS (re)connect fires during the dispatch window and tries to claim the + # same PENDING row via the drain — it must lose (row already RUNNING). + drain = svc.claim_pending_run(first.id, group_id=group.id) + db.commit() + + assert initial is True + assert drain is False + db.refresh(first) + assert first.status == BenchmarkRunStatus.RUNNING # claimed exactly once + + def test_drainFindsNothing_afterInitialClaimPersisted(self, db): + # Once the initial dispatch has persisted its RUNNING claim, the drain's + # agent-wide precheck returns nothing — no second dispatch is attempted. + agent = _agent(db, name="agent-initial-vs-drain") + group = _group(db, status="pending", total_runs=2) + first = _child(db, group.id, "pending", agent_id=agent.id, variant_label="first") + _child(db, group.id, "pending", agent_id=agent.id, variant_label="second") + + svc = BenchmarkService(db) + assert svc.claim_pending_run(first.id, group_id=group.id) is True + db.commit() + assert svc.get_first_pending_run_for_agent(agent.id) is None + + +# --------------------------------------------------------------------------- +# MINOR-3 — the connect-drain's shared dispatch path (_dispatch_next_group_child). +# +# The drain routes grouped runs through the SAME gated dispatcher the terminal WS +# handlers use, so these cover the drain's actual claim→send→(group flip) path and +# its release_claimed_run rollback on send failure. send_command_to_agent is +# monkeypatched (no real WebSocket). +# --------------------------------------------------------------------------- + +class TestDispatchNextGroupChildDrainPath: + async def test_dispatch_claimsAndSends_thenGroupFlipsRunning(self, db, monkeypatch): + import routes.benchmarks as bench_routes + + sent = [] + + async def fake_send(agent_id, command): + sent.append((agent_id, command)) + return True + + monkeypatch.setattr(bench_routes, "send_command_to_agent", fake_send) + + group = _group(db, status="pending", total_runs=1) + child = _child( + db, group.id, "pending", variant_label="only", + config_snapshot={"concurrency": 7}, + ) + svc = BenchmarkService(db) + + await bench_routes._dispatch_next_group_child(svc, agent_id=42, group_id=group.id) + db.commit() + + # claim → send happened exactly once, carrying the run_id + config. + assert len(sent) == 1 + assert sent[0][0] == 42 + assert sent[0][1]["run_id"] == child.id + assert sent[0][1]["config"] == {"concurrency": 7} + db.refresh(child) + assert child.status == BenchmarkRunStatus.RUNNING + # The drain's group PENDING→RUNNING transition condition holds: a child is + # now running, so the drain would flip the group. + assert svc.find_running_group_child(group.id) is not None + + async def test_dispatch_revertsClaim_onSendFailure(self, db, monkeypatch): + import routes.benchmarks as bench_routes + + async def fake_send(agent_id, command): + return False # WS send failed + + monkeypatch.setattr(bench_routes, "send_command_to_agent", fake_send) + + group = _group(db, status="pending", total_runs=1) + child = _child(db, group.id, "pending", variant_label="only") + svc = BenchmarkService(db) + + await bench_routes._dispatch_next_group_child(svc, agent_id=1, group_id=group.id) + db.commit() + + db.refresh(child) + # Winning claim was reverted so a later reconnect can re-dispatch it. + assert child.status == BenchmarkRunStatus.PENDING + assert child.started_at is None + # No running child → the drain leaves the group PENDING. + assert svc.find_running_group_child(group.id) is None + + async def test_dispatch_skipsWhenSiblingAlreadyRunning(self, db, monkeypatch): + # MAJOR-2 deterministic: the drain races a run just having been dispatched + # to a sibling. get_next_pending_group_run picks the pending child, but the + # group-guarded claim refuses because a sibling is RUNNING → no send, and + # the second sibling never starts. + import routes.benchmarks as bench_routes + + sent = [] + + async def fake_send(agent_id, command): + sent.append(command) + return True + + monkeypatch.setattr(bench_routes, "send_command_to_agent", fake_send) + + group = _group(db, status="running", total_runs=2) + _child(db, group.id, "running", variant_label="s0") + s1 = _child(db, group.id, "pending", variant_label="s1") + svc = BenchmarkService(db) + + await bench_routes._dispatch_next_group_child(svc, agent_id=9, group_id=group.id) + db.commit() + + assert sent == [] # nothing dispatched + db.refresh(s1) + assert s1.status == BenchmarkRunStatus.PENDING + # Still exactly one running child in the group. + running = [ + c for c in svc.get_run_group(group.id).runs + if c.status == BenchmarkRunStatus.RUNNING + ] + assert len(running) == 1 + + async def test_dispatch_noPendingChild_isNoop(self, db, monkeypatch): + import routes.benchmarks as bench_routes + + sent = [] + + async def fake_send(agent_id, command): + sent.append(command) + return True + + monkeypatch.setattr(bench_routes, "send_command_to_agent", fake_send) + + group = _group(db, status="completed", total_runs=1) + _child(db, group.id, "completed", variant_label="done", latency_p50=0.1) + svc = BenchmarkService(db) + + await bench_routes._dispatch_next_group_child(svc, agent_id=3, group_id=group.id) + assert sent == [] + + +# --------------------------------------------------------------------------- +# NIT-D — Atomic run-group PENDING→RUNNING transition on connect-drain +# --------------------------------------------------------------------------- + +class TestMarkRunGroupRunningIfPending: + def test_transitions_pendingGroup_to_running_when_child_running(self, db): + group = _group(db, status="pending", total_runs=2) + _child(db, group.id, "running", variant_label="r0") + _child(db, group.id, "pending", variant_label="r1") + svc = BenchmarkService(db) + + transitioned = svc.mark_run_group_running_if_pending(group.id) + db.commit() + + assert transitioned is True + db.refresh(group) + assert group.status == BenchmarkRunStatus.RUNNING + assert group.started_at is not None + + def test_noop_when_no_running_child(self, db): + group = _group(db, status="pending", total_runs=2) + _child(db, group.id, "pending", variant_label="p0") + svc = BenchmarkService(db) + + transitioned = svc.mark_run_group_running_if_pending(group.id) + db.commit() + + assert transitioned is False + db.refresh(group) + assert group.status == BenchmarkRunStatus.PENDING + + def test_noop_when_group_already_running(self, db): + group = _group(db, status="running", total_runs=2) + _child(db, group.id, "running", variant_label="r0") + svc = BenchmarkService(db) + + transitioned = svc.mark_run_group_running_if_pending(group.id) + db.commit() + + # Already RUNNING, rowcount == 0 so returns False + assert transitioned is False + diff --git a/backend/tests/unit/test_proxy_deploy_new_proxies.py b/backend/tests/unit/test_proxy_deploy_new_proxies.py index 6ef033a3..40e94003 100644 --- a/backend/tests/unit/test_proxy_deploy_new_proxies.py +++ b/backend/tests/unit/test_proxy_deploy_new_proxies.py @@ -659,6 +659,8 @@ def test_uses_upstream_service_tag(self): tags={"upstream_service": "vllm", "upstream_port": 80}, ) vals = svc._values_haproxy(MagicMock(), t) + assert vals["service"]["ports"]["http"] == PROXY_LISTEN_PORT + assert vals["containerPorts"]["http"] == PROXY_LISTEN_PORT config = vals["config"] assert "vllm.awsbnkctl-scn-aiinference.svc.cluster.local:80" in config diff --git a/backend/tests/unit/test_proxy_deploy_resolve_url.py b/backend/tests/unit/test_proxy_deploy_resolve_url.py new file mode 100644 index 00000000..58e29ac1 --- /dev/null +++ b/backend/tests/unit/test_proxy_deploy_resolve_url.py @@ -0,0 +1,147 @@ +""" +Unit tests for ``_resolve_service_external_url`` in ProxyDeployService. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from kubernetes.client.rest import ApiException + +from services.proxy_deploy_service import ProxyDeployService + + +def _svc(svc_type: str, ports: list | None = None) -> MagicMock: + svc = MagicMock() + svc.metadata.name = "perf-haproxy-test" + svc.metadata.namespace = "perf-proxies" + svc.spec.type = svc_type + svc.spec.ports = ports or [] + return svc + + +def _port(port: int = 80, node_port: int | None = None, name: str = "http") -> MagicMock: + p = MagicMock() + p.port = port + p.node_port = node_port + p.name = name + return p + + +def _node(internal_ip: str | None = None, external_ip: str | None = None) -> MagicMock: + node = MagicMock() + addrs = [] + if internal_ip: + a = MagicMock() + a.type = "InternalIP" + a.address = internal_ip + addrs.append(a) + if external_ip: + a = MagicMock() + a.type = "ExternalIP" + a.address = external_ip + addrs.append(a) + node.status.addresses = addrs + return node + + +class TestResolveServiceExternalUrl: + @patch("services.proxy_deploy_service.k8s_client.CoreV1Api") + @patch("services.proxy_deploy_service.KubernetesService") + def test_nodeport_with_internal_ip(self, mock_k8s_cls, mock_core_cls): + mock_k8s = MagicMock() + mock_k8s_cls.return_value = mock_k8s + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + svc_obj = _svc("NodePort", [_port(80, node_port=31235, name="http")]) + mock_core.read_namespaced_service.return_value = svc_obj + mock_core.list_node.return_value = MagicMock(items=[_node(internal_ip="10.0.1.45")]) + + service = ProxyDeployService(db=MagicMock()) + result = service._resolve_service_external_url( + cluster=MagicMock(), + release="perf-haproxy-test", + namespace="perf-proxies", + ) + assert result == "http://10.0.1.45:31235" + + @patch("services.proxy_deploy_service.k8s_client.CoreV1Api") + @patch("services.proxy_deploy_service.KubernetesService") + def test_nodeport_with_external_ip_fallback(self, mock_k8s_cls, mock_core_cls): + mock_k8s = MagicMock() + mock_k8s_cls.return_value = mock_k8s + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + svc_obj = _svc("NodePort", [_port(80, node_port=31235, name="http")]) + mock_core.read_namespaced_service.return_value = svc_obj + mock_core.list_node.return_value = MagicMock(items=[_node(external_ip="203.0.113.120")]) + + service = ProxyDeployService(db=MagicMock()) + result = service._resolve_service_external_url( + cluster=MagicMock(), + release="perf-haproxy-test", + namespace="perf-proxies", + ) + assert result == "http://203.0.113.120:31235" + + @patch("services.proxy_deploy_service.k8s_client.CoreV1Api") + @patch("services.proxy_deploy_service.KubernetesService") + def test_loadbalancer_with_hostname(self, mock_k8s_cls, mock_core_cls): + mock_k8s = MagicMock() + mock_k8s_cls.return_value = mock_k8s + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + svc_obj = _svc("LoadBalancer", [_port(8080, name="http")]) + ingress = MagicMock() + ingress.hostname = "a123.elb.us-east-1.amazonaws.com" + ingress.ip = None + svc_obj.status.load_balancer.ingress = [ingress] + mock_core.read_namespaced_service.return_value = svc_obj + + service = ProxyDeployService(db=MagicMock()) + result = service._resolve_service_external_url( + cluster=MagicMock(), + release="perf-haproxy-test", + namespace="perf-proxies", + ) + assert result == "http://a123.elb.us-east-1.amazonaws.com:8080" + + @patch("services.proxy_deploy_service.k8s_client.CoreV1Api") + @patch("services.proxy_deploy_service.KubernetesService") + def test_clusterip_returns_none(self, mock_k8s_cls, mock_core_cls): + mock_k8s = MagicMock() + mock_k8s_cls.return_value = mock_k8s + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + svc_obj = _svc("ClusterIP", [_port(80, name="http")]) + mock_core.read_namespaced_service.return_value = svc_obj + + service = ProxyDeployService(db=MagicMock()) + result = service._resolve_service_external_url( + cluster=MagicMock(), + release="perf-haproxy-test", + namespace="perf-proxies", + ) + assert result is None + + @patch("services.proxy_deploy_service.k8s_client.CoreV1Api") + @patch("services.proxy_deploy_service.KubernetesService") + def test_service_not_found_returns_none(self, mock_k8s_cls, mock_core_cls): + mock_k8s = MagicMock() + mock_k8s_cls.return_value = mock_k8s + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + mock_core.read_namespaced_service.side_effect = ApiException(status=404) + mock_core.list_namespaced_service.return_value = MagicMock(items=[]) + + service = ProxyDeployService(db=MagicMock()) + result = service._resolve_service_external_url( + cluster=MagicMock(), + release="perf-haproxy-test", + namespace="perf-proxies", + ) + assert result is None diff --git a/backend/tests/unit/test_validate_target.py b/backend/tests/unit/test_validate_target.py new file mode 100644 index 00000000..ff873477 --- /dev/null +++ b/backend/tests/unit/test_validate_target.py @@ -0,0 +1,143 @@ +""" +Unit tests for ``validate_target`` in BenchmarkTargetService. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from models.enums import BenchmarkTargetStatus +from services.benchmark_target_service import BenchmarkTargetService + + +def _target( + target_id: int = 1, + llm_base_url: str = "http://vllm.awsbnkctl-scn-aiinference:80", + cluster_id: int | None = 2, + llm_namespace: str = "awsbnkctl-scn-aiinference", +) -> MagicMock: + target = MagicMock() + target.id = target_id + target.name = "vllm-test" + target.llm_base_url = llm_base_url + target.cluster_id = cluster_id + target.llm_namespace = llm_namespace + target.cluster = MagicMock() + target.status = BenchmarkTargetStatus.VALIDATING + target.validation_msg = None + target.last_validated = None + target.updated_at = None + return target + + +class TestValidateTarget: + @patch("requests.get") + def test_http_probe_success(self, mock_get): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + db = MagicMock() + target = _target() + query_mock = db.query.return_value + query_mock.options.return_value = query_mock + query_mock.filter.return_value.first.return_value = target + + svc = BenchmarkTargetService(db) + result = svc.validate_target(1) + + assert result.status == BenchmarkTargetStatus.ACTIVE + assert "HTTP 200" in result.validation_msg + + @patch("socket.create_connection") + @patch("requests.get") + def test_tcp_probe_success(self, mock_get, mock_conn): + mock_get.side_effect = Exception("Connection refused") + mock_sock = MagicMock() + mock_conn.return_value = mock_sock + + db = MagicMock() + target = _target() + query_mock = db.query.return_value + query_mock.options.return_value = query_mock + query_mock.filter.return_value.first.return_value = target + + svc = BenchmarkTargetService(db) + result = svc.validate_target(1) + + assert result.status == BenchmarkTargetStatus.ACTIVE + assert "TCP connect OK" in result.validation_msg + + @patch("kubernetes.client.CoreV1Api") + @patch("services.kubernetes.KubernetesService.load_kubeconfig") + @patch("socket.create_connection") + @patch("requests.get") + def test_k8s_fallback_success_with_ready_pods( + self, mock_get, mock_conn, mock_load, mock_core_cls + ): + mock_get.side_effect = Exception("Connection refused") + mock_conn.side_effect = OSError("Name or service not known") + + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + # Mock Service + svc_mock = MagicMock() + svc_mock.metadata.name = "vllm" + svc_mock.metadata.namespace = "awsbnkctl-scn-aiinference" + svc_mock.spec.selector = {"app": "vllm"} + mock_core.read_namespaced_service.return_value = svc_mock + + # Mock Pod with Ready condition + pod = MagicMock() + pod.metadata.name = "vllm-pod-1" + cond = MagicMock() + cond.type = "Ready" + cond.status = "True" + pod.status.conditions = [cond] + mock_core.list_namespaced_pod.return_value = MagicMock(items=[pod]) + + db = MagicMock() + target = _target() + query_mock = db.query.return_value + query_mock.options.return_value = query_mock + query_mock.filter.return_value.first.return_value = target + + svc = BenchmarkTargetService(db) + result = svc.validate_target(1) + + assert result.status == BenchmarkTargetStatus.ACTIVE + assert "1 ready pod(s)" in result.validation_msg + + @patch("kubernetes.client.CoreV1Api") + @patch("services.kubernetes.KubernetesService.load_kubeconfig") + @patch("socket.create_connection") + @patch("requests.get") + def test_k8s_fallback_fails_with_zero_ready_pods( + self, mock_get, mock_conn, mock_load, mock_core_cls + ): + mock_get.side_effect = Exception("Connection refused") + mock_conn.side_effect = OSError("Name or service not known") + + mock_core = MagicMock() + mock_core_cls.return_value = mock_core + + svc_mock = MagicMock() + svc_mock.spec.selector = {"app": "vllm"} + mock_core.read_namespaced_service.return_value = svc_mock + + pod = MagicMock() + pod.status.conditions = [] + mock_core.list_namespaced_pod.return_value = MagicMock(items=[pod]) + + db = MagicMock() + target = _target() + query_mock = db.query.return_value + query_mock.options.return_value = query_mock + query_mock.filter.return_value.first.return_value = target + + svc = BenchmarkTargetService(db) + result = svc.validate_target(1) + + assert result.status == BenchmarkTargetStatus.ERROR + assert "0 ready pods" in result.validation_msg diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index fc4ce794..d8b4e051 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -552,6 +552,7 @@ All QKView endpoints accept `cluster_id` as a query parameter. | GET | `/api/benchmarks/agents` | viewer | — | `list[BenchmarkAgentResponse]` | List agents | | GET | `/api/benchmarks/agents/{id}` | viewer | — | `BenchmarkAgentResponse` | Get agent | | DELETE | `/api/benchmarks/agents/{id}` | public | — | 204 | Deregister agent | +| WS | `/ws/benchmarks/agents/{id}` | JWT / agent-token | — | WebSocket | Persistent bidirectional agent connection (heartbeat, run dispatch, auto-drain on connect) | ### Analysis diff --git a/frontend-v2/src/hooks/useBenchmarks.ts b/frontend-v2/src/hooks/useBenchmarks.ts index 9833a808..33e72009 100644 --- a/frontend-v2/src/hooks/useBenchmarks.ts +++ b/frontend-v2/src/hooks/useBenchmarks.ts @@ -86,6 +86,7 @@ export const useBenchmarkRuns = (params?: { tool?: string; model?: string; status?: string; + cluster_id?: number; limit?: number; offset?: number; pollingEnabled?: boolean; diff --git a/frontend-v2/src/lib/api/benchmarks.ts b/frontend-v2/src/lib/api/benchmarks.ts index e692ad70..49288749 100644 --- a/frontend-v2/src/lib/api/benchmarks.ts +++ b/frontend-v2/src/lib/api/benchmarks.ts @@ -64,7 +64,7 @@ export const benchmarksApi = { // ── Runs (load test results) ───────────────────────────────────────── - listRuns: (params?: { proxy?: string; tool?: string; model?: string; status?: string; limit?: number; offset?: number }) => + listRuns: (params?: { proxy?: string; tool?: string; model?: string; status?: string; cluster_id?: number; limit?: number; offset?: number }) => apiClient.get('/api/benchmarks/runs', { params }).then((res) => res.data), getRun: (runId: number) => diff --git a/frontend-v2/src/pages/BenchmarkOverviewTab.tsx b/frontend-v2/src/pages/BenchmarkOverviewTab.tsx index 859a406b..690b7520 100644 --- a/frontend-v2/src/pages/BenchmarkOverviewTab.tsx +++ b/frontend-v2/src/pages/BenchmarkOverviewTab.tsx @@ -52,6 +52,7 @@ interface BenchmarkOverviewTabProps { /** Opens the Run benchmark wizard. Pass `true` to prefill from the most * recent completed run ("Re-run last"). */ onOpenWizard: (reRunLast?: boolean) => void; + selectedClusterId?: number; } export function BenchmarkOverviewTab({ @@ -60,16 +61,25 @@ export function BenchmarkOverviewTab({ onGoToRunsList, onGoToTrends, onOpenWizard, + selectedClusterId, }: BenchmarkOverviewTabProps) { - const { data: targetsData, isLoading: targetsLoading } = useBenchmarkTargets(); + const { data: targetsData, isLoading: targetsLoading } = useBenchmarkTargets( + selectedClusterId ? { cluster_id: selectedClusterId } : undefined + ); const { data: agents, isLoading: agentsLoading } = useBenchmarkAgents(); const { data: configs, isLoading: configsLoading } = useBenchmarkConfigs(); const { data: summary } = useBenchmarkSummary(); const { data: runsData, isLoading: runsLoading } = useBenchmarkRuns({ + cluster_id: selectedClusterId, limit: RECENT_RUNS_LIMIT, pollingEnabled: true, }); - const { data: completedRunsData } = useBenchmarkRuns({ status: 'completed', limit: 1, pollingEnabled: false }); + const { data: completedRunsData } = useBenchmarkRuns({ + cluster_id: selectedClusterId, + status: 'completed', + limit: 1, + pollingEnabled: false, + }); const hasCompletedRun = (completedRunsData?.runs?.length ?? 0) > 0; const targets = targetsData?.targets ?? []; diff --git a/frontend-v2/src/pages/BenchmarkRunsTab.tsx b/frontend-v2/src/pages/BenchmarkRunsTab.tsx index 1bca4a14..de23a10e 100644 --- a/frontend-v2/src/pages/BenchmarkRunsTab.tsx +++ b/frontend-v2/src/pages/BenchmarkRunsTab.tsx @@ -56,6 +56,7 @@ interface RunsTabProps { onToggleCompare: (id: number) => void; onCompare: () => void; onViewTrends?: () => void; + selectedClusterId?: number; } export function BenchmarkRunsTab({ @@ -63,10 +64,12 @@ export function BenchmarkRunsTab({ statusFilter, onStatusFilterChange, searchQuery, onSearchChange, onSelectRun, compareRunIds, onToggleCompare, onCompare, onViewTrends, + selectedClusterId, }: RunsTabProps) { const { data, isLoading } = useBenchmarkRuns({ proxy: proxyFilter || undefined, status: statusFilter || undefined, + cluster_id: selectedClusterId, pollingEnabled: true, }); const cancelRun = useCancelBenchmarkRun(); diff --git a/frontend-v2/src/pages/BenchmarkTargetsTab.tsx b/frontend-v2/src/pages/BenchmarkTargetsTab.tsx index c08209a6..f3156c28 100644 --- a/frontend-v2/src/pages/BenchmarkTargetsTab.tsx +++ b/frontend-v2/src/pages/BenchmarkTargetsTab.tsx @@ -42,11 +42,11 @@ import { Zap, Trash2, Search, - CheckCircle2, Loader2, Plus, Play, RefreshCw, + Server, } from 'lucide-react'; import { useBenchmarkTargets, @@ -107,12 +107,14 @@ function proxyDeployBadge(status: string) { return PROXY_DEPLOY_BADGE[(status as ProxyDeployStatus)] ?? PROXY_DEPLOY_BADGE.pending; } -// ============================================================================ -// Main Component -// ============================================================================ +export interface BenchmarkTargetsTabProps { + selectedClusterId?: number; +} -export function BenchmarkTargetsTab() { - const { data: targetsData, isLoading } = useBenchmarkTargets(); +export function BenchmarkTargetsTab({ selectedClusterId }: BenchmarkTargetsTabProps = {}) { + const { data: targetsData, isLoading } = useBenchmarkTargets( + selectedClusterId ? { cluster_id: selectedClusterId } : undefined + ); const [selectedTargetId, setSelectedTargetId] = useState(null); const { data: targetDetail } = useBenchmarkTarget(selectedTargetId ?? undefined); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -182,7 +184,7 @@ export function BenchmarkTargetsTab() { const resetForm = () => { setFormName(''); setFormDescription(''); - setFormClusterId(''); + setFormClusterId(selectedClusterId ? String(selectedClusterId) : ''); setFormLlmBaseUrl(''); setFormLlmModel(''); setFormLlmNamespace(''); @@ -201,52 +203,64 @@ export function BenchmarkTargetsTab() { ); } - // Detail view when a target is selected + // Detail view for selected target if (selectedTargetId && targetDetail) { + const isVal = validateTarget.isPending; + const tBadge = targetBadge(targetDetail.status); + const clusterName = targetDetail.cluster_name || clusters.find(c => c.id === targetDetail.cluster_id)?.name || `Cluster #${targetDetail.cluster_id}`; const proxies = (targetDetail as BenchmarkTargetDetail).proxy_deployments ?? []; const deployedTypes = new Set(proxies.map(p => p.proxy_type)); const availableTypes = AVAILABLE_PROXY_TYPES.filter(t => !deployedTypes.has(t)); - const tBadge = targetBadge(targetDetail.status); return (
- {/* Back + Header */} -
- -
-

- {targetDetail.name} -

-

{targetDetail.description || 'No description'}

+ {/* Header with back button */} +
+
+ +

{targetDetail.name}

+ {tBadge.label} +
+
+ +
- {tBadge.label} - - -
{/* Target Info */} -
+
+
+ Cluster +

+ + {clusterName} +

+
LLM endpoint

{targetDetail.llm_base_url}

@@ -508,7 +522,7 @@ export function BenchmarkTargetsTab() { K8s clusters + LLM endpoints used as benchmark targets. Deploy proxies and trigger tests.

- @@ -530,7 +544,7 @@ export function BenchmarkTargetsTab() { Scan a cluster to auto-discover LLM services and proxies, or add a target manually.

- @@ -548,6 +562,7 @@ export function BenchmarkTargetsTab() { Name + Cluster LLM endpoint Model Proxies @@ -559,9 +574,16 @@ export function BenchmarkTargetsTab() { {targets.map(target => { const tBadge = targetBadge(target.status); + const clusterName = target.cluster_name || clusters.find(c => c.id === target.cluster_id)?.name || `Cluster #${target.cluster_id}`; return ( { setActiveRunGroupId(null); setSelectedTargetId(target.id); }}> {target.name} + + + + {clusterName} + + {target.llm_base_url} {target.llm_model} diff --git a/frontend-v2/src/pages/Benchmarks.tsx b/frontend-v2/src/pages/Benchmarks.tsx index d689adc7..79a9ca79 100644 --- a/frontend-v2/src/pages/Benchmarks.tsx +++ b/frontend-v2/src/pages/Benchmarks.tsx @@ -10,7 +10,6 @@ */ import { useCallback, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { cn } from '@/lib/utils'; import { PageHeader } from '@/components/layout/PageHeader'; import { usePageRefresh } from '@/hooks/usePageRefresh'; import { Tabs, TabsContent } from '@/components/ui/tabs'; @@ -24,9 +23,6 @@ import { Activity, Server, LayoutDashboard, - CheckCircle2, - Circle, - ArrowRight, ArrowLeft, X, BookOpen, @@ -34,7 +30,6 @@ import { ChevronRight, SearchX, } from 'lucide-react'; -import { useBenchmarkTargets, useBenchmarkAgents } from '@/hooks/useBenchmarks'; import { BenchmarkTargetsTab } from './BenchmarkTargetsTab'; import { BenchmarkAgentsTab } from './BenchmarkAgentsTab'; @@ -47,13 +42,7 @@ import { BenchmarkRunGroupView } from './BenchmarkRunGroupView'; import { BenchmarkOverviewTab } from './BenchmarkOverviewTab'; import { RunBenchmarkWizard, type RunBenchmarkWizardLaunchResult } from './RunBenchmarkWizard'; import { deriveRunsViewState, derivePrimaryTabState, type SetupSection } from './benchmark-runs-view'; - -interface StepState { - label: string; - description: string; - done: boolean; - tab: string; -} +import { ClusterPicker } from '@/components/observability/pickers'; // ────────────────────────────────────────────────────────────────────────────── // Getting Started Banner — dismissible, collapses to a re-openable pill (never @@ -158,62 +147,7 @@ function SetupGuidePill({ onExpand }: { onExpand: () => void }) { ); } -// ────────────────────────────────────────────────────────────────────────────── -// Stepper banner — token-pure progress bar + step buttons -// ────────────────────────────────────────────────────────────────────────────── -function StepperBanner({ - steps, - onStepClick, -}: { - steps: StepState[]; - onStepClick: (tab: string) => void; -}) { - const completedCount = steps.filter((s) => s.done).length; - const allDone = completedCount === steps.length; - if (allDone) return null; - - return ( -
-
-

- Setup progress — {completedCount}/{steps.length} -

-
-
-
-
-
- {steps.map((step, i) => ( -
- - {i < steps.length - 1 && ( - - )} -
- ))} -
-
- ); -} // ────────────────────────────────────────────────────────────────────────────── // Setup tab — Targets/Agents/Configs as sub-sections, existing components as-is @@ -222,9 +156,11 @@ function StepperBanner({ function BenchmarkSetupSection({ activeSection, onSectionChange, + selectedClusterId, }: { activeSection: SetupSection; onSectionChange: (section: SetupSection) => void; + selectedClusterId?: number; }) { return ( onSectionChange(v as SetupSection)}> @@ -240,7 +176,7 @@ function BenchmarkSetupSection({ ]} /> - + @@ -258,9 +194,10 @@ function BenchmarkSetupSection({ // only additive support for ?group= (scenario launches from the wizard). // ────────────────────────────────────────────────────────────────────────────── -function BenchmarkRunsSection({ searchParams, setSearchParams }: { +function BenchmarkRunsSection({ searchParams, setSearchParams, selectedClusterId }: { searchParams: URLSearchParams; setSearchParams: (params: URLSearchParams) => void; + selectedClusterId?: number; }) { const [proxyFilter, setProxyFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); @@ -387,6 +324,7 @@ function BenchmarkRunsSection({ searchParams, setSearchParams }: { onToggleCompare={toggleCompare} onCompare={() => goToCompare(pendingCompareIds)} onViewTrends={goToTrends} + selectedClusterId={selectedClusterId} /> ); } @@ -403,10 +341,27 @@ export default function Benchmarks() { const [wizardOpen, setWizardOpen] = useState(false); const [wizardReRunLast, setWizardReRunLast] = useState(false); + const clusterParam = searchParams.get('cluster'); + const selectedClusterId = clusterParam ? Number(clusterParam) : undefined; + + const handleClusterChange = useCallback((id: number | undefined) => { + const next = new URLSearchParams(searchParams); + if (id != null) { + next.set('cluster', String(id)); + } else { + next.delete('cluster'); + } + setSearchParams(next); + }, [searchParams, setSearchParams]); + const { primaryTab, setupSection } = derivePrimaryTabState(searchParams); const goToPrimaryTab = useCallback((tab: string) => { - const next = new URLSearchParams(); + const next = new URLSearchParams(searchParams); + next.delete('run'); + next.delete('group'); + next.delete('compare'); + next.delete('view'); if (tab === 'runs') { next.set('tab', 'runs'); } else if (tab === 'setup') { @@ -416,45 +371,48 @@ export default function Benchmarks() { next.set('tab', 'overview'); } setSearchParams(next); - }, [setSearchParams, setupSection]); + }, [searchParams, setSearchParams, setupSection]); const goToSetupSection = useCallback((section: SetupSection) => { - const next = new URLSearchParams(); + const next = new URLSearchParams(searchParams); + next.delete('run'); + next.delete('group'); + next.delete('compare'); + next.delete('view'); next.set('tab', 'setup'); next.set('section', section); setSearchParams(next); - }, [setSearchParams]); - - // Stepper steps use legacy tab-string semantics: 'runs' -> primary Runs tab, - // anything else -> that Setup sub-section. Keeps StepperBanner itself untouched. - const handleStepClick = useCallback((tab: string) => { - if (tab === 'runs') { - goToPrimaryTab('runs'); - return; - } - goToSetupSection(tab as SetupSection); - }, [goToPrimaryTab, goToSetupSection]); + }, [searchParams, setSearchParams]); const goToRunDetail = useCallback((runId: number) => { - const next = new URLSearchParams(); + const next = new URLSearchParams(searchParams); + next.delete('group'); + next.delete('compare'); + next.delete('view'); next.set('tab', 'runs'); next.set('run', String(runId)); setSearchParams(next); - }, [setSearchParams]); + }, [searchParams, setSearchParams]); const goToRunGroup = useCallback((groupId: number) => { - const next = new URLSearchParams(); + const next = new URLSearchParams(searchParams); + next.delete('run'); + next.delete('compare'); + next.delete('view'); next.set('tab', 'runs'); next.set('group', String(groupId)); setSearchParams(next); - }, [setSearchParams]); + }, [searchParams, setSearchParams]); const goToTrends = useCallback(() => { - const next = new URLSearchParams(); + const next = new URLSearchParams(searchParams); + next.delete('run'); + next.delete('group'); + next.delete('compare'); next.set('tab', 'runs'); next.set('view', 'trends'); setSearchParams(next); - }, [setSearchParams]); + }, [searchParams, setSearchParams]); const openWizard = useCallback((reRunLast = false) => { setWizardReRunLast(reRunLast); @@ -481,59 +439,27 @@ export default function Benchmarks() { const { refresh: handleRefresh, isRefreshing } = usePageRefresh(); - const { data: targetsData } = useBenchmarkTargets(); - const { data: agents } = useBenchmarkAgents(); - const targets = targetsData?.targets ?? []; - const hasTargets = targets.length > 0; - const hasProxies = targets.some((t) => (t.proxy_count ?? 0) > 0); - const hasAgent = (agents ?? []).length > 0; - - const steps: StepState[] = [ - { - label: 'Add target', - description: 'Scan or add a K8s cluster + LLM endpoint', - done: hasTargets, - tab: 'targets', - }, - { - label: 'Deploy proxies', - description: 'Deploy envoy/nginx/haproxy/BNK', - done: hasProxies, - tab: 'targets', - }, - { - label: 'Connect agent', - description: 'Register a test machine', - done: hasAgent, - tab: 'agents', - }, - { - label: 'Run test', - description: 'Trigger a benchmark run', - done: hasTargets && hasProxies && hasAgent, - tab: 'runs', - }, - ]; - return (
{/* Header */} -
- - {guideCollapsed && } -
+ + + {guideCollapsed && } +
+ } + onRefresh={handleRefresh} + isRefreshing={isRefreshing} + /> goToSetupSection('agents')} /> - goToPrimaryTab('runs')} onGoToTrends={goToTrends} onOpenWizard={() => openWizard(false)} + selectedClusterId={selectedClusterId} />
- + - +
diff --git a/frontend-v2/src/pages/RunBenchmarkWizard.tsx b/frontend-v2/src/pages/RunBenchmarkWizard.tsx index 3fee57bb..c2791361 100644 --- a/frontend-v2/src/pages/RunBenchmarkWizard.tsx +++ b/frontend-v2/src/pages/RunBenchmarkWizard.tsx @@ -298,7 +298,9 @@ export function RunBenchmarkWizard({ {targets.map((t) => ( - {t.name} + + {t.name}{t.cluster_name ? ` (${t.cluster_name})` : ''} + ))} diff --git a/frontend-v2/src/pages/__tests__/BenchmarkTargetsTab.test.tsx b/frontend-v2/src/pages/__tests__/BenchmarkTargetsTab.test.tsx new file mode 100644 index 00000000..023b7030 --- /dev/null +++ b/frontend-v2/src/pages/__tests__/BenchmarkTargetsTab.test.tsx @@ -0,0 +1,134 @@ +/** + * Tests for BenchmarkTargetsTab — cluster indicator and filtering. + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '@/test/mocks/server'; + +vi.mock('@/context/ThemeContext', () => ({ + useTheme: () => ({ isDark: true, theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import { BenchmarkTargetsTab } from '@/pages/BenchmarkTargetsTab'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +const mockTargets = [ + { + id: 1, + name: 'vllm-aws-target', + description: 'AWS GPU cluster target', + cluster_id: 10, + cluster_name: 'aws-eks-cluster', + llm_base_url: 'http://vllm-openai.default:8000', + llm_model: 'meta-llama/Llama-3-8B-Instruct', + llm_namespace: 'default', + llm_endpoint: '/v1/chat/completions', + proxy_namespace: 'perf-proxies', + status: 'ready', + last_validated: '2026-06-02T10:00:00Z', + validation_msg: null, + tags: null, + proxy_count: 2, + created_at: '2026-06-02T10:00:00Z', + updated_at: '2026-06-02T10:00:00Z', + }, + { + id: 2, + name: 'vllm-gcp-target', + description: 'GCP GKE target', + cluster_id: 20, + cluster_name: 'gke-cluster-prod', + llm_base_url: 'http://vllm-gcp.default:8000', + llm_model: 'mistralai/Mistral-7B', + llm_namespace: 'default', + llm_endpoint: '/v1/chat/completions', + proxy_namespace: 'perf-proxies', + status: 'ready', + last_validated: null, + validation_msg: null, + tags: null, + proxy_count: 1, + created_at: '2026-06-02T10:00:00Z', + updated_at: '2026-06-02T10:00:00Z', + }, +]; + +describe('BenchmarkTargetsTab', () => { + it('renders target table with Cluster column and cluster name', async () => { + server.use( + http.get('*/api/benchmarks/targets', () => + HttpResponse.json({ + targets: mockTargets, + total: 2, + }) + ), + http.get('*/api/kubernetes/clusters', () => + HttpResponse.json({ + clusters: [ + { id: 10, name: 'aws-eks-cluster', status: 'active' }, + { id: 20, name: 'gke-cluster-prod', status: 'active' }, + ], + }) + ), + http.get('*/api/benchmarks/agents', () => + HttpResponse.json([]) + ), + http.get('*/api/benchmarks/scenarios', () => + HttpResponse.json({ scenarios: [] }) + ) + ); + + render(, { wrapper: createWrapper() }); + + // Cluster column header + await waitFor(() => expect(screen.getByText('Cluster')).toBeInTheDocument()); + expect(screen.getByText('vllm-aws-target')).toBeInTheDocument(); + expect(screen.getByText('aws-eks-cluster')).toBeInTheDocument(); + expect(screen.getByText('vllm-gcp-target')).toBeInTheDocument(); + expect(screen.getByText('gke-cluster-prod')).toBeInTheDocument(); + }); + + it('filters targets by cluster_id query param when selectedClusterId is passed', async () => { + let capturedUrl: string | null = null; + server.use( + http.get('*/api/benchmarks/targets', ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json({ + targets: [mockTargets[0]], + total: 1, + }); + }), + http.get('*/api/kubernetes/clusters', () => + HttpResponse.json({ + clusters: [{ id: 10, name: 'aws-eks-cluster', status: 'active' }], + }) + ), + http.get('*/api/benchmarks/agents', () => + HttpResponse.json([]) + ), + http.get('*/api/benchmarks/scenarios', () => + HttpResponse.json({ scenarios: [] }) + ) + ); + + render(, { wrapper: createWrapper() }); + + await waitFor(() => expect(screen.getByText('vllm-aws-target')).toBeInTheDocument()); + expect(capturedUrl).toContain('cluster_id=10'); + }); +}); diff --git a/frontend-v2/src/types/api-generated.ts b/frontend-v2/src/types/api-generated.ts index 44a03e85..5732a6c4 100644 --- a/frontend-v2/src/types/api-generated.ts +++ b/frontend-v2/src/types/api-generated.ts @@ -13532,6 +13532,8 @@ export interface components { agent_id: number | null; /** Target Id */ target_id: number | null; + /** Cluster Name */ + cluster_name?: string | null; /** Proxy Deployment Id */ proxy_deployment_id: number | null; /** Scenario Key */ @@ -13637,6 +13639,8 @@ export interface components { agent_id: number | null; /** Target Id */ target_id: number | null; + /** Cluster Name */ + cluster_name?: string | null; /** Proxy Deployment Id */ proxy_deployment_id: number | null; /** Scenario Key */ @@ -13774,6 +13778,8 @@ export interface components { description: string | null; /** Cluster Id */ cluster_id: number; + /** Cluster Name */ + cluster_name?: string | null; /** Llm Base Url */ llm_base_url: string; /** Llm Model */ @@ -13838,6 +13844,8 @@ export interface components { description: string | null; /** Cluster Id */ cluster_id: number; + /** Cluster Name */ + cluster_name?: string | null; /** Llm Base Url */ llm_base_url: string; /** Llm Model */ @@ -21162,6 +21170,8 @@ export interface components { status: string; /** Target Id */ target_id: number | null; + /** Cluster Name */ + cluster_name?: string | null; /** Proxy */ proxy: string | null; /** Model */ @@ -40905,6 +40915,7 @@ export interface operations { tool?: string | null; model?: string | null; status?: string | null; + cluster_id?: number | null; limit?: number; offset?: number; }; diff --git a/frontend-v2/src/types/benchmarks.ts b/frontend-v2/src/types/benchmarks.ts index cfc68e96..daa101a6 100644 --- a/frontend-v2/src/types/benchmarks.ts +++ b/frontend-v2/src/types/benchmarks.ts @@ -74,6 +74,7 @@ export interface BenchmarkRun { config_id: number | null; agent_id: number | null; target_id?: number | null; + cluster_name?: string | null; proxy_deployment_id?: number | null; scenario_key?: string | null; status: BenchmarkRunStatus; @@ -320,6 +321,7 @@ export interface BenchmarkTarget { name: string; description: string | null; cluster_id: number; + cluster_name?: string | null; llm_base_url: string; llm_model: string; llm_namespace: string;