diff --git a/.gitignore b/.gitignore index 8145aa8..3191873 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,14 @@ docker-compose.override.yml # OPT-001: Smart build script stores last-build commit hash .build-hash +# Custom TLS CA certificates (corporate proxy certs, etc.) +# The certs/ directory itself is tracked (see certs/.gitkeep), but certificate +# files should never be committed. +/certs/*.crt +/certs/*.pem +/certs/*.cer +/certs/*.der + # Environment variables .env .env.local diff --git a/.trivyignore b/.trivyignore index 7c23206..2a2e574 100644 --- a/.trivyignore +++ b/.trivyignore @@ -27,6 +27,16 @@ CVE-2024-45337 exp:2026-11-30 # Added: 2026-04-15 CVE-2026-33186 exp:2026-11-30 +# CVE-2026-56854: golang.org/x/crypto/ssh - Authentication bypass due to unenforced source-address restrictions +# Affects: helm, oras, and tofu binaries bundled into Docker images (golang.org/x/crypto v0.31.0 - v0.46.0) +# Fixed in: golang.org/x/crypto >= 0.55.0 +# Not exploitable in our context: Forge invokes client CLI commands against Kubernetes APIs and +# OCI/container registries. No container runs an SSH server or accepts incoming SSH connections. +# Status: Waiting for upstream helm, oras, and opentofu releases built with patched golang.org/x/crypto. +# Added: 2026-09-02 +CVE-2026-56854 exp:2026-11-30 + + # CVE-2026-7598: libssh2 — integer overflow via large username/password # Affects: libssh2-1t64 1.11.1-1 in Debian trixie base image # Pulled in transitively (git/curl/apt deps); not directly used by Forge — diff --git a/backend/alembic/versions/v2_156_add_cluster_discovery_metadata.py b/backend/alembic/versions/v2_156_add_cluster_discovery_metadata.py new file mode 100644 index 0000000..26f58e1 --- /dev/null +++ b/backend/alembic/versions/v2_156_add_cluster_discovery_metadata.py @@ -0,0 +1,34 @@ +"""Add account_id and discovery_status to kubernetes_clusters. + +Revision ID: v2_156 +Revises: v2_155 + +Adds cloud-account metadata and a coarse discovery status to the +KubernetesCluster table so that fleet-health and cluster-list views can +surface per-cluster cloud context (account/subscription) and discovery +state without extra joins. +""" +import sqlalchemy as sa + +from alembic import op + +revision = "v2_156" +down_revision = "v2_155" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "kubernetes_clusters", + sa.Column("account_id", sa.String(length=100), nullable=True), + ) + op.add_column( + "kubernetes_clusters", + sa.Column("discovery_status", sa.String(length=50), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("kubernetes_clusters", "discovery_status") + op.drop_column("kubernetes_clusters", "account_id") diff --git a/backend/alembic/versions/v2_157_add_cluster_metadata_fields.py b/backend/alembic/versions/v2_157_add_cluster_metadata_fields.py new file mode 100644 index 0000000..7ff0aa3 --- /dev/null +++ b/backend/alembic/versions/v2_157_add_cluster_metadata_fields.py @@ -0,0 +1,48 @@ +"""Add cluster metadata fields to kubernetes_clusters. + +Revision ID: v2_157 +Revises: v2_156 + +Adds node_count, connectivity_status, integration_status, zones, and +access_method so cluster list/detail views can surface per-cluster +metadata without extra joins or probes. +""" +import sqlalchemy as sa + +from alembic import op + +revision = "v2_157" +down_revision = "v2_156" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "kubernetes_clusters", + sa.Column("node_count", sa.Integer(), nullable=True), + ) + op.add_column( + "kubernetes_clusters", + sa.Column("connectivity_status", sa.String(length=50), nullable=True), + ) + op.add_column( + "kubernetes_clusters", + sa.Column("integration_status", sa.String(length=50), nullable=True), + ) + op.add_column( + "kubernetes_clusters", + sa.Column("zones", sa.JSON(), nullable=True), + ) + op.add_column( + "kubernetes_clusters", + sa.Column("access_method", sa.String(length=50), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("kubernetes_clusters", "access_method") + op.drop_column("kubernetes_clusters", "zones") + op.drop_column("kubernetes_clusters", "integration_status") + op.drop_column("kubernetes_clusters", "connectivity_status") + op.drop_column("kubernetes_clusters", "node_count") diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index d676018..326b743 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -5,6 +5,41 @@ echo "================================================" echo "BNK-Forge Backend Startup" echo "================================================" +# Install user-supplied TLS CA certificates (e.g. corporate SSL inspection proxy). +# Corporate proxies re-sign outbound TLS (GitHub, Docker Hub, cloud APIs) with an +# internal CA that is not shipped in the public ca-certificates package. Mounting +# those CA files into /app/certs lets Forge trust them without baking them into +# the image or disabling certificate verification. +# +# The entrypoint runs as the non-root bnkforge user, so we cannot update the +# system-wide store. Instead we build a per-user bundle and export the standard +# environment variables that git, Python requests/urllib3, curl, Go binaries +# (Helm, OpenTofu), and the AWS CLI honor. +CUSTOM_CERT_DIR="/app/certs" +CUSTOM_BUNDLE="/home/bnkforge/.bnk-forge-ca-bundle.crt" +if [ -d "$CUSTOM_CERT_DIR" ]; then + installed_count=0 + for cert in "$CUSTOM_CERT_DIR"/*.crt "$CUSTOM_CERT_DIR"/*.pem "$CUSTOM_CERT_DIR"/*.cer "$CUSTOM_CERT_DIR"/*.der; do + [ -e "$cert" ] || continue + installed_count=$((installed_count + 1)) + done + if [ "$installed_count" -gt 0 ]; then + echo "Installing $installed_count custom CA certificate(s) from $CUSTOM_CERT_DIR" + # Start from the current system bundle, then append custom certs. + cp /etc/ssl/certs/ca-certificates.crt "$CUSTOM_BUNDLE" + for cert in "$CUSTOM_CERT_DIR"/*.crt "$CUSTOM_CERT_DIR"/*.pem "$CUSTOM_CERT_DIR"/*.cer "$CUSTOM_CERT_DIR"/*.der; do + [ -e "$cert" ] || continue + cat "$cert" >> "$CUSTOM_BUNDLE" + done + # Make the bundle available to common TLS consumers. + export SSL_CERT_FILE="$CUSTOM_BUNDLE" + export GIT_SSL_CAINFO="$CUSTOM_BUNDLE" + # curl and Node/Go tooling may also honor these. + export CURL_CA_BUNDLE="$CUSTOM_BUNDLE" + export REQUESTS_CA_BUNDLE="$CUSTOM_BUNDLE" + fi +fi + # Fix volume permissions on first run # Docker volumes are created as root, but we run as bnkforge (uid 1000) # The Makefile install target handles permissions, but we also check here diff --git a/backend/models/kubernetes.py b/backend/models/kubernetes.py index b12c2be..59c17b6 100644 --- a/backend/models/kubernetes.py +++ b/backend/models/kubernetes.py @@ -41,6 +41,13 @@ class KubernetesCluster(Base): kubeconfig_encrypted = Column(Text, nullable=True) # Base64 encoded encrypted kubeconfig cloud_provider = Column(String(50)) # aws, azure, gcp, on-prem region = Column(String(100)) # Cloud region + account_id = Column(String(100), nullable=True) # Cloud account / subscription ID + discovery_status = Column(String(50), nullable=True) # pending/probing/completed/failed + node_count = Column(Integer, nullable=True) + connectivity_status = Column(String(50), nullable=True) # connected/reachable/partial/unreachable/unknown + integration_status = Column(String(50), nullable=True) # agent_connected/agent_disconnected/direct + zones = Column(JSON, nullable=True) # List of availability zones from nodes + access_method = Column(String(50), nullable=True) # kubeconfig/ssh_tunnel/operator default_namespace = Column(String(255), default="default") # PLATFORM-CONTEXT-002: detected cluster platform context (additive) diff --git a/backend/openapi.json b/backend/openapi.json index 8ebd672..333e7fb 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": { "/": { @@ -1026,6 +1026,47 @@ } } }, + "/api/projects/{project_id}/k8s/clusters/detect-credentials": { + "post": { + "tags": [ + "k8s-clusters" + ], + "summary": "Detect And Register Clusters From Credentials", + "description": "Discover Kubernetes clusters via the project's cloud credential templates.", + "operationId": "detect_and_register_clusters_from_credentials_api_projects__project_id__k8s_clusters_detect_credentials_post", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/projects/{project_id}/k8s/clusters": { "post": { "tags": [ @@ -1164,6 +1205,49 @@ } } }, + "/api/projects/{project_id}/connectivity": { + "get": { + "tags": [ + "k8s-clusters" + ], + "summary": "Project Batch Connectivity Check", + "description": "Probe connectivity for all clusters in a project in parallel.", + "operationId": "project_batch_connectivity_check_api_projects__project_id__connectivity_get", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchConnectivityResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/k8s/clusters/{cluster_id}": { "get": { "tags": [ @@ -3737,7 +3821,7 @@ "k8s-f5bnk" ], "summary": "Get Bnk Data", - "description": "Unified BNK data endpoint \u2014 fetches all 16 CRD types + pods once.\n\nReturns health analysis, topology graph, and policy associations\nin a single response. The frontend caches this under one query key\nso switching between Health, Topology, and Policy Map tabs is instant.", + "description": "Unified BNK data endpoint \u2014 fetches all 16 CRD types + pods once.\n\nReturns health analysis, topology graph, and policy associations\nin a single response. The frontend caches this under one query key\nso switching between Health, Topology, and Policy Map tabs is instant.\n\nQuery parameters:\n - force: bypass the 15-second BNK data / TMM traffic-stats cache.", "operationId": "get_bnk_data_api_k8s_clusters__cluster_id__f5bnk_data_get", "parameters": [ { @@ -3764,6 +3848,16 @@ ], "title": "Namespace" } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Force" + } } ], "responses": { @@ -3771,7 +3865,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/BnkDataResponse" + } } } }, @@ -3828,7 +3924,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/BnkHealthEndpointResponse" + } } } }, @@ -3885,7 +3983,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/GatewayTopologyResponse" + } } } }, @@ -3942,7 +4042,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/F5PolicyGatewayAssociationsResponse" + } } } }, @@ -9476,6 +9578,16 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Force" + } } ], "responses": { @@ -15264,6 +15376,28 @@ } } }, + "/api/system/bnk-consumption": { + "get": { + "tags": [ + "system" + ], + "summary": "Get Bnk Consumption", + "description": "Get fleet-wide BNK resource consumption.", + "operationId": "get_bnk_consumption_api_system_bnk_consumption_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BnkConsumptionResponse" + } + } + } + } + } + } + }, "/api/system/queue-metrics": { "get": { "tags": [ @@ -25035,6 +25169,16 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Force" + } } ], "responses": { @@ -25078,6 +25222,16 @@ "type": "integer", "title": "Cluster Id" } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Force" + } } ], "responses": { @@ -37181,6 +37335,123 @@ ], "title": "BnkClusterConfigSummary" }, + "BnkClusterConsumption": { + "properties": { + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "cluster_name": { + "type": "string", + "title": "Cluster Name" + }, + "reachable": { + "type": "boolean", + "title": "Reachable" + }, + "bnk_installed": { + "type": "boolean", + "title": "Bnk Installed" + }, + "bnk_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bnk Version" + }, + "status": { + "type": "string", + "title": "Status" + }, + "node_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Node Count" + }, + "control_plane": { + "$ref": "#/components/schemas/BnkPlaneConsumption" + }, + "data_plane": { + "$ref": "#/components/schemas/BnkPlaneConsumption" + }, + "total": { + "$ref": "#/components/schemas/BnkPlaneConsumption" + }, + "node_capacity": { + "$ref": "#/components/schemas/BnkNodeCapacity" + }, + "metrics_available": { + "type": "boolean", + "title": "Metrics Available" + }, + "metrics_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metrics Error" + }, + "dpf": { + "$ref": "#/components/schemas/BnkClusterDpfSummary" + }, + "top_pods": { + "items": { + "$ref": "#/components/schemas/BnkTopPod" + }, + "type": "array", + "title": "Top Pods" + } + }, + "type": "object", + "required": [ + "cluster_id", + "cluster_name", + "reachable", + "bnk_installed", + "status", + "control_plane", + "data_plane", + "total", + "metrics_available", + "dpf" + ], + "title": "BnkClusterConsumption", + "description": "Per-cluster BNK resource consumption breakdown." + }, + "BnkClusterDpfSummary": { + "properties": { + "detected": { + "type": "boolean", + "title": "Detected" + }, + "dpu_count": { + "type": "integer", + "title": "Dpu Count" + } + }, + "type": "object", + "required": [ + "detected", + "dpu_count" + ], + "title": "BnkClusterDpfSummary", + "description": "Lightweight DPF/DPU summary for a single cluster." + }, "BnkClusterMemberAssignRequest": { "properties": { "control_plane_host_id": { @@ -37262,89 +37533,117 @@ ], "title": "BnkClusterMemberAssignResponse" }, - "BnkReleaseListResponse": { + "BnkConsumptionResponse": { "properties": { - "releases": { + "timestamp": { + "type": "string", + "title": "Timestamp" + }, + "fleet_summary": { + "$ref": "#/components/schemas/BnkFleetSummary" + }, + "clusters": { "items": { - "$ref": "#/components/schemas/ReleaseRegistryItemResponse" + "$ref": "#/components/schemas/BnkClusterConsumption" }, "type": "array", - "title": "Releases" - }, - "total": { - "type": "integer", - "title": "Total" + "title": "Clusters" } }, "type": "object", "required": [ - "releases", - "total" + "timestamp", + "fleet_summary", + "clusters" ], - "title": "BnkReleaseListResponse" + "title": "BnkConsumptionResponse", + "description": "Response for GET /api/system/bnk-consumption." }, - "BnkReleaseSyncResponse": { + "BnkDataResponse": { "properties": { - "tags_fetched": { - "type": "integer", - "title": "Tags Fetched" + "health": { + "$ref": "#/components/schemas/BnkHealthResponse" }, - "matched": { - "type": "integer", - "title": "Matched" + "topology": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Topology" }, - "unmatched": { - "type": "integer", - "title": "Unmatched" + "dataPlane": { + "additionalProperties": true, + "type": "object", + "title": "Dataplane" }, - "upserted": { - "type": "integer", - "title": "Upserted" - } - }, - "type": "object", - "required": [ - "tags_fetched", - "matched", - "unmatched", - "upserted" - ], - "title": "BnkReleaseSyncResponse" - }, - "Body_create_file_secret_api_projects__project_id__secrets_file_post": { - "properties": { - "file": { - "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" + "referenceGrants": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Referencegrants" }, - "name": { - "type": "string", - "title": "Name" + "topologyCounts": { + "additionalProperties": true, + "type": "object", + "title": "Topologycounts" }, - "description": { + "policyAssociations": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Policyassociations" + }, + "policyCount": { + "type": "integer", + "title": "Policycount" + }, + "backends": { "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { "type": "null" } ], - "title": "Description" + "title": "Backends" }, - "target_module_path": { + "palette": { "anyOf": [ { - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Target Module Path" + "title": "Palette" }, - "target_variable_name": { + "trafficStats": { + "anyOf": [ + { + "$ref": "#/components/schemas/BnkTrafficStatsResponse" + }, + { + "type": "null" + } + ] + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "namespace": { "anyOf": [ { "type": "string" @@ -37353,144 +37652,965 @@ "type": "null" } ], - "title": "Target Variable Name" + "title": "Namespace" } }, + "additionalProperties": true, "type": "object", "required": [ - "file", - "name" + "health", + "topology", + "dataPlane", + "referenceGrants", + "topologyCounts", + "policyAssociations", + "policyCount", + "cluster_id" ], - "title": "Body_create_file_secret_api_projects__project_id__secrets_file_post" + "title": "BnkDataResponse", + "description": "Wrapper for the unified /f5bnk/data endpoint.\n\nThe ``health`` and ``trafficStats`` keys are strongly typed; the remaining\nkeys are kept as loose dicts because their schemas are large and already\ntyped manually in the frontend. This lets OpenAPI capture new fields\nwithout coupling the whole topology/palette response to Pydantic." }, - "Body_import_special_secret_api_projects__project_id__secrets_import_post": { + "BnkEgressTrafficStats": { "properties": { - "secret_name": { + "egressName": { "type": "string", - "title": "Secret Name" + "title": "Egressname" }, - "file": { + "namespace": { "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" + "title": "Namespace" + }, + "clientsideBytesIn": { + "type": "integer", + "title": "Clientsidebytesin", + "default": 0 + }, + "clientsideBytesOut": { + "type": "integer", + "title": "Clientsidebytesout", + "default": 0 + }, + "clientsideCurConns": { + "type": "integer", + "title": "Clientsidecurconns", + "default": 0 + }, + "clientsideTotConns": { + "type": "integer", + "title": "Clientsidetotconns", + "default": 0 + }, + "serversideBytesIn": { + "type": "integer", + "title": "Serversidebytesin", + "default": 0 + }, + "serversideBytesOut": { + "type": "integer", + "title": "Serversidebytesout", + "default": 0 + }, + "serversideCurConns": { + "type": "integer", + "title": "Serversidecurconns", + "default": 0 + }, + "serversideTotConns": { + "type": "integer", + "title": "Serversidetotconns", + "default": 0 } }, "type": "object", "required": [ - "secret_name", - "file" + "egressName", + "namespace" ], - "title": "Body_import_special_secret_api_projects__project_id__secrets_import_post" + "title": "BnkEgressTrafficStats" }, - "Body_restore_backup_api_system_restore_post": { + "BnkFirewallRuleTrafficStats": { "properties": { - "file": { + "policyName": { "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File", - "description": "Backup archive (.tar.gz)" + "title": "Policyname" }, - "passphrase": { + "namespace": { "type": "string", - "minLength": 12, - "title": "Passphrase", - "description": "Archive passphrase" + "title": "Namespace" + }, + "ruleName": { + "type": "string", + "title": "Rulename" + }, + "action": { + "type": "string", + "title": "Action", + "default": "" + }, + "ipProtocol": { + "type": "string", + "title": "Ipprotocol", + "default": "" + }, + "hitCount": { + "type": "integer", + "title": "Hitcount", + "default": 0 } }, "type": "object", "required": [ - "file", - "passphrase" + "policyName", + "namespace", + "ruleName" ], - "title": "Body_restore_backup_api_system_restore_post" + "title": "BnkFirewallRuleTrafficStats" }, - "Body_update_file_secret_api_projects__project_id__secrets__secret_id__file_put": { + "BnkFleetSummary": { "properties": { - "file": { - "anyOf": [ - { - "type": "string", - "contentMediaType": "application/octet-stream" - }, - { - "type": "null" - } - ], - "title": "File" + "total_clusters": { + "type": "integer", + "title": "Total Clusters" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" + "reachable_clusters": { + "type": "integer", + "title": "Reachable Clusters" }, - "target_module_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Target Module Path" + "bnk_installed_clusters": { + "type": "integer", + "title": "Bnk Installed Clusters" }, - "target_variable_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Target Variable Name" + "total_bnk_pods": { + "type": "integer", + "title": "Total Bnk Pods" + }, + "control_plane_pods": { + "type": "integer", + "title": "Control Plane Pods" + }, + "data_plane_pods": { + "type": "integer", + "title": "Data Plane Pods" + }, + "total_cpu_millicores": { + "type": "integer", + "title": "Total Cpu Millicores" + }, + "total_memory_bytes": { + "type": "integer", + "title": "Total Memory Bytes" + }, + "node_capacity_cpu_millicores": { + "type": "integer", + "title": "Node Capacity Cpu Millicores", + "default": 0 + }, + "node_capacity_memory_bytes": { + "type": "integer", + "title": "Node Capacity Memory Bytes", + "default": 0 + }, + "dpf_detected_clusters": { + "type": "integer", + "title": "Dpf Detected Clusters" + }, + "dpu_count": { + "type": "integer", + "title": "Dpu Count" } }, "type": "object", - "title": "Body_update_file_secret_api_projects__project_id__secrets__secret_id__file_put" + "required": [ + "total_clusters", + "reachable_clusters", + "bnk_installed_clusters", + "total_bnk_pods", + "control_plane_pods", + "data_plane_pods", + "total_cpu_millicores", + "total_memory_bytes", + "dpf_detected_clusters", + "dpu_count" + ], + "title": "BnkFleetSummary", + "description": "Fleet-wide BNK consumption rollup." }, - "Body_upload_chart_api_helm_charts_upload_post": { + "BnkHealthAISection": { "properties": { - "file": { + "severity": { "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "analyzers": { + "type": "integer", + "title": "Analyzers" + }, + "analyzerDetails": { + "items": { + "$ref": "#/components/schemas/HealthAnalyzerDetail" + }, + "type": "array", + "title": "Analyzerdetails" } }, "type": "object", "required": [ - "file" + "severity", + "analyzers", + "analyzerDetails" ], - "title": "Body_upload_chart_api_helm_charts_upload_post" + "title": "BnkHealthAISection" }, - "BulkRunOut": { + "BnkHealthDataPlaneSection": { "properties": { - "id": { - "type": "integer", - "title": "Id" + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" }, - "decision_id": { - "type": "integer", - "title": "Decision Id" + "tmm": { + "$ref": "#/components/schemas/HealthTmmComponent" }, - "project_id": { + "cneInstance": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/HealthCneInstance" }, { - "type": "null" + "additionalProperties": true, + "type": "object" } ], - "title": "Project Id" - }, - "action": { + "title": "Cneinstance" + } + }, + "type": "object", + "required": [ + "severity", + "tmm", + "cneInstance" + ], + "title": "BnkHealthDataPlaneSection" + }, + "BnkHealthEndpointResponse": { + "properties": { + "overall": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Overall" + }, + "installShape": { + "type": "string", + "title": "Installshape", + "default": "unknown" + }, + "installMethod": { + "type": "string", + "title": "Installmethod", + "default": "Unknown" + }, + "connectivity": { + "$ref": "#/components/schemas/HealthConnectivityStatus" + }, + "integration": { + "$ref": "#/components/schemas/HealthIntegrationStatus" + }, + "platform": { + "$ref": "#/components/schemas/BnkHealthPlatformSection" + }, + "dataPlane": { + "$ref": "#/components/schemas/BnkHealthDataPlaneSection" + }, + "networking": { + "$ref": "#/components/schemas/BnkHealthNetworkingSection" + }, + "security": { + "$ref": "#/components/schemas/BnkHealthSecuritySection" + }, + "ai": { + "$ref": "#/components/schemas/BnkHealthAISection" + }, + "counts": { + "$ref": "#/components/schemas/HealthCounts" + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "overall", + "connectivity", + "integration", + "platform", + "dataPlane", + "networking", + "security", + "ai", + "counts", + "cluster_id" + ], + "title": "BnkHealthEndpointResponse" + }, + "BnkHealthNetworkingSection": { + "properties": { + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "gateways": { + "$ref": "#/components/schemas/HealthGatewayComponent" + }, + "vlans": { + "$ref": "#/components/schemas/HealthVlanComponent" + }, + "listeners": { + "type": "integer", + "title": "Listeners" + }, + "httpRoutes": { + "type": "integer", + "title": "Httproutes" + }, + "staticRoutes": { + "type": "integer", + "title": "Staticroutes" + }, + "snatPools": { + "type": "integer", + "title": "Snatpools" + } + }, + "type": "object", + "required": [ + "severity", + "gateways", + "vlans", + "listeners", + "httpRoutes", + "staticRoutes", + "snatPools" + ], + "title": "BnkHealthNetworkingSection" + }, + "BnkHealthPlatformSection": { + "properties": { + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "flo": { + "$ref": "#/components/schemas/HealthPlatformComponent" + }, + "controller": { + "$ref": "#/components/schemas/HealthPlatformComponent" + }, + "crdInstaller": { + "$ref": "#/components/schemas/HealthPlatformComponent" + }, + "analyzer": { + "$ref": "#/components/schemas/HealthPlatformComponent" + } + }, + "type": "object", + "required": [ + "severity", + "flo", + "controller", + "crdInstaller", + "analyzer" + ], + "title": "BnkHealthPlatformSection" + }, + "BnkHealthResponse": { + "properties": { + "overall": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Overall" + }, + "installShape": { + "type": "string", + "title": "Installshape", + "default": "unknown" + }, + "installMethod": { + "type": "string", + "title": "Installmethod", + "default": "Unknown" + }, + "connectivity": { + "$ref": "#/components/schemas/HealthConnectivityStatus" + }, + "integration": { + "$ref": "#/components/schemas/HealthIntegrationStatus" + }, + "platform": { + "$ref": "#/components/schemas/BnkHealthPlatformSection" + }, + "dataPlane": { + "$ref": "#/components/schemas/BnkHealthDataPlaneSection" + }, + "networking": { + "$ref": "#/components/schemas/BnkHealthNetworkingSection" + }, + "security": { + "$ref": "#/components/schemas/BnkHealthSecuritySection" + }, + "ai": { + "$ref": "#/components/schemas/BnkHealthAISection" + }, + "counts": { + "$ref": "#/components/schemas/HealthCounts" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "overall", + "connectivity", + "integration", + "platform", + "dataPlane", + "networking", + "security", + "ai", + "counts" + ], + "title": "BnkHealthResponse" + }, + "BnkHealthSecuritySection": { + "properties": { + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "firewallPolicies": { + "type": "integer", + "title": "Firewallpolicies" + }, + "securityPolicies": { + "type": "integer", + "title": "Securitypolicies" + }, + "networkPolicies": { + "type": "integer", + "title": "Networkpolicies" + }, + "addressLists": { + "type": "integer", + "title": "Addresslists" + }, + "portLists": { + "type": "integer", + "title": "Portlists" + }, + "irules": { + "$ref": "#/components/schemas/HealthIRulesComponent" + } + }, + "type": "object", + "required": [ + "severity", + "firewallPolicies", + "securityPolicies", + "networkPolicies", + "addressLists", + "portLists", + "irules" + ], + "title": "BnkHealthSecuritySection" + }, + "BnkListenerTrafficStats": { + "properties": { + "gatewayName": { + "type": "string", + "title": "Gatewayname" + }, + "gatewayNamespace": { + "type": "string", + "title": "Gatewaynamespace" + }, + "listenerName": { + "type": "string", + "title": "Listenername" + }, + "clientsideBytesIn": { + "type": "integer", + "title": "Clientsidebytesin", + "default": 0 + }, + "clientsideBytesOut": { + "type": "integer", + "title": "Clientsidebytesout", + "default": 0 + }, + "clientsideCurConns": { + "type": "integer", + "title": "Clientsidecurconns", + "default": 0 + }, + "clientsideTotConns": { + "type": "integer", + "title": "Clientsidetotconns", + "default": 0 + }, + "serversideBytesIn": { + "type": "integer", + "title": "Serversidebytesin", + "default": 0 + }, + "serversideBytesOut": { + "type": "integer", + "title": "Serversidebytesout", + "default": 0 + }, + "serversideCurConns": { + "type": "integer", + "title": "Serversidecurconns", + "default": 0 + }, + "serversideTotConns": { + "type": "integer", + "title": "Serversidetotconns", + "default": 0 + } + }, + "type": "object", + "required": [ + "gatewayName", + "gatewayNamespace", + "listenerName" + ], + "title": "BnkListenerTrafficStats" + }, + "BnkNodeCapacity": { + "properties": { + "cpu_millicores": { + "type": "integer", + "title": "Cpu Millicores", + "description": "Aggregated node allocatable CPU in millicores", + "default": 0 + }, + "memory_bytes": { + "type": "integer", + "title": "Memory Bytes", + "description": "Aggregated node allocatable memory in bytes", + "default": 0 + } + }, + "type": "object", + "title": "BnkNodeCapacity", + "description": "Node allocatable CPU/memory capacity for a cluster." + }, + "BnkPlaneConsumption": { + "properties": { + "count": { + "type": "integer", + "title": "Count", + "description": "Number of BNK pods in this plane" + }, + "cpu_millicores": { + "type": "integer", + "title": "Cpu Millicores", + "description": "Aggregated CPU usage in millicores" + }, + "memory_bytes": { + "type": "integer", + "title": "Memory Bytes", + "description": "Aggregated memory usage in bytes" + } + }, + "type": "object", + "required": [ + "count", + "cpu_millicores", + "memory_bytes" + ], + "title": "BnkPlaneConsumption", + "description": "CPU/memory/pod count for a single BNK plane (control-plane or data-plane)." + }, + "BnkReleaseListResponse": { + "properties": { + "releases": { + "items": { + "$ref": "#/components/schemas/ReleaseRegistryItemResponse" + }, + "type": "array", + "title": "Releases" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "releases", + "total" + ], + "title": "BnkReleaseListResponse" + }, + "BnkReleaseSyncResponse": { + "properties": { + "tags_fetched": { + "type": "integer", + "title": "Tags Fetched" + }, + "matched": { + "type": "integer", + "title": "Matched" + }, + "unmatched": { + "type": "integer", + "title": "Unmatched" + }, + "upserted": { + "type": "integer", + "title": "Upserted" + } + }, + "type": "object", + "required": [ + "tags_fetched", + "matched", + "unmatched", + "upserted" + ], + "title": "BnkReleaseSyncResponse" + }, + "BnkTopPod": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "role": { + "type": "string", + "title": "Role" + }, + "cpu_millicores": { + "type": "integer", + "title": "Cpu Millicores" + }, + "memory_bytes": { + "type": "integer", + "title": "Memory Bytes" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "role", + "cpu_millicores", + "memory_bytes" + ], + "title": "BnkTopPod", + "description": "A single BNK pod ranked by resource consumption." + }, + "BnkTrafficStatsResponse": { + "properties": { + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "podName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Podname" + }, + "sampledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sampledat" + }, + "available": { + "type": "boolean", + "title": "Available", + "default": false + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "listeners": { + "items": { + "$ref": "#/components/schemas/BnkListenerTrafficStats" + }, + "type": "array", + "title": "Listeners" + }, + "egresses": { + "items": { + "$ref": "#/components/schemas/BnkEgressTrafficStats" + }, + "type": "array", + "title": "Egresses" + }, + "firewallRules": { + "items": { + "$ref": "#/components/schemas/BnkFirewallRuleTrafficStats" + }, + "type": "array", + "title": "Firewallrules" + } + }, + "type": "object", + "title": "BnkTrafficStatsResponse", + "description": "Traffic statistics mapped from TMM dataplane counters." + }, + "Body_create_file_secret_api_projects__project_id__secrets_file_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "target_module_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Module Path" + }, + "target_variable_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Variable Name" + } + }, + "type": "object", + "required": [ + "file", + "name" + ], + "title": "Body_create_file_secret_api_projects__project_id__secrets_file_post" + }, + "Body_import_special_secret_api_projects__project_id__secrets_import_post": { + "properties": { + "secret_name": { + "type": "string", + "title": "Secret Name" + }, + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "secret_name", + "file" + ], + "title": "Body_import_special_secret_api_projects__project_id__secrets_import_post" + }, + "Body_restore_backup_api_system_restore_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File", + "description": "Backup archive (.tar.gz)" + }, + "passphrase": { + "type": "string", + "minLength": 12, + "title": "Passphrase", + "description": "Archive passphrase" + } + }, + "type": "object", + "required": [ + "file", + "passphrase" + ], + "title": "Body_restore_backup_api_system_restore_post" + }, + "Body_update_file_secret_api_projects__project_id__secrets__secret_id__file_put": { + "properties": { + "file": { + "anyOf": [ + { + "type": "string", + "contentMediaType": "application/octet-stream" + }, + { + "type": "null" + } + ], + "title": "File" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "target_module_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Module Path" + }, + "target_variable_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Variable Name" + } + }, + "type": "object", + "title": "Body_update_file_secret_api_projects__project_id__secrets__secret_id__file_put" + }, + "Body_upload_chart_api_helm_charts_upload_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_chart_api_helm_charts_upload_post" + }, + "BulkRunOut": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "decision_id": { + "type": "integer", + "title": "Decision Id" + }, + "project_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Project Id" + }, + "action": { "type": "string", "title": "Action" }, @@ -38868,6 +39988,79 @@ ], "title": "Meta Data" }, + "node_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Node Count" + }, + "account_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Account Id" + }, + "discovery_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Discovery Status" + }, + "connectivity_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connectivity Status" + }, + "integration_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Integration Status" + }, + "zones": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Zones" + }, + "access_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Method" + }, "deployable_release_id": { "anyOf": [ { @@ -39448,6 +40641,68 @@ ], "title": "Node Count" }, + "account_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Account Id" + }, + "discovery_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Discovery Status" + }, + "connectivity_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connectivity Status" + }, + "integration_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Integration Status" + }, + "zones": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Zones" + }, + "access_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Method" + }, "deployable_release_id": { "anyOf": [ { @@ -45791,6 +47046,303 @@ "type": "object", "title": "F5DeviceUpdate" }, + "F5EgressPolicyAssociation": { + "properties": { + "kind": { + "type": "string", + "const": "egress", + "title": "Kind", + "default": "egress" + }, + "egress_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Egress Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "captured_namespaces": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Captured Namespaces" + }, + "snat_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Snat Type" + }, + "firewall_policy_name": { + "type": "string", + "title": "Firewall Policy Name" + }, + "rules_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Rules Count" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/F5FirewallRule" + }, + "type": "array", + "title": "Rules" + }, + "egress_status": { + "$ref": "#/components/schemas/PolicyStatus" + } + }, + "type": "object", + "required": [ + "namespace", + "firewall_policy_name" + ], + "title": "F5EgressPolicyAssociation" + }, + "F5FirewallRule": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "action": { + "type": "string", + "title": "Action" + }, + "ipProtocol": { + "type": "string", + "title": "Ipprotocol" + }, + "source": { + "$ref": "#/components/schemas/F5FirewallRuleEndpoint" + }, + "destination": { + "$ref": "#/components/schemas/F5FirewallRuleEndpoint" + }, + "logging": { + "type": "boolean", + "title": "Logging" + } + }, + "type": "object", + "required": [ + "name", + "action", + "ipProtocol", + "source", + "destination", + "logging" + ], + "title": "F5FirewallRule" + }, + "F5FirewallRuleEndpoint": { + "properties": { + "addresses": { + "items": {}, + "type": "array", + "title": "Addresses" + }, + "ports": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ports" + }, + "addressLists": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Addresslists" + }, + "portLists": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Portlists" + } + }, + "type": "object", + "required": [ + "addresses", + "ports", + "addressLists", + "portLists" + ], + "title": "F5FirewallRuleEndpoint" + }, + "F5GatewayPolicyAssociation": { + "properties": { + "kind": { + "type": "string", + "const": "gateway", + "title": "Kind", + "default": "gateway" + }, + "bnk_policy_name": { + "type": "string", + "title": "Bnk Policy Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "gateway_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gateway Name" + }, + "listener_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Listener Name" + }, + "firewall_policy_name": { + "type": "string", + "title": "Firewall Policy Name" + }, + "gateway_ip": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gateway Ip" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Port" + }, + "protocol": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Protocol" + }, + "rules_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Rules Count" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/F5FirewallRule" + }, + "type": "array", + "title": "Rules" + }, + "bnk_policy_status": { + "$ref": "#/components/schemas/PolicyStatus" + } + }, + "type": "object", + "required": [ + "bnk_policy_name", + "namespace", + "firewall_policy_name" + ], + "title": "F5GatewayPolicyAssociation" + }, + "F5PolicyGatewayAssociationsResponse": { + "properties": { + "associations": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/F5GatewayPolicyAssociation" + }, + { + "$ref": "#/components/schemas/F5EgressPolicyAssociation" + } + ] + }, + "type": "array", + "title": "Associations" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Namespace" + } + }, + "type": "object", + "required": [ + "associations", + "count", + "cluster_id" + ], + "title": "F5PolicyGatewayAssociationsResponse" + }, "FailedTag": { "properties": { "tag": { @@ -46592,6 +48144,50 @@ } ], "title": "Detected Platform Provider" + }, + "cloud_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cloud Provider" + }, + "region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Region" + }, + "account_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Account Id" + }, + "discovery_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Discovery Status" } }, "type": "object", @@ -46616,6 +48212,54 @@ "title": "FleetOperatorHealth", "description": "Health summary for a single operator/cluster in the fleet view." }, + "GatewayTopologyResponse": { + "properties": { + "topology": { + "items": { + "$ref": "#/components/schemas/TopologyGateway" + }, + "type": "array", + "title": "Topology" + }, + "dataPlane": { + "$ref": "#/components/schemas/TopologyDataPlane" + }, + "referenceGrants": { + "items": { + "$ref": "#/components/schemas/TopologyReferenceGrant" + }, + "type": "array", + "title": "Referencegrants" + }, + "counts": { + "$ref": "#/components/schemas/TopologyCounts" + }, + "cluster_id": { + "type": "integer", + "title": "Cluster Id" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Namespace" + } + }, + "type": "object", + "required": [ + "topology", + "dataPlane", + "referenceGrants", + "counts", + "cluster_id" + ], + "title": "GatewayTopologyResponse" + }, "GitSourceValidation": { "properties": { "git_url": { @@ -46648,115 +48292,198 @@ "type": "object", "title": "HTTPValidationError" }, - "HealthSubmission": { + "HealthAnalyzerDetail": { "properties": { - "cluster": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Cluster" + "name": { + "type": "string", + "title": "Name" }, - "bnk": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Bnk" + "namespace": { + "type": "string", + "title": "Namespace" + }, + "schedule": { + "type": "string", + "title": "Schedule" } }, "type": "object", - "title": "HealthSubmission" + "required": [ + "name", + "namespace", + "schedule" + ], + "title": "HealthAnalyzerDetail" }, - "HeartbeatSubmission": { + "HealthCneInstance": { "properties": { - "operator_version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "name": { + "type": "string", + "title": "Name" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "name" + ], + "title": "HealthCneInstance" + }, + "HealthConnectivityStatus": { + "properties": { + "status": { + "type": "string", + "enum": [ + "connected", + "reachable", + "partial", + "unreachable", + "unknown" ], - "title": "Operator Version" + "title": "Status" }, - "uptime_seconds": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Uptime Seconds" + "message": { + "type": "string", + "title": "Message" }, - "commands_executed": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } + "checkedAt": { + "type": "string", + "title": "Checkedat" + } + }, + "type": "object", + "required": [ + "status", + "message", + "checkedAt" + ], + "title": "HealthConnectivityStatus" + }, + "HealthCounts": { + "properties": { + "gateways": { + "type": "integer", + "title": "Gateways" + }, + "listeners": { + "type": "integer", + "title": "Listeners" + }, + "httpRoutes": { + "type": "integer", + "title": "Httproutes" + }, + "vlans": { + "type": "integer", + "title": "Vlans" + }, + "firewallPolicies": { + "type": "integer", + "title": "Firewallpolicies" + }, + "irules": { + "type": "integer", + "title": "Irules" + }, + "analyzers": { + "type": "integer", + "title": "Analyzers" + }, + "cneInstances": { + "type": "integer", + "title": "Cneinstances" + }, + "tmm_pods": { + "type": "integer", + "title": "Tmm Pods" + }, + "tmm_running": { + "type": "integer", + "title": "Tmm Running" + }, + "tmm_containers": { + "type": "string", + "title": "Tmm Containers" + } + }, + "type": "object", + "required": [ + "gateways", + "listeners", + "httpRoutes", + "vlans", + "firewallPolicies", + "irules", + "analyzers", + "cneInstances", + "tmm_pods", + "tmm_running", + "tmm_containers" + ], + "title": "HealthCounts" + }, + "HealthGatewayComponent": { + "properties": { + "total": { + "type": "integer", + "title": "Total" + }, + "programmed": { + "type": "integer", + "title": "Programmed" + }, + "accepted": { + "type": "integer", + "title": "Accepted" + }, + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" ], - "title": "Commands Executed" + "title": "Severity" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "addresses": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Addresses" } }, "type": "object", - "title": "HeartbeatSubmission" + "required": [ + "total", + "programmed", + "accepted", + "severity", + "explanation", + "addresses" + ], + "title": "HealthGatewayComponent" }, - "HelmChartInfo": { + "HealthIRuleDetail": { "properties": { "name": { "type": "string", "title": "Name" }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Version" - }, - "app_version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "App Version" + "accepted": { + "type": "boolean", + "title": "Accepted" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" + "programmed": { + "type": "boolean", + "title": "Programmed" }, - "repository": { + "error": { "anyOf": [ { "type": "string" @@ -46765,73 +48492,90 @@ "type": "null" } ], - "title": "Repository" + "title": "Error" } }, "type": "object", "required": [ - "name" + "name", + "accepted", + "programmed" ], - "title": "HelmChartInfo", - "description": "Single Helm chart in browse/search results." + "title": "HealthIRuleDetail" }, - "HelmChartSearchResponse": { + "HealthIRulesComponent": { "properties": { - "success": { - "type": "boolean", - "title": "Success", - "default": true + "total": { + "type": "integer", + "title": "Total" }, - "charts": { + "accepted": { + "type": "integer", + "title": "Accepted" + }, + "programmed": { + "type": "integer", + "title": "Programmed" + }, + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "details": { "items": { - "$ref": "#/components/schemas/HelmChartInfo" + "$ref": "#/components/schemas/HealthIRuleDetail" }, "type": "array", - "title": "Charts" - }, - "count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Count" + "title": "Details" } }, "type": "object", "required": [ - "charts" + "total", + "accepted", + "programmed", + "severity", + "explanation", + "details" ], - "title": "HelmChartSearchResponse", - "description": "Response for GET /api/helm/charts/search." + "title": "HealthIRulesComponent" }, - "HelmOperationResponse": { + "HealthIntegrationStatus": { "properties": { - "success": { + "status": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Status" + }, + "operatorConnected": { "type": "boolean", - "title": "Success", - "default": true + "title": "Operatorconnected" }, - "message": { + "operatorMode": { "type": "string", - "title": "Message" - }, - "result": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } + "enum": [ + "direct_ws", + "polling", + "kubeconfig" ], - "title": "Result" + "title": "Operatormode" }, - "task_id": { + "operatorVersion": { "anyOf": [ { "type": "string" @@ -46840,9 +48584,9 @@ "type": "null" } ], - "title": "Task Id" + "title": "Operatorversion" }, - "status": { + "lastSeen": { "anyOf": [ { "type": "string" @@ -46851,62 +48595,132 @@ "type": "null" } ], - "title": "Status" + "title": "Lastseen" + }, + "message": { + "type": "string", + "title": "Message" } }, "type": "object", "required": [ + "status", + "operatorConnected", + "operatorMode", "message" ], - "title": "HelmOperationResponse", - "description": "Generic response for install/upgrade/rollback/uninstall operations." + "title": "HealthIntegrationStatus" }, - "HelmReleaseDetailResponse": { + "HealthPlatformComponent": { "properties": { - "success": { - "type": "boolean", - "title": "Success", - "default": true + "explanation": { + "type": "string", + "title": "Explanation" }, - "release": { - "additionalProperties": true, - "type": "object", - "title": "Release" + "podDetails": { + "items": { + "$ref": "#/components/schemas/HealthPodDetail" + }, + "type": "array", + "title": "Poddetails" + }, + "remediationActions": { + "items": { + "$ref": "#/components/schemas/HealthRemediationAction" + }, + "type": "array", + "title": "Remediationactions" + }, + "namespaces": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Namespaces" + }, + "zones": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Zones" + }, + "nodes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Nodes" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "running": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Running" + }, + "completed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completed" + }, + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" } }, "type": "object", "required": [ - "release" + "explanation", + "podDetails", + "remediationActions", + "total", + "severity" ], - "title": "HelmReleaseDetailResponse", - "description": "Response for GET /api/k8s/{cluster_id}/helm/releases/{name}." + "title": "HealthPlatformComponent" }, - "HelmReleaseInfo": { + "HealthPodDetail": { "properties": { - "name": { + "podName": { "type": "string", - "title": "Name" + "title": "Podname" }, "namespace": { "type": "string", "title": "Namespace" }, - "revision": { + "nodeName": { "anyOf": [ { "type": "string" }, { - "type": "integer" + "type": "null" } ], - "title": "Revision" - }, - "status": { - "type": "string", - "title": "Status" + "title": "Nodename" }, - "chart": { + "nodeZone": { "anyOf": [ { "type": "string" @@ -46915,9 +48729,9 @@ "type": "null" } ], - "title": "Chart" + "title": "Nodezone" }, - "chart_version": { + "nodeInstanceType": { "anyOf": [ { "type": "string" @@ -46926,9 +48740,9 @@ "type": "null" } ], - "title": "Chart Version" + "title": "Nodeinstancetype" }, - "app_version": { + "hostIP": { "anyOf": [ { "type": "string" @@ -46937,61 +48751,587 @@ "type": "null" } ], - "title": "App Version" + "title": "Hostip" }, - "updated": { + "phase": { + "type": "string", + "title": "Phase" + }, + "restartCount": { + "type": "integer", + "title": "Restartcount" + }, + "containersReady": { + "type": "string", + "title": "Containersready" + }, + "issue": { + "type": "string", + "title": "Issue" + } + }, + "type": "object", + "required": [ + "podName", + "namespace", + "phase", + "restartCount", + "containersReady", + "issue" + ], + "title": "HealthPodDetail" + }, + "HealthRemediationAction": { + "properties": { + "label": { + "type": "string", + "title": "Label" + }, + "action": { + "type": "string", + "enum": [ + "view_logs", + "restart_pod", + "describe", + "diagnostics" + ], + "title": "Action" + }, + "target": { + "type": "string", + "title": "Target" + }, + "namespace": { + "type": "string", + "title": "Namespace" + } + }, + "type": "object", + "required": [ + "label", + "action", + "target", + "namespace" + ], + "title": "HealthRemediationAction" + }, + "HealthSubmission": { + "properties": { + "cluster": { "anyOf": [ { - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Updated" + "title": "Cluster" }, - "description": { + "bnk": { "anyOf": [ { - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Description" + "title": "Bnk" } }, "type": "object", - "required": [ - "name", - "namespace", - "revision", - "status" - ], - "title": "HelmReleaseInfo", - "description": "Single Helm release in list response." + "title": "HealthSubmission" }, - "HelmReleaseListResponse": { + "HealthTmmComponent": { "properties": { - "success": { - "type": "boolean", - "title": "Success", - "default": true + "explanation": { + "type": "string", + "title": "Explanation" }, - "releases": { + "podDetails": { "items": { - "$ref": "#/components/schemas/HelmReleaseInfo" + "$ref": "#/components/schemas/HealthPodDetail" }, "type": "array", - "title": "Releases" + "title": "Poddetails" }, - "count": { - "anyOf": [ - { - "type": "integer" - }, - { + "remediationActions": { + "items": { + "$ref": "#/components/schemas/HealthRemediationAction" + }, + "type": "array", + "title": "Remediationactions" + }, + "namespaces": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Namespaces" + }, + "zones": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Zones" + }, + "nodes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Nodes" + }, + "pods": { + "type": "integer", + "title": "Pods" + }, + "running": { + "type": "integer", + "title": "Running" + }, + "containersTotal": { + "type": "integer", + "title": "Containerstotal" + }, + "containersReady": { + "type": "integer", + "title": "Containersready" + }, + "totalRestarts": { + "type": "integer", + "title": "Totalrestarts" + }, + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + } + }, + "type": "object", + "required": [ + "explanation", + "podDetails", + "remediationActions", + "pods", + "running", + "containersTotal", + "containersReady", + "totalRestarts", + "severity" + ], + "title": "HealthTmmComponent" + }, + "HealthVlanComponent": { + "properties": { + "total": { + "type": "integer", + "title": "Total" + }, + "programmed": { + "type": "integer", + "title": "Programmed" + }, + "severity": { + "type": "string", + "enum": [ + "healthy", + "warning", + "critical", + "unknown" + ], + "title": "Severity" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "details": { + "items": { + "$ref": "#/components/schemas/HealthVlanDetail" + }, + "type": "array", + "title": "Details" + } + }, + "type": "object", + "required": [ + "total", + "programmed", + "severity", + "explanation", + "details" + ], + "title": "HealthVlanComponent" + }, + "HealthVlanDetail": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "programmed": { + "type": "boolean", + "title": "Programmed" + }, + "interfaces": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Interfaces" + }, + "selfIPs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Selfips" + }, + "mtu": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mtu" + } + }, + "type": "object", + "required": [ + "name", + "programmed", + "interfaces", + "selfIPs" + ], + "title": "HealthVlanDetail" + }, + "HeartbeatSubmission": { + "properties": { + "operator_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Operator Version" + }, + "uptime_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Uptime Seconds" + }, + "commands_executed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Commands Executed" + } + }, + "type": "object", + "title": "HeartbeatSubmission" + }, + "HelmChartInfo": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + }, + "app_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Version" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Repository" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "HelmChartInfo", + "description": "Single Helm chart in browse/search results." + }, + "HelmChartSearchResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": true + }, + "charts": { + "items": { + "$ref": "#/components/schemas/HelmChartInfo" + }, + "type": "array", + "title": "Charts" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Count" + } + }, + "type": "object", + "required": [ + "charts" + ], + "title": "HelmChartSearchResponse", + "description": "Response for GET /api/helm/charts/search." + }, + "HelmOperationResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": true + }, + "message": { + "type": "string", + "title": "Message" + }, + "result": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Result" + }, + "task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "HelmOperationResponse", + "description": "Generic response for install/upgrade/rollback/uninstall operations." + }, + "HelmReleaseDetailResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": true + }, + "release": { + "additionalProperties": true, + "type": "object", + "title": "Release" + } + }, + "type": "object", + "required": [ + "release" + ], + "title": "HelmReleaseDetailResponse", + "description": "Response for GET /api/k8s/{cluster_id}/helm/releases/{name}." + }, + "HelmReleaseInfo": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "title": "Revision" + }, + "status": { + "type": "string", + "title": "Status" + }, + "chart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chart" + }, + "chart_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Chart Version" + }, + "app_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Version" + }, + "updated": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "revision", + "status" + ], + "title": "HelmReleaseInfo", + "description": "Single Helm release in list response." + }, + "HelmReleaseListResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": true + }, + "releases": { + "items": { + "$ref": "#/components/schemas/HelmReleaseInfo" + }, + "type": "array", + "title": "Releases" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { "type": "null" } ], @@ -51157,6 +53497,36 @@ ], "title": "PolicyOut" }, + "PolicyStatus": { + "properties": { + "resolved": { + "type": "boolean", + "title": "Resolved", + "default": false + }, + "programmed": { + "type": "boolean", + "title": "Programmed", + "default": false + }, + "messages": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object", + "title": "Messages" + } + }, + "type": "object", + "title": "PolicyStatus" + }, "PreviewMemberOut": { "properties": { "id": { @@ -59991,6 +62361,332 @@ "title": "TaskIdsRequest", "description": "Body for bulk operations log operations (#21)." }, + "TopologyAddressList": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "addresses": { + "items": {}, + "type": "array", + "title": "Addresses" + } + }, + "type": "object", + "required": [ + "name", + "addresses" + ], + "title": "TopologyAddressList" + }, + "TopologyAnalyzer": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "schedule": { + "type": "string", + "title": "Schedule" + }, + "scriptType": { + "type": "string", + "title": "Scripttype" + }, + "dataSources": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Datasources" + }, + "parameters": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Parameters" + } + }, + "type": "object", + "required": [ + "name", + "schedule", + "scriptType", + "dataSources", + "parameters" + ], + "title": "TopologyAnalyzer" + }, + "TopologyCneInstance": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "features": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object", + "title": "Features" + }, + "networkAttachments": { + "items": {}, + "type": "array", + "title": "Networkattachments" + }, + "containerPlatform": { + "type": "string", + "title": "Containerplatform" + }, + "phase": { + "type": "string", + "title": "Phase" + }, + "ready": { + "type": "boolean", + "title": "Ready" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "features", + "networkAttachments", + "containerPlatform", + "phase", + "ready" + ], + "title": "TopologyCneInstance" + }, + "TopologyCondition": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "status": { + "type": "string", + "title": "Status" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "lastTransitionTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lasttransitiontime" + } + }, + "type": "object", + "required": [ + "type", + "status" + ], + "title": "TopologyCondition" + }, + "TopologyCounts": { + "properties": { + "gateways": { + "type": "integer", + "title": "Gateways" + }, + "listeners": { + "type": "integer", + "title": "Listeners" + }, + "httpRoutes": { + "type": "integer", + "title": "Httproutes" + }, + "grpcRoutes": { + "type": "integer", + "title": "Grpcroutes" + }, + "tcpRoutes": { + "type": "integer", + "title": "Tcproutes" + }, + "udpRoutes": { + "type": "integer", + "title": "Udproutes" + }, + "tlsRoutes": { + "type": "integer", + "title": "Tlsroutes" + }, + "l4Routes": { + "type": "integer", + "title": "L4Routes" + }, + "totalRoutes": { + "type": "integer", + "title": "Totalroutes" + }, + "referenceGrants": { + "type": "integer", + "title": "Referencegrants" + }, + "securityPolicies": { + "type": "integer", + "title": "Securitypolicies" + }, + "networkPolicies": { + "type": "integer", + "title": "Networkpolicies" + }, + "firewallPolicies": { + "type": "integer", + "title": "Firewallpolicies" + }, + "iRules": { + "type": "integer", + "title": "Irules" + }, + "analyzers": { + "type": "integer", + "title": "Analyzers" + }, + "vlans": { + "type": "integer", + "title": "Vlans" + }, + "cneInstances": { + "type": "integer", + "title": "Cneinstances" + }, + "staticRoutes": { + "type": "integer", + "title": "Staticroutes" + }, + "snatPools": { + "type": "integer", + "title": "Snatpools" + }, + "egresses": { + "type": "integer", + "title": "Egresses" + }, + "hslPublishers": { + "type": "integer", + "title": "Hslpublishers" + }, + "logProfiles": { + "type": "integer", + "title": "Logprofiles" + } + }, + "type": "object", + "required": [ + "gateways", + "listeners", + "httpRoutes", + "grpcRoutes", + "tcpRoutes", + "udpRoutes", + "tlsRoutes", + "l4Routes", + "totalRoutes", + "referenceGrants", + "securityPolicies", + "networkPolicies", + "firewallPolicies", + "iRules", + "analyzers", + "vlans", + "cneInstances", + "staticRoutes", + "snatPools", + "egresses", + "hslPublishers", + "logProfiles" + ], + "title": "TopologyCounts" + }, + "TopologyDataPlane": { + "properties": { + "vlans": { + "items": { + "$ref": "#/components/schemas/TopologyVlan" + }, + "type": "array", + "title": "Vlans" + }, + "cneInstances": { + "items": { + "$ref": "#/components/schemas/TopologyCneInstance" + }, + "type": "array", + "title": "Cneinstances" + }, + "staticRoutes": { + "items": { + "$ref": "#/components/schemas/TopologyStaticRoute" + }, + "type": "array", + "title": "Staticroutes" + }, + "snatPools": { + "items": { + "$ref": "#/components/schemas/TopologySnatPool" + }, + "type": "array", + "title": "Snatpools" + }, + "egresses": { + "items": { + "$ref": "#/components/schemas/TopologyEgress" + }, + "type": "array", + "title": "Egresses" + }, + "logging": { + "$ref": "#/components/schemas/TopologyLogging" + } + }, + "type": "object", + "required": [ + "vlans", + "cneInstances", + "staticRoutes", + "snatPools", + "egresses", + "logging" + ], + "title": "TopologyDataPlane" + }, "TopologyEdge": { "properties": { "id": { @@ -60020,6 +62716,218 @@ "title": "TopologyEdge", "description": "A directed edge in the topology graph." }, + "TopologyEgress": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "snatType": { + "type": "string", + "title": "Snattype" + }, + "egressSnatpool": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Egresssnatpool" + }, + "firewallEnforcedPolicy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Firewallenforcedpolicy" + }, + "logProfile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logprofile" + }, + "capturedNamespaces": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Capturednamespaces" + }, + "vxlan": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Vxlan" + }, + "ready": { + "type": "boolean", + "title": "Ready" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "snatType", + "capturedNamespaces", + "ready" + ], + "title": "TopologyEgress" + }, + "TopologyFirewallPolicy": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "rules": { + "items": { + "$ref": "#/components/schemas/TopologyFwRule" + }, + "type": "array", + "title": "Rules" + }, + "addressLists": { + "items": { + "$ref": "#/components/schemas/TopologyAddressList" + }, + "type": "array", + "title": "Addresslists" + }, + "portLists": { + "items": { + "$ref": "#/components/schemas/TopologyPortList" + }, + "type": "array", + "title": "Portlists" + } + }, + "type": "object", + "required": [ + "name", + "rules", + "addressLists", + "portLists" + ], + "title": "TopologyFirewallPolicy" + }, + "TopologyFwRule": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "action": { + "type": "string", + "title": "Action" + }, + "ipProtocol": { + "type": "string", + "title": "Ipprotocol" + }, + "logging": { + "type": "boolean", + "title": "Logging" + } + }, + "type": "object", + "required": [ + "name", + "action", + "ipProtocol", + "logging" + ], + "title": "TopologyFwRule" + }, + "TopologyGateway": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "gatewayClassName": { + "type": "string", + "title": "Gatewayclassname" + }, + "addresses": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Addresses" + }, + "accepted": { + "type": "boolean", + "title": "Accepted", + "default": false + }, + "programmed": { + "type": "boolean", + "title": "Programmed", + "default": false + }, + "conditions": { + "items": { + "$ref": "#/components/schemas/TopologyCondition" + }, + "type": "array", + "title": "Conditions" + }, + "listeners": { + "items": { + "$ref": "#/components/schemas/TopologyListener" + }, + "type": "array", + "title": "Listeners" + }, + "securityPolicies": { + "items": { + "$ref": "#/components/schemas/TopologySecurityPolicy" + }, + "type": "array", + "title": "Securitypolicies" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "gatewayClassName", + "addresses", + "listeners", + "securityPolicies" + ], + "title": "TopologyGateway" + }, "TopologyGraphResponse": { "properties": { "nodes": { @@ -60066,6 +62974,190 @@ "title": "TopologyGraphResponse", "description": "Response for GET /api/k8s/clusters/{cluster_id}/topology." }, + "TopologyListener": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "protocol": { + "type": "string", + "title": "Protocol" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Port" + }, + "attachedRouteCount": { + "type": "integer", + "title": "Attachedroutecount", + "default": 0 + }, + "conditions": { + "items": { + "$ref": "#/components/schemas/TopologyCondition" + }, + "type": "array", + "title": "Conditions" + }, + "routes": { + "items": { + "$ref": "#/components/schemas/TopologyRoute" + }, + "type": "array", + "title": "Routes" + }, + "networkPolicies": { + "items": { + "$ref": "#/components/schemas/TopologyNetworkPolicy" + }, + "type": "array", + "title": "Networkpolicies" + } + }, + "type": "object", + "required": [ + "name", + "protocol", + "routes", + "networkPolicies" + ], + "title": "TopologyListener" + }, + "TopologyLogging": { + "properties": { + "hslPublishers": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Hslpublishers" + }, + "logProfiles": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Logprofiles" + } + }, + "type": "object", + "required": [ + "hslPublishers", + "logProfiles" + ], + "title": "TopologyLogging" + }, + "TopologyNetworkPolicy": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "extensions": { + "items": { + "$ref": "#/components/schemas/TopologyNetworkPolicyExtension" + }, + "type": "array", + "title": "Extensions" + }, + "resolvedCount": { + "type": "integer", + "title": "Resolvedcount" + }, + "totalExtensions": { + "type": "integer", + "title": "Totalextensions" + }, + "resolved": { + "type": "boolean", + "title": "Resolved", + "default": false + }, + "programmed": { + "type": "boolean", + "title": "Programmed", + "default": false + }, + "messages": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object", + "title": "Messages" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "extensions", + "resolvedCount", + "totalExtensions" + ], + "title": "TopologyNetworkPolicy" + }, + "TopologyNetworkPolicyExtension": { + "properties": { + "kind": { + "type": "string", + "title": "Kind" + }, + "name": { + "type": "string", + "title": "Name" + }, + "group": { + "type": "string", + "title": "Group" + }, + "lineCount": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Linecount" + }, + "eventHandlers": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Eventhandlers" + } + }, + "type": "object", + "required": [ + "kind", + "name", + "group" + ], + "title": "TopologyNetworkPolicyExtension" + }, "TopologyNode": { "properties": { "id": { @@ -60116,6 +63208,405 @@ "title": "TopologyNode", "description": "A single node in the topology graph (Service, Pod, or owning workload)." }, + "TopologyPortList": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "ports": { + "items": {}, + "type": "array", + "title": "Ports" + } + }, + "type": "object", + "required": [ + "name", + "ports" + ], + "title": "TopologyPortList" + }, + "TopologyReferenceGrant": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "from": { + "items": { + "$ref": "#/components/schemas/TopologyReferenceGrantFrom" + }, + "type": "array", + "title": "From" + }, + "to": { + "items": { + "$ref": "#/components/schemas/TopologyReferenceGrantTo" + }, + "type": "array", + "title": "To" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "from", + "to" + ], + "title": "TopologyReferenceGrant" + }, + "TopologyReferenceGrantFrom": { + "properties": { + "group": { + "type": "string", + "title": "Group" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "namespace": { + "type": "string", + "title": "Namespace" + } + }, + "type": "object", + "required": [ + "group", + "kind", + "namespace" + ], + "title": "TopologyReferenceGrantFrom" + }, + "TopologyReferenceGrantTo": { + "properties": { + "group": { + "type": "string", + "title": "Group" + }, + "kind": { + "type": "string", + "title": "Kind" + } + }, + "type": "object", + "required": [ + "group", + "kind" + ], + "title": "TopologyReferenceGrantTo" + }, + "TopologyRoute": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "hostnames": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Hostnames" + }, + "backends": { + "items": { + "$ref": "#/components/schemas/TopologyRouteBackend" + }, + "type": "array", + "title": "Backends" + }, + "analyzers": { + "items": { + "$ref": "#/components/schemas/TopologyAnalyzer" + }, + "type": "array", + "title": "Analyzers" + }, + "accepted": { + "type": "boolean", + "title": "Accepted", + "default": false + }, + "conditions": { + "items": { + "$ref": "#/components/schemas/TopologyCondition" + }, + "type": "array", + "title": "Conditions" + }, + "conditionMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Conditionmessage" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "kind", + "hostnames", + "backends", + "analyzers" + ], + "title": "TopologyRoute" + }, + "TopologyRouteBackend": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Namespace" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Port" + }, + "weight": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Weight" + }, + "kind": { + "type": "string", + "title": "Kind", + "default": "Service" + }, + "group": { + "type": "string", + "title": "Group", + "default": "" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "TopologyRouteBackend" + }, + "TopologySecurityPolicy": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "targetListener": { + "type": "string", + "title": "Targetlistener" + }, + "firewallPolicies": { + "items": { + "$ref": "#/components/schemas/TopologyFirewallPolicy" + }, + "type": "array", + "title": "Firewallpolicies" + }, + "resolved": { + "type": "boolean", + "title": "Resolved", + "default": false + }, + "programmed": { + "type": "boolean", + "title": "Programmed", + "default": false + }, + "messages": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object", + "title": "Messages" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "targetListener", + "firewallPolicies" + ], + "title": "TopologySecurityPolicy" + }, + "TopologySnatPool": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "addresses": { + "items": {}, + "type": "array", + "title": "Addresses" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "addresses" + ], + "title": "TopologySnatPool" + }, + "TopologyStaticRoute": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "destination": { + "type": "string", + "title": "Destination" + }, + "gateway": { + "type": "string", + "title": "Gateway" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "destination", + "gateway" + ], + "title": "TopologyStaticRoute" + }, + "TopologyVlan": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "interfaces": { + "items": {}, + "type": "array", + "title": "Interfaces" + }, + "selfipV4s": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Selfipv4S" + }, + "prefixLen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefixlen" + }, + "mtu": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mtu" + }, + "internal": { + "type": "boolean", + "title": "Internal" + }, + "autoLasthop": { + "type": "string", + "title": "Autolasthop" + }, + "ready": { + "type": "boolean", + "title": "Ready" + } + }, + "type": "object", + "required": [ + "name", + "namespace", + "interfaces", + "selfipV4s", + "internal", + "autoLasthop", + "ready" + ], + "title": "TopologyVlan" + }, "TransferOwnershipRequest": { "properties": { "new_owner_id": { diff --git a/backend/routes/credential_templates.py b/backend/routes/credential_templates.py index 64be9be..f571704 100644 --- a/backend/routes/credential_templates.py +++ b/backend/routes/credential_templates.py @@ -15,7 +15,7 @@ from database import get_db from routes.auth import require_operator, require_viewer from services.credential_template_service import CredentialTemplateService -from utils.validators import validate_aws_region +from utils.validators import validate_aws_region, validate_azure_region, validate_gcp_region, validate_ibm_region logger = logging.getLogger(__name__) @@ -65,8 +65,14 @@ def _validate_regions(self): if self.provider == "aws": validate_aws_region(self.region, field_name="region") validate_aws_region(self.aws_sso_region, field_name="aws_sso_region") - if self.provider == "ibm" and not self.ibmcloud_api_key: - raise ValueError("IBM Cloud API key is required for IBM credential templates") + if self.provider == "ibm": + validate_ibm_region(self.region, field_name="region") + if not self.ibmcloud_api_key: + raise ValueError("IBM Cloud API key is required for IBM credential templates") + if self.provider == "azure": + validate_azure_region(self.region, field_name="region") + if self.provider == "gcp": + validate_gcp_region(self.region, field_name="region") return self @@ -113,6 +119,12 @@ def _validate_regions(self): validate_aws_region(self.aws_sso_region, field_name="aws_sso_region") if self.region and self.provider == "aws": validate_aws_region(self.region, field_name="region") + if self.region and self.provider == "ibm": + validate_ibm_region(self.region, field_name="region") + if self.region and self.provider == "azure": + validate_azure_region(self.region, field_name="region") + if self.region and self.provider == "gcp": + validate_gcp_region(self.region, field_name="region") return self diff --git a/backend/routes/k8s/_shared.py b/backend/routes/k8s/_shared.py index 6d81b93..3eba019 100644 --- a/backend/routes/k8s/_shared.py +++ b/backend/routes/k8s/_shared.py @@ -13,7 +13,12 @@ from models import KubernetesCluster from services.platform_context_service import PlatformContextService from utils.provider_config import normalize_cloud_provider -from utils.validators import validate_aws_region +from utils.validators import ( + validate_aws_region, + validate_azure_region, + validate_gcp_region, + validate_ibm_region, +) logger = logging.getLogger(__name__) @@ -56,9 +61,16 @@ def serialize_cluster( "platform_capabilities": platform_context.platform_capabilities, "platform_constraints": platform_context.platform_constraints, "region": cluster.region, + "account_id": cluster.account_id, + "discovery_status": cluster.discovery_status, "default_namespace": cluster.default_namespace, "status": cluster.status, "version": cluster.version, + "node_count": cluster.node_count, + "connectivity_status": cluster.connectivity_status, + "integration_status": cluster.integration_status, + "zones": list(cluster.zones or []), + "access_method": cluster.access_method, "last_synced_at": cluster.last_synced_at.isoformat() if cluster.last_synced_at else None, "created_at": cluster.created_at.isoformat() if cluster.created_at else None, # SSH tunnel per-cluster config @@ -180,6 +192,12 @@ def _validate_region(self): self.cloud_provider = normalize_cloud_provider(self.cloud_provider) if self.cloud_provider in {"aws", "eks"}: validate_aws_region(self.region, field_name="region") + if self.cloud_provider == "ibm": + validate_ibm_region(self.region, field_name="region") + if self.cloud_provider == "azure": + validate_azure_region(self.region, field_name="region") + if self.cloud_provider == "gcp": + validate_gcp_region(self.region, field_name="region") return self @@ -208,6 +226,12 @@ def _validate_region(self): self.cloud_provider = normalize_cloud_provider(self.cloud_provider) if self.cloud_provider in {"aws", "eks"}: validate_aws_region(self.region, field_name="region") + if self.cloud_provider == "ibm": + validate_ibm_region(self.region, field_name="region") + if self.cloud_provider == "azure": + validate_azure_region(self.region, field_name="region") + if self.cloud_provider == "gcp": + validate_gcp_region(self.region, field_name="region") return self diff --git a/backend/routes/k8s/clusters.py b/backend/routes/k8s/clusters.py index 3211514..57f2120 100644 --- a/backend/routes/k8s/clusters.py +++ b/backend/routes/k8s/clusters.py @@ -44,6 +44,7 @@ NodeReadinessProbeResponse, ResourceTypeCatalogResponse, ) +from services.cluster_discovery_service import ClusterDiscoveryService from services.cluster_management_service import ClusterManagementService from services.kubernetes_service import KubernetesService from tasks.cluster_scan_task import enqueue_cluster_scan @@ -64,6 +65,17 @@ def detect_and_register_eks_clusters(project_id: int, user: User = Depends(requi return ClusterManagementService(db).detect_managed_clusters(project_id) +@router.post("/projects/{project_id}/k8s/clusters/detect-credentials") +@handle_route_errors("detect clusters from credentials") +def detect_and_register_clusters_from_credentials( + project_id: int, + user: User = Depends(require_project_owner), + db: Session = Depends(get_db) +): + """Discover Kubernetes clusters via the project's cloud credential templates.""" + return ClusterDiscoveryService(db).detect_clusters_from_credentials(project_id) + + @router.post("/projects/{project_id}/k8s/clusters", response_model=ClusterCreateResponse) @handle_route_errors("add cluster") def add_cluster_to_project(project_id: int, cluster_data: ClusterCreateRequest, user: User = Depends(require_project_owner), db: Session = Depends(get_db)): @@ -89,6 +101,14 @@ def batch_connectivity_check(db: Session = Depends(get_db)): return ConnectivityProbeService(db).probe_all_clusters() +@router.get("/projects/{project_id}/connectivity", response_model=BatchConnectivityResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("project batch connectivity check") +def project_batch_connectivity_check(project_id: int, db: Session = Depends(get_db)): + """Probe connectivity for all clusters in a project in parallel.""" + from services.connectivity_probe_service import ConnectivityProbeService + return ConnectivityProbeService(db).probe_project_clusters(project_id) + + @router.get("/projects/{project_id}/k8s/clusters", response_model=ClusterListResponse, dependencies=[Depends(require_viewer)]) @handle_route_errors("list project clusters") def list_project_clusters(project_id: int, db: Session = Depends(get_db)): diff --git a/backend/routes/k8s/f5bnk.py b/backend/routes/k8s/f5bnk.py index d283348..fb9244d 100644 --- a/backend/routes/k8s/f5bnk.py +++ b/backend/routes/k8s/f5bnk.py @@ -10,6 +10,8 @@ """ import logging +from datetime import datetime +from typing import Any from fastapi import APIRouter, Depends from kubernetes import client as k8s_client @@ -17,7 +19,10 @@ from core.errors import handle_route_errors from database import get_db +from models import ConnectedOperator, KubernetesCluster from routes.auth import require_operator, require_viewer +from schemas.bnk import BnkDataResponse, BnkHealthEndpointResponse +from schemas.f5bnk import F5PolicyGatewayAssociationsResponse, GatewayTopologyResponse from schemas.k8s import ( AbortMigrationRequest, CisTranslateRequest, @@ -40,10 +45,13 @@ analyze_health, analyze_policy_associations, analyze_topology, + analyze_traffic_stats, extract_palette_data, fetch_all_bnk_data, + fetch_tmm_traffic_stats, ) from services.kubernetes_service import KubernetesService +from services.operator_registry import is_operator_live_connected from services.proxy_discovery_service import ( _safe_list_all_custom, _safe_list_all_ingresses, @@ -55,15 +63,98 @@ router = APIRouter(prefix="/api", tags=["k8s-f5bnk"]) +# ============================================================================ +# Connectivity / integration context helpers +# ============================================================================ + + +def _dt_to_str(dt: datetime | None) -> str | None: + """Format a datetime as ISO-8601, returning None if absent.""" + if not dt: + return None + return dt.isoformat().replace("+00:00", "Z") + + +def _build_bnk_context(cluster: KubernetesCluster, db: Session) -> dict[str, Any]: + """Build connectivity and integration metadata for the BNK health response. + + Connectivity reuses the cluster's persisted ``status`` field, which is the + same value surfaced by the cluster list/detail endpoints. The live probe + details remain available on ``/api/k8s/clusters/{id}/connectivity``. + + Integration reflects whether a BNK operator is linked to this cluster and + is currently live-connected (via the shared helper used by fleet health + and the operator list). Kubeconfig-only management is a supported mode + and reports as healthy. + """ + status_map = { + "active": "connected", + "connecting": "unknown", + "inactive": "unreachable", + "error": "unreachable", + } + cluster_status = (cluster.status or "").lower() + connectivity_status = status_map.get(cluster_status, "unknown") + connectivity_message = { + "connected": "Kubernetes API is accessible", + "unknown": "Connectivity status is unknown", + "unreachable": "Kubernetes API is unreachable", + }.get(connectivity_status, "Connectivity status is unknown") + + connectivity = { + "status": connectivity_status, + "message": connectivity_message, + "checkedAt": _dt_to_str(cluster.last_synced_at), + } + + linked_op = ( + db.query(ConnectedOperator) + .filter(ConnectedOperator.cluster_id == cluster.id) + .first() + ) + if linked_op: + operator_connected = is_operator_live_connected(linked_op) + operator_mode = linked_op.connectivity_mode or "direct_ws" + last_seen = _dt_to_str(linked_op.last_heartbeat_at or linked_op.last_connected_at) + integration = { + "status": "healthy" if operator_connected else "warning", + "operatorConnected": operator_connected, + "operatorMode": operator_mode, + "operatorVersion": linked_op.operator_version, + "lastSeen": last_seen, + "message": ( + f"Operator {linked_op.operator_id} is connected" + if operator_connected + else f"Operator {linked_op.operator_id} is disconnected" + ), + } + else: + integration = { + "status": "healthy", + "operatorConnected": False, + "operatorMode": "kubeconfig", + "operatorVersion": None, + "lastSeen": _dt_to_str(cluster.last_synced_at), + "message": "Cluster managed via kubeconfig", + } + + return {"connectivity": connectivity, "integration": integration} + + # ============================================================================ # Unified BNK Data Endpoint # ============================================================================ -@router.get("/k8s/clusters/{cluster_id}/f5bnk/data", dependencies=[Depends(require_viewer)]) +@router.get( + "/k8s/clusters/{cluster_id}/f5bnk/data", + response_model=BnkDataResponse, + dependencies=[Depends(require_viewer)], +) @handle_route_errors("fetch BNK data") def get_bnk_data( cluster_id: int, namespace: str | None = None, + force: bool = False, db: Session = Depends(get_db), ): """ @@ -72,9 +163,30 @@ def get_bnk_data( Returns health analysis, topology graph, and policy associations in a single response. The frontend caches this under one query key so switching between Health, Topology, and Policy Map tabs is instant. + + Query parameters: + - force: bypass the 15-second BNK data / TMM traffic-stats cache. """ k8s_service = KubernetesService(db) - data = fetch_all_bnk_data(k8s_service, cluster_id, namespace) + cluster = k8s_service.get_cluster(cluster_id) + data = fetch_all_bnk_data( + k8s_service, cluster_id, namespace, include_nodes=True, force=force + ) + data.update(_build_bnk_context(cluster, db)) + + # Traffic stats require the TMM debug sidecar. Fetching them here keeps + # the unified response shape so all insight tabs share the same cache key. + # Errors are captured inside the trafficStats envelope; they never fail + # the whole /f5bnk/data request. + try: + api_client = k8s_service.load_kubeconfig(cluster) + raw_stats = fetch_tmm_traffic_stats( + api_client, data.get("classified_pods", {}), cluster_id=cluster_id, force=force + ) + traffic_stats = analyze_traffic_stats(data, raw_stats) + except Exception: + logger.exception("Failed to collect TMM traffic stats for cluster %d", cluster_id) + traffic_stats = analyze_traffic_stats(data, None) health = analyze_health(data) topo = analyze_topology(data) @@ -92,6 +204,7 @@ def get_bnk_data( "policyCount": policy["count"], "backends": backends, "palette": palette, + "trafficStats": traffic_stats, "cluster_id": cluster_id, "namespace": namespace, } @@ -102,7 +215,11 @@ def get_bnk_data( # These now delegate to the shared data service. # ============================================================================ -@router.get("/k8s/clusters/{cluster_id}/f5bnk/health", dependencies=[Depends(require_viewer)]) +@router.get( + "/k8s/clusters/{cluster_id}/f5bnk/health", + response_model=BnkHealthEndpointResponse, + dependencies=[Depends(require_viewer)], +) @handle_route_errors("get BNK health") def get_bnk_health( cluster_id: int, @@ -111,13 +228,19 @@ def get_bnk_health( ): """BNK health dashboard — delegates to shared data service.""" k8s_service = KubernetesService(db) - data = fetch_all_bnk_data(k8s_service, cluster_id, namespace) + cluster = k8s_service.get_cluster(cluster_id) + data = fetch_all_bnk_data(k8s_service, cluster_id, namespace, include_nodes=True) + data.update(_build_bnk_context(cluster, db)) result = analyze_health(data) result["cluster_id"] = cluster_id return result -@router.get("/k8s/clusters/{cluster_id}/f5bnk/gateway-topology", dependencies=[Depends(require_viewer)]) +@router.get( + "/k8s/clusters/{cluster_id}/f5bnk/gateway-topology", + response_model=GatewayTopologyResponse, + dependencies=[Depends(require_viewer)], +) @handle_route_errors("get gateway topology") def get_gateway_topology( cluster_id: int, @@ -126,14 +249,20 @@ def get_gateway_topology( ): """Gateway topology graph — delegates to shared data service.""" k8s_service = KubernetesService(db) - data = fetch_all_bnk_data(k8s_service, cluster_id, namespace) + # include_nodes=True aligns the cache key with /f5bnk/data and /f5bnk/health + # so switching tabs reuses the same fetched BNK state. + data = fetch_all_bnk_data(k8s_service, cluster_id, namespace, include_nodes=True) result = analyze_topology(data) result["cluster_id"] = cluster_id result["namespace"] = namespace return result -@router.get("/k8s/clusters/{cluster_id}/f5bnk/policy-gateway-associations", dependencies=[Depends(require_viewer)]) +@router.get( + "/k8s/clusters/{cluster_id}/f5bnk/policy-gateway-associations", + response_model=F5PolicyGatewayAssociationsResponse, + dependencies=[Depends(require_viewer)], +) @handle_route_errors("get policy-gateway associations") def get_policy_gateway_associations( cluster_id: int, @@ -142,7 +271,9 @@ def get_policy_gateway_associations( ): """Policy-gateway associations — delegates to shared data service.""" k8s_service = KubernetesService(db) - data = fetch_all_bnk_data(k8s_service, cluster_id, namespace) + # include_nodes=True aligns the cache key with /f5bnk/data and /f5bnk/health + # so switching tabs reuses the same fetched BNK state. + data = fetch_all_bnk_data(k8s_service, cluster_id, namespace, include_nodes=True) result = analyze_policy_associations(data) result["cluster_id"] = cluster_id result["namespace"] = namespace @@ -225,7 +356,8 @@ def get_a2a_agents( cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) if probe else None - data = fetch_all_bnk_data(k8s_service, cluster_id, namespace) + # include_nodes=True aligns the cache key with the other BNK insight endpoints. + data = fetch_all_bnk_data(k8s_service, cluster_id, namespace, include_nodes=True) topo = analyze_topology(data) agents = discover_a2a_agents( diff --git a/backend/routes/k8s/recovery.py b/backend/routes/k8s/recovery.py index d49899e..cfe528c 100644 --- a/backend/routes/k8s/recovery.py +++ b/backend/routes/k8s/recovery.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from core.cache import cache from core.errors import handle_route_errors from database import get_db from routes.auth import require_operator @@ -331,6 +332,7 @@ def _find_and_restart_pods( @handle_route_errors("check recovery status") def get_recovery_status( cluster_id: int, + force: bool = False, db: Session = Depends(get_db), ): """ @@ -340,6 +342,12 @@ def get_recovery_status( - CWC cert staleness (cwc-license-certs vs cert-manager) - VLAN programming failures (Programmed: False) """ + cache_key = f"recovery:status:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + k8s_service = KubernetesService(db) cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) @@ -347,7 +355,7 @@ def get_recovery_status( cert_stale, cert_detail, cert_status = _check_cwc_cert_stale(api_client) vlans_failed, vlans_detail = _check_vlans_failed(api_client) - return RecoveryStatusResponse( + res = RecoveryStatusResponse( cwc_cert_stale=cert_stale, cwc_cert_status=cert_status, vlans_failed=vlans_failed, @@ -355,6 +363,8 @@ def get_recovery_status( vlans_detail=vlans_detail, platform_healthy=not cert_stale and not vlans_failed, ) + cache.set(cache_key, res, ttl_seconds=60) + return res @router.post( @@ -377,6 +387,11 @@ def resync_cwc_certs( 3. Restart CWC pod (so it re-reads the updated secret) 4. Clean up stale agent pods (they may have old certs cached) """ + cache.delete(f"recovery:status:{cluster_id}") + cache.delete(f"cwc:setup_status:{cluster_id}") + cache.delete(f"cwc:available:{cluster_id}") + cache.delete(f"license:status:{cluster_id}") + k8s_service = KubernetesService(db) cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) @@ -599,6 +614,10 @@ def platform_restart( "message": f"No TMM pods found in {bnk_ns} namespace", }) + cache.delete(f"recovery:status:{cluster_id}") + cache.delete(f"bnk:pods:{cluster_id}") + cache.delete(f"tmm:debug:pods:{cluster_id}") + component_list = ", ".join(r["component"] for r in restarted if r["status"] == "restarted") return PlatformRestartResponse( success=any(r["status"] == "restarted" for r in restarted), diff --git a/backend/routes/k8s/tmm_debug.py b/backend/routes/k8s/tmm_debug.py index cfa1e61..dcae24b 100644 --- a/backend/routes/k8s/tmm_debug.py +++ b/backend/routes/k8s/tmm_debug.py @@ -125,7 +125,7 @@ def get_tmm_debug_pods( cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) - pods = list_tmm_debug_pods(api_client) + pods = list_tmm_debug_pods(api_client, cluster_id) return { "pods": pods, @@ -199,6 +199,7 @@ def post_tmm_debug_tmctl( body.columns, body.width, body.directory, + cluster_id=cluster_id, ) return result @@ -222,7 +223,9 @@ def post_tmm_debug_configview( api_client = k8s_service.load_kubeconfig(cluster) namespace = body.namespace or _resolve_namespace(cluster) - result = exec_configview(api_client, body.pod_name, namespace, body.uuid) + result = exec_configview( + api_client, body.pod_name, namespace, body.uuid, cluster_id=cluster_id + ) return result @@ -245,7 +248,9 @@ def post_tmm_debug_configview_uuids( api_client = k8s_service.load_kubeconfig(cluster) namespace = body.namespace or _resolve_namespace(cluster) - result = discover_configview_uuids(api_client, body.pod_name, namespace) + result = discover_configview_uuids( + api_client, body.pod_name, namespace, cluster_id=cluster_id + ) return result @@ -270,6 +275,8 @@ def post_tmm_debug_bdt( api_client = k8s_service.load_kubeconfig(cluster) namespace = body.namespace or _resolve_namespace(cluster) - result = exec_bdt_cli(api_client, body.pod_name, namespace, body.subcommand) + result = exec_bdt_cli( + api_client, body.pod_name, namespace, body.subcommand, cluster_id=cluster_id + ) return result diff --git a/backend/routes/licensing.py b/backend/routes/licensing.py index 653a258..5d8bfa5 100644 --- a/backend/routes/licensing.py +++ b/backend/routes/licensing.py @@ -235,9 +235,19 @@ async def _try_operator_dispatch( dependencies=[Depends(require_viewer)], ) async def get_license_status_endpoint( - cluster_id: int, db: Session = Depends(get_db), + cluster_id: int, + force: bool = False, + db: Session = Depends(get_db), ): """Get CWC license and telemetry status for a cluster.""" + from core.cache import cache + + cache_key = f"license:status:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + # Try operator path result = await _try_operator_dispatch( db, cluster_id, "cwc_license_status", @@ -248,12 +258,14 @@ async def get_license_status_endpoint( status_code=502, detail=result.get("error_message", "Failed to get license status"), ) - return {**result, "operator_dispatch": True} + response = {**result, "operator_dispatch": True} + cache.set(cache_key, response, ttl_seconds=30) + return response # Legacy fallback try: k8s_service = KubernetesService(db) - return legacy_get_license_status(k8s_service, cluster_id) + return legacy_get_license_status(k8s_service, cluster_id, force=force) except QKViewError as e: raise HTTPException( status_code=e.status_code or 502, @@ -267,7 +279,9 @@ async def get_license_status_endpoint( dependencies=[Depends(require_viewer)], ) async def get_license_report_endpoint( - cluster_id: int, db: Session = Depends(get_db), + cluster_id: int, + force: bool = False, + db: Session = Depends(get_db), ): """ Get CWC telemetry report for a cluster. @@ -275,6 +289,14 @@ async def get_license_report_endpoint( The report is only available when CWC telemetry state is "Config Report Ready to Download". """ + from core.cache import cache + + cache_key = f"license:report:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + # Try operator path result = await _try_operator_dispatch( db, cluster_id, "cwc_license_report", timeout=30.0, @@ -285,12 +307,14 @@ async def get_license_report_endpoint( status_code=502, detail=result.get("error_message", "Failed to get license report"), ) - return {**result, "operator_dispatch": True} + response = {**result, "operator_dispatch": True} + cache.set(cache_key, response, ttl_seconds=60) + return response # Legacy fallback try: k8s_service = KubernetesService(db) - return legacy_get_license_report(k8s_service, cluster_id) + return legacy_get_license_report(k8s_service, cluster_id, force=force) except QKViewError as e: raise HTTPException( status_code=e.status_code or 502, @@ -315,6 +339,8 @@ async def activate_license_endpoint( Use this to activate an evaluation license, switch from eval to paid, or update an existing license. """ + from core.cache import cache + # Try operator path result = await _try_operator_dispatch( db, cluster_id, "cwc_license_activate", @@ -326,6 +352,8 @@ async def activate_license_endpoint( status_code=502, detail=result.get("error_message", "Failed to activate license"), ) + cache.delete(f"license:status:{cluster_id}") + cache.delete(f"license:report:{cluster_id}") return {**result, "operator_dispatch": True} # Legacy fallback @@ -342,6 +370,8 @@ async def activate_license_endpoint( status_code=502, detail=result.get("error_message", "License activation failed"), ) + cache.delete(f"license:status:{cluster_id}") + cache.delete(f"license:report:{cluster_id}") return result @@ -374,6 +404,8 @@ async def renew_license_endpoint( where no previous license state exists, use POST /activate instead. """ # Try operator path + from core.cache import cache + action = "cwc_license_force_renew" if request.force else "cwc_license_renew" timeout = 180.0 if request.force else 60.0 result = await _try_operator_dispatch( @@ -387,6 +419,8 @@ async def renew_license_endpoint( status_code=502, detail=result.get("error_message", "Failed to renew license"), ) + cache.delete(f"license:status:{cluster_id}") + cache.delete(f"license:report:{cluster_id}") return {**result, "operator_dispatch": True} # Legacy fallback @@ -405,6 +439,8 @@ async def renew_license_endpoint( status_code=502, detail=result.get("error_message", "License renewal failed"), ) + cache.delete(f"license:status:{cluster_id}") + cache.delete(f"license:report:{cluster_id}") return result @@ -425,6 +461,8 @@ async def post_license_receipt_endpoint( the F5 licensing server, and receiving a signed manifest back, use this endpoint to deliver the manifest to CWC. """ + from core.cache import cache + # Try operator path result = await _try_operator_dispatch( db, cluster_id, "cwc_license_receipt", @@ -436,6 +474,7 @@ async def post_license_receipt_endpoint( status_code=502, detail=result.get("error_message", "Failed to post license receipt"), ) + cache.delete(f"license:report:{cluster_id}") return {**result, "operator_dispatch": True} # Legacy fallback @@ -454,6 +493,7 @@ async def post_license_receipt_endpoint( status_code=502, detail=result.get("error_message", "License receipt failed"), ) + cache.delete(f"license:report:{cluster_id}") return result diff --git a/backend/routes/operators/__init__.py b/backend/routes/operators/__init__.py index f384d41..61f4e64 100644 --- a/backend/routes/operators/__init__.py +++ b/backend/routes/operators/__init__.py @@ -13,12 +13,12 @@ The kubeconfig-first fleet architecture (Decision D3) made them obsolete. """ import logging -from datetime import UTC, datetime +from datetime import datetime from fastapi import APIRouter, Request from pydantic import BaseModel -from services.operator_registry import operator_connections +from services.operator_registry import is_operator_live_connected, operator_connections logger = logging.getLogger(__name__) @@ -119,6 +119,11 @@ class FleetOperatorHealth(BaseModel): dpu_cluster_count: int = 0 detected_platform_profile: str = "unknown" detected_platform_provider: str | None = None + # Cloud context (KubernetesCluster metadata) + cloud_provider: str | None = None + region: str | None = None + account_id: str | None = None + discovery_status: str | None = None class FleetHealthResponse(BaseModel): @@ -235,15 +240,7 @@ def _dt_to_str(dt: datetime | None) -> str | None: def _operator_to_response(op) -> dict: - # For polling-mode operators, check heartbeat recency instead of WS state - is_connected_ws = operator_connections.is_connected(op.operator_id) - is_connected_polling = False - if op.connectivity_mode == "polling" and op.last_heartbeat_at: - from datetime import timedelta - heartbeat_age = (datetime.now(UTC) - op.last_heartbeat_at).total_seconds() - is_connected_polling = heartbeat_age < 60 # Polling operator is "connected" if heartbeat within 60s - - is_connected = is_connected_ws or is_connected_polling + is_connected = is_operator_live_connected(op) return { "id": op.id, diff --git a/backend/routes/operators/fleet.py b/backend/routes/operators/fleet.py index c9319d8..2a0d017 100644 --- a/backend/routes/operators/fleet.py +++ b/backend/routes/operators/fleet.py @@ -26,8 +26,8 @@ from services.dpf.fetch import detect_dpf from services.kubernetes_service import KubernetesService from services.operator_registry import ( + is_operator_live_connected, list_operators, - operator_connections, ) from services.platform_context_service import PlatformContextService @@ -672,12 +672,7 @@ def get_fleet_health(db: Session = Depends(get_db)): # Enrich with operator metadata if linked linked_op = op_by_cluster.get(cluster.id) if linked_op: - is_connected_ws = operator_connections.is_connected(linked_op.operator_id) - is_connected_polling = False - if linked_op.connectivity_mode == "polling" and linked_op.last_heartbeat_at: - heartbeat_age = (datetime.now(UTC) - linked_op.last_heartbeat_at).total_seconds() - is_connected_polling = heartbeat_age < 60 - _is_connected = is_connected_ws or is_connected_polling + _is_connected = is_operator_live_connected(linked_op) operator_version = linked_op.operator_version connectivity_mode = linked_op.connectivity_mode or "direct_ws" @@ -735,6 +730,10 @@ def get_fleet_health(db: Session = Depends(get_db)): "dpu_cluster_count": result.get("dpu_cluster_count", 0), "detected_platform_profile": platform_context.detected_platform_profile, "detected_platform_provider": platform_context.detected_platform_provider, + "cloud_provider": cluster.cloud_provider, + "region": cluster.region, + "account_id": cluster.account_id, + "discovery_status": cluster.discovery_status, }) response = { diff --git a/backend/routes/system.py b/backend/routes/system.py index e88ba0c..f97fb91 100644 --- a/backend/routes/system.py +++ b/backend/routes/system.py @@ -23,9 +23,9 @@ from core.errors import BadRequestError, InternalError, handle_route_errors from core.maintenance import get_maintenance_status from database import get_db -from routes.auth import require_admin +from routes.auth import require_admin, require_viewer from schemas.backup import BackupCreateRequest, BackupStatusResponse, MaintenanceStatusResponse, RestoreResponse -from schemas.system import SystemHealthResponse +from schemas.system import BnkConsumptionResponse, SystemHealthResponse from services.backup_service import BackupService from services.system_service import SystemService @@ -155,6 +155,13 @@ def get_process_metrics() -> ProcessMetricsResponse: # Task Queue Metrics # ============================================================ +@public_router.get("/bnk-consumption", response_model=BnkConsumptionResponse, dependencies=[Depends(require_viewer)]) +@handle_route_errors("BNK consumption") +def get_bnk_consumption(db: Session = Depends(get_db)): + """Get fleet-wide BNK resource consumption.""" + return SystemService(db).get_bnk_consumption() + + @router.get("/queue-metrics") @handle_route_errors("queue metrics") def get_queue_metrics(db: Session = Depends(get_db)): diff --git a/backend/schemas/bnk.py b/backend/schemas/bnk.py new file mode 100644 index 0000000..5b90d1e --- /dev/null +++ b/backend/schemas/bnk.py @@ -0,0 +1,279 @@ +""" +Pydantic schemas for F5 BNK responses. + +The BNK health dashboard returns a nested legacy vocabulary ("critical" / +"warning" / "healthy" / "unknown"). These schemas type that response so the +frontend generated types stay in sync and OpenAPI captures the shape. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +HealthSeverityV1 = Literal["healthy", "warning", "critical", "unknown"] +ConnectivityStatusV1 = Literal["connected", "reachable", "partial", "unreachable", "unknown"] +OperatorModeV1 = Literal["direct_ws", "polling", "kubeconfig"] + + +class HealthRemediationAction(BaseModel): + label: str + action: Literal["view_logs", "restart_pod", "describe", "diagnostics"] + target: str + namespace: str + + +class HealthPodDetail(BaseModel): + podName: str + namespace: str + nodeName: str | None = None + nodeZone: str | None = None + nodeInstanceType: str | None = None + hostIP: str | None = None + phase: str + restartCount: int + containersReady: str + issue: str + + +class HealthComponentEnrichment(BaseModel): + explanation: str + podDetails: list[HealthPodDetail] + remediationActions: list[HealthRemediationAction] + namespaces: list[str] = Field(default_factory=list) + zones: list[str] = Field(default_factory=list) + nodes: list[str] = Field(default_factory=list) + + +class HealthPlatformComponent(HealthComponentEnrichment): + total: int + running: int | None = None + completed: int | None = None + severity: HealthSeverityV1 + + +class HealthTmmComponent(HealthComponentEnrichment): + pods: int + running: int + containersTotal: int + containersReady: int + totalRestarts: int + severity: HealthSeverityV1 + + +class HealthGatewayComponent(BaseModel): + total: int + programmed: int + accepted: int + severity: HealthSeverityV1 + explanation: str + addresses: list[str] + + +class HealthVlanDetail(BaseModel): + name: str + programmed: bool + interfaces: list[str] + selfIPs: list[str] + mtu: int | None = None + + +class HealthVlanComponent(BaseModel): + total: int + programmed: int + severity: HealthSeverityV1 + explanation: str + details: list[HealthVlanDetail] + + +class HealthIRuleDetail(BaseModel): + name: str + accepted: bool + programmed: bool + error: str | None = None + + +class HealthIRulesComponent(BaseModel): + total: int + accepted: int + programmed: int + severity: HealthSeverityV1 + explanation: str + details: list[HealthIRuleDetail] + + +class HealthCneInstance(BaseModel): + name: str + + class Config: + extra = "allow" + + +class HealthAnalyzerDetail(BaseModel): + name: str + namespace: str + schedule: str + + +class HealthCounts(BaseModel): + gateways: int + listeners: int + httpRoutes: int + vlans: int + firewallPolicies: int + irules: int + analyzers: int + cneInstances: int + tmm_pods: int + tmm_running: int + tmm_containers: str + + +class HealthConnectivityStatus(BaseModel): + status: ConnectivityStatusV1 + message: str + checkedAt: str + + +class HealthIntegrationStatus(BaseModel): + status: HealthSeverityV1 + operatorConnected: bool + operatorMode: OperatorModeV1 + operatorVersion: str | None = None + lastSeen: str | None = None + message: str + + +class BnkHealthPlatformSection(BaseModel): + severity: HealthSeverityV1 + flo: HealthPlatformComponent + controller: HealthPlatformComponent + crdInstaller: HealthPlatformComponent + analyzer: HealthPlatformComponent + + +class BnkHealthDataPlaneSection(BaseModel): + severity: HealthSeverityV1 + tmm: HealthTmmComponent + cneInstance: HealthCneInstance | dict[str, Any] + + +class BnkHealthNetworkingSection(BaseModel): + severity: HealthSeverityV1 + gateways: HealthGatewayComponent + vlans: HealthVlanComponent + listeners: int + httpRoutes: int + staticRoutes: int + snatPools: int + + +class BnkHealthSecuritySection(BaseModel): + severity: HealthSeverityV1 + firewallPolicies: int + securityPolicies: int + networkPolicies: int + addressLists: int + portLists: int + irules: HealthIRulesComponent + + +class BnkHealthAISection(BaseModel): + severity: HealthSeverityV1 + analyzers: int + analyzerDetails: list[HealthAnalyzerDetail] + + +class BnkHealthResponse(BaseModel): + overall: HealthSeverityV1 + installShape: str = "unknown" + installMethod: str = "Unknown" + connectivity: HealthConnectivityStatus + integration: HealthIntegrationStatus + platform: BnkHealthPlatformSection + dataPlane: BnkHealthDataPlaneSection + networking: BnkHealthNetworkingSection + security: BnkHealthSecuritySection + ai: BnkHealthAISection + counts: HealthCounts + + class Config: + extra = "allow" + + +class BnkHealthEndpointResponse(BnkHealthResponse): + cluster_id: int + + +class BnkListenerTrafficStats(BaseModel): + gatewayName: str + gatewayNamespace: str + listenerName: str + clientsideBytesIn: int = 0 + clientsideBytesOut: int = 0 + clientsideCurConns: int = 0 + clientsideTotConns: int = 0 + serversideBytesIn: int = 0 + serversideBytesOut: int = 0 + serversideCurConns: int = 0 + serversideTotConns: int = 0 + + +class BnkEgressTrafficStats(BaseModel): + egressName: str + namespace: str + clientsideBytesIn: int = 0 + clientsideBytesOut: int = 0 + clientsideCurConns: int = 0 + clientsideTotConns: int = 0 + serversideBytesIn: int = 0 + serversideBytesOut: int = 0 + serversideCurConns: int = 0 + serversideTotConns: int = 0 + + +class BnkFirewallRuleTrafficStats(BaseModel): + policyName: str + namespace: str + ruleName: str + action: str = "" + ipProtocol: str = "" + hitCount: int = 0 + + +class BnkTrafficStatsResponse(BaseModel): + """Traffic statistics mapped from TMM dataplane counters.""" + + source: str | None = None + podName: str | None = None + sampledAt: str | None = None + available: bool = False + error: str | None = None + listeners: list[BnkListenerTrafficStats] = Field(default_factory=list) + egresses: list[BnkEgressTrafficStats] = Field(default_factory=list) + firewallRules: list[BnkFirewallRuleTrafficStats] = Field(default_factory=list) + + +class BnkDataResponse(BaseModel): + """Wrapper for the unified /f5bnk/data endpoint. + + The ``health`` and ``trafficStats`` keys are strongly typed; the remaining + keys are kept as loose dicts because their schemas are large and already + typed manually in the frontend. This lets OpenAPI capture new fields + without coupling the whole topology/palette response to Pydantic. + """ + + health: BnkHealthResponse + topology: list[dict[str, Any]] + dataPlane: dict[str, Any] + referenceGrants: list[dict[str, Any]] + topologyCounts: dict[str, Any] + policyAssociations: list[dict[str, Any]] + policyCount: int + backends: list[dict[str, Any]] | None = None + palette: dict[str, Any] | None = None + trafficStats: BnkTrafficStatsResponse | None = None + cluster_id: int + namespace: str | None = None + + class Config: + extra = "allow" diff --git a/backend/schemas/f5bnk.py b/backend/schemas/f5bnk.py new file mode 100644 index 0000000..98a8fa0 --- /dev/null +++ b/backend/schemas/f5bnk.py @@ -0,0 +1,318 @@ +""" +Pydantic response schemas for F5 BNK insight endpoints. + +These models type the standalone /f5bnk/gateway-topology and +/f5bnk/policy-gateway-associations endpoints. The unified /f5bnk/data +endpoint keeps loose topology/palette keys (see schemas.bnk.BnkDataResponse) +to avoid coupling the entire nested response to Pydantic, while still +exposing strongly-typed trafficStats and health sections. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Shared operational-state building blocks +# --------------------------------------------------------------------------- + + +class TopologyCondition(BaseModel): + type: str + status: str + reason: str | None = None + message: str | None = None + lastTransitionTime: str | None = None + + +class PolicyStatus(BaseModel): + resolved: bool = False + programmed: bool = False + messages: dict[str, str | None] = Field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Gateway topology +# --------------------------------------------------------------------------- + + +class TopologyRouteBackend(BaseModel): + name: str + namespace: str | None = None + port: int | None = None + weight: int | None = None + kind: str = "Service" + group: str = "" + + +class TopologyAnalyzer(BaseModel): + name: str + schedule: str + scriptType: str + dataSources: list[str] + parameters: dict[str, str] + + +class TopologyRoute(BaseModel): + name: str + namespace: str + kind: str + hostnames: list[str] + backends: list[TopologyRouteBackend] + analyzers: list[TopologyAnalyzer] + accepted: bool = False + conditions: list[TopologyCondition] = Field(default_factory=list) + conditionMessage: str | None = None + + +class TopologyNetworkPolicyExtension(BaseModel): + kind: str + name: str + group: str + lineCount: int | None = None + eventHandlers: list[str] = Field(default_factory=list) + + +class TopologyNetworkPolicy(BaseModel): + name: str + namespace: str + extensions: list[TopologyNetworkPolicyExtension] + resolvedCount: int + totalExtensions: int + resolved: bool = False + programmed: bool = False + messages: dict[str, str | None] = Field(default_factory=dict) + + +class TopologyAddressList(BaseModel): + name: str + addresses: list[Any] + + +class TopologyPortList(BaseModel): + name: str + ports: list[Any] + + +class TopologyFwRule(BaseModel): + name: str + action: str + ipProtocol: str + logging: bool + + +class TopologyFirewallPolicy(BaseModel): + name: str + rules: list[TopologyFwRule] + addressLists: list[TopologyAddressList] + portLists: list[TopologyPortList] + + +class TopologySecurityPolicy(BaseModel): + name: str + namespace: str + targetListener: str + firewallPolicies: list[TopologyFirewallPolicy] + resolved: bool = False + programmed: bool = False + messages: dict[str, str | None] = Field(default_factory=dict) + + +class TopologyListener(BaseModel): + name: str + protocol: str + port: int | None = None + attachedRouteCount: int = 0 + conditions: list[TopologyCondition] = Field(default_factory=list) + routes: list[TopologyRoute] + networkPolicies: list[TopologyNetworkPolicy] + + +class TopologyGateway(BaseModel): + name: str + namespace: str + gatewayClassName: str + addresses: list[str] + accepted: bool = False + programmed: bool = False + conditions: list[TopologyCondition] = Field(default_factory=list) + listeners: list[TopologyListener] + securityPolicies: list[TopologySecurityPolicy] + + +# --------------------------------------------------------------------------- +# Data plane +# --------------------------------------------------------------------------- + + +class TopologyVlan(BaseModel): + name: str + namespace: str + interfaces: list[Any] + selfipV4s: list[str] + prefixLen: int | str | None = None + mtu: int | None = None + internal: bool + autoLasthop: str + ready: bool + + +class TopologyCneInstance(BaseModel): + name: str + namespace: str + features: dict[str, bool] + networkAttachments: list[Any] + containerPlatform: str + phase: str + ready: bool + + +class TopologyStaticRoute(BaseModel): + name: str + namespace: str + destination: str + gateway: str + + +class TopologySnatPool(BaseModel): + name: str + namespace: str + addresses: list[Any] + + +class TopologyEgress(BaseModel): + name: str + namespace: str + snatType: str + egressSnatpool: str | None = None + firewallEnforcedPolicy: str | None = None + logProfile: str | None = None + capturedNamespaces: list[str] + vxlan: dict[str, str] | None = None + ready: bool + + +class TopologyLogging(BaseModel): + hslPublishers: list[dict[str, Any]] + logProfiles: list[dict[str, Any]] + + +class TopologyDataPlane(BaseModel): + vlans: list[TopologyVlan] + cneInstances: list[TopologyCneInstance] + staticRoutes: list[TopologyStaticRoute] + snatPools: list[TopologySnatPool] + egresses: list[TopologyEgress] + logging: TopologyLogging + + +# --------------------------------------------------------------------------- +# Reference grants + counts + response wrappers +# --------------------------------------------------------------------------- + + +class TopologyReferenceGrantFrom(BaseModel): + group: str + kind: str + namespace: str + + +class TopologyReferenceGrantTo(BaseModel): + group: str + kind: str + + +class TopologyReferenceGrant(BaseModel): + name: str + namespace: str + from_: list[TopologyReferenceGrantFrom] = Field(alias="from") + to: list[TopologyReferenceGrantTo] + + +class TopologyCounts(BaseModel): + gateways: int + listeners: int + httpRoutes: int + grpcRoutes: int + tcpRoutes: int + udpRoutes: int + tlsRoutes: int + l4Routes: int + totalRoutes: int + referenceGrants: int + securityPolicies: int + networkPolicies: int + firewallPolicies: int + iRules: int + analyzers: int + vlans: int + cneInstances: int + staticRoutes: int + snatPools: int + egresses: int + hslPublishers: int + logProfiles: int + + +class GatewayTopologyResponse(BaseModel): + topology: list[TopologyGateway] + dataPlane: TopologyDataPlane + referenceGrants: list[TopologyReferenceGrant] + counts: TopologyCounts + cluster_id: int + namespace: str | None = None + + +# --------------------------------------------------------------------------- +# Policy-gateway associations +# --------------------------------------------------------------------------- + + +class F5FirewallRuleEndpoint(BaseModel): + addresses: list[Any] + ports: list[str] + addressLists: list[str] + portLists: list[str] + + +class F5FirewallRule(BaseModel): + name: str + action: str + ipProtocol: str + source: F5FirewallRuleEndpoint + destination: F5FirewallRuleEndpoint + logging: bool + + +class F5GatewayPolicyAssociation(BaseModel): + kind: Literal["gateway"] = "gateway" + bnk_policy_name: str + namespace: str + gateway_name: str | None = None + listener_name: str | None = None + firewall_policy_name: str + gateway_ip: str | None = None + port: int | None = None + protocol: str | None = None + rules_count: int | None = None + rules: list[F5FirewallRule] = Field(default_factory=list) + bnk_policy_status: PolicyStatus = Field(default_factory=PolicyStatus) + + +class F5EgressPolicyAssociation(BaseModel): + kind: Literal["egress"] = "egress" + egress_name: str | None = None + namespace: str + captured_namespaces: list[str] = Field(default_factory=list) + snat_type: str | None = None + firewall_policy_name: str + rules_count: int | None = None + rules: list[F5FirewallRule] = Field(default_factory=list) + egress_status: PolicyStatus = Field(default_factory=PolicyStatus) + + +class F5PolicyGatewayAssociationsResponse(BaseModel): + associations: list[F5GatewayPolicyAssociation | F5EgressPolicyAssociation] + count: int + cluster_id: int + namespace: str | None = None diff --git a/backend/schemas/k8s.py b/backend/schemas/k8s.py index 21ab73d..9f508be 100644 --- a/backend/schemas/k8s.py +++ b/backend/schemas/k8s.py @@ -110,6 +110,13 @@ class ClusterSummary(BaseModel): enabled_prerequisites: list[str] | None = None bnk_config: BnkClusterConfigSummary | None = None node_count: int | None = None + # Cloud-account / discovery metadata (PLAT-REL-001 / fleet health) + account_id: str | None = None + discovery_status: str | None = None + connectivity_status: str | None = None + integration_status: str | None = None + zones: list[str] = Field(default_factory=list) + access_method: str | None = None # ADR-478/494: release FK ids — deployable = intent (set at deploy time); # running = observed (set by discovery scan). Both nullable. deployable_release_id: int | None = None @@ -148,6 +155,14 @@ class ClusterDetailResponse(BaseModel): ssh_host_override: str | None = None enabled_prerequisites: list[str] | None = None meta_data: dict[str, Any] | None = None + node_count: int | None = None + # Cloud-account / discovery metadata (PLAT-REL-001 / fleet health) + account_id: str | None = None + discovery_status: str | None = None + connectivity_status: str | None = None + integration_status: str | None = None + zones: list[str] = Field(default_factory=list) + access_method: str | None = None # ADR-478/494: release FK ids — deployable = intent (set at deploy time); # running = observed (set by discovery scan). Both nullable. deployable_release_id: int | None = None diff --git a/backend/schemas/projects.py b/backend/schemas/projects.py index d76d0ad..95929a2 100644 --- a/backend/schemas/projects.py +++ b/backend/schemas/projects.py @@ -21,7 +21,13 @@ slugify_gcp_name, slugify_ibm_name, ) -from utils.validators import validate_aws_region, validate_cidr_fields, validate_ibm_region +from utils.validators import ( + validate_aws_region, + validate_azure_region, + validate_cidr_fields, + validate_gcp_region, + validate_ibm_region, +) # ============================================================================= # Shared / Nested Models @@ -84,10 +90,15 @@ def _validate_region(self) -> Self: is_aws = self.cloud_provider == "aws" or (self.project_type and self.project_type.startswith("cloud-aws")) is_ibm = self.cloud_provider == "ibm" or (self.project_type and self.project_type.startswith("cloud-ibm")) is_gcp = self.cloud_provider == "gcp" or (self.project_type and self.project_type.startswith("cloud-gcp")) + is_azure = self.cloud_provider == "azure" or (self.project_type and self.project_type.startswith("cloud-azure")) if is_aws: validate_aws_region(self.region, field_name="region") if is_ibm: validate_ibm_region(self.region, field_name="region") + if is_gcp: + validate_gcp_region(self.region, field_name="region") + if is_azure: + validate_azure_region(self.region, field_name="region") if is_aws and self.name and not is_aws_safe_name(self.name): suggested = slugify_aws_name(self.name) raise ValueError( @@ -134,10 +145,15 @@ def _validate_region(self) -> Self: is_aws = self.cloud_provider == "aws" or (self.project_type and self.project_type.startswith("cloud-aws")) is_ibm = self.cloud_provider == "ibm" or (self.project_type and self.project_type.startswith("cloud-ibm")) is_gcp = self.cloud_provider == "gcp" or (self.project_type and self.project_type.startswith("cloud-gcp")) + is_azure = self.cloud_provider == "azure" or (self.project_type and self.project_type.startswith("cloud-azure")) if is_aws: validate_aws_region(self.region, field_name="region") if is_ibm: validate_ibm_region(self.region, field_name="region") + if is_gcp: + validate_gcp_region(self.region, field_name="region") + if is_azure: + validate_azure_region(self.region, field_name="region") if is_aws and self.name and not is_aws_safe_name(self.name): suggested = slugify_aws_name(self.name) raise ValueError( diff --git a/backend/schemas/system.py b/backend/schemas/system.py index 2a14988..41e063b 100644 --- a/backend/schemas/system.py +++ b/backend/schemas/system.py @@ -174,3 +174,84 @@ class ConfigImportRequest(BaseModel): config: dict[str, Any] = Field( ..., description="BNK configuration to import (from export YAML/JSON)" ) + + +# ============================================================================= +# BNK Resource Consumption Dashboard +# ============================================================================= + +class BnkPlaneConsumption(BaseModel): + """CPU/memory/pod count for a single BNK plane (control-plane or data-plane).""" + + count: int = Field(..., description="Number of BNK pods in this plane") + cpu_millicores: int = Field(..., description="Aggregated CPU usage in millicores") + memory_bytes: int = Field(..., description="Aggregated memory usage in bytes") + + +class BnkTopPod(BaseModel): + """A single BNK pod ranked by resource consumption.""" + + name: str + namespace: str + role: str + cpu_millicores: int + memory_bytes: int + + +class BnkClusterDpfSummary(BaseModel): + """Lightweight DPF/DPU summary for a single cluster.""" + + detected: bool + dpu_count: int + + +class BnkNodeCapacity(BaseModel): + """Node allocatable CPU/memory capacity for a cluster.""" + + cpu_millicores: int = Field(default=0, description="Aggregated node allocatable CPU in millicores") + memory_bytes: int = Field(default=0, description="Aggregated node allocatable memory in bytes") + + +class BnkClusterConsumption(BaseModel): + """Per-cluster BNK resource consumption breakdown.""" + + cluster_id: int + cluster_name: str + reachable: bool + bnk_installed: bool + bnk_version: str | None = None + status: str + node_count: int | None = None + control_plane: BnkPlaneConsumption + data_plane: BnkPlaneConsumption + total: BnkPlaneConsumption + node_capacity: BnkNodeCapacity = Field(default_factory=BnkNodeCapacity) + metrics_available: bool + metrics_error: str | None = None + dpf: BnkClusterDpfSummary + top_pods: list[BnkTopPod] = Field(default_factory=list) + + +class BnkFleetSummary(BaseModel): + """Fleet-wide BNK consumption rollup.""" + + total_clusters: int + reachable_clusters: int + bnk_installed_clusters: int + total_bnk_pods: int + control_plane_pods: int + data_plane_pods: int + total_cpu_millicores: int + total_memory_bytes: int + node_capacity_cpu_millicores: int = 0 + node_capacity_memory_bytes: int = 0 + dpf_detected_clusters: int + dpu_count: int + + +class BnkConsumptionResponse(BaseModel): + """Response for GET /api/system/bnk-consumption.""" + + timestamp: str + fleet_summary: BnkFleetSummary + clusters: list[BnkClusterConsumption] diff --git a/backend/services/azure_service.py b/backend/services/azure_service.py new file mode 100644 index 0000000..ee51077 --- /dev/null +++ b/backend/services/azure_service.py @@ -0,0 +1,215 @@ +"""Azure-specific helpers for cluster discovery and kubeconfig generation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import requests +import yaml + +from core.encryption import decrypt_value +from services.azure_oauth_service import request_azure_oauth_token + +logger = logging.getLogger(__name__) + +AZURE_MANAGEMENT_URL = "https://management.azure.com" +AKS_AAD_SERVER_APP_ID = "6dae42f8-4368-4678-94ff-3960e28e3630" + + +def _azure_credentials_from_template(template) -> dict[str, str]: + """Decrypt and parse Azure service-principal credentials from a template.""" + creds_json = decrypt_value(template.azure_credentials_encrypted) if template.azure_credentials_encrypted else None + if not creds_json: + raise ValueError("Azure credentials are required to discover AKS clusters") + + creds = json.loads(creds_json) + client_id = creds.get("client_id") or creds.get("clientId") + client_secret = creds.get("client_secret") or creds.get("clientSecret") + if not client_id or not client_secret: + raise ValueError("Azure credentials must contain client_id and client_secret") + + return {"client_id": client_id, "client_secret": client_secret} + + +def _azure_management_token(template) -> str: + """Exchange Azure service-principal credentials for a management access token.""" + if not template.azure_tenant_id: + raise ValueError("Azure tenant_id is required to discover AKS clusters") + + creds = _azure_credentials_from_template(template) + token_data = request_azure_oauth_token( + tenant_id=template.azure_tenant_id, + data={ + "grant_type": "client_credentials", + "client_id": creds["client_id"], + "client_secret": creds["client_secret"], + "scope": "https://management.azure.com/.default", + }, + timeout=30, + ) + return token_data["access_token"] + + +def list_aks_clusters_from_template(template) -> list[dict[str, Any]]: + """List AKS clusters across the template's subscription. + + If no subscription_id is configured on the template, an empty list is + returned — Azure requires an explicit subscription scope. + """ + if not template.azure_subscription_id: + raise ValueError("Azure subscription_id is required to list AKS clusters") + + token = _azure_management_token(template) + url = ( + f"{AZURE_MANAGEMENT_URL}/subscriptions/{template.azure_subscription_id}" + "/providers/Microsoft.ContainerService/managedClusters" + "?api-version=2023-10-01" + ) + response = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=30, + ) + if response.status_code in {401, 403}: + raise RuntimeError("Azure credentials are not authorized to list AKS clusters") + if not response.ok: + raise RuntimeError(f"Azure AKS list failed with status {response.status_code}") + + clusters = [] + for item in response.json().get("value", []): + props = item.get("properties", {}) + name = item.get("name") + if not name: + continue + + clusters.append({ + "name": name, + "resource_group": _resource_group_from_id(item.get("id", "")), + "subscription_id": template.azure_subscription_id, + "tenant_id": template.azure_tenant_id, + "fqdn": props.get("fqdn"), + "version": props.get("kubernetesVersion"), + "location": item.get("location"), + }) + + return clusters + + +def _resource_group_from_id(resource_id: str) -> str | None: + """Extract resourceGroup from an Azure resource id path.""" + # /subscriptions/.../resourceGroups/.../providers/... + parts = resource_id.lower().split("/") + try: + idx = parts.index("resourcegroups") + return parts[idx + 1] if idx + 1 < len(parts) else None + except ValueError: + return None + + +def generate_aks_kubeconfig( + cluster_name: str, + server: str, + ca_data: str, + token: str, +) -> str: + """Generate a portable kubeconfig YAML for an AKS cluster. + + Embeds a short-lived AAD bearer token inline. The token must be refreshed + periodically via the cluster kubeconfig refresh path, but the kubeconfig + itself is portable and contains no local file references or exec plugins. + """ + kubeconfig = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [ + { + "name": cluster_name, + "cluster": { + "server": f"https://{server}:443", + "certificate-authority-data": ca_data, + }, + } + ], + "contexts": [ + { + "name": cluster_name, + "context": { + "cluster": cluster_name, + "user": cluster_name, + }, + } + ], + "current-context": cluster_name, + "users": [ + { + "name": cluster_name, + "user": {"token": token}, + } + ], + } + return yaml.dump(kubeconfig, default_flow_style=False) + + +def fetch_aks_bearer_token(template) -> str: + """Fetch an AKS AAD server-scoped bearer token for service-principal auth.""" + if not template.azure_tenant_id: + raise ValueError("Azure tenant_id is required to fetch an AKS bearer token") + + creds = _azure_credentials_from_template(template) + token_data = request_azure_oauth_token( + tenant_id=template.azure_tenant_id, + data={ + "grant_type": "client_credentials", + "client_id": creds["client_id"], + "client_secret": creds["client_secret"], + "scope": f"{AKS_AAD_SERVER_APP_ID}/.default", + }, + timeout=30, + ) + return token_data["access_token"] + + +def fetch_aks_cluster_credentials( + cluster_name: str, + resource_group: str, + subscription_id: str, + template, +) -> dict[str, Any]: + """Fetch AKS kubeconfig credential bundle via Azure management API. + + Returns a dict with ``server`` and ``certificate_authority_data``. + """ + token = _azure_management_token(template) + url = ( + f"{AZURE_MANAGEMENT_URL}/subscriptions/{subscription_id}" + f"/resourceGroups/{resource_group}" + "/providers/Microsoft.ContainerService/managedClusters" + f"/{cluster_name}" + "?api-version=2023-10-01" + ) + response = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"Azure AKS cluster get failed: {response.status_code}") + + props = response.json().get("properties", {}) + server = props.get("fqdn") + ca_data = props.get("certificateAuthority", {}).get("data") + if not server or not ca_data: + raise RuntimeError(f"AKS cluster {cluster_name} is missing fqdn or certificate authority") + + return { + "server": server, + "certificate_authority_data": ca_data, + } diff --git a/backend/services/blueprint_catalog_common.py b/backend/services/blueprint_catalog_common.py index a12d6e7..7dbd2af 100644 --- a/backend/services/blueprint_catalog_common.py +++ b/backend/services/blueprint_catalog_common.py @@ -4,13 +4,20 @@ from typing import Any +CATEGORY_ALIASES: dict[str, str] = { + "infra": "infrastructure", + "app": "solution", + "apps": "solution", + "solutions": "solution", +} + def _resolve_category(manifest: dict[str, Any] | None, source_path: str | None = None) -> str: """Return the category for a blueprint release. Priority: 1. ``manifest["category"]`` when the manifest is present and the value is - a non-empty string. + a non-empty string (normalized via CATEGORY_ALIASES). 2. Fall back to ``"bnk"`` in all other cases (empty manifest, None manifest, or a manifest that has no ``category`` key / an empty value). @@ -21,5 +28,6 @@ def _resolve_category(manifest: dict[str, Any] | None, source_path: str | None = if manifest: category = manifest.get("category") if category and isinstance(category, str) and category.strip(): - return category.strip() + cat = category.strip().lower() + return CATEGORY_ALIASES.get(cat, cat) return "bnk" diff --git a/backend/services/bnk/__init__.py b/backend/services/bnk/__init__.py index 513e5b0..fd789d1 100644 --- a/backend/services/bnk/__init__.py +++ b/backend/services/bnk/__init__.py @@ -36,6 +36,7 @@ from services.bnk.palette import extract_palette_data from services.bnk.policy_associations import analyze_policy_associations from services.bnk.topology import analyze_topology +from services.bnk.traffic_stats import analyze_traffic_stats, fetch_tmm_traffic_stats __all__ = [ # Fetch @@ -47,6 +48,8 @@ "analyze_backends", "extract_palette_data", "analyze_policy_associations", + "analyze_traffic_stats", + "fetch_tmm_traffic_stats", # Helpers "build_route_ref_map", "safe_get", diff --git a/backend/services/bnk/consumption.py b/backend/services/bnk/consumption.py new file mode 100644 index 0000000..7c3b49c --- /dev/null +++ b/backend/services/bnk/consumption.py @@ -0,0 +1,243 @@ +""" +BNK resource consumption aggregation. + +Pure functions that turn fetched BNK data + pod metrics into the response +shape used by ``GET /api/system/bnk-consumption``. +""" + +from typing import Any + +from services.bnk_pod_discovery import detect_install_shape + +# Roles that belong to the BNK control plane. +_CONTROL_PLANE_ROLES: frozenset[str] = frozenset({"flo", "controller", "analyzer", "crd_installer"}) +_DATA_PLANE_ROLES: frozenset[str] = frozenset({"tmm"}) + + +def _empty_plane() -> dict[str, int]: + return {"count": 0, "cpu_millicores": 0, "memory_bytes": 0} + + +def _empty_capacity() -> dict[str, int]: + return {"cpu_millicores": 0, "memory_bytes": 0} + + +def _sum_node_capacity(nodes: dict[str, dict[str, Any]]) -> dict[str, int]: + """Sum allocatable CPU/memory across all nodes in the enrichment map.""" + total_cpu = 0 + total_memory = 0 + for node in nodes.values(): + total_cpu += int(node.get("allocatable_cpu", 0) or 0) + total_memory += int(node.get("allocatable_memory", 0) or 0) + return {"cpu_millicores": total_cpu, "memory_bytes": total_memory} + + +def _classify_role(role: str | None) -> str: + """Classify a pod role into 'control_plane', 'data_plane', or 'other'.""" + if role in _DATA_PLANE_ROLES: + return "data_plane" + if role in _CONTROL_PLANE_ROLES: + return "control_plane" + return "other" + + +def _build_metrics_lookup(pod_metrics: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]: + """Index pod metrics by (namespace, name) for O(1) lookup.""" + lookup: dict[tuple[str, str], dict[str, Any]] = {} + for metric in pod_metrics: + name = metric.get("name") + namespace = metric.get("namespace") + if name and namespace: + lookup[(namespace, name)] = metric + return lookup + + +def _aggregate_plane( + pods: list[dict[str, Any]], + metrics_lookup: dict[tuple[str, str], dict[str, Any]], +) -> dict[str, Any]: + """Sum CPU/memory for a list of pods using the metrics lookup.""" + total_cpu = 0 + total_memory = 0 + for pod in pods: + key = (pod.get("namespace", ""), pod.get("name", "")) + metric = metrics_lookup.get(key) + if metric: + total_cpu += int(metric.get("cpu_millicores", 0) or 0) + total_memory += int(metric.get("memory_bytes", 0) or 0) + return { + "count": len(pods), + "cpu_millicores": total_cpu, + "memory_bytes": total_memory, + } + + +def _build_top_pods( + classified_pods: dict[str, list[dict[str, Any]]], + metrics_lookup: dict[tuple[str, str], dict[str, Any]], + limit: int = 5, +) -> list[dict[str, Any]]: + """Return the top BNK pods by CPU usage.""" + scored: list[tuple[int, dict[str, Any]]] = [] + for role, pods in classified_pods.items(): + for pod in pods: + key = (pod.get("namespace", ""), pod.get("name", "")) + metric = metrics_lookup.get(key) + cpu = int(metric.get("cpu_millicores", 0) or 0) if metric else 0 + memory = int(metric.get("memory_bytes", 0) or 0) if metric else 0 + scored.append((cpu, { + "name": pod.get("name", ""), + "namespace": pod.get("namespace", ""), + "role": role, + "cpu_millicores": cpu, + "memory_bytes": memory, + })) + + scored.sort(key=lambda x: x[0], reverse=True) + return [item[1] for item in scored[:limit]] + + +def aggregate_cluster_consumption( + cluster_id: int, + cluster_name: str, + node_count: int | None, + status: str, + bnk_data: dict[str, Any] | None, + pod_metrics_response: dict[str, Any] | None, + dpf_summary: dict[str, Any] | None, + reachable: bool = True, +) -> dict[str, Any]: + """ + Build a per-cluster consumption dict from BNK data + metrics. + + Pure function — all I/O must be performed by the caller. + """ + metrics_response = pod_metrics_response or {} + metrics_available = bool(metrics_response.get("available")) + metrics_error = metrics_response.get("error") if not metrics_available else None + pod_metrics = metrics_response.get("metrics", []) if metrics_available else [] + metrics_lookup = _build_metrics_lookup(pod_metrics) + + dpf = dpf_summary or {"detected": False, "dpu_count": 0} + nodes = (bnk_data or {}).get("nodes", {}) or {} + node_capacity = _sum_node_capacity(nodes) + + if not reachable or bnk_data is None: + return { + "cluster_id": cluster_id, + "cluster_name": cluster_name, + "reachable": False, + "bnk_installed": False, + "bnk_version": None, + "status": "offline" if not reachable else status, + "node_count": node_count, + "control_plane": _empty_plane(), + "data_plane": _empty_plane(), + "total": _empty_plane(), + "node_capacity": node_capacity, + "metrics_available": metrics_available, + "metrics_error": metrics_error, + "dpf": {"detected": bool(dpf.get("detected")), "dpu_count": int(dpf.get("dpu_count", 0))}, + "top_pods": [], + } + + classified = bnk_data.get("classified_pods", {}) or {} + install_shape = detect_install_shape(classified) + bnk_installed = install_shape in ("flo", "helm") + + control_pods: list[dict[str, Any]] = [] + data_pods: list[dict[str, Any]] = [] + for role, pods in classified.items(): + for pod in pods: + if role in _DATA_PLANE_ROLES: + data_pods.append(pod) + elif role in _CONTROL_PLANE_ROLES: + control_pods.append(pod) + + control_plane = _aggregate_plane(control_pods, metrics_lookup) + data_plane = _aggregate_plane(data_pods, metrics_lookup) + total = { + "count": control_plane["count"] + data_plane["count"], + "cpu_millicores": control_plane["cpu_millicores"] + data_plane["cpu_millicores"], + "memory_bytes": control_plane["memory_bytes"] + data_plane["memory_bytes"], + } + + # Derive BNK version from pod images (reuses fleet health heuristic) + bnk_version = _extract_bnk_version(classified) + + return { + "cluster_id": cluster_id, + "cluster_name": cluster_name, + "reachable": True, + "bnk_installed": bnk_installed, + "bnk_version": bnk_version, + "status": status, + "node_count": node_count, + "control_plane": control_plane, + "data_plane": data_plane, + "total": total, + "node_capacity": node_capacity, + "metrics_available": metrics_available, + "metrics_error": metrics_error, + "dpf": {"detected": bool(dpf.get("detected")), "dpu_count": int(dpf.get("dpu_count", 0))}, + "top_pods": _build_top_pods(classified, metrics_lookup), + } + + +def aggregate_fleet_summary(clusters: list[dict[str, Any]]) -> dict[str, Any]: + """Roll up per-cluster consumption into fleet totals.""" + total_clusters = len(clusters) + reachable_clusters = sum(1 for c in clusters if c.get("reachable")) + bnk_installed_clusters = sum(1 for c in clusters if c.get("bnk_installed")) + dpf_detected_clusters = sum(1 for c in clusters if c.get("dpf", {}).get("detected")) + dpu_count = sum(c.get("dpf", {}).get("dpu_count", 0) for c in clusters) + + total_bnk_pods = 0 + control_plane_pods = 0 + data_plane_pods = 0 + total_cpu = 0 + total_memory = 0 + total_node_capacity_cpu = 0 + total_node_capacity_memory = 0 + + for cluster in clusters: + total_plane = cluster.get("total", {}) + control_plane = cluster.get("control_plane", {}) + data_plane = cluster.get("data_plane", {}) + node_capacity = cluster.get("node_capacity", {}) + total_bnk_pods += int(total_plane.get("count", 0)) + control_plane_pods += int(control_plane.get("count", 0)) + data_plane_pods += int(data_plane.get("count", 0)) + total_cpu += int(total_plane.get("cpu_millicores", 0)) + total_memory += int(total_plane.get("memory_bytes", 0)) + total_node_capacity_cpu += int(node_capacity.get("cpu_millicores", 0)) + total_node_capacity_memory += int(node_capacity.get("memory_bytes", 0)) + + return { + "total_clusters": total_clusters, + "reachable_clusters": reachable_clusters, + "bnk_installed_clusters": bnk_installed_clusters, + "total_bnk_pods": total_bnk_pods, + "control_plane_pods": control_plane_pods, + "data_plane_pods": data_plane_pods, + "total_cpu_millicores": total_cpu, + "total_memory_bytes": total_memory, + "node_capacity_cpu_millicores": total_node_capacity_cpu, + "node_capacity_memory_bytes": total_node_capacity_memory, + "dpf_detected_clusters": dpf_detected_clusters, + "dpu_count": dpu_count, + } + + +def _extract_bnk_version(classified_pods: dict[str, list[dict[str, Any]]]) -> str | None: + """Extract BNK version from TMM/FLO/controller container images.""" + import re + + for role in ("tmm", "flo", "controller"): + for pod in classified_pods.get(role, []): + for container in pod.get("containers", []): + image = container.get("image", "") + match = re.search(r":v?(\d+\.\d+\.\d+)", image) + if match: + return match.group(1) + return None diff --git a/backend/services/bnk/fetch.py b/backend/services/bnk/fetch.py index 7bf3058..a47c235 100644 --- a/backend/services/bnk/fetch.py +++ b/backend/services/bnk/fetch.py @@ -6,40 +6,159 @@ ``fetch_all_bnk_data`` and are pure data transformations. """ +import os from concurrent.futures import ThreadPoolExecutor from typing import Any from kubernetes import client as k8s_client +from core.cache import cache from services.bnk.helpers import BNK_RESOURCE_TYPES from services.bnk_pod_discovery import ( classify_f5_pods, discover_f5_pods, ) +from services.kubernetes._metrics import parse_cpu_to_millicores, parse_memory_to_bytes from services.kubernetes._resources import resolve_resource_type from services.kubernetes_service import KubernetesService +from services.scanner.nodes import parse_node + +# Short-term cache for BNK data fetch. The dashboard polls /f5bnk/data and +# /f5bnk/gateway-topology, and fleet BNK consumption aggregates the same data +# across clusters. A 60-second TTL prevents redundant expensive K8s API bursts +# when the user navigates/polls, while keeping staleness acceptable for views. +_BNK_DATA_CACHE_TTL = 60 +_BNK_POD_DISCOVERY_CACHE_TTL = 60 + +# Shared executor for BNK CRD/pod fetches. A per-request executor with +# max_workers=20 explodes the process thread count when multiple BNK pages +# are open (100+ threads observed on a laptop). Because this pool is shared +# across all requests, we can keep more workers available without thread +# explosion; the limit is network/batch parallelism to the K8s API. +_BNK_FETCH_WORKERS = min(16, (os.cpu_count() or 4) + 4) +_bnk_fetch_executor: ThreadPoolExecutor | None = None + + +def _get_bnk_fetch_executor() -> ThreadPoolExecutor: + global _bnk_fetch_executor + if _bnk_fetch_executor is None: + _bnk_fetch_executor = ThreadPoolExecutor( + max_workers=_BNK_FETCH_WORKERS, + thread_name_prefix="bnk-fetch-", + ) + return _bnk_fetch_executor _CRD_INSTALLER_NAMESPACE = "f5-utils" _CRD_INSTALLER_LABEL = "app=crd-installer" +def _node_enrichment(node) -> dict[str, Any] | None: + """Extract placement-relevant fields from a V1Node. + + Reuses ``services.scanner.nodes.parse_node`` so the label fallback logic + for zone and instance-type stays in one place. Also includes allocatable + and capacity CPU/memory so fleet BNK resources can fall back to node + capacity when cluster metrics-server is not installed. + """ + meta = getattr(node, "metadata", None) + if not meta or not getattr(meta, "name", None): + return None + parsed = parse_node(node) + allocatable = parsed.get("allocatable", {}) + capacity = parsed.get("capacity", {}) + return { + "name": parsed["name"], + "zone": parsed.get("zone"), + "instance_type": parsed.get("instance_type"), + "labels": parsed.get("labels", {}), + "allocatable_cpu": parse_cpu_to_millicores(allocatable.get("cpu")), + "allocatable_memory": parse_memory_to_bytes(allocatable.get("memory")), + "capacity_cpu": parse_cpu_to_millicores(capacity.get("cpu")), + "capacity_memory": parse_memory_to_bytes(capacity.get("memory")), + } + + +def _fetch_nodes(api_client) -> dict[str, dict[str, Any]]: + """Fetch cluster nodes and return a name-indexed enrichment map.""" + try: + v1 = k8s_client.CoreV1Api(api_client) + nodes = v1.list_node(_request_timeout=10).items or [] + result: dict[str, dict[str, Any]] = {} + for node in nodes: + enriched = _node_enrichment(node) + if enriched: + result[enriched["name"]] = enriched + return result + except Exception: + return {} + + +def _bnk_data_cache_key(cluster_id: int, namespace: str | None, include_nodes: bool) -> str: + return f"bnk:data:{cluster_id}:{namespace or 'all'}:{include_nodes}" + + +def _cached_discover_f5_pods( + cluster_id: int, + api_client, + extra_namespaces: list[str], +) -> tuple[list[dict], list[dict]]: + """Discover F5 pods with a short-lived per-cluster cache. + + Pod discovery is I/O-heavy (parallel namespace queries + optional cluster- + wide sweep). Caching the result for a few seconds removes the duplicate work + when the BNK page loads multiple insight endpoints in quick succession. + """ + from core.cache import cache + + cache_key = f"bnk:pods:{cluster_id}" + cached = cache.get(cache_key) + if cached is not None: + return cached + + result = discover_f5_pods(api_client, extra_namespaces=extra_namespaces) + cache.set(cache_key, result, ttl_seconds=_BNK_POD_DISCOVERY_CACHE_TTL) + return result + + def fetch_all_bnk_data( k8s_service: KubernetesService, cluster_id: int, namespace: str | None = None, + *, + include_nodes: bool = False, + force: bool = False, ) -> dict[str, Any]: """ Fetch all BNK CRD resources + pods in one parallel burst. + Results are cached for 15 seconds by (cluster_id, namespace, include_nodes). + Pass ``force=True`` to bypass the cache (used by explicit "Rescan"/refresh + actions and by operations that need the freshest state). + Returns a dict with: - resources: {resource_type_key: [items...]} for all CRD types - pods: {tenant: [...], utils: [...]} - classified_pods: {tmm: [...], flo: [...], controller: [...], ...} + - nodes: {nodeName: {zone, instance_type, labels}} (only if include_nodes=True) - cluster_id, namespace """ + cache_key = _bnk_data_cache_key(cluster_id, namespace, include_nodes) + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) + # Seed BNK pod discovery with namespaces the scanner has previously + # observed F5 components in. This lets the fast-path phase find pods + # in non-standard namespaces without relying on the expensive cluster- + # wide sweep on every BNK page load. + persisted_namespaces: list[str] = list( + getattr(cluster, "discovered_namespaces", None) or [] + ) + def safe_fetch(resource_type_key: str) -> list[dict]: try: rt = resolve_resource_type(k8s_service.db, cluster_id, resource_type_key) @@ -82,23 +201,32 @@ def fetch_crd_installer_job() -> dict | None: except Exception: return None - # Fire all CRD fetches + pod discovery + job status in parallel - with ThreadPoolExecutor(max_workers=20) as executor: - crd_futures = {rt: executor.submit(safe_fetch, rt) for rt in BNK_RESOURCE_TYPES} - pods_future = executor.submit(discover_f5_pods, api_client) - job_future = executor.submit(fetch_crd_installer_job) - - resources = {rt: fut.result() for rt, fut in crd_futures.items()} - tenant_pods, utils_pods = pods_future.result() - crd_installer_job = job_future.result() + # Fire all CRD fetches + pod discovery + job status + nodes in parallel. + # Use the module-level shared executor so concurrent BNK page loads do not + # each spawn 20 threads and overwhelm the backend process. + executor = _get_bnk_fetch_executor() + crd_futures = {rt: executor.submit(safe_fetch, rt) for rt in BNK_RESOURCE_TYPES} + pods_future = executor.submit( + _cached_discover_f5_pods, cluster_id, api_client, persisted_namespaces + ) + job_future = executor.submit(fetch_crd_installer_job) + nodes_future = executor.submit(_fetch_nodes, api_client) if include_nodes else None + + resources = {rt: fut.result() for rt, fut in crd_futures.items()} + tenant_pods, utils_pods = pods_future.result() + crd_installer_job = job_future.result() + nodes = nodes_future.result() if nodes_future is not None else {} classified = classify_f5_pods(tenant_pods, utils_pods) - return { + result = { "resources": resources, "pods": {"tenant": tenant_pods, "utils": utils_pods}, "classified_pods": classified, "crd_installer_job": crd_installer_job, + "nodes": nodes, "cluster_id": cluster_id, "namespace": namespace, } + cache.set(cache_key, result, ttl_seconds=_BNK_DATA_CACHE_TTL) + return result diff --git a/backend/services/bnk/health.py b/backend/services/bnk/health.py index 094a7eb..a4fd91c 100644 --- a/backend/services/bnk/health.py +++ b/backend/services/bnk/health.py @@ -7,8 +7,13 @@ Pure data transformation: takes the dict from ``fetch_all_bnk_data`` and returns a structured health dashboard dict. + +Connectivity and integration context can be injected by the caller via the +``connectivity`` and ``integration`` keys in the input dict. When absent, +defaults are derived from the fact that fetch_all_bnk_data succeeded. """ +from datetime import UTC, datetime from typing import Any # Components whose absence (zero pods) is not a failure — they're optional or @@ -81,15 +86,22 @@ def _pod_issue_summary(pod: dict) -> str: return "" -def _pod_details(pod: dict) -> dict[str, Any]: +def _pod_details( + pod: dict, + nodes_by_name: dict[str, dict[str, Any]] | None = None, +) -> dict[str, Any]: """Extract display-friendly details from a pod dict.""" containers = pod.get("containers", []) total_restarts = sum(c.get("restartCount", 0) for c in containers) ready_count = sum(1 for c in containers if c.get("ready")) + node_name = pod.get("nodeName") + node_info = nodes_by_name.get(node_name) if nodes_by_name and node_name else None return { "podName": pod.get("name", ""), "namespace": pod.get("namespace", ""), - "nodeName": pod.get("nodeName"), + "nodeName": node_name, + "nodeZone": node_info.get("zone") if node_info else None, + "nodeInstanceType": node_info.get("instance_type") if node_info else None, "hostIP": pod.get("hostIP"), "phase": pod.get("phase", "Unknown"), "restartCount": total_restarts, @@ -103,10 +115,11 @@ def _build_component_health( pods: list[dict], healthy_pods: list[dict], severity: str, + nodes_by_name: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build enriched component health with explanation, pod details, and actions.""" explanation = COMPONENT_EXPLANATIONS.get(component_key, "") - pod_detail_list = [_pod_details(p) for p in pods] + pod_detail_list = [_pod_details(p, nodes_by_name) for p in pods] # Use set of ids for O(1) membership test instead of O(n) list scan healthy_ids = {id(p) for p in healthy_pods} @@ -123,10 +136,27 @@ def _build_component_health( actions.append({"label": "Restart Pod", "action": "restart_pod", "target": pod_name, "namespace": pod_ns}) actions.append({"label": "Describe", "action": "describe", "target": pod_name, "namespace": pod_ns}) + # Placement context: namespaces, nodes, and availability zones the + # component's pods run in. Empty when RBAC hides nodes or pods lack + # node assignment — the UI degrades gracefully. + namespaces = sorted({p.get("namespace") for p in pods if p.get("namespace")}) + nodes = sorted({p.get("nodeName") for p in pods if p.get("nodeName")}) + zones = sorted( + { + (nodes_by_name or {}).get(p.get("nodeName"), {}).get("zone") + for p in pods + if p.get("nodeName") + } + - {None} + ) + return { "explanation": explanation, "podDetails": pod_detail_list, "remediationActions": actions, + "namespaces": namespaces, + "nodes": nodes, + "zones": zones, } @@ -214,6 +244,7 @@ def _build_platform_health( classified: dict[str, list], crd_installer_job: dict | None = None, install_shape: str = "unknown", + nodes_by_name: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build the platform health section (FLO, controller, CRD installer, analyzer).""" sections = { @@ -235,7 +266,7 @@ def _build_platform_health( "total": len(pods), count_key: len(healthy), "severity": sev, - **_build_component_health(key, pods, healthy, sev), + **_build_component_health(key, pods, healthy, sev, nodes_by_name), } if include_in_rollup: rollup_inputs.append(sev) @@ -246,7 +277,7 @@ def _build_platform_health( "total": len(pods), count_key: len(healthy), "severity": sev, - **_build_component_health(key, pods, healthy, sev), + **_build_component_health(key, pods, healthy, sev, nodes_by_name), } # Analyzer is optional; its absence shouldn't degrade rollup. @@ -266,6 +297,7 @@ def _build_platform_health( def _build_data_plane_health( classified: dict[str, list], cneinstances: list[dict], + nodes_by_name: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """Build the data plane health section (TMM pods, CNE features).""" tmm_pods = classified["tmm"] @@ -290,7 +322,7 @@ def _build_data_plane_health( "containersReady": tmm_containers_ready, "totalRestarts": tmm_restarts, "severity": tmm_sev, - **_build_component_health("tmm", tmm_pods, tmm_running, tmm_sev), + **_build_component_health("tmm", tmm_pods, tmm_running, tmm_sev, nodes_by_name), }, "cneInstance": _extract_cne_features(cneinstances), } @@ -427,6 +459,65 @@ def _build_ai_health( return ai_health +# --------------------------------------------------------------------------- +# Connectivity / integration status +# --------------------------------------------------------------------------- + + +def _utc_now_iso() -> str: + """Return current UTC time as an ISO-8601 string.""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _build_connectivity_status(data: dict[str, Any]) -> dict[str, Any]: + """Build cluster connectivity status. + + Callers that already performed a reachability probe should pass the + result in ``data["connectivity"]``. Otherwise we infer "connected" + from the fact that ``fetch_all_bnk_data`` succeeded and produced data. + """ + injected = data.get("connectivity") + if isinstance(injected, dict): + return { + "status": injected.get("status", "connected"), + "message": injected.get("message", "Kubernetes API is accessible"), + "checkedAt": injected.get("checkedAt") or _utc_now_iso(), + } + return { + "status": "connected", + "message": "Kubernetes API is accessible", + "checkedAt": _utc_now_iso(), + } + + +def _build_integration_status(data: dict[str, Any]) -> dict[str, Any]: + """Build BNK integration status (operator / CWC / license context). + + Callers should pass a pre-computed ``data["integration"]`` dict built + from the linked ``ConnectedOperator`` record. When absent we return a + kubeconfig-mode default (healthy, because the dashboard is reachable via + kubeconfig and that is a supported operational mode). + """ + injected = data.get("integration") + if isinstance(injected, dict): + return { + "status": injected.get("status", "unknown"), + "operatorConnected": injected.get("operatorConnected", False), + "operatorMode": injected.get("operatorMode", "kubeconfig"), + "operatorVersion": injected.get("operatorVersion"), + "lastSeen": injected.get("lastSeen"), + "message": injected.get("message", ""), + } + return { + "status": "healthy", + "operatorConnected": False, + "operatorMode": "kubeconfig", + "operatorVersion": None, + "lastSeen": None, + "message": "Cluster managed via kubeconfig", + } + + # --------------------------------------------------------------------------- # Main analysis # --------------------------------------------------------------------------- @@ -438,10 +529,11 @@ def analyze_health(data: dict[str, Any]) -> dict[str, Any]: classified = data["classified_pods"] cneinstances = resources.get("cneinstance", []) crd_installer_job: dict | None = data.get("crd_installer_job") + nodes_by_name: dict[str, dict[str, Any]] = data.get("nodes") or {} install_shape = detect_install_shape(classified) - platform = _build_platform_health(classified, crd_installer_job, install_shape) - data_plane = _build_data_plane_health(classified, cneinstances) + platform = _build_platform_health(classified, crd_installer_job, install_shape, nodes_by_name) + data_plane = _build_data_plane_health(classified, cneinstances, nodes_by_name) networking = _build_networking_health(resources) security = _build_security_health(resources) cne_features = data_plane["cneInstance"] @@ -484,6 +576,9 @@ def analyze_health(data: dict[str, Any]) -> dict[str, Any]: for g in gateways ) + connectivity = _build_connectivity_status(data) + integration = _build_integration_status(data) + return { "overall": overall, "installShape": install_shape, @@ -492,6 +587,8 @@ def analyze_health(data: dict[str, Any]) -> dict[str, Any]: "helm": "Helm / manual", "unknown": "Unknown", }[install_shape], + "connectivity": connectivity, + "integration": integration, "platform": platform, "dataPlane": data_plane, "networking": networking, diff --git a/backend/services/bnk/helpers.py b/backend/services/bnk/helpers.py index 77b4f37..46f562d 100644 --- a/backend/services/bnk/helpers.py +++ b/backend/services/bnk/helpers.py @@ -49,13 +49,20 @@ def resource_key(resource: dict) -> str: # --------------------------------------------------------------------------- -def has_condition(resource: dict, cond_type: str, expected: str = "True") -> bool: - """Check if a K8s resource has a condition of given type with expected status.""" +def has_condition(resource: dict, cond_type: str, expected: str | bool = "True") -> bool: + """Check if a K8s resource (or status/conditions container) has a condition of given type with expected status.""" if not isinstance(resource, dict): return False - conditions = safe_get(resource, "status", "conditions", default=[]) or [] + conditions = resource.get("conditions") + if conditions is None: + conditions = safe_get(resource, "status", "conditions", default=[]) + if not isinstance(conditions, list): + return False + exp_str = str(expected).lower() return any( - isinstance(c, dict) and c.get("type") == cond_type and c.get("status") == expected + isinstance(c, dict) + and str(c.get("type", "")).lower() == cond_type.lower() + and str(c.get("status", "")).lower() == exp_str for c in conditions ) @@ -64,14 +71,102 @@ def get_condition_message(resource: dict, cond_type: str) -> str: """Get the message string from a K8s resource condition.""" if not isinstance(resource, dict): return "" - conditions = safe_get(resource, "status", "conditions", default=[]) or [] + conditions = resource.get("conditions") + if conditions is None: + conditions = safe_get(resource, "status", "conditions", default=[]) + if not isinstance(conditions, list): + return "" + type_lower = cond_type.lower() for c in conditions: - if isinstance(c, dict) and c.get("type") == cond_type: - msg: str = c.get("message", "") + if isinstance(c, dict) and str(c.get("type", "")).lower() == type_lower: + msg: str = c.get("message", "") or c.get("reason", "") return msg return "" +def get_policy_operational_status(resource: dict) -> dict[str, Any]: + """Derive resolved/programmed operational state from a BNK policy resource (BNKSecPolicy, BNKNetPolicy).""" + if not isinstance(resource, dict): + return {"resolved": False, "programmed": False, "messages": {"resolved": "", "programmed": ""}} + + status = resource.get("status", {}) or {} + ancestors = status.get("ancestors") + descendants = status.get("descendants") + + if (isinstance(ancestors, list) and len(ancestors) > 0) or (isinstance(descendants, list) and len(descendants) > 0): + ancestors_list = ancestors if isinstance(ancestors, list) else [] + descendants_list = descendants if isinstance(descendants, list) else [] + + ancestor_errors: list[str] = [] + descendant_errors: list[str] = [] + + for a in ancestors_list: + if not isinstance(a, dict): + continue + a_conds = a.get("conditions", []) + is_ok = any( + isinstance(c, dict) + and str(c.get("status", "")).lower() == "true" + and c.get("reason") != "RefNotFound" + for c in a_conds + ) + if not is_ok: + for c in a_conds: + if isinstance(c, dict) and c.get("message"): + ancestor_errors.append(str(c.get("message"))) + break + + for d in descendants_list: + if not isinstance(d, dict): + continue + d_conds = d.get("conditions", []) + is_ok = any( + isinstance(c, dict) + and str(c.get("status", "")).lower() == "true" + and c.get("reason") != "RefNotFound" + for c in d_conds + ) + if not is_ok: + for c in d_conds: + if isinstance(c, dict) and c.get("message"): + descendant_errors.append(str(c.get("message"))) + break + + all_ok = len(ancestor_errors) == 0 and len(descendant_errors) == 0 + resolved_msg = "; ".join(ancestor_errors) if ancestor_errors else "" + programmed_msg = "; ".join(descendant_errors) if descendant_errors else "" + + return { + "resolved": all_ok or (len(ancestor_errors) == 0 and len(ancestors_list) > 0), + "programmed": all_ok, + "messages": { + "resolved": resolved_msg, + "programmed": programmed_msg, + }, + } + + resolved = ( + has_condition(resource, "Resolved") + or has_condition(resource, "ResolvedRefs") + or has_condition(resource, "Accepted") + or has_condition(resource, "Ready") + ) + has_programmed_false = has_condition(resource, "Programmed", "False") + programmed = ( + has_condition(resource, "Programmed") + or (has_condition(resource, "Ready") and not has_programmed_false) + or (resolved and not has_programmed_false and not get_condition_message(resource, "Programmed")) + ) + return { + "resolved": resolved, + "programmed": programmed, + "messages": { + "resolved": get_condition_message(resource, "Resolved") or get_condition_message(resource, "ResolvedRefs") or get_condition_message(resource, "Accepted"), + "programmed": get_condition_message(resource, "Programmed"), + }, + } + + # --------------------------------------------------------------------------- # Severity helpers # --------------------------------------------------------------------------- diff --git a/backend/services/bnk/policy_associations.py b/backend/services/bnk/policy_associations.py index 6e248d9..1c237e6 100644 --- a/backend/services/bnk/policy_associations.py +++ b/backend/services/bnk/policy_associations.py @@ -14,7 +14,18 @@ from typing import Any -from services.bnk.helpers import make_resource_map, resolve_list_refs, resource_name, resource_ns +from services.bnk.helpers import ( + get_policy_operational_status, + make_resource_map, + resolve_list_refs, + resource_name, + resource_ns, +) + + +def _policy_status(resource: dict) -> dict[str, Any]: + """Derive resolved/programmed operational state from a BNK resource.""" + return get_policy_operational_status(resource) def analyze_policy_associations(data: dict[str, Any]) -> dict[str, Any]: @@ -119,6 +130,8 @@ def _build_association( policy, addr_map or {}, port_map or {}, bnk_ns, ) + association["bnk_policy_status"] = _policy_status(bnk) + return association @@ -209,4 +222,6 @@ def _build_egress_association( policy, addr_map or {}, port_map or {}, egress_ns, ) + association["egress_status"] = _policy_status(egress) + return association diff --git a/backend/services/bnk/topology.py b/backend/services/bnk/topology.py index 1a4db70..e4e882a 100644 --- a/backend/services/bnk/topology.py +++ b/backend/services/bnk/topology.py @@ -13,6 +13,7 @@ from typing import Any from services.bnk.helpers import ( + get_policy_operational_status, has_condition, make_resource_map, resolve_list_refs, @@ -34,6 +35,56 @@ ] +# --------------------------------------------------------------------------- +# Operational-state helpers +# --------------------------------------------------------------------------- + + +def _listener_status(gw_status: dict, listener_name: str) -> dict: + """Find the Gateway status entry for a listener by name.""" + for entry in gw_status.get("listeners", []) or []: + if isinstance(entry, dict) and entry.get("name") == listener_name: + return entry + return {} + + +def _route_parent_status(route: dict, gw_name: str, gw_ns: str, listener_name: str) -> dict | None: + """Find the route's parent status entry matching a gateway/listener.""" + status = route.get("status", {}) or {} + parents = status.get("parents") or status.get("parentRefs") or [] + for parent in parents: + if not isinstance(parent, dict): + continue + ref = parent.get("parentRef", parent) + if not isinstance(ref, dict): + continue + parent_ns = ref.get("namespace", route.get("metadata", {}).get("namespace", "")) + if ref.get("name") == gw_name and parent_ns == gw_ns: + section = ref.get("sectionName") + if section and section != listener_name: + continue + return parent + # Fallback: legacy controllers may put conditions directly on status + if status.get("conditions"): + return status + return None + + +def _first_condition_message(conditions: list) -> str: + """Return the first non-empty condition message, or an empty string.""" + for cond in conditions: + if isinstance(cond, dict): + msg = cond.get("message") + if msg: + return str(msg) + return "" + + +def _policy_status(resource: dict) -> dict[str, Any]: + """Derive resolved/programmed operational state from a BNK policy resource.""" + return get_policy_operational_status(resource) + + # --------------------------------------------------------------------------- # Main analysis # --------------------------------------------------------------------------- @@ -120,7 +171,7 @@ def _build_gateway_node( listeners_data = [ _build_listener_node( listener, all_routes, analyzers, bnknetpolicies, irule_map, - gw_name, gw_ns, + gw_name, gw_ns, gw_status, ) for listener in gw_spec.get("listeners", []) ] @@ -134,6 +185,9 @@ def _build_gateway_node( "namespace": gw_ns, "gatewayClassName": gw_spec.get("gatewayClassName", ""), "addresses": [a.get("value", "") for a in gw_status.get("addresses", [])], + "accepted": has_condition(gw, "Accepted"), + "programmed": has_condition(gw, "Programmed"), + "conditions": gw_status.get("conditions", []) or [], "listeners": listeners_data, "securityPolicies": sec_policies_data, } @@ -152,13 +206,17 @@ def _build_listener_node( irule_map: dict[str, dict], gw_name: str, gw_ns: str, + gw_status: dict, ) -> dict[str, Any]: """Build a single listener node with routes and network policies.""" listener_name = listener.get("name", "") + status = _listener_status(gw_status, listener_name) return { "name": listener_name, "protocol": listener.get("protocol", ""), "port": listener.get("port"), + "attachedRouteCount": status.get("attachedRoutes", 0) or 0, + "conditions": status.get("conditions", []) or [], "routes": _match_routes_to_listener( all_routes, analyzers, gw_name, gw_ns, listener_name, ), @@ -209,6 +267,16 @@ def _match_routes_to_listener( ] route_name = route_meta.get("name", "") + parent_status = _route_parent_status(route, gw_name, gw_ns, listener_name) + parent_conditions = parent_status.get("conditions", []) if parent_status else [] + accepted = ( + has_condition(parent_status, "Accepted") + or has_condition(parent_status, "Programmed") + or has_condition(parent_status, "Ready") + or has_condition(route, "Accepted") + or has_condition(route, "Programmed") + ) if parent_status else (has_condition(route, "Accepted") or has_condition(route, "Programmed")) + routes_data.append({ "name": route_name, "namespace": route_ns, @@ -218,6 +286,9 @@ def _match_routes_to_listener( "analyzers": _match_analyzers( analyzers, route_name, route_kind, route_ns, gw_ns, ), + "accepted": accepted, + "conditions": parent_conditions, + "conditionMessage": _first_condition_message(parent_conditions) if not accepted else "", }) return routes_data @@ -280,12 +351,16 @@ def _match_net_policies( if any(c.get("status") == "True" for c in d.get("conditions", [])) ) + status = _policy_status(np_item) result.append({ "name": resource_name(np_item), "namespace": np_ns, "extensions": extensions, "resolvedCount": resolved_count, "totalExtensions": len(extensions), + "resolved": status["resolved"], + "programmed": status["programmed"], + "messages": status["messages"], }) return result @@ -334,11 +409,15 @@ def _match_sec_policies( fw_refs = _build_firewall_refs( sp_spec.get("extensionRefs", []), fw_map, addr_map, port_map, sp_ns, ) + status = _policy_status(sp) result.append({ "name": resource_name(sp), "namespace": sp_ns, "targetListener": target.get("sectionName", ""), "firewallPolicies": fw_refs, + "resolved": status["resolved"], + "programmed": status["programmed"], + "messages": status["messages"], }) return result @@ -471,13 +550,24 @@ def _build_cne_instance(cne: dict) -> dict[str, Any]: for key, val in c_spec.items() if isinstance(val, dict) and "enabled" in val } + c_status = cne.get("status", {}) or {} + phase = c_status.get("phase", "") + ready = ( + has_condition(cne, "Programmed") + or has_condition(cne, "Ready") + or has_condition(cne, "Available") + or has_condition(cne, "Reconciled") + ) + if not phase and ready: + phase = "Ready" if (has_condition(cne, "Ready") or has_condition(cne, "Available")) else "Active" return { "name": resource_name(cne), "namespace": resource_ns(cne), "features": features, "networkAttachments": c_spec.get("networkAttachments", []), "containerPlatform": c_spec.get("containerPlatform", ""), - "phase": cne.get("status", {}).get("phase", ""), + "phase": phase, + "ready": ready, } diff --git a/backend/services/bnk/traffic_stats.py b/backend/services/bnk/traffic_stats.py new file mode 100644 index 0000000..1538f1d --- /dev/null +++ b/backend/services/bnk/traffic_stats.py @@ -0,0 +1,741 @@ +""" +BNK traffic statistics — map TMM dataplane counters to Gateway listeners / egresses. + +Data flow: + 1. ``fetch_tmm_traffic_stats`` contacts one TMM debug sidecar and pulls + ``virtual_server_stat`` + ``fw_rule_stat`` plus optional ``configview`` + UUID metadata. This is the only I/O in the module. + 2. ``analyze_traffic_stats`` takes the raw TMM output plus the BNK resource + bundle from ``fetch_all_bnk_data`` and returns structured traffic stats + per listener, egress, and firewall rule. Pure data transformation. + +Mapping strategy: + - Primary: explicit hints from ``configview uuid `` output + (gateway/listener/egress names recorded in TMM config). + - Fallback: heuristic name matching between TMM virtual-server names and + Gateway/Listener or Egress CR names. + +Graceful degradation: + - If no TMM pods, no debug sidecar, or any exec fails, the function returns + an empty stats envelope with ``available: false`` and a short error/message. + The caller (``/f5bnk/data``) always gets a 200 response. +""" + +import json +import logging +import os +import re +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from typing import Any + +from kubernetes import client as k8s_client + +from core.cache import cache +from services.bnk.helpers import resource_name, resource_ns +from services.tmm_debug_service import ( + DEFAULT_EXEC_TIMEOUT, + discover_configview_uuids, + exec_configview, + exec_tmctl, +) + +# Shared executor for configview uuid probes. Each probe is a kubectl exec +# (~2s) and the old sequential loop could take 100s on busy clusters. A small +# shared pool keeps latency bounded without spawning threads per request. +_TMM_CONFIGVIEW_WORKERS = min(4, (os.cpu_count() or 2) + 1) +_tmm_configview_executor: ThreadPoolExecutor | None = None + +# Short-term cache for TMM traffic stats. The expensive part is configview uuid +# kubectl exec probes; cache the whole envelope so dashboard polling and tab +# switching don't re-probe every few seconds. +_TMM_TRAFFIC_STATS_CACHE_TTL = 120 + +# Configview uuid-to-resource mappings are stable for a given TMM pod (they +# reflect configured virtual servers/gateways). Cache them separately for 5 +# minutes so the per-request stats fetch only does the fast tmctl calls. +_TMM_CONFIGVIEW_MAP_CACHE_TTL = 300 + + +def _get_tmm_configview_executor() -> ThreadPoolExecutor: + global _tmm_configview_executor + if _tmm_configview_executor is None: + _tmm_configview_executor = ThreadPoolExecutor( + max_workers=_TMM_CONFIGVIEW_WORKERS, + thread_name_prefix="tmm-cv-", + ) + return _tmm_configview_executor + + +def _tmm_traffic_stats_cache_key(cluster_id: int) -> str: + return f"bnk:tmm_stats:{cluster_id}" + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_TMCTL_DIRECTORY = "blade" +_VIRTUAL_SERVER_STAT_TABLE = "virtual_server_stat" +_FW_RULE_STAT_TABLE = "fw_rule_stat" + +# Columns we care about from tmctl tables. We request a superset so the +# response is useful even if a particular TMM build omits one column. +_VIRTUAL_SERVER_COLUMNS = [ + "name", + "clientside.bytes_in", + "clientside.bytes_out", + "clientside.cur_conns", + "clientside.tot_conns", + "serverside.bytes_in", + "serverside.bytes_out", + "serverside.cur_conns", + "serverside.tot_conns", +] + +_FW_RULE_COLUMNS = [ + "name", + "hit_count", + "action", +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def fetch_tmm_traffic_stats( + api_client: k8s_client.ApiClient, + classified_pods: dict[str, list[dict]], + timeout: int = DEFAULT_EXEC_TIMEOUT, + cluster_id: int | None = None, + force: bool = False, +) -> dict[str, Any]: + """ + Fetch raw traffic statistics from a TMM debug sidecar. + + Args: + api_client: Authenticated K8s API client. + classified_pods: Output of ``classify_f5_pods``; used to find TMM pods. + timeout: Max seconds to wait for each ``tmctl``/``configview`` exec. + cluster_id: When provided, results are cached for 15 seconds per cluster. + force: Bypass the cache (used for explicit refresh actions). + + Returns: + Dict with keys: + - source: "tmctl" + - podName, namespace + - virtualServerStat: parsed tmctl result dict + - fwRuleStat: parsed tmctl result dict + - configviewMappings: list of parsed configview uuid outputs + - error: None or a short error string + - durationMs: total fetch time + """ + cache_key = _tmm_traffic_stats_cache_key(cluster_id) if cluster_id is not None else None + if not force and cache_key is not None: + cached = cache.get(cache_key) + if cached is not None: + return cached + + start = datetime.now(UTC) + result: dict[str, Any] = { + "source": "tmctl", + "podName": None, + "namespace": None, + "virtualServerStat": None, + "fwRuleStat": None, + "configviewMappings": [], + "error": None, + } + + tmm_pods = classified_pods.get("tmm", []) if isinstance(classified_pods, dict) else [] + pod = _pick_tmm_pod(tmm_pods) + if not pod: + result["error"] = "No TMM pods with debug sidecar found" + result["durationMs"] = _elapsed_ms(start) + return result + + pod_name = pod["name"] + namespace = pod["namespace"] + result["podName"] = pod_name + result["namespace"] = namespace + + try: + vs_result = exec_tmctl( + api_client, pod_name, namespace, + _VIRTUAL_SERVER_STAT_TABLE, _VIRTUAL_SERVER_COLUMNS, + directory=_TMCTL_DIRECTORY, timeout=timeout, + cluster_id=cluster_id, + ) + fw_result = exec_tmctl( + api_client, pod_name, namespace, + _FW_RULE_STAT_TABLE, _FW_RULE_COLUMNS, + directory=_TMCTL_DIRECTORY, timeout=timeout, + cluster_id=cluster_id, + ) + result["virtualServerStat"] = vs_result + result["fwRuleStat"] = fw_result + + # Only probe configview if tmctl succeeded and there are virtual servers + # to map. Skipping the uuid probes when vs_rows is empty avoids ~50 + # sequential kubectl exec calls (each ~2s) on clusters with no traffic. + if vs_result.get("exit_code") == 0 and _tmctl_rows_as_dicts(vs_result): + mappings = _fetch_configview_mappings( + api_client, pod_name, namespace, timeout, cluster_id=cluster_id + ) + result["configviewMappings"] = mappings + except Exception as exc: # pragma: no cover - defensive catch-all + logger.exception("Failed to fetch TMM traffic stats") + result["error"] = f"TMM exec failed: {exc}" + + result["durationMs"] = _elapsed_ms(start) + if cache_key is not None: + cache.set(cache_key, result, ttl_seconds=_TMM_TRAFFIC_STATS_CACHE_TTL) + return result + + +def analyze_traffic_stats( + data: dict[str, Any], + raw_stats: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Map raw TMM traffic statistics to Gateway listeners / egresses / firewall rules. + + Pure function — all I/O must happen before this call (see + ``fetch_tmm_traffic_stats``). + + Args: + data: The BNK resource bundle from ``fetch_all_bnk_data``. + raw_stats: Optional raw TMM stats envelope. If None or unavailable, + an empty but valid envelope is returned. + + Returns: + Dict with keys: + - source, podName, sampledAt, available, error + - listeners: list of BnkListenerTrafficStats-shaped dicts + - egresses: list of BnkEgressTrafficStats-shaped dicts + - firewallRules: list of BnkFirewallRuleTrafficStats-shaped dicts + """ + topology = data.get("topology", []) or [] + data_plane = data.get("dataPlane", {}) or {} + + envelope: dict[str, Any] = { + "source": raw_stats.get("source") if raw_stats else None, + "podName": raw_stats.get("podName") if raw_stats else None, + "sampledAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "available": False, + "error": (raw_stats.get("error") if raw_stats else None) or None, + "listeners": [], + "egresses": [], + "firewallRules": [], + } + + if not raw_stats or raw_stats.get("error"): + return envelope + + vs_result = raw_stats.get("virtualServerStat") or {} + if vs_result.get("exit_code") != 0: + envelope["error"] = envelope["error"] or vs_result.get("stderr") or "virtual_server_stat failed" + return envelope + + fw_result = raw_stats.get("fwRuleStat") or {} + vs_rows = _tmctl_rows_as_dicts(vs_result) + fw_rows = _tmctl_rows_as_dicts(fw_result) + + listener_index = _build_listener_index(topology) + egress_index = _build_egress_index(data_plane.get("egresses", [])) + configview_index = _build_configview_index(raw_stats.get("configviewMappings", [])) + + envelope["available"] = True + envelope["listeners"] = _analyze_listener_stats(vs_rows, listener_index, configview_index) + envelope["egresses"] = _analyze_egress_stats(vs_rows, egress_index, configview_index) + envelope["firewallRules"] = _analyze_firewall_rule_stats(fw_rows, data) + + return envelope + + +# --------------------------------------------------------------------------- +# TMM pod selection +# --------------------------------------------------------------------------- + + +def _pick_tmm_pod(tmm_pods: list[dict]) -> dict | None: + """Pick a Running TMM pod that has a debug sidecar container.""" + for pod in tmm_pods: + if not isinstance(pod, dict): + continue + phase = (pod.get("phase") or "").lower() + if phase != "running": + continue + containers = pod.get("containers", []) or [] + container_names = {c.get("name", "") for c in containers if isinstance(c, dict)} + if "debug" in container_names: + return pod + return None + + +# --------------------------------------------------------------------------- +# configview mapping discovery +# --------------------------------------------------------------------------- + + +def _fetch_configview_mappings( + api_client: k8s_client.ApiClient, + pod_name: str, + namespace: str, + timeout: int, + cluster_id: int | None = None, +) -> list[dict[str, Any]]: + """Run configview list + uuid for each UUID and return parsed metadata.""" + cache_key = f"bnk:configview_mappings:{cluster_id}:{pod_name}:{namespace}" if cluster_id is not None else None + if cluster_id is not None: + cached = cache.get(cache_key) + if cached is not None: + return cached + + mappings: list[dict[str, Any]] = [] + try: + uuids_result = discover_configview_uuids( + api_client, pod_name, namespace, timeout, cluster_id=cluster_id + ) + if uuids_result.get("exit_code") != 0: + return mappings + uuids = uuids_result.get("uuids", []) or [] + # Cap probing to avoid long exec bursts on busy clusters. + uuids = uuids[:20] + + def _probe_uuid(uuid: str) -> dict[str, Any] | None: + try: + cv_result = exec_configview( + api_client, pod_name, namespace, uuid, timeout, cluster_id=cluster_id + ) + if cv_result.get("exit_code") == 0: + hints = _parse_configview_uuid_output(cv_result.get("stdout", "")) + if hints: + return {"uuid": uuid, **hints} + except Exception: + logger.debug("configview uuid %s probe failed", uuid, exc_info=True) + return None + + executor = _get_tmm_configview_executor() + futures = [executor.submit(_probe_uuid, uuid) for uuid in uuids] + for future in futures: + result = future.result() + if result: + mappings.append(result) + except Exception: + logger.debug("configview list probe failed", exc_info=True) + + if cache_key is not None: + cache.set(cache_key, mappings, ttl_seconds=_TMM_CONFIGVIEW_MAP_CACHE_TTL) + return mappings + + +def _parse_configview_uuid_output(raw: str) -> dict[str, str]: + """ + Extract mapping hints from a ``configview uuid`` output. + + TMM configview output varies by CR kind and release. We first attempt JSON + parsing, then fall back to line-oriented key/value extraction for common + field names. + """ + hints: dict[str, str] = {} + if not raw or not raw.strip(): + return hints + + # Try JSON first + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + _extract_configview_hints_from_dict(parsed, hints) + return hints + except json.JSONDecodeError: + pass + + # Line-oriented fallback + for line in raw.splitlines(): + match = re.match(r"^\s*([a-zA-Z0-9_\-]+)\s*[:=]\s*(.+?)\s*$", line) + if not match: + continue + key = match.group(1).lower().replace("-", "").replace("_", "") + value = match.group(2).strip().strip('"\'') + if key in ("name", "virtualserver", "virtualservername", "vs"): + hints["virtual_server_name"] = value + elif key in ("gateway", "gatewayname"): + hints["gateway_name"] = value + elif key in ("listener", "listenername"): + hints["listener_name"] = value + elif key in ("egress", "egressname"): + hints["egress_name"] = value + elif key in ("namespace", "ns"): + hints["namespace"] = value + elif key in ("kind",): + hints["kind"] = value + + return hints + + +def _extract_configview_hints_from_dict(data: dict[str, Any], hints: dict[str, str]) -> None: + """Recursively pull known keys out of a parsed configview JSON object.""" + if not isinstance(data, dict): + return + + def _set(key: str, *paths: tuple[str, ...]) -> None: + for path in paths: + value = data + for segment in path: + if isinstance(value, dict): + value = value.get(segment) + else: + value = None + break + if isinstance(value, str) and value: + hints[key] = value + return + + _set("virtual_server_name", ("name",), ("virtual_server", "name"), ("vs", "name")) + _set("gateway_name", ("gateway",), ("gateway_name",), ("spec", "gateway", "name")) + _set("listener_name", ("listener",), ("listener_name",), ("spec", "listener", "name")) + _set("egress_name", ("egress",), ("egress_name",), ("spec", "name")) + _set("namespace", ("namespace",), ("metadata", "namespace")) + _set("kind", ("kind",)) + + # Recurse into nested dicts that might hold the actual config + for value in data.values(): + if isinstance(value, dict): + _extract_configview_hints_from_dict(value, hints) + + +def _build_configview_index(mappings: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """ + Build a lookup from normalized TMM virtual-server name to configview hints. + """ + index: dict[str, dict[str, Any]] = {} + for mapping in mappings: + vs_name = mapping.get("virtual_server_name") + if vs_name: + index[_normalize_name(vs_name)] = mapping + return index + + +# --------------------------------------------------------------------------- +# Index builders for topology objects +# --------------------------------------------------------------------------- + + +def _normalize_name(name: str) -> str: + """Normalize a TMM/CR name for fuzzy matching.""" + return re.sub(r"[-_/.:]", "", str(name).lower()) + + +def _build_listener_index(topology: list[dict]) -> dict[str, dict[str, Any]]: + """Build a normalized lookup from plausible virtual-server names to listeners.""" + index: dict[str, dict[str, Any]] = {} + for gw in topology: + gw_name = gw.get("name", "") + gw_ns = gw.get("namespace", "") + for listener in gw.get("listeners", []) or []: + listener_name = listener.get("name", "") + entry = { + "gatewayName": gw_name, + "gatewayNamespace": gw_ns, + "listenerName": listener_name, + "protocol": listener.get("protocol", ""), + "port": listener.get("port"), + } + # Candidate virtual-server name patterns seen in BNK TMM configs + candidates = { + _normalize_name(f"{gw_name}_{listener_name}"), + _normalize_name(f"{gw_ns}_{gw_name}_{listener_name}"), + _normalize_name(f"{gw_name}-{listener_name}"), + _normalize_name(f"{gw_ns}_{gw_name}-{listener_name}"), + _normalize_name(listener_name), + _normalize_name(f"{gw_name}{listener_name}"), + } + for key in candidates: + index.setdefault(key, entry) + return index + + +def _build_egress_index(egresses: list[dict]) -> dict[str, dict[str, Any]]: + """Build a normalized lookup from plausible virtual-server names to egresses.""" + index: dict[str, dict[str, Any]] = {} + for egress in egresses or []: + name = egress.get("name", "") + ns = egress.get("namespace", "") + entry = {"egressName": name, "namespace": ns} + candidates = { + _normalize_name(name), + _normalize_name(f"{ns}_{name}"), + _normalize_name(f"egress-{name}"), + _normalize_name(f"{ns}_egress-{name}"), + } + for key in candidates: + index.setdefault(key, entry) + return index + + +# --------------------------------------------------------------------------- +# tmctl row parsing +# --------------------------------------------------------------------------- + + +def _tmctl_rows_as_dicts(tmctl_result: dict[str, Any]) -> list[dict[str, str]]: + """Convert tmctl {columns, rows} into list of dicts keyed by column name.""" + columns = tmctl_result.get("columns", []) or [] + rows = tmctl_result.get("rows", []) or [] + result: list[dict[str, str]] = [] + for row in rows: + if not isinstance(row, (list, tuple)): + continue + row_dict = {} + for i, col in enumerate(columns): + row_dict[col] = row[i] if i < len(row) else "" + result.append(row_dict) + return result + + +def _parse_int(value: Any) -> int: + """Parse a tmctl counter string to int, defaulting to 0.""" + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return 0 + return 0 + + +def _find_counter(row: dict[str, str], *candidates: str) -> int: + """Return the first matching integer counter from a tmctl row.""" + for key in candidates: + if key in row: + return _parse_int(row[key]) + return 0 + + +# --------------------------------------------------------------------------- +# Analysis: virtual-server → listener / egress +# --------------------------------------------------------------------------- + + +def _match_virtual_server_row( + row: dict[str, str], + listener_index: dict[str, dict[str, Any]], + egress_index: dict[str, dict[str, Any]], + configview_index: dict[str, dict[str, Any]], +) -> tuple[str | None, dict[str, Any] | None]: + """ + Return ("listener" | "egress" | None, matched object) for a tmctl row. + + Priority: + 1. Explicit configview mapping on the TMM virtual-server name. + 2. Listener index match. + 3. Egress index match. + """ + vs_name = (row.get("name") or "").strip() + normalized = _normalize_name(vs_name) + if not normalized: + return None, None + + # 1. configview hint + cv = configview_index.get(normalized) + if cv: + if cv.get("gateway_name") and cv.get("listener_name"): + return "listener", { + "gatewayName": cv.get("gateway_name", ""), + "gatewayNamespace": cv.get("namespace", ""), + "listenerName": cv.get("listener_name", ""), + } + if cv.get("egress_name"): + return "egress", { + "egressName": cv.get("egress_name", ""), + "namespace": cv.get("namespace", ""), + } + + # 2. listener match + listener = listener_index.get(normalized) + if listener: + return "listener", listener + + # 3. egress match + egress = egress_index.get(normalized) + if egress: + return "egress", egress + + # 4. Substring fallback: virtual-server name contains a listener or egress key + for key, listener in listener_index.items(): + if key and (key in normalized or normalized in key): + return "listener", listener + for key, egress in egress_index.items(): + if key and (key in normalized or normalized in key): + return "egress", egress + + return None, None + + +def _virtual_server_stats(row: dict[str, str]) -> dict[str, int]: + """Extract counters from a virtual_server_stat row.""" + return { + "clientsideBytesIn": _find_counter(row, "clientside.bytes_in"), + "clientsideBytesOut": _find_counter(row, "clientside.bytes_out"), + "clientsideCurConns": _find_counter(row, "clientside.cur_conns"), + "clientsideTotConns": _find_counter(row, "clientside.tot_conns"), + "serversideBytesIn": _find_counter(row, "serverside.bytes_in"), + "serversideBytesOut": _find_counter(row, "serverside.bytes_out"), + "serversideCurConns": _find_counter(row, "serverside.cur_conns"), + "serversideTotConns": _find_counter(row, "serverside.tot_conns"), + } + + +def _analyze_listener_stats( + vs_rows: list[dict[str, str]], + listener_index: dict[str, dict[str, Any]], + configview_index: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Sum virtual-server stats for each Gateway listener.""" + totals: dict[tuple[str, str, str], dict[str, Any]] = {} + + for row in vs_rows: + kind, matched = _match_virtual_server_row(row, listener_index, {}, configview_index) + if kind != "listener" or not matched: + continue + + key = ( + matched.get("gatewayNamespace", ""), + matched.get("gatewayName", ""), + matched.get("listenerName", ""), + ) + if key not in totals: + totals[key] = { + "gatewayName": matched["gatewayName"], + "gatewayNamespace": matched["gatewayNamespace"], + "listenerName": matched["listenerName"], + **{k: 0 for k in _virtual_server_stats({}).keys()}, + } + for counter_key, value in _virtual_server_stats(row).items(): + totals[key][counter_key] += value + + return list(totals.values()) + + +def _analyze_egress_stats( + vs_rows: list[dict[str, str]], + egress_index: dict[str, dict[str, Any]], + configview_index: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Sum virtual-server stats for each F5SPKEgress.""" + totals: dict[tuple[str, str], dict[str, Any]] = {} + + for row in vs_rows: + kind, matched = _match_virtual_server_row(row, {}, egress_index, configview_index) + if kind != "egress" or not matched: + continue + + key = (matched.get("namespace", ""), matched.get("egressName", "")) + if key not in totals: + totals[key] = { + "egressName": matched["egressName"], + "namespace": matched["namespace"], + **{k: 0 for k in _virtual_server_stats({}).keys()}, + } + for counter_key, value in _virtual_server_stats(row).items(): + totals[key][counter_key] += value + + return list(totals.values()) + + +# --------------------------------------------------------------------------- +# Analysis: firewall rule hits +# --------------------------------------------------------------------------- + + +def _analyze_firewall_rule_stats( + fw_rows: list[dict[str, str]], + data: dict[str, Any], +) -> list[dict[str, Any]]: + """Map fw_rule_stat rows to F5BigFwPolicy rules.""" + resources = data.get("resources", {}) or {} + policies = resources.get("f5bigfwpolicy", []) or [] + + # Build a lookup from plausible TMM rule name → policy + rule + rule_index: dict[str, tuple[str, str, dict]] = {} + for policy in policies: + policy_name = resource_name(policy) + policy_ns = resource_ns(policy) + for rule in (policy.get("spec", {}).get("rule", []) or []): + rule_name = rule.get("name", "") + if not rule_name: + continue + tmm_name = _normalize_name(f"{policy_name}_{rule_name}") + rule_index[tmm_name] = (policy_name, policy_ns, rule) + # Some TMM builds use just the rule name + rule_index[_normalize_name(rule_name)] = (policy_name, policy_ns, rule) + + results: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for row in fw_rows: + raw_name = (row.get("name") or "").strip() + if not raw_name: + continue + normalized = _normalize_name(raw_name) + hit_count = _find_counter(row, "hit_count", "hits", "hitcount") + + matched = rule_index.get(normalized) + if not matched: + # Try splitting on common separators: policy_rule + for sep in ("_", "-", "."): + if sep in raw_name: + parts = raw_name.split(sep) + for i in range(1, len(parts)): + candidate = _normalize_name(sep.join(parts[i:])) + matched = rule_index.get(candidate) + if matched: + break + if matched: + break + + if matched: + policy_name, policy_ns, rule = matched + key = (policy_ns, policy_name, rule.get("name", "")) + if key in seen: + # Sum hits if the same rule appears in multiple TMM rows + existing = next(r for r in results if r["policyName"] == policy_name + and r["namespace"] == policy_ns + and r["ruleName"] == rule["name"]) + existing["hitCount"] += hit_count + else: + seen.add(key) + results.append({ + "policyName": policy_name, + "namespace": policy_ns, + "ruleName": rule.get("name", ""), + "action": rule.get("action", ""), + "ipProtocol": rule.get("ipProtocol", ""), + "hitCount": hit_count, + }) + else: + # Unmatched rule — still surface the raw hit count for observability + results.append({ + "policyName": "", + "namespace": "", + "ruleName": raw_name, + "action": row.get("action", ""), + "ipProtocol": "", + "hitCount": hit_count, + }) + + return results + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + + +def _elapsed_ms(start: datetime) -> int: + """Return milliseconds elapsed since ``start``.""" + return int((datetime.now(UTC) - start).total_seconds() * 1000) diff --git a/backend/services/bnk_data_service.py b/backend/services/bnk_data_service.py index 6b5691f..519fa03 100644 --- a/backend/services/bnk_data_service.py +++ b/backend/services/bnk_data_service.py @@ -21,9 +21,11 @@ analyze_health, analyze_policy_associations, analyze_topology, + analyze_traffic_stats, calc_severity, extract_palette_data, fetch_all_bnk_data, + fetch_tmm_traffic_stats, get_condition_message, has_condition, rollup_severity, diff --git a/backend/services/cluster_discovery_service.py b/backend/services/cluster_discovery_service.py new file mode 100644 index 0000000..75140bb --- /dev/null +++ b/backend/services/cluster_discovery_service.py @@ -0,0 +1,506 @@ +"""Credential-template-driven Kubernetes cluster discovery. + +This service queries cloud provider APIs using a project's credential templates +and registers discovered clusters in the same way as module-output-driven +discovery (``services/cluster_management_service.py``). + +Supported providers: aws, ibm, azure, gcp. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy.orm import Session + +from core.encryption import encrypt_value +from models import CloudCredentialTemplate, KubernetesCluster +from services.azure_service import ( + fetch_aks_bearer_token, + fetch_aks_cluster_credentials, + generate_aks_kubeconfig, + list_aks_clusters_from_template, +) +from services.base_service import BaseService +from services.eks_service import generate_eks_kubeconfig, list_eks_clusters_from_template +from services.gcp_service import ( + fetch_gke_cluster_credentials, + generate_gke_kubeconfig, + list_gke_clusters_from_template, +) +from services.ibm_cloud_service import ( + IBMCloudService, + describe_roks_cluster, + generate_roks_kubeconfig, + list_roks_clusters_from_template, +) +from services.kubeconfig_normalizer import NormalizationSource, normalize_kubeconfig +from services.platform_context_service import PlatformContextService + +logger = logging.getLogger(__name__) + +SUPPORTED_PROVIDERS = {"aws", "ibm", "azure", "gcp"} + + +def register_discovered_cluster( + db: Session, + project_id: int, + name: str, + api_server: str, + cloud_provider: str, + region: str | None, + kubeconfig_yaml: str, + context_name: str | None = None, + version: str | None = None, + account_id: str | None = None, + access_method: str = "kubeconfig", + meta_data: dict[str, Any] | None = None, +) -> KubernetesCluster: + """Register a cloud-discovered cluster, reusing existing normalization. + + Idempotent: if a cluster with the same name already exists in the project, + the existing record is returned as a skip. + + This helper is the single registration path for credential-template + discovery so EKS/ROKS/Azure/GCP discovery do not duplicate cluster creation + logic. + """ + existing = db.query(KubernetesCluster).filter( + KubernetesCluster.name == name, + KubernetesCluster.project_id == project_id, + ).first() + if existing: + logger.info(f"Cluster {name} already registered (id={existing.id}), skipping") + return existing + + kubeconfig_yaml = normalize_kubeconfig( + kubeconfig_yaml, source=NormalizationSource.CLOUD_API_GENERATED + ) + kubeconfig_encrypted = encrypt_value(kubeconfig_yaml) + + cluster = KubernetesCluster( + name=name, + context=context_name or name, + api_server=api_server, + version=version, + status="active", + project_id=project_id, + kubeconfig_encrypted=kubeconfig_encrypted, + cloud_provider=cloud_provider, + region=region, + account_id=account_id, + discovery_status="completed", + access_method=access_method, + default_namespace="default", + meta_data={ + "auto_registered": True, + "discovery_source": "credential_template", + **(meta_data or {}), + }, + ) + PlatformContextService.apply_cluster_context(cluster) + db.add(cluster) + db.flush() + db.refresh(cluster) + + # D-022: upsert fleet membership for the newly discovered cluster. + try: + from services.fleet_reconcile_service import reconcile_fleet_member + reconcile_fleet_member(db, "cluster", cluster.id) + except Exception: + logger.exception("Fleet reconcile failed for discovered cluster id=%s", cluster.id) + + logger.info(f"Registered discovered {cloud_provider} cluster {name} (id={cluster.id})") + return cluster + + +class ClusterDiscoveryService(BaseService): + """Discover Kubernetes clusters from a project's credential templates.""" + + def _project_templates(self, project_id: int) -> list[CloudCredentialTemplate]: + """Return cloud credential templates relevant to *project_id*. + + Priority: + 1. The project's explicitly bound credential template (if any). + 2. Default templates for each supported provider that has no explicit + template yet. + """ + project = self._get_project(project_id) + templates: list[CloudCredentialTemplate] = [] + seen_providers: set[str] = set() + + if project.credential_template_id and project.credential_template: + templates.append(project.credential_template) + seen_providers.add(project.credential_template.provider) + + for provider in SUPPORTED_PROVIDERS - seen_providers: + default = self.db.query(CloudCredentialTemplate).filter( + CloudCredentialTemplate.provider == provider, + CloudCredentialTemplate.is_default.is_(True), + ).first() + if default: + templates.append(default) + + return templates + + def detect_clusters_from_credentials(self, project_id: int) -> dict[str, Any]: + """Query cloud APIs from credential templates and register clusters.""" + self._get_project(project_id) + templates = self._project_templates(project_id) + + if not templates: + return { + "success": True, + "message": "No cloud credential templates configured for this project", + "registered": [], + "skipped": [], + "errors": [], + } + + registered_clusters: list[dict[str, Any]] = [] + skipped_clusters: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + for template in templates: + provider = template.provider + try: + provider_registered, provider_skipped, provider_errors = self._detect_for_provider( + project_id, template + ) + registered_clusters.extend(provider_registered) + skipped_clusters.extend(provider_skipped) + errors.extend(provider_errors) + except Exception as e: + logger.exception(f"Credential discovery failed for provider {provider}") + errors.append({ + "provider": provider, + "name": None, + "error": str(e), + }) + + total_found = len(registered_clusters) + len(skipped_clusters) + message = ( + f"Discovered {total_found} cluster(s) from credential templates, " + f"registered {len(registered_clusters)} new cluster(s)" + ) + if not total_found and not errors: + message = "No clusters found via credential templates" + + return { + "success": True, + "message": message, + "registered": registered_clusters, + "skipped": skipped_clusters, + "errors": errors, + } + + def _detect_for_provider( + self, + project_id: int, + template: CloudCredentialTemplate, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Dispatch to the correct provider discovery implementation.""" + provider = template.provider + if provider == "aws": + return self._detect_aws_clusters(project_id, template) + if provider == "ibm": + return self._detect_ibm_clusters(project_id, template) + if provider == "azure": + return self._detect_azure_clusters(project_id, template) + if provider == "gcp": + return self._detect_gcp_clusters(project_id, template) + + return [], [], [{"provider": provider, "name": None, "error": "Unsupported provider"}] + + def _detect_aws_clusters( + self, + project_id: int, + template: CloudCredentialTemplate, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + registered: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + try: + clusters = list_eks_clusters_from_template(template) + except Exception as e: + return [], [], [{"provider": "aws", "name": None, "error": str(e)}] + + for cluster in clusters: + name = cluster["name"] + try: + existing = self.db.query(KubernetesCluster).filter( + KubernetesCluster.name == name, + KubernetesCluster.project_id == project_id, + ).first() + if existing: + skipped.append({ + "provider": "aws", + "name": name, + "reason": "already_registered", + }) + continue + + kubeconfig_yaml = generate_eks_kubeconfig( + cluster_name=name, + cluster_endpoint=cluster["endpoint"], + cluster_ca_data=cluster["certificate_authority_data"], + region=cluster["region"], + ) + registered_cluster = register_discovered_cluster( + self.db, + project_id=project_id, + name=name, + api_server=cluster["endpoint"], + cloud_provider="aws", + region=cluster["region"], + kubeconfig_yaml=kubeconfig_yaml, + context_name=name, + version=cluster["version"], + account_id=cluster.get("account_id"), + access_method="kubeconfig", + meta_data={ + "cluster_arn": cluster["arn"], + "account_id": cluster.get("account_id"), + }, + ) + registered.append({ + "id": registered_cluster.id, + "name": registered_cluster.name, + "provider": "aws", + "status": "registered", + }) + except Exception as e: + logger.warning(f"Failed to register discovered AWS cluster {name}: {e}") + errors.append({"provider": "aws", "name": name, "error": str(e)}) + + return registered, skipped, errors + + def _detect_ibm_clusters( + self, + project_id: int, + template: CloudCredentialTemplate, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + from core.encryption import decrypt_value + + registered: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + try: + clusters = list_roks_clusters_from_template(template) + except Exception as e: + return [], [], [{"provider": "ibm", "name": None, "error": str(e)}] + + api_key = decrypt_value(template.ibmcloud_api_key_encrypted) if template.ibmcloud_api_key_encrypted else None + access_token = IBMCloudService(None)._exchange_api_key(api_key, template=template) if api_key else None + + for cluster in clusters: + name = cluster["name"] + try: + existing = self.db.query(KubernetesCluster).filter( + KubernetesCluster.name == name, + KubernetesCluster.project_id == project_id, + ).first() + if existing: + skipped.append({ + "provider": "ibm", + "name": name, + "reason": "already_registered", + }) + continue + + config = describe_roks_cluster(name, access_token) + server_url = config.get("serverURL") or cluster.get("server_url") + ca_cert = config.get("caCert") + if not server_url or not ca_cert: + errors.append({ + "provider": "ibm", + "name": name, + "error": "Cluster config missing server URL or CA certificate", + }) + continue + + if not access_token: + errors.append({ + "provider": "ibm", + "name": name, + "error": "Unable to obtain IBM IAM token", + }) + continue + + kubeconfig_yaml = generate_roks_kubeconfig( + cluster_name=name, + server_url=server_url, + ca_cert=ca_cert, + token=access_token, + ) + registered_cluster = register_discovered_cluster( + self.db, + project_id=project_id, + name=name, + api_server=server_url, + cloud_provider="ibm", + region=cluster.get("region") or template.region, + kubeconfig_yaml=kubeconfig_yaml, + context_name=name, + access_method="kubeconfig", + meta_data={ + "cluster_id": cluster.get("id"), + "resource_group": cluster.get("resource_group"), + }, + ) + registered.append({ + "id": registered_cluster.id, + "name": registered_cluster.name, + "provider": "ibm", + "status": "registered", + }) + except Exception as e: + logger.warning(f"Failed to register discovered IBM cluster {name}: {e}") + errors.append({"provider": "ibm", "name": name, "error": str(e)}) + + return registered, skipped, errors + + def _detect_azure_clusters( + self, + project_id: int, + template: CloudCredentialTemplate, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + registered: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + try: + clusters = list_aks_clusters_from_template(template) + except Exception as e: + return [], [], [{"provider": "azure", "name": None, "error": str(e)}] + + for cluster in clusters: + name = cluster["name"] + try: + existing = self.db.query(KubernetesCluster).filter( + KubernetesCluster.name == name, + KubernetesCluster.project_id == project_id, + ).first() + if existing: + skipped.append({ + "provider": "azure", + "name": name, + "reason": "already_registered", + }) + continue + + creds = fetch_aks_cluster_credentials( + cluster_name=name, + resource_group=cluster["resource_group"], + subscription_id=cluster["subscription_id"], + template=template, + ) + token = fetch_aks_bearer_token(template) + kubeconfig_yaml = generate_aks_kubeconfig( + cluster_name=name, + server=creds["server"], + ca_data=creds["certificate_authority_data"], + token=token, + ) + registered_cluster = register_discovered_cluster( + self.db, + project_id=project_id, + name=name, + api_server=f"https://{creds['server']}:443", + cloud_provider="azure", + region=cluster.get("location"), + kubeconfig_yaml=kubeconfig_yaml, + context_name=name, + version=cluster.get("version"), + account_id=cluster.get("subscription_id"), + access_method="kubeconfig", + meta_data={ + "subscription_id": cluster.get("subscription_id"), + "tenant_id": cluster.get("tenant_id"), + }, + ) + registered.append({ + "id": registered_cluster.id, + "name": registered_cluster.name, + "provider": "azure", + "status": "registered", + }) + except Exception as e: + logger.warning(f"Failed to register discovered Azure cluster {name}: {e}") + errors.append({"provider": "azure", "name": name, "error": str(e)}) + + return registered, skipped, errors + + def _detect_gcp_clusters( + self, + project_id: int, + template: CloudCredentialTemplate, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + registered: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + try: + clusters = list_gke_clusters_from_template(template) + except Exception as e: + return [], [], [{"provider": "gcp", "name": None, "error": str(e)}] + + for cluster in clusters: + name = cluster["name"] + try: + existing = self.db.query(KubernetesCluster).filter( + KubernetesCluster.name == name, + KubernetesCluster.project_id == project_id, + ).first() + if existing: + skipped.append({ + "provider": "gcp", + "name": name, + "reason": "already_registered", + }) + continue + + creds = fetch_gke_cluster_credentials( + cluster_name=name, + project_id=cluster["project_id"], + location=cluster["location"], + template=template, + ) + kubeconfig_yaml = generate_gke_kubeconfig( + cluster_name=name, + project_id=cluster["project_id"], + location=cluster["location"], + server=creds["server"], + certificate_authority_data=creds["certificate_authority_data"], + ) + registered_cluster = register_discovered_cluster( + self.db, + project_id=project_id, + name=name, + api_server=creds["server"], + cloud_provider="gcp", + region=cluster.get("location"), + kubeconfig_yaml=kubeconfig_yaml, + context_name=name, + version=cluster.get("version"), + account_id=cluster.get("project_id"), + access_method="kubeconfig", + meta_data={ + "project_id": cluster.get("project_id"), + "full_name": cluster.get("full_name"), + }, + ) + registered.append({ + "id": registered_cluster.id, + "name": registered_cluster.name, + "provider": "gcp", + "status": "registered", + }) + except Exception as e: + logger.warning(f"Failed to register discovered GCP cluster {name}: {e}") + errors.append({"provider": "gcp", "name": name, "error": str(e)}) + + return registered, skipped, errors diff --git a/backend/services/cluster_management_service.py b/backend/services/cluster_management_service.py index c4629fd..8bf8de4 100644 --- a/backend/services/cluster_management_service.py +++ b/backend/services/cluster_management_service.py @@ -432,6 +432,13 @@ def get_cluster_details(self, cluster_id: int) -> dict[str, Any]: "platform_constraints": context.platform_constraints, "region": cluster.region, "default_namespace": cluster.default_namespace, "status": cluster.status, "version": cluster.version, + "node_count": cluster.node_count, + "account_id": cluster.account_id, + "discovery_status": cluster.discovery_status, + "connectivity_status": cluster.connectivity_status, + "integration_status": cluster.integration_status, + "zones": list(cluster.zones or []), + "access_method": cluster.access_method, "project_id": cluster.project_id, # ADR-478/494: release FK ids — deployable = intent; running = observed by scan. "deployable_release_id": cluster.deployable_release_id, diff --git a/backend/services/connectivity_probe_service.py b/backend/services/connectivity_probe_service.py index 6c571ad..162d87b 100644 --- a/backend/services/connectivity_probe_service.py +++ b/backend/services/connectivity_probe_service.py @@ -360,6 +360,60 @@ def probe_all_clusters(self) -> dict[str, Any]: return {"results": results, "summary": summary} + def probe_project_clusters(self, project_id: int) -> dict[str, Any]: + """ + Run connectivity probes against all clusters in a project in parallel. + Returns the same shape as probe_all_clusters. + """ + clusters = ( + self.db.query(KubernetesCluster) + .filter(KubernetesCluster.project_id == project_id) + .all() + ) + if not clusters: + return {"results": [], "summary": {"total": 0, "connected": 0, "reachable": 0, "partial": 0, "unreachable": 0, "unknown": 0}} + + results = [] + with ThreadPoolExecutor(max_workers=min(len(clusters), 10)) as pool: + futures = {pool.submit(self._probe_cluster_obj, c): c for c in clusters} + for future in as_completed(futures): + try: + results.append(future.result()) + except Exception as e: + cluster = futures[future] + logger.error(f"Probe failed for cluster {cluster.name}: {e}") + results.append({ + "cluster_id": cluster.id, + "cluster_name": cluster.name, + "api_server": cluster.api_server, + "status": ConnectivityStatus.UNKNOWN, + "message": f"Probe error: {e}", + "suggestion": "An unexpected error occurred during the connectivity probe.", + "icmp": {"reachable": False, "latency_ms": None}, + "tcp": {"open": False, "connect_ms": None, "port": None}, + "k8s_api": {"accessible": False, "version": None, "status_code": None}, + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }) + + results.sort(key=lambda r: r["cluster_id"]) + + summary: dict[str, int] = { + "total": len(results), + "connected": 0, + "reachable": 0, + "partial": 0, + "unreachable": 0, + "unknown": 0, + } + for r in results: + status = str(r.get("status", ConnectivityStatus.UNKNOWN)) + if status in summary: + summary[status] += 1 + else: + summary["unknown"] += 1 + + return {"results": results, "summary": summary} + def _probe_cluster_obj(self, cluster: KubernetesCluster) -> dict[str, Any]: """Run the actual probe against a cluster object.""" host, port = _parse_api_server(cluster.api_server) diff --git a/backend/services/eks_service.py b/backend/services/eks_service.py index 505fe0d..9c0cf03 100644 --- a/backend/services/eks_service.py +++ b/backend/services/eks_service.py @@ -6,6 +6,7 @@ """ import logging +from typing import Any import yaml from sqlalchemy.orm import Session @@ -255,3 +256,85 @@ def unregister_eks_cluster(db: Session, eks_module: ProjectModule) -> bool: logger.info(f"No registered cluster found for module {eks_module.id}") return False + + +# --------------------------------------------------------------------------- +# Credential-template-driven discovery +# --------------------------------------------------------------------------- + +def _boto3_session_from_template(template) -> Any: + """Build a boto3 Session from a CloudCredentialTemplate. + + Reuses the same credential-resolution rules as CredentialTemplateService + so credential-template discovery never duplicates auth logic. + """ + import boto3 + + if template.aws_auth_method == 'access_keys': + from core.encryption import decrypt_value + + secret_key = decrypt_value(template.aws_secret_access_key_encrypted) \ + if template.aws_secret_access_key_encrypted else None + session_token = decrypt_value(template.aws_session_token_encrypted) \ + if template.aws_session_token_encrypted else None + return boto3.Session( + aws_access_key_id=template.aws_access_key_id, + aws_secret_access_key=secret_key, + aws_session_token=session_token, + region_name=template.region or None, + ) + + if template.aws_auth_method == 'profile': + return boto3.Session(profile_name=template.aws_profile, region_name=template.region or None) + + # SSO / default session — picks up whatever the SSO flow already populated. + return boto3.Session(region_name=template.region or None) + + +def list_eks_clusters_from_template(template) -> list[dict[str, Any]]: + """List EKS clusters reachable via *template* credentials. + + Returns a list of cluster info dicts with the keys required by + ``generate_eks_kubeconfig`` plus metadata for registration. + """ + from botocore.exceptions import BotoCoreError, ClientError + + from core.aws_config import adaptive_retry_config + + session = _boto3_session_from_template(template) + region = template.region or session.region_name + if not region: + raise ValueError("AWS region is required to list EKS clusters") + + try: + eks = session.client('eks', region_name=region, config=adaptive_retry_config()) + response = eks.list_clusters() + cluster_names = response.get('clusters', []) + except (ClientError, BotoCoreError) as e: + raise RuntimeError(f"Failed to list EKS clusters in {region}: {e}") from e + + clusters = [] + for name in cluster_names: + try: + detail = eks.describe_cluster(name=name)['cluster'] + except (ClientError, BotoCoreError) as e: + logger.warning(f"Skipping EKS cluster {name}: describe_cluster failed: {e}") + continue + + endpoint = detail.get('endpoint') + ca_data = detail.get('certificateAuthority', {}).get('data') + if not endpoint or not ca_data: + logger.warning(f"Skipping EKS cluster {name}: missing endpoint or CA data") + continue + + clusters.append({ + "name": name, + "endpoint": endpoint, + "certificate_authority_data": ca_data, + "region": region, + "version": detail.get('version'), + "arn": detail.get('arn'), + "account_id": detail.get('arn', '').split(":")[4] if detail.get('arn') else None, + }) + + return clusters diff --git a/backend/services/gcp_service.py b/backend/services/gcp_service.py new file mode 100644 index 0000000..1891fa8 --- /dev/null +++ b/backend/services/gcp_service.py @@ -0,0 +1,175 @@ +"""GCP-specific helpers for GKE cluster discovery and kubeconfig generation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import requests +import yaml + +from core.encryption import decrypt_value + +logger = logging.getLogger(__name__) + +GKE_API_URL = "https://container.googleapis.com/v1" + + +def _gcp_service_account_info_from_template(template) -> dict[str, Any]: + """Decrypt and parse GCP service-account JSON from a template.""" + sa_json = decrypt_value(template.gcp_credentials_encrypted) if template.gcp_credentials_encrypted else None + if not sa_json: + raise ValueError("GCP credentials are required to discover GKE clusters") + return json.loads(sa_json) + + +def _gcp_access_token(template) -> str: + """Mint a GCP access token from the template's service-account JSON.""" + try: + from google.auth.transport.requests import Request + from google.oauth2.service_account import Credentials + except ImportError as exc: # pragma: no cover - dependency may be absent in minimal installs + raise RuntimeError("google-auth is required for GKE cluster discovery") from exc + + sa_info = _gcp_service_account_info_from_template(template) + creds = Credentials.from_service_account_info( + sa_info, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + creds.refresh(Request()) + return creds.token + + +def list_gke_clusters_from_template(template) -> list[dict[str, Any]]: + """List GKE clusters across the template's project. + + If no gcp_project_id is configured, an empty list is returned. + """ + if not template.gcp_project_id: + raise ValueError("GCP project_id is required to list GKE clusters") + + token = _gcp_access_token(template) + url = f"{GKE_API_URL}/projects/{template.gcp_project_id}/locations/-/clusters" + response = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=30, + ) + if response.status_code in {401, 403}: + raise RuntimeError("GCP credentials are not authorized to list GKE clusters") + if not response.ok: + raise RuntimeError(f"GKE cluster list failed with status {response.status_code}") + + clusters = [] + for item in response.json().get("clusters", []): + name = item.get("name") + if not name: + continue + + location = item.get("location", "") + # Self-link format: projects/.../locations/.../clusters/... + full_name = item.get("selfLink") or f"projects/{template.gcp_project_id}/locations/{location}/clusters/{name}" + clusters.append({ + "name": name, + "project_id": template.gcp_project_id, + "location": location, + "full_name": full_name, + "endpoint": item.get("endpoint"), + "master_auth": item.get("masterAuth", {}), + "version": item.get("currentMasterVersion"), + }) + + return clusters + + +def generate_gke_kubeconfig( + cluster_name: str, + project_id: str, + location: str, + server: str, + certificate_authority_data: str, +) -> str: + """Generate a portable kubeconfig YAML for a GKE cluster. + + Uses the ``gke-gcloud-auth-plugin`` exec plugin. The binary is required at + runtime; the kubeconfig itself contains no local file references. + """ + kubeconfig = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [ + { + "name": cluster_name, + "cluster": { + "server": server, + "certificate-authority-data": certificate_authority_data, + }, + } + ], + "contexts": [ + { + "name": cluster_name, + "context": { + "cluster": cluster_name, + "user": cluster_name, + }, + } + ], + "current-context": cluster_name, + "users": [ + { + "name": cluster_name, + "user": { + "exec": { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "command": "gke-gcloud-auth-plugin", + "args": [], + "env": [ + {"name": "CLOUDSDK_CORE_PROJECT", "value": project_id}, + ], + "provideClusterInfo": True, + } + }, + } + ], + } + return yaml.dump(kubeconfig, default_flow_style=False) + + +def fetch_gke_cluster_credentials( + cluster_name: str, + project_id: str, + location: str, + template, +) -> dict[str, Any]: + """Fetch GKE cluster endpoint and CA via Container API. + + Returns a dict with ``server`` and ``certificate_authority_data``. + """ + token = _gcp_access_token(template) + url = f"{GKE_API_URL}/projects/{project_id}/locations/{location}/clusters/{cluster_name}" + response = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"GKE cluster get failed: {response.status_code}") + + cluster = response.json() + endpoint = cluster.get("endpoint") + ca_data = cluster.get("masterAuth", {}).get("clusterCaCertificate") + if not endpoint or not ca_data: + raise RuntimeError(f"GKE cluster {cluster_name} is missing endpoint or CA certificate") + + return { + "server": f"https://{endpoint}", + "certificate_authority_data": ca_data, + } diff --git a/backend/services/ibm_cloud_service.py b/backend/services/ibm_cloud_service.py index 0e18229..b0f120b 100644 --- a/backend/services/ibm_cloud_service.py +++ b/backend/services/ibm_cloud_service.py @@ -3,8 +3,10 @@ from __future__ import annotations import logging +from typing import Any import requests +import yaml from sqlalchemy.orm import Session from core.encryption import decrypt_value, encrypt_value @@ -17,6 +19,7 @@ IBM_IAM_TOKEN_URL = "https://iam.cloud.ibm.com/identity/token" IBM_SEARCH_URL = "https://api.global-search-tagging.cloud.ibm.com/v3/resources/search" IBM_RESOURCE_KEYS_URL = "https://resource-controller.cloud.ibm.com/v2/resource_keys" +IBM_CONTAINERS_V1_URL = "https://containers.cloud.ibm.com/global/v1/clusters" IBM_REGIONS: list[dict[str, str]] = [ {"value": "au-syd", "label": "Australia (Sydney)"}, @@ -364,3 +367,110 @@ def _exchange_api_key(self, api_key: str, *, template: CloudCredentialTemplate | logger.warning(f"Failed to cache IBM IAM token on template {template.id}: {e}") return access_token + + +# --------------------------------------------------------------------------- +# ROKS / IBM Cloud Kubernetes Service cluster discovery +# --------------------------------------------------------------------------- + +def list_roks_clusters_from_template(template: CloudCredentialTemplate) -> list[dict[str, Any]]: + """List IBM Cloud Kubernetes Service (IKS/ROKS) clusters via *template*. + + Returns cluster info dicts containing the keys required for kubeconfig + generation and registration. + """ + from core.encryption import decrypt_value + + api_key = decrypt_value(template.ibmcloud_api_key_encrypted) if template.ibmcloud_api_key_encrypted else None + if not api_key: + raise ValueError("IBM Cloud API key is required to list clusters") + + svc = IBMCloudService(None) + access_token = svc._exchange_api_key(api_key, template=template) + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + response = requests.get( + IBM_CONTAINERS_V1_URL, + headers=headers, + timeout=30, + ) + if response.status_code in {401, 403}: + raise RuntimeError("IBM Cloud API key is not authorized to list clusters") + if not response.ok: + raise RuntimeError(f"IBM Cloud cluster list failed with status {response.status_code}") + + clusters = [] + for item in response.json() or []: + name = item.get("name") + if not name: + continue + clusters.append({ + "name": name, + "id": item.get("id"), + "region": item.get("region") or template.region, + "resource_group": item.get("resourceGroup"), + "server_url": item.get("serverURL") or item.get("publicServiceEndpointURL"), + "master_status": item.get("masterStatus"), + }) + + return clusters + + +def describe_roks_cluster(name: str, access_token: str) -> dict[str, Any]: + """Fetch full details for an IBM Cloud Kubernetes Service cluster. + + Includes certificate authority and server URL needed for kubeconfig. + """ + config_url = f"{IBM_CONTAINERS_V1_URL}/{name}/config" + response = requests.get( + config_url, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + timeout=30, + ) + if not response.ok: + raise RuntimeError(f"IBM Cloud cluster config fetch failed: {response.status_code}") + return response.json() + + +def generate_roks_kubeconfig(cluster_name: str, server_url: str, ca_cert: str, token: str) -> str: + """Generate a portable kubeconfig YAML for an IBM ROKS/IKS cluster. + + Uses a static IAM bearer token. Callers are responsible for refreshing + the token via ``refresh_kubeconfig_iam_token`` before the token expires. + """ + kubeconfig = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [ + { + "name": cluster_name, + "cluster": { + "server": server_url, + "certificate-authority-data": ca_cert, + }, + } + ], + "contexts": [ + { + "name": cluster_name, + "context": { + "cluster": cluster_name, + "user": cluster_name, + }, + } + ], + "current-context": cluster_name, + "users": [ + { + "name": cluster_name, + "user": {"token": token}, + } + ], + } + return yaml.dump(kubeconfig, default_flow_style=False) diff --git a/backend/services/kubernetes/_base.py b/backend/services/kubernetes/_base.py index fd69f0a..0c6990c 100644 --- a/backend/services/kubernetes/_base.py +++ b/backend/services/kubernetes/_base.py @@ -12,6 +12,7 @@ from kubernetes.client.rest import ApiException from sqlalchemy.orm import Session +from core.cache import cache from core.encryption import decrypt_value from models import KubernetesCluster from services.cluster_utils import _maybe_open_ssh_tunnel @@ -19,6 +20,11 @@ from services.kubeconfig_normalizer import NormalizationSource, normalize_kubeconfig from services.reachability import with_breaker +# EKS/GCP bearer tokens are valid for ~15 minutes. Generating them on every +# kubeconfig load is expensive (boto3/google-auth crypto + STS calls) and shows +# up prominently under concurrent BNK page loads. Cache per cluster for 10 min. +_TOKEN_TTL_SECONDS = 600 + logger = logging.getLogger(__name__) @@ -206,6 +212,15 @@ def _generate_eks_token(cluster: KubernetesCluster, aws_env: dict) -> str | None """ import base64 + region = cluster.region or aws_env.get("AWS_REGION") or aws_env.get("AWS_DEFAULT_REGION") or "us-east-1" + access_key = aws_env.get("AWS_ACCESS_KEY_ID") or "default" + cluster_id = getattr(cluster, "id", getattr(cluster, "name", "default")) + cache_key = f"eks_token:{cluster_id}:{region}:{access_key}" + cached = cache.get(cache_key) + if cached: + logger.debug("Using cached EKS token for cluster %s", getattr(cluster, "name", "unknown")) + return cached + try: import boto3 from botocore.auth import SigV4QueryAuth @@ -284,6 +299,7 @@ def _generate_eks_token(cluster: KubernetesCluster, aws_env: dict) -> str | None # Encode as k8s-aws-v1 token token = "k8s-aws-v1." + base64.urlsafe_b64encode(signed_url.encode("utf-8")).rstrip(b"=").decode("utf-8") + cache.set(cache_key, token, ttl_seconds=_TOKEN_TTL_SECONDS) logger.info("Generated EKS bearer token for cluster %s (region=%s)", cluster_name, region) return token @@ -299,6 +315,14 @@ def _generate_gcp_token(sa_info: dict) -> str | None: Returns the token string, or None on failure. """ + client_email = sa_info.get("client_email", "unknown") + private_key_id = sa_info.get("private_key_id", "unknown") + cache_key = f"gcp_token:{client_email}:{private_key_id}" + cached = cache.get(cache_key) + if cached: + logger.debug("Using cached GCP token for service account %s", client_email) + return cached + try: from google.auth.transport.requests import Request from google.oauth2 import service_account @@ -311,11 +335,13 @@ def _generate_gcp_token(sa_info: dict) -> str | None: scopes=["https://www.googleapis.com/auth/cloud-platform"], ) credentials.refresh(Request()) + token = credentials.token + cache.set(cache_key, token, ttl_seconds=_TOKEN_TTL_SECONDS) logger.info( "Generated GCP access token for service account %s", - sa_info.get("client_email", ""), + client_email, ) - return credentials.token + return token @staticmethod def _maybe_open_ssh_tunnel(cluster: KubernetesCluster) -> int | None: diff --git a/backend/services/kubernetes/_metrics.py b/backend/services/kubernetes/_metrics.py index dc1bb8c..d8cbb68 100644 --- a/backend/services/kubernetes/_metrics.py +++ b/backend/services/kubernetes/_metrics.py @@ -11,6 +11,55 @@ logger = logging.getLogger(__name__) +def parse_cpu_to_millicores(cpu_str: str | None) -> int: + """Parse a Kubernetes CPU quantity string to millicores.""" + if not cpu_str or cpu_str == "0": + return 0 + cpu_str = cpu_str.strip() + if cpu_str.endswith("n"): + return int(float(cpu_str[:-1]) / 1_000_000) + if cpu_str.endswith("u"): + return int(float(cpu_str[:-1]) / 1_000) + if cpu_str.endswith("m"): + return int(float(cpu_str[:-1])) + return int(float(cpu_str) * 1000) + + +def parse_memory_to_bytes(memory_str: str | None) -> int: + """Parse a Kubernetes memory quantity string to bytes.""" + if not memory_str or memory_str == "0": + return 0 + memory_str = memory_str.strip() + try: + return int(float(memory_str)) + except ValueError: + pass + + suffixes = { + "Ki": 1024, + "Mi": 1024**2, + "Gi": 1024**3, + "Ti": 1024**4, + "Pi": 1024**5, + "Ei": 1024**6, + "k": 1000, + "M": 1000**2, + "G": 1000**3, + "T": 1000**4, + "P": 1000**5, + "E": 1000**6, + } + for suffix, multiplier in suffixes.items(): + if memory_str.endswith(suffix): + return int(float(memory_str[: -len(suffix)]) * multiplier) + + try: + return int(float(memory_str)) + except ValueError: + logger.warning(f"Could not parse memory string: {memory_str}") + return 0 + + class MetricsMixin: """Mixin for pod/node metrics (requires metrics-server).""" @@ -44,8 +93,8 @@ def get_pod_metrics( total_memory = 0 for container in containers: usage = container.get("usage", {}) - total_cpu += self._parse_cpu_string(usage.get("cpu", "0")) - total_memory += self._parse_memory_string(usage.get("memory", "0")) + total_cpu += parse_cpu_to_millicores(usage.get("cpu", "0")) + total_memory += parse_memory_to_bytes(usage.get("memory", "0")) pod_metrics.append({ "name": metadata.get("name"), @@ -96,8 +145,8 @@ def get_node_metrics( node_allocatable = {} for node in nodes.items: node_allocatable[node.metadata.name] = { - "cpu": self._parse_cpu_string(node.status.allocatable.get("cpu", "0")), - "memory": self._parse_memory_string(node.status.allocatable.get("memory", "0")), + "cpu": parse_cpu_to_millicores(node.status.allocatable.get("cpu", "0")), + "memory": parse_memory_to_bytes(node.status.allocatable.get("memory", "0")), "pods": int(node.status.allocatable.get("pods", "0")) } @@ -107,8 +156,8 @@ def get_node_metrics( usage = item.get("usage", {}) node_name = metadata.get("name") - cpu_millicores = self._parse_cpu_string(usage.get("cpu", "0")) - memory_bytes = self._parse_memory_string(usage.get("memory", "0")) + cpu_millicores = parse_cpu_to_millicores(usage.get("cpu", "0")) + memory_bytes = parse_memory_to_bytes(usage.get("memory", "0")) allocatable = node_allocatable.get(node_name, {}) allocatable_cpu = allocatable.get("cpu", 0) @@ -150,39 +199,10 @@ def get_node_metrics( logger.error(f"Unexpected error getting node metrics: {e}") raise - def _parse_cpu_string(self, cpu_str: str) -> int: + def _parse_cpu_string(self, cpu_str: str | None) -> int: """Parse Kubernetes CPU string to millicores.""" - if not cpu_str or cpu_str == "0": - return 0 - cpu_str = cpu_str.strip() - if cpu_str.endswith("n"): - return int(float(cpu_str[:-1]) / 1_000_000) - if cpu_str.endswith("u"): - return int(float(cpu_str[:-1]) / 1_000) - if cpu_str.endswith("m"): - return int(float(cpu_str[:-1])) - return int(float(cpu_str) * 1000) - - def _parse_memory_string(self, memory_str: str) -> int: - """Parse Kubernetes memory string to bytes.""" - if not memory_str or memory_str == "0": - return 0 - memory_str = memory_str.strip() - try: - return int(float(memory_str)) - except ValueError: - pass - - suffixes = { - "Ki": 1024, "Mi": 1024**2, "Gi": 1024**3, "Ti": 1024**4, "Pi": 1024**5, "Ei": 1024**6, - "k": 1000, "M": 1000**2, "G": 1000**3, "T": 1000**4, "P": 1000**5, "E": 1000**6, - } - for suffix, multiplier in suffixes.items(): - if memory_str.endswith(suffix): - return int(float(memory_str[:-len(suffix)]) * multiplier) + return parse_cpu_to_millicores(cpu_str) - try: - return int(float(memory_str)) - except ValueError: - logger.warning(f"Could not parse memory string: {memory_str}") - return 0 + def _parse_memory_string(self, memory_str: str | None) -> int: + """Parse Kubernetes memory string to bytes.""" + return parse_memory_to_bytes(memory_str) diff --git a/backend/services/module_source_service.py b/backend/services/module_source_service.py index 853afff..060db88 100644 --- a/backend/services/module_source_service.py +++ b/backend/services/module_source_service.py @@ -264,6 +264,30 @@ def _set_default_module_source(self, source: ModuleSource) -> None: set_default(self.db, "module_library.git_url", source.url) set_default(self.db, "module_library.git_ref", source.git_ref or source.branch or "main") + def _reconcile_official_source_ref(self, source: ModuleSource) -> None: + """Keep the canonical official module library source aligned with settings. + + The official source is identified by matching the configured + ``module_library.git_url``. When the operator changes that setting (or + the source row was created under an older value), a direct source sync + should use the current setting rather than a stale branch/git_ref. + """ + if source.source_type != "git": + return + default_url = self._get_default_module_source_url() + if not default_url: + return + normalized_source = ( + GitAuthService.strip_url_credentials(source.url).rstrip("/").removesuffix(".git") + ) + normalized_default = ( + GitAuthService.strip_url_credentials(default_url).rstrip("/").removesuffix(".git") + ) + if normalized_source == normalized_default: + default_ref = self._get_default_module_source_ref() + source.branch = default_ref + source.git_ref = default_ref + def _create_module_source_audit( self, *, @@ -1440,6 +1464,7 @@ def sync_source(self, source_id: int) -> dict[str, Any]: if source.source_type == 'git': from services.module_sync_service import ModuleSyncService + self._reconcile_official_source_ref(source) sync_service = ModuleSyncService(self.db) try: results = sync_service.sync_git_source(source) diff --git a/backend/services/module_sync_service.py b/backend/services/module_sync_service.py index 84ef943..f17b255 100644 --- a/backend/services/module_sync_service.py +++ b/backend/services/module_sync_service.py @@ -556,9 +556,13 @@ def _clone_repository(self, source: ModuleSource) -> str: # Add depth 1 for faster cloning (shallow clone) git_cmd.extend(['--depth', '1']) - # Add branch if specified - if source.branch: - git_cmd.extend(['--branch', source.branch]) + # Prefer git_ref over branch for the clone target: `git clone --branch` + # accepts branch names and tag names, and the resulting working tree is + # already checked out at that ref. Using a shallow branch clone followed + # by `git checkout ` fails because `--depth 1` does not fetch tags. + clone_ref = source.git_ref or source.branch + if clone_ref: + git_cmd.extend(['--branch', clone_ref]) # Handle authentication git_cmd.append(safe_source_url) @@ -612,20 +616,8 @@ def _clone_repository(self, source: ModuleSource) -> str: ) raise RuntimeError(f"Git clone failed [{error_kind}] {guidance}: {sanitized}") - # Checkout specific ref if specified - if source.git_ref: - checkout_result = subprocess.run( - ['git', 'checkout', source.git_ref], - cwd=temp_dir, - env=env, - capture_output=True, - text=True, - timeout=60 - ) - if checkout_result.returncode != 0: - error_kind, guidance = GitAuthService.classify_git_failure(checkout_result.stderr) - sanitized = GitAuthService.sanitize_error_text(checkout_result.stderr, secrets=[auth_ctx.secret]) - raise RuntimeError(f"Git checkout failed [{error_kind}] {guidance}: {sanitized}") + # No separate checkout is needed: git clone --branch already checked + # out the requested ref (branch or tag). finally: cleanup_env() diff --git a/backend/services/operator_registry.py b/backend/services/operator_registry.py index cc4b77a..7bb505b 100644 --- a/backend/services/operator_registry.py +++ b/backend/services/operator_registry.py @@ -602,5 +602,21 @@ def get_operator_ws(self, operator_id: str) -> WebSocket | None: return self._connections.get(operator_id) +def is_operator_live_connected(op: ConnectedOperator, polling_threshold_seconds: int = 60) -> bool: + """Return whether an operator is currently connected. + + Direct-WebSocket operators are live when their socket is in the in-memory + registry. Polling operators are live when their most recent heartbeat is + within ``polling_threshold_seconds``. This is the single source of truth + used by the operator list, fleet health, and BNK health views. + """ + if operator_connections.is_connected(op.operator_id): + return True + if op.connectivity_mode == "polling" and op.last_heartbeat_at: + heartbeat_age = (datetime.now(UTC) - op.last_heartbeat_at).total_seconds() + return heartbeat_age < polling_threshold_seconds + return False + + # Global singleton operator_connections = OperatorConnectionManager() diff --git a/backend/services/qkview_service.py b/backend/services/qkview_service.py index 53c6818..44173c1 100644 --- a/backend/services/qkview_service.py +++ b/backend/services/qkview_service.py @@ -37,6 +37,7 @@ from kubernetes import client as k8s_client from kubernetes.stream import stream as k8s_stream +from core.cache import cache from services.kubernetes_service import KubernetesService logger = logging.getLogger(__name__) @@ -1121,7 +1122,7 @@ def _restart_cwc_pod(api_client: k8s_client.ApiClient, cwc_namespace: str = CWC_ def check_setup_status( - k8s_service: KubernetesService, cluster_id: int + k8s_service: KubernetesService, cluster_id: int, force: bool = False ) -> dict[str, Any]: """ Check whether QKView mTLS setup has been completed for this cluster. @@ -1139,6 +1140,12 @@ def check_setup_status( "message": str, } """ + cache_key = f"cwc:setup_status:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + try: cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) @@ -1178,13 +1185,15 @@ def check_setup_status( "certificates and restart the CWC pod (one-time operation)." ) - return { + res = { "setup_complete": setup_complete, "cert_manager_available": cert_manager_ok, "server_cert_exists": server_cert_ok, "client_cert_exists": client_cert_ok, "message": message, } + cache.set(cache_key, res, ttl_seconds=60) + return res def setup_cwc_api_certs( @@ -1290,6 +1299,11 @@ def setup_cwc_api_certs( _cleanup_all_client_pods(api_client, cwc_ns) steps.append({"step": "stale_pods_cleanup", "status": "ok"}) + # Invalidate cached CWC status + cache.delete(f"cwc:setup_status:{cluster_id}") + cache.delete(f"cwc:available:{cluster_id}") + cache.delete(f"license:status:{cluster_id}") + return { "success": True, "message": "CWC API mTLS setup complete. CWC REST API now uses cert-manager certs.", @@ -1302,7 +1316,7 @@ def setup_cwc_api_certs( def check_cwc_available( - k8s_service: KubernetesService, cluster_id: int + k8s_service: KubernetesService, cluster_id: int, force: bool = False ) -> dict[str, Any]: """ Check if the CWC service is available and the mTLS certs are accessible. @@ -1310,6 +1324,12 @@ def check_cwc_available( QKView and licensing both depend on the same CWC REST API path, so this is the shared health check for those workflows. """ + cache_key = f"cwc:available:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + try: cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) @@ -1339,7 +1359,9 @@ def check_cwc_available( except QKViewError as e: return {"available": False, "message": str(e)} - return {"available": True, "message": f"CWC is available in namespace '{cwc_ns}'"} + res = {"available": True, "message": f"CWC is available in namespace '{cwc_ns}'"} + cache.set(cache_key, res, ttl_seconds=60) + return res except Exception as e: return { @@ -1349,21 +1371,30 @@ def check_cwc_available( def list_qkviews( - k8s_service: KubernetesService, cluster_id: int + k8s_service: KubernetesService, cluster_id: int, force: bool = False ) -> list[dict]: """List all QKView jobs on the cluster.""" + cache_key = f"cwc:qkviews:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) result = _cwc_request(api_client, "GET", "/v1/qkview") + items = [] if isinstance(result, list): - return result - if isinstance(result, dict): + items = result + elif isinstance(result, dict): if "items" in result: - return result["items"] + items = result["items"] # Single item or empty - return [result] if result.get("id") or result.get("filename") else [] - return [] + elif result.get("id") or result.get("filename"): + items = [result] + cache.set(cache_key, items, ttl_seconds=30) + return items def create_qkview( @@ -1372,6 +1403,7 @@ def create_qkview( options: dict[str, Any] | None = None, ) -> dict[str, Any]: """Create a new QKView diagnostic tarball.""" + cache.delete(f"cwc:qkviews:{cluster_id}") cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) # Only send a body if there are actual options @@ -1418,6 +1450,7 @@ def delete_qkview( k8s_service: KubernetesService, cluster_id: int, qkview_id: str ) -> dict: """Delete a specific QKView by ID.""" + cache.delete(f"cwc:qkviews:{cluster_id}") cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) try: @@ -1437,6 +1470,7 @@ def cancel_qkview( k8s_service: KubernetesService, cluster_id: int, qkview_id: str ) -> dict: """Cancel a running QKView job.""" + cache.delete(f"cwc:qkviews:{cluster_id}") cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) result = _cwc_request( @@ -1622,7 +1656,7 @@ def _format_switch_failure_message( def get_license_status( - k8s_service: KubernetesService, cluster_id: int + k8s_service: KubernetesService, cluster_id: int, force: bool = False ) -> dict[str, Any]: """ Get CWC license and telemetry status for a cluster. @@ -1631,27 +1665,49 @@ def get_license_status( Returns normalized license state, entitlement type, expiry, telemetry status, etc. The raw CWC response is included as ``raw_cwc_response`` for debugging. + + Results are cached for 30 seconds per cluster; pass ``force=True`` to + bypass the cache. """ + cache_key = f"license:status:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) result = _cwc_request(api_client, "GET", "/status") normalized = _normalize_cwc_status(result if isinstance(result, dict) else {}) - return {"success": True, **normalized} + response = {"success": True, **normalized} + cache.set(cache_key, response, ttl_seconds=30) + return response def get_license_report( - k8s_service: KubernetesService, cluster_id: int + k8s_service: KubernetesService, cluster_id: int, force: bool = False ) -> dict[str, Any]: """ Get CWC telemetry report for a cluster. CWC endpoint: GET /report Only available when CWC telemetry state is "Config Report Ready to Download". + + Results are cached for 60 seconds per cluster; pass ``force=True`` to + bypass the cache. """ + cache_key = f"license:report:{cluster_id}" + if not force: + cached = cache.get(cache_key) + if cached is not None: + return cached + cluster = k8s_service.get_cluster(cluster_id) api_client = k8s_service.load_kubeconfig(cluster) result = _cwc_request(api_client, "GET", "/report") - return {"success": True, **result} + response = {"success": True, **result} + cache.set(cache_key, response, ttl_seconds=60) + return response def activate_license( @@ -1750,6 +1806,11 @@ def activate_license( response["jwks_validation"] = jwks_status elif jwks_validation: response["jwks_validation"] = jwks_validation + + # Activation changed license state; invalidate cached status/report so the + # next read reflects the new state instead of a stale cached value. + cache.delete(f"license:status:{cluster_id}") + cache.delete(f"license:report:{cluster_id}") return response diff --git a/backend/services/scanner/__init__.py b/backend/services/scanner/__init__.py index e65082d..b89b5a7 100644 --- a/backend/services/scanner/__init__.py +++ b/backend/services/scanner/__init__.py @@ -204,6 +204,10 @@ def scan(self, cluster_id: int) -> dict[str, Any]: ) self.db.flush() + # Persist cluster metadata discovered by the scan so list/detail views + # can surface it without re-querying the cluster. + self._persist_cluster_metadata(cluster, cluster_info, data["nodes"]) + from services.scanner.recommendations import resolve_enabled_prereqs enabled_prereq_set = resolve_enabled_prereqs(cluster.enabled_prerequisites) @@ -256,6 +260,53 @@ def scan(self, cluster_id: int) -> dict[str, Any]: "platform_context": platform_context.to_dict(), } + def _persist_cluster_metadata( + self, + cluster, + cluster_info: dict[str, Any], + nodes: list[dict[str, Any]], + ) -> None: + """Write scan-derived metadata back to the cluster record. + + Updates version, node_count, zones, last_synced_at, connectivity, + integration, and access_method so fleet/list/detail views can read + them without extra cloud/operator lookups. + """ + from services.operator_registry import is_operator_live_connected + + now = datetime.now(UTC) + + cluster.version = cluster_info.get("version") or getattr(cluster, "version", None) + cluster.node_count = cluster_info.get("node_count") or len(nodes) or getattr(cluster, "node_count", None) + cluster.zones = sorted({ + n.get("zone") for n in nodes if n.get("zone") + }) or getattr(cluster, "zones", None) + cluster.last_synced_at = now + cluster.connectivity_status = "connected" + cluster.access_method = "ssh_tunnel" if getattr(cluster, "ssh_tunnel_enabled", False) else "kubeconfig" + + try: + from models import ConnectedOperator + linked_op = ( + self.db.query(ConnectedOperator) + .filter(ConnectedOperator.cluster_id == cluster.id) + .first() + ) + if linked_op: + cluster.integration_status = ( + "agent_connected" if is_operator_live_connected(linked_op) else "agent_disconnected" + ) + else: + cluster.integration_status = "direct" + except Exception as exc: + logger.warning("Failed to determine cluster integration status (non-fatal): %s", exc) + cluster.integration_status = getattr(cluster, "integration_status", None) or "direct" + + try: + self.db.flush() + except Exception: + pass + __all__ = [ "ClusterScanner", diff --git a/backend/services/system_service.py b/backend/services/system_service.py index 4000cb9..221f750 100644 --- a/backend/services/system_service.py +++ b/backend/services/system_service.py @@ -17,6 +17,8 @@ import subprocess import threading import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError from datetime import UTC, datetime, timedelta from typing import Any @@ -30,7 +32,11 @@ from core.worker_heartbeat import heartbeat from models import ApplicationSetting, AuditLog, DeploymentLog, ModuleLibrary, Project, ProjectModule, Task from models.enums import TaskStatus +from services.bnk.consumption import aggregate_cluster_consumption, aggregate_fleet_summary +from services.bnk.fetch import fetch_all_bnk_data from services.defaults_service import DEFAULT_REPO_URL +from services.dpf.fetch import detect_dpf +from services.kubernetes_service import KubernetesService logger = logging.getLogger(__name__) @@ -106,6 +112,117 @@ def get_health(self) -> dict[str, Any]: cache.set("system:health", health_data, ttl_seconds=30) return health_data + # ================================================================ + # BNK Resource Consumption + # ================================================================ + + _BNK_CONSUMPTION_CACHE_KEY = "system:bnk_consumption" + _BNK_CONSUMPTION_TTL_SECONDS = 60 + + def get_bnk_consumption(self) -> dict[str, Any]: + """ + Aggregate BNK resource consumption across all clusters. + + Combines ``fetch_all_bnk_data`` with pod metrics to produce a + fleet-wide summary plus a per-cluster breakdown split by control + plane vs data plane. Results are cached in-memory for 20 seconds. + """ + cached = cache.get(self._BNK_CONSUMPTION_CACHE_KEY) + if cached is not None: + return cached + + from models.kubernetes import KubernetesCluster + + clusters = self.db.query(KubernetesCluster).all() + k8s_service = KubernetesService(self.db) + + def _collect_cluster(cluster) -> dict[str, Any]: + bnk_data: dict[str, Any] | None = None + pod_metrics_response: dict[str, Any] | None = None + dpf_summary: dict[str, Any] | None = None + reachable = True + + try: + # This single call both checks reachability and returns BNK inventory. + # include_nodes=True so we can fall back to node allocatable capacity + # when cluster metrics-server is not installed. + bnk_data = fetch_all_bnk_data(k8s_service, cluster.id, include_nodes=True) + except Exception as exc: + logger.warning(f"BNK consumption: cluster {cluster.name} (id={cluster.id}) unreachable: {exc}") + reachable = False + + if reachable and bnk_data is not None: + try: + pod_metrics_response = k8s_service.get_pod_metrics(cluster.id) + except Exception as exc: + logger.warning(f"BNK consumption: pod metrics failed for cluster {cluster.id}: {exc}") + pod_metrics_response = {"available": False, "error": str(exc)} + + try: + dpf = detect_dpf(k8s_service, cluster.id) + dpf_summary = { + "detected": bool(dpf.get("detected")), + "dpu_count": int(dpf.get("devices", {}).get("total", 0)), + } + except Exception as exc: + logger.warning(f"BNK consumption: DPF detection failed for cluster {cluster.id}: {exc}") + dpf_summary = {"detected": False, "dpu_count": 0} + + return aggregate_cluster_consumption( + cluster_id=cluster.id, + cluster_name=cluster.name, + node_count=getattr(cluster, "node_count", None), + status=cluster.status or "unknown", + bnk_data=bnk_data, + pod_metrics_response=pod_metrics_response, + dpf_summary=dpf_summary, + reachable=reachable, + ) + + # Collect per-cluster data in parallel with a per-cluster timeout. + # Without timeouts a single unreachable cluster can stall the whole + # fleet view for minutes (e.g. metrics-server not installed). + cluster_results: list[dict[str, Any]] = [] + per_cluster_timeout = 30 # seconds + with ThreadPoolExecutor(max_workers=min(len(clusters) or 1, 8)) as executor: + futures = {executor.submit(_collect_cluster, c): c for c in clusters} + for future in futures: + cluster = futures[future] + try: + cluster_results.append(future.result(timeout=per_cluster_timeout)) + except FuturesTimeoutError: + logger.warning(f"BNK consumption: cluster {cluster.name} (id={cluster.id}) timed out after {per_cluster_timeout}s") + cluster_results.append(aggregate_cluster_consumption( + cluster_id=cluster.id, + cluster_name=cluster.name, + node_count=getattr(cluster, "node_count", None), + status=cluster.status or "unknown", + bnk_data=None, + pod_metrics_response={"available": False, "error": "Timed out collecting cluster consumption"}, + dpf_summary={"detected": False, "dpu_count": 0}, + reachable=False, + )) + except Exception as exc: + logger.warning(f"BNK consumption: cluster {cluster.name} (id={cluster.id}) failed: {exc}") + cluster_results.append(aggregate_cluster_consumption( + cluster_id=cluster.id, + cluster_name=cluster.name, + node_count=getattr(cluster, "node_count", None), + status=cluster.status or "unknown", + bnk_data=None, + pod_metrics_response={"available": False, "error": str(exc)}, + dpf_summary={"detected": False, "dpu_count": 0}, + reachable=False, + )) + + result = { + "timestamp": datetime.now(UTC).isoformat(), + "fleet_summary": aggregate_fleet_summary(cluster_results), + "clusters": cluster_results, + } + cache.set(self._BNK_CONSUMPTION_CACHE_KEY, result, ttl_seconds=self._BNK_CONSUMPTION_TTL_SECONDS) + return result + # ================================================================ # Queue Metrics # ================================================================ diff --git a/backend/services/tmm_debug_service.py b/backend/services/tmm_debug_service.py index 3efb2b9..a616580 100644 --- a/backend/services/tmm_debug_service.py +++ b/backend/services/tmm_debug_service.py @@ -27,6 +27,7 @@ from kubernetes.client.rest import ApiException from kubernetes.stream import stream as k8s_stream +from core.cache import cache from services.bnk_pod_discovery import classify_f5_pods, discover_f5_pods logger = logging.getLogger(__name__) @@ -37,19 +38,36 @@ DEBUG_CONTAINER_NAME = "debug" +# Short-term cache for TMM pod listing (read-only diagnostics; pods don't +# change second-to-second). Avoids re-discovering F5 pods on every diagnostic +# panel render / command run. +_TMM_POD_LIST_CACHE_TTL = 60 + # Default timeout for one-shot debug commands (seconds) DEFAULT_EXEC_TIMEOUT = 30 # bdt_cli default socket address (TMM instance 0) BDT_CLI_DEFAULT_SOCKET = "tmm0:8850" +# Cache TTL for read-only TMM debug command output. Diagnostic commands like +# tmctl and configview are expensive (~1-3s each via kubectl exec) but their +# output changes slowly; caching them makes the diagnostics panel responsive. +_TMM_EXEC_CACHE_TTL = 10 + # --------------------------------------------------------------------------- # Pod Discovery — list TMM pods with debug sidecar availability # --------------------------------------------------------------------------- -def list_tmm_debug_pods(api_client: k8s_client.ApiClient) -> list[dict[str, Any]]: +def _tmm_pod_list_cache_key(cluster_id: int) -> str: + return f"tmm:debug:pods:{cluster_id}" + + +def list_tmm_debug_pods( + api_client: k8s_client.ApiClient, + cluster_id: int, +) -> list[dict[str, Any]]: """ List TMM pods that have a debug sidecar container. @@ -57,11 +75,34 @@ def list_tmm_debug_pods(api_client: k8s_client.ApiClient) -> list[dict[str, Any] [{ name, namespace, has_debug, containers: [str, ...] }] Uses bnk_pod_discovery to find TMM pods, then checks each pod's - container list for the "debug" container. + container list for the "debug" container. Results are cached to avoid + re-discovery when the diagnostics panel renders. Also checks warm + caches from BNK health / pod discovery. """ - tenant_pods, utils_pods = discover_f5_pods(api_client) - classified = classify_f5_pods(tenant_pods, utils_pods) - tmm_pods = classified.get("tmm", []) + cache_key = _tmm_pod_list_cache_key(cluster_id) + cached = cache.get(cache_key) + if cached is not None: + return cached + + # Check if pods were already discovered by BNK data fetch / health dashboard + tmm_pods = None + bnk_pods_cached = cache.get(f"bnk:pods:{cluster_id}") + if bnk_pods_cached and isinstance(bnk_pods_cached, (tuple, list)) and len(bnk_pods_cached) == 2: + tenant_pods, utils_pods = bnk_pods_cached + classified = classify_f5_pods(tenant_pods, utils_pods) + tmm_pods = classified.get("tmm", []) + else: + for suffix in ("all:False", "all:True"): + data_cached = cache.get(f"bnk:data:{cluster_id}:{suffix}") + if data_cached and isinstance(data_cached, dict) and "classified_pods" in data_cached: + tmm_pods = data_cached["classified_pods"].get("tmm", []) + break + + if tmm_pods is None: + tenant_pods, utils_pods = discover_f5_pods(api_client) + cache.set(f"bnk:pods:{cluster_id}", (tenant_pods, utils_pods), ttl_seconds=_TMM_POD_LIST_CACHE_TTL) + classified = classify_f5_pods(tenant_pods, utils_pods) + tmm_pods = classified.get("tmm", []) results = [] for pod in tmm_pods: @@ -74,6 +115,7 @@ def list_tmm_debug_pods(api_client: k8s_client.ApiClient) -> list[dict[str, Any] "phase": pod.get("phase", "Unknown"), }) + cache.set(cache_key, results, ttl_seconds=_TMM_POD_LIST_CACHE_TTL) return results @@ -208,6 +250,36 @@ def exec_debug_command( } +def _tmm_exec_cache_key( + cluster_id: int, + pod_name: str, + namespace: str, + command: list[str], +) -> str: + """Cache key for read-only TMM debug command output.""" + command_hash = "_".join(command).replace(" ", "_") + return f"tmm:exec:{cluster_id}:{namespace}:{pod_name}:{command_hash}" + + +def _cached_exec_debug_command( + cluster_id: int, + api_client: k8s_client.ApiClient, + pod_name: str, + namespace: str, + command: list[str], + timeout: int = DEFAULT_EXEC_TIMEOUT, +) -> dict[str, Any]: + """Execute a read-only debug command with short-term caching.""" + cache_key = _tmm_exec_cache_key(cluster_id, pod_name, namespace, command) + cached = cache.get(cache_key) + if cached is not None: + return cached + + result = exec_debug_command(api_client, pod_name, namespace, command, timeout) + cache.set(cache_key, result, ttl_seconds=_TMM_EXEC_CACHE_TTL) + return result + + # --------------------------------------------------------------------------- # tmctl — structured table queries # --------------------------------------------------------------------------- @@ -222,6 +294,7 @@ def exec_tmctl( width: int = 200, directory: str = "blade", timeout: int = DEFAULT_EXEC_TIMEOUT, + cluster_id: int | None = None, ) -> dict[str, Any]: """ Execute a tmctl command and parse the tabular output. @@ -240,7 +313,9 @@ def exec_tmctl( cmd.extend(["-w", str(width)]) - result = exec_debug_command(api_client, pod_name, namespace, cmd, timeout) + result = _cached_exec_debug_command( + cluster_id or 0, api_client, pod_name, namespace, cmd, timeout + ) # Parse tabular output if the command succeeded parsed = parse_tmctl_output(result["stdout"]) if result["exit_code"] == 0 else None @@ -343,6 +418,7 @@ def exec_configview( namespace: str, uuid: str, timeout: int = DEFAULT_EXEC_TIMEOUT, + cluster_id: int | None = None, ) -> dict[str, Any]: """ Execute 'configview uuid ' to inspect a specific CR config. @@ -355,7 +431,9 @@ def exec_configview( raise ValueError(f"Invalid UUID format: {uuid}") cmd = ["configview", "uuid", uuid] - return exec_debug_command(api_client, pod_name, namespace, cmd, timeout) + return _cached_exec_debug_command( + cluster_id or 0, api_client, pod_name, namespace, cmd, timeout + ) def discover_configview_uuids( @@ -363,6 +441,7 @@ def discover_configview_uuids( pod_name: str, namespace: str, timeout: int = DEFAULT_EXEC_TIMEOUT, + cluster_id: int | None = None, ) -> dict[str, Any]: """ Run 'configview list' to discover available configuration UUIDs. @@ -371,7 +450,9 @@ def discover_configview_uuids( { uuids: [str, ...], raw: str, exit_code, duration_ms, command } """ cmd = ["configview", "list"] - result = exec_debug_command(api_client, pod_name, namespace, cmd, timeout) + result = _cached_exec_debug_command( + cluster_id or 0, api_client, pod_name, namespace, cmd, timeout + ) # Parse UUIDs from the output uuids = [] @@ -407,6 +488,7 @@ def exec_bdt_cli( subcommand: str, socket: str = BDT_CLI_DEFAULT_SOCKET, timeout: int = DEFAULT_EXEC_TIMEOUT, + cluster_id: int | None = None, ) -> dict[str, Any]: """ Execute 'bdt_cli -u -s ' for networking diagnostics. @@ -421,4 +503,6 @@ def exec_bdt_cli( raise ValueError(f"Invalid bdt_cli subcommand: {subcommand}") cmd = ["bdt_cli", "-u", "-s", socket] + subcommand.split() - return exec_debug_command(api_client, pod_name, namespace, cmd, timeout) + return _cached_exec_debug_command( + cluster_id or 0, api_client, pod_name, namespace, cmd, timeout + ) diff --git a/backend/tests/integration/test_routes_k8s_clusters.py b/backend/tests/integration/test_routes_k8s_clusters.py index 0623b9f..b0f9355 100644 --- a/backend/tests/integration/test_routes_k8s_clusters.py +++ b/backend/tests/integration/test_routes_k8s_clusters.py @@ -226,3 +226,38 @@ def test_viewer_cannot_test_connection(self, client, viewer_headers, all_test_us cluster = make_k8s_cluster(project=sample_project) response = client.post(f"/api/k8s/clusters/{cluster.id}/test", headers=viewer_headers) assert response.status_code == 403 + + +class TestDetectClustersFromCredentials: + """POST /api/projects/{pid}/k8s/clusters/detect-credentials.""" + + @patch("routes.k8s.clusters.ClusterDiscoveryService") + def test_detect_credentials_owner_allowed(self, mock_svc_cls, client, admin_headers, sample_user, sample_project): + """Project owner/admin can trigger credential-driven discovery.""" + mock_svc = MagicMock() + mock_svc.detect_clusters_from_credentials.return_value = { + "success": True, + "message": "Discovered 1 cluster(s)", + "registered": [{"id": 1, "name": "eks-prod", "provider": "aws", "status": "registered"}], + "skipped": [], + "errors": [], + } + mock_svc_cls.return_value = mock_svc + + response = client.post( + f"/api/projects/{sample_project.id}/k8s/clusters/detect-credentials", + headers=admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["registered"]) == 1 + mock_svc.detect_clusters_from_credentials.assert_called_once_with(sample_project.id) + + def test_detect_credentials_viewer_forbidden(self, client, viewer_headers, all_test_users, sample_project): + """Viewer cannot trigger credential-driven discovery — returns 403.""" + response = client.post( + f"/api/projects/{sample_project.id}/k8s/clusters/detect-credentials", + headers=viewer_headers, + ) + assert response.status_code == 403 diff --git a/backend/tests/integration/test_routes_k8s_f5bnk.py b/backend/tests/integration/test_routes_k8s_f5bnk.py index 443505b..725e4c2 100644 --- a/backend/tests/integration/test_routes_k8s_f5bnk.py +++ b/backend/tests/integration/test_routes_k8s_f5bnk.py @@ -160,6 +160,7 @@ def test_returns_complete_response( ): """Unified endpoint returns health, topology, backends, palette, policy.""" cluster = make_k8s_cluster(project=sample_project, name="bnk-data-cluster") + mock_k8s_svc_cls.return_value.get_cluster.return_value = cluster response = client.get( f"/api/k8s/clusters/{cluster.id}/f5bnk/data", @@ -215,6 +216,7 @@ def test_passes_namespace_filter( ): """Namespace query param is forwarded to fetch_all_bnk_data.""" cluster = make_k8s_cluster(project=sample_project, name="ns-filter-cluster") + mock_k8s_svc_cls.return_value.get_cluster.return_value = cluster response = client.get( f"/api/k8s/clusters/{cluster.id}/f5bnk/data?namespace=f5-bnk", @@ -237,6 +239,7 @@ def test_returns_health_analysis( ): """Health endpoint runs real analysis and returns structured result.""" cluster = make_k8s_cluster(project=sample_project, name="bnk-health-cluster") + mock_k8s_svc_cls.return_value.get_cluster.return_value = cluster response = client.get( f"/api/k8s/clusters/{cluster.id}/f5bnk/health", diff --git a/backend/tests/integration/test_routes_system.py b/backend/tests/integration/test_routes_system.py index 5ecdc47..b39ea86 100644 --- a/backend/tests/integration/test_routes_system.py +++ b/backend/tests/integration/test_routes_system.py @@ -103,6 +103,64 @@ def test_performance_metrics(self, client, admin_headers, sample_user): assert response.status_code == 200 + def test_bnk_consumption_requires_auth(self, client): + """GET /api/system/bnk-consumption requires auth.""" + response = client.get("/api/system/bnk-consumption") + assert response.status_code == 401 + + def test_bnk_consumption_viewer_allowed(self, client, viewer_headers, all_test_users): + """Viewer can retrieve BNK consumption.""" + mock_consumption = { + "timestamp": "2026-09-01T12:00:00Z", + "fleet_summary": { + "total_clusters": 2, + "reachable_clusters": 2, + "bnk_installed_clusters": 1, + "total_bnk_pods": 5, + "control_plane_pods": 2, + "data_plane_pods": 3, + "total_cpu_millicores": 1700, + "total_memory_bytes": 3400000000, + "dpf_detected_clusters": 0, + "dpu_count": 0, + }, + "clusters": [], + } + with patch("routes.system.SystemService") as MockService: + MockService.return_value.get_bnk_consumption.return_value = mock_consumption + response = client.get("/api/system/bnk-consumption", headers=viewer_headers) + + assert response.status_code == 200 + data = response.json() + assert data["fleet_summary"]["total_clusters"] == 2 + + def test_bnk_consumption_admin_allowed(self, client, admin_headers, sample_user): + """Admin can retrieve BNK consumption.""" + mock_consumption = { + "timestamp": "2026-09-01T12:00:00Z", + "fleet_summary": { + "total_clusters": 2, + "reachable_clusters": 2, + "bnk_installed_clusters": 1, + "total_bnk_pods": 5, + "control_plane_pods": 2, + "data_plane_pods": 3, + "total_cpu_millicores": 1700, + "total_memory_bytes": 3400000000, + "dpf_detected_clusters": 0, + "dpu_count": 0, + }, + "clusters": [], + } + with patch("routes.system.SystemService") as MockService: + MockService.return_value.get_bnk_consumption.return_value = mock_consumption + response = client.get("/api/system/bnk-consumption", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["fleet_summary"]["total_clusters"] == 2 + assert data["timestamp"] == "2026-09-01T12:00:00Z" + # ============================================================================ # Recent Errors diff --git a/backend/tests/unit/test_azure_service.py b/backend/tests/unit/test_azure_service.py new file mode 100644 index 0000000..6a6d503 --- /dev/null +++ b/backend/tests/unit/test_azure_service.py @@ -0,0 +1,36 @@ +"""Unit tests for Azure service helpers.""" + +import yaml + +from services.azure_service import _resource_group_from_id, generate_aks_kubeconfig + + +def test_generate_aks_kubeconfig(): + """AKS kubeconfig embeds a bearer token and contains no local file refs.""" + kubeconfig_yaml = generate_aks_kubeconfig( + cluster_name="aks-prod", + server="aks-prod.hcp.eastus.azmk8s.io", + ca_data="LS0tLS1CRUdJTi4u.", + token="test-aad-token", + ) + + cfg = yaml.safe_load(kubeconfig_yaml) + assert cfg["apiVersion"] == "v1" + assert cfg["kind"] == "Config" + assert cfg["current-context"] == "aks-prod" + + cluster = cfg["clusters"][0] + assert cluster["name"] == "aks-prod" + assert cluster["cluster"]["server"] == "https://aks-prod.hcp.eastus.azmk8s.io:443" + assert cluster["cluster"]["certificate-authority-data"] == "LS0tLS1CRUdJTi4u." + + user = cfg["users"][0] + assert user["name"] == "aks-prod" + assert user["user"]["token"] == "test-aad-token" + + +def test_resource_group_from_id(): + assert _resource_group_from_id( + "/subscriptions/sub-123/resourceGroups/rg-prod/providers/Microsoft.ContainerService/managedClusters/aks-prod" + ) == "rg-prod" + assert _resource_group_from_id("/subscriptions/sub-123/") is None diff --git a/backend/tests/unit/test_blueprint_catalog_common.py b/backend/tests/unit/test_blueprint_catalog_common.py index 55883c9..4b23a29 100644 --- a/backend/tests/unit/test_blueprint_catalog_common.py +++ b/backend/tests/unit/test_blueprint_catalog_common.py @@ -15,8 +15,8 @@ def test_returns_stripped_category(self): assert _resolve_category(manifest) == "bnk" def test_returns_category_regardless_of_source_path(self): - manifest = {"category": "infra"} - assert _resolve_category(manifest, source_path="infra/aws/some/module") == "infra" + manifest = {"category": "security"} + assert _resolve_category(manifest, source_path="infra/aws/some/module") == "security" class TestResolveCategoryFallsBackToBnk: diff --git a/backend/tests/unit/test_bnk_consumption.py b/backend/tests/unit/test_bnk_consumption.py new file mode 100644 index 0000000..6c85770 --- /dev/null +++ b/backend/tests/unit/test_bnk_consumption.py @@ -0,0 +1,217 @@ +""" +Unit tests for BNK consumption aggregation logic. + +Covers the pure functions in ``services.bnk.consumption`` that turn fetched +BNK data + pod metrics into the dashboard response shape. +""" + +import pytest + +from services.bnk.consumption import aggregate_cluster_consumption, aggregate_fleet_summary + + +def _make_pod(name: str, namespace: str = "f5-bnk", image: str = "f5/spk-tmm:v2.5.0") -> dict: + return { + "name": name, + "namespace": namespace, + "phase": "Running", + "containers": [{"name": "tmm", "image": image, "ready": True}], + } + + +def _make_metric(name: str, namespace: str, cpu: int, memory: int) -> dict: + return {"name": name, "namespace": namespace, "cpu_millicores": cpu, "memory_bytes": memory} + + +class TestAggregateClusterConsumption: + def test_unreachable_cluster_returns_zeros(self): + result = aggregate_cluster_consumption( + cluster_id=1, + cluster_name="offline", + node_count=3, + status="connected", + bnk_data=None, + pod_metrics_response=None, + dpf_summary=None, + reachable=False, + ) + + assert result["cluster_id"] == 1 + assert result["reachable"] is False + assert result["bnk_installed"] is False + assert result["status"] == "offline" + assert result["control_plane"]["count"] == 0 + assert result["data_plane"]["count"] == 0 + assert result["metrics_available"] is False + + def test_cluster_without_bnk(self): + result = aggregate_cluster_consumption( + cluster_id=2, + cluster_name="plain-k8s", + node_count=3, + status="connected", + bnk_data={"classified_pods": {}}, + pod_metrics_response={"available": True, "metrics": []}, + dpf_summary={"detected": False, "dpu_count": 0}, + reachable=True, + ) + + assert result["reachable"] is True + assert result["bnk_installed"] is False + assert result["total"]["count"] == 0 + + def test_metrics_unavailable_gracefully_degrades(self): + bnk_data = { + "classified_pods": { + "tmm": [_make_pod("f5-tmm-abc", image="f5/spk-tmm:v2.5.0")], + }, + } + result = aggregate_cluster_consumption( + cluster_id=3, + cluster_name="no-metrics", + node_count=3, + status="connected", + bnk_data=bnk_data, + pod_metrics_response={"available": False, "error": "Metrics server not installed"}, + dpf_summary={"detected": False, "dpu_count": 0}, + reachable=True, + ) + + assert result["metrics_available"] is False + assert result["metrics_error"] == "Metrics server not installed" + assert result["data_plane"]["count"] == 1 + assert result["data_plane"]["cpu_millicores"] == 0 + assert result["data_plane"]["memory_bytes"] == 0 + + def test_sums_resources_by_plane(self): + bnk_data = { + "classified_pods": { + "tmm": [ + _make_pod("f5-tmm-a", image="f5/spk-tmm:v2.5.0"), + _make_pod("f5-tmm-b", image="f5/spk-tmm:v2.5.0"), + ], + "controller": [ + _make_pod("f5ingress-ctrl", image="f5/bnk-controller:v2.5.0"), + ], + "flo": [ + _make_pod("flo-operator", image="f5/ln-operator:v2.5.0"), + ], + }, + } + metrics = { + "available": True, + "metrics": [ + _make_metric("f5-tmm-a", "f5-bnk", 1000, 2_000_000_000), + _make_metric("f5-tmm-b", "f5-bnk", 500, 1_000_000_000), + _make_metric("f5ingress-ctrl", "f5-bnk", 200, 500_000_000), + _make_metric("flo-operator", "f5-bnk", 150, 400_000_000), + ], + } + + result = aggregate_cluster_consumption( + cluster_id=4, + cluster_name="bnk-prod", + node_count=6, + status="connected", + bnk_data=bnk_data, + pod_metrics_response=metrics, + dpf_summary={"detected": True, "dpu_count": 2}, + reachable=True, + ) + + assert result["bnk_installed"] is True + assert result["bnk_version"] == "2.5.0" + assert result["dpf"]["detected"] is True + assert result["dpf"]["dpu_count"] == 2 + assert result["data_plane"]["count"] == 2 + assert result["data_plane"]["cpu_millicores"] == 1500 + assert result["data_plane"]["memory_bytes"] == 3_000_000_000 + assert result["control_plane"]["count"] == 2 + assert result["control_plane"]["cpu_millicores"] == 350 + assert result["total"]["count"] == 4 + assert result["total"]["cpu_millicores"] == 1850 + assert result["total"]["memory_bytes"] == 3_900_000_000 + assert result["metrics_available"] is True + assert result["metrics_error"] is None + + def test_top_pods_sorted_by_cpu(self): + bnk_data = { + "classified_pods": { + "tmm": [ + _make_pod("f5-tmm-a"), + _make_pod("f5-tmm-b"), + ], + }, + } + metrics = { + "available": True, + "metrics": [ + _make_metric("f5-tmm-a", "f5-bnk", 500, 1_000_000_000), + _make_metric("f5-tmm-b", "f5-bnk", 1000, 2_000_000_000), + ], + } + + result = aggregate_cluster_consumption( + cluster_id=5, + cluster_name="top-prod", + node_count=3, + status="connected", + bnk_data=bnk_data, + pod_metrics_response=metrics, + dpf_summary={"detected": False, "dpu_count": 0}, + reachable=True, + ) + + assert len(result["top_pods"]) == 2 + assert result["top_pods"][0]["name"] == "f5-tmm-b" + assert result["top_pods"][0]["cpu_millicores"] == 1000 + + +class TestAggregateFleetSummary: + def test_rollup_across_clusters(self): + clusters = [ + { + "reachable": True, + "bnk_installed": True, + "control_plane": {"count": 2, "cpu_millicores": 200, "memory_bytes": 400_000_000}, + "data_plane": {"count": 3, "cpu_millicores": 1500, "memory_bytes": 3_000_000_000}, + "total": {"count": 5, "cpu_millicores": 1700, "memory_bytes": 3_400_000_000}, + "dpf": {"detected": True, "dpu_count": 2}, + }, + { + "reachable": False, + "bnk_installed": False, + "control_plane": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "data_plane": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "total": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "dpf": {"detected": False, "dpu_count": 0}, + }, + { + "reachable": True, + "bnk_installed": False, + "control_plane": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "data_plane": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "total": {"count": 0, "cpu_millicores": 0, "memory_bytes": 0}, + "dpf": {"detected": False, "dpu_count": 0}, + }, + ] + + summary = aggregate_fleet_summary(clusters) + + assert summary["total_clusters"] == 3 + assert summary["reachable_clusters"] == 2 + assert summary["bnk_installed_clusters"] == 1 + assert summary["total_bnk_pods"] == 5 + assert summary["control_plane_pods"] == 2 + assert summary["data_plane_pods"] == 3 + assert summary["total_cpu_millicores"] == 1700 + assert summary["total_memory_bytes"] == 3_400_000_000 + assert summary["dpf_detected_clusters"] == 1 + assert summary["dpu_count"] == 2 + + def test_empty_fleet(self): + summary = aggregate_fleet_summary([]) + assert summary["total_clusters"] == 0 + assert summary["reachable_clusters"] == 0 + assert summary["total_bnk_pods"] == 0 + assert summary["total_cpu_millicores"] == 0 diff --git a/backend/tests/unit/test_bnk_health.py b/backend/tests/unit/test_bnk_health.py index 8163aae..19e8093 100644 --- a/backend/tests/unit/test_bnk_health.py +++ b/backend/tests/unit/test_bnk_health.py @@ -10,6 +10,8 @@ from services.bnk.health import ( COMPONENT_EXPLANATIONS, _build_component_health, + _build_connectivity_status, + _build_integration_status, _crd_installer_severity, _extract_cne_features, _feature_enabled, @@ -61,11 +63,12 @@ def _empty_classified() -> dict: return {"tmm": [], "flo": [], "controller": [], "analyzer": [], "crd_installer": [], "other": []} -def _make_data(resources=None, classified=None) -> dict: +def _make_data(resources=None, classified=None, nodes=None) -> dict: return { "resources": resources or _empty_resources(), "classified_pods": classified or _empty_classified(), "pods": {"tenant": [], "utils": []}, + "nodes": nodes, } @@ -140,6 +143,8 @@ def test_healthy_pod(self): assert d["restartCount"] == 0 assert d["containersReady"] == "1/1" assert d["issue"] == "" + assert d["nodeZone"] is None + assert d["nodeInstanceType"] is None def test_unhealthy_pod(self): d = _pod_details(_pod(ready=False, restarts=6)) @@ -159,6 +164,27 @@ def test_multi_container_pod(self): assert d["containersReady"] == "2/2" assert d["restartCount"] == 2 + def test_pod_enrichment_from_nodes(self): + pod = _pod(name="tmm-1", namespace="f5-bnk") + pod["nodeName"] = "worker-1" + nodes = { + "worker-1": {"zone": "us-east-1a", "instance_type": "m5.large"}, + } + d = _pod_details(pod, nodes) + assert d["nodeZone"] == "us-east-1a" + assert d["nodeInstanceType"] == "m5.large" + + def test_pod_enrichment_unknown_node(self): + pod = _pod(name="tmm-1", namespace="f5-bnk") + pod["nodeName"] = "missing-node" + d = _pod_details(pod, {}) + assert d["nodeZone"] is None + assert d["nodeInstanceType"] is None + + def test_pod_enrichment_no_node_assignment(self): + d = _pod_details(_pod(name="tmm-1"), {"worker-1": {"zone": "us-east-1a"}}) + assert d["nodeZone"] is None + # --------------------------------------------------------------------------- # _build_component_health @@ -193,6 +219,30 @@ def test_unknown_component_key(self): h = _build_component_health("nonexistent", [_pod()], [_pod()], "healthy") assert h["explanation"] == "" + def test_component_placement_aggregation(self): + pods = [ + _pod(name="tmm-1", namespace="f5-bnk"), + _pod(name="tmm-2", namespace="f5-bnk"), + ] + pods[0]["nodeName"] = "worker-1" + pods[1]["nodeName"] = "worker-2" + nodes = { + "worker-1": {"zone": "us-east-1a"}, + "worker-2": {"zone": "us-east-1b"}, + } + h = _build_component_health("tmm", pods, pods, "healthy", nodes) + assert h["namespaces"] == ["f5-bnk"] + assert h["nodes"] == ["worker-1", "worker-2"] + assert h["zones"] == ["us-east-1a", "us-east-1b"] + + def test_component_aggregation_degrades_without_nodes(self): + pods = [_pod(name="tmm-1", namespace="f5-bnk")] + pods[0]["nodeName"] = "worker-1" + h = _build_component_health("tmm", pods, pods, "healthy") + assert h["namespaces"] == ["f5-bnk"] + assert h["nodes"] == ["worker-1"] + assert h["zones"] == [] + # --------------------------------------------------------------------------- # _extract_cne_features @@ -506,6 +556,39 @@ def test_unknown_shape_when_no_tmm_no_controller_no_flo(self): assert result["installShape"] == "unknown" assert result["installMethod"] == "Unknown" + def test_node_az_enrichment_in_component_summaries(self): + """Nodes and zones are aggregated into platform + data-plane summaries.""" + classified = _empty_classified() + classified["tmm"] = [_pod(name="tmm-1", namespace="f5-bnk")] + classified["tmm"][0]["nodeName"] = "worker-1" + classified["flo"] = [_pod(name="flo-1", namespace="f5-bnk")] + classified["flo"][0]["nodeName"] = "worker-1" + classified["controller"] = [_pod(name="ctrl-1", namespace="f5-utils")] + classified["controller"][0]["nodeName"] = "worker-2" + + nodes = { + "worker-1": {"zone": "us-east-1a", "instance_type": "m5.large"}, + "worker-2": {"zone": "us-east-1b"}, + } + + result = analyze_health(_make_data(classified=classified, nodes=nodes)) + + # Platform aggregation + assert result["platform"]["flo"]["nodes"] == ["worker-1"] + assert result["platform"]["flo"]["zones"] == ["us-east-1a"] + assert result["platform"]["controller"]["nodes"] == ["worker-2"] + assert result["platform"]["controller"]["zones"] == ["us-east-1b"] + assert result["platform"]["controller"]["namespaces"] == ["f5-utils"] + + # Data-plane aggregation + assert result["dataPlane"]["tmm"]["nodes"] == ["worker-1"] + assert result["dataPlane"]["tmm"]["zones"] == ["us-east-1a"] + + # Pod detail enrichment + tmm_pod = result["dataPlane"]["tmm"]["podDetails"][0] + assert tmm_pod["nodeZone"] == "us-east-1a" + assert tmm_pod["nodeInstanceType"] == "m5.large" + # --------------------------------------------------------------------------- # _crd_installer_severity unit tests @@ -542,3 +625,66 @@ def test_job_active_is_pending_excluded(self): sev, include = _crd_installer_severity([], job) assert sev == "unknown" assert include is False + + +# --------------------------------------------------------------------------- +# Connectivity / integration status +# --------------------------------------------------------------------------- + + +class TestConnectivityStatus: + def test_default_is_connected_when_no_injection(self): + result = _build_connectivity_status({}) + assert result["status"] == "connected" + assert "Kubernetes API is accessible" in result["message"] + assert result["checkedAt"] + + def test_injected_status_is_used(self): + result = _build_connectivity_status({ + "connectivity": {"status": "partial", "message": "ICMP only", "checkedAt": "2026-01-01T00:00:00Z"}, + }) + assert result["status"] == "partial" + assert result["message"] == "ICMP only" + assert result["checkedAt"] == "2026-01-01T00:00:00Z" + + +class TestIntegrationStatus: + def test_default_is_kubeconfig_healthy(self): + result = _build_integration_status({}) + assert result["status"] == "healthy" + assert result["operatorMode"] == "kubeconfig" + assert result["operatorConnected"] is False + assert "kubeconfig" in result["message"].lower() + + def test_injected_operator_status_is_used(self): + result = _build_integration_status({ + "integration": { + "status": "warning", + "operatorConnected": False, + "operatorMode": "direct_ws", + "operatorVersion": "1.2.3", + "lastSeen": "2026-01-01T00:00:00Z", + "message": "Operator op-1 is disconnected", + }, + }) + assert result["status"] == "warning" + assert result["operatorMode"] == "direct_ws" + assert result["operatorVersion"] == "1.2.3" + assert result["lastSeen"] == "2026-01-01T00:00:00Z" + + +class TestAnalyzeHealthConnectivityIntegration: + def test_analyze_health_includes_connectivity_and_integration(self): + result = analyze_health(_make_data()) + assert "connectivity" in result + assert "integration" in result + assert result["connectivity"]["status"] == "connected" + assert result["integration"]["status"] == "healthy" + + def test_analyze_health_uses_injected_context(self): + data = _make_data() + data["connectivity"] = {"status": "unreachable", "message": "API down", "checkedAt": "2026-01-01T00:00:00Z"} + data["integration"] = {"status": "critical", "operatorConnected": False, "operatorMode": "polling", "message": "Lost"} + result = analyze_health(data) + assert result["connectivity"]["status"] == "unreachable" + assert result["integration"]["status"] == "critical" diff --git a/backend/tests/unit/test_bnk_policy_associations.py b/backend/tests/unit/test_bnk_policy_associations.py index 0b71f45..ed74b20 100644 --- a/backend/tests/unit/test_bnk_policy_associations.py +++ b/backend/tests/unit/test_bnk_policy_associations.py @@ -61,6 +61,9 @@ def test_sec_policy_with_gateway_and_firewall(self): assert a["rules_count"] == 1 assert a["rules"][0]["action"] == "drop" assert a["rules"][0]["logging"] is True + assert "bnk_policy_status" in a + assert a["bnk_policy_status"]["resolved"] is False + assert a["bnk_policy_status"]["programmed"] is False # Referenced port list has no matching resource — name kept, ports empty assert a["rules"][0]["destination"]["ports"] == [] assert a["rules"][0]["destination"]["portLists"] == ["ssh-ports"] @@ -115,6 +118,27 @@ def test_missing_firewall_policy_no_rules(self): assert "rules" not in a assert "rules_count" not in a + def test_sec_policy_status_extracted_from_conditions(self): + resources = _empty_resources() + resources["gateway"] = [_resource("gw-prod")] + resources["bnksecpolicy"] = [_resource("sp", spec={ + "targetRefs": [{"name": "gw-prod", "kind": "Gateway"}], + "extensionRefs": [{"kind": "F5BigFwPolicy", "name": "fw-1"}], + }, status={ + "conditions": [ + {"type": "Resolved", "status": "True", "message": "resolved"}, + {"type": "Programmed", "status": "False", "message": "pending"}, + ], + })] + resources["f5bigfwpolicy"] = [_resource("fw-1", spec={"rule": []})] + + result = analyze_policy_associations({"resources": resources}) + status = result["associations"][0]["bnk_policy_status"] + assert status["resolved"] is True + assert status["programmed"] is False + assert status["messages"]["resolved"] == "resolved" + assert status["messages"]["programmed"] == "pending" + class TestEgressAssociations: def test_egress_with_firewall_policy_produces_association(self): @@ -155,6 +179,21 @@ def test_egress_without_firewall_policy_produces_no_association(self): result = analyze_policy_associations({"resources": resources}) assert result["count"] == 0 + def test_egress_status_extracted_from_conditions(self): + resources = _empty_resources() + resources["f5bigfwpolicy"] = [_resource("egress-demo-fw", spec={"rule": []})] + resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ + "snatType": "SRC_TRANS_AUTOMAP", + "firewallEnforcedPolicy": "egress-demo-fw", + }, status={ + "conditions": [{"type": "Programmed", "status": "True", "message": "programmed"}], + })] + + result = analyze_policy_associations({"resources": resources}) + status = result["associations"][0]["egress_status"] + assert status["programmed"] is True + assert status["messages"]["programmed"] == "programmed" + def test_egress_with_missing_firewall_policy_no_rules(self): resources = _empty_resources() resources["f5spkegress"] = [_resource("bnk-egress-demo", spec={ diff --git a/backend/tests/unit/test_bnk_topology.py b/backend/tests/unit/test_bnk_topology.py index 6daaec6..1bf5a35 100644 --- a/backend/tests/unit/test_bnk_topology.py +++ b/backend/tests/unit/test_bnk_topology.py @@ -33,24 +33,36 @@ def _resource(name: str, namespace: str = "f5-bnk", **kw) -> dict: } -def _gateway(name: str = "gw-prod", namespace: str = "f5-bnk", listeners=None, addresses=None) -> dict: - return _resource(name, namespace, spec={ - "gatewayClassName": "f5-bnk", - "listeners": listeners or [{"name": "http", "protocol": "HTTP", "port": 80}], - }, status={ +def _gateway(name: str = "gw-prod", namespace: str = "f5-bnk", listeners=None, addresses=None, + conditions=None, listener_status=None) -> dict: + listeners = listeners or [{"name": "http", "protocol": "HTTP", "port": 80}] + status: dict = { "addresses": [{"value": a} for a in (addresses or ["10.0.0.1"])], - "conditions": [ + "conditions": conditions or [ {"type": "Programmed", "status": "True"}, {"type": "Accepted", "status": "True"}, ], - }) + } + if listener_status: + status["listeners"] = listener_status + return _resource(name, namespace, spec={ + "gatewayClassName": "f5-bnk", + "listeners": listeners, + }, status=status) def _httproute(name: str, gw_name: str, gw_ns: str = "f5-bnk", - namespace: str = "f5-bnk", backends=None, section_name=None) -> dict: + namespace: str = "f5-bnk", backends=None, section_name=None, + parent_conditions=None) -> dict: parent_ref: dict = {"name": gw_name, "namespace": gw_ns} if section_name: parent_ref["sectionName"] = section_name + status: dict = {} + if parent_conditions is not None: + status["parents"] = [{ + "parentRef": {"name": gw_name, "namespace": gw_ns, "sectionName": section_name} if section_name else {"name": gw_name, "namespace": gw_ns}, + "conditions": parent_conditions, + }] return _resource(name, namespace, spec={ "parentRefs": [parent_ref], "hostnames": ["example.com"], @@ -59,7 +71,7 @@ def _httproute(name: str, gw_name: str, gw_ns: str = "f5-bnk", {"name": "svc-1", "port": 8080, "kind": "Service", "group": ""}, ], }], - }) + }, status=status) def _empty_resources() -> dict: @@ -202,6 +214,47 @@ def test_reference_grants_included(self): assert len(result["referenceGrants"]) == 1 assert result["referenceGrants"][0]["name"] == "rg-1" + def test_gateway_operational_state(self): + resources = _empty_resources() + resources["gateway"] = [_gateway( + conditions=[ + {"type": "Accepted", "status": "True"}, + {"type": "Programmed", "status": "False", "message": "address conflict"}, + ], + )] + + result = analyze_topology({"resources": resources}) + gw = result["topology"][0] + assert gw["accepted"] is True + assert gw["programmed"] is False + assert any(c["type"] == "Programmed" and c["status"] == "False" for c in gw["conditions"]) + + def test_listener_operational_state(self): + resources = _empty_resources() + resources["gateway"] = [_gateway(listener_status=[{ + "name": "http", + "attachedRoutes": 3, + "conditions": [{"type": "Accepted", "status": "True"}], + }])] + + result = analyze_topology({"resources": resources}) + listener = result["topology"][0]["listeners"][0] + assert listener["attachedRouteCount"] == 3 + assert listener["conditions"][0]["type"] == "Accepted" + + def test_route_operational_state(self): + resources = _empty_resources() + resources["gateway"] = [_gateway()] + resources["httproute"] = [_httproute("web-route", "gw-prod", parent_conditions=[ + {"type": "Accepted", "status": "False", "message": "no matching listener"}, + ])] + + result = analyze_topology({"resources": resources}) + route = result["topology"][0]["listeners"][0]["routes"][0] + assert route["accepted"] is False + assert route["conditionMessage"] == "no matching listener" + assert route["conditions"][0]["type"] == "Accepted" + # --------------------------------------------------------------------------- # _match_routes_to_listener @@ -273,6 +326,22 @@ def test_policy_different_listener_not_matched(self): result = _match_net_policies([np], {}, "gw-prod", "http") assert result == [] + def test_net_policy_operational_state(self): + np = _resource("np-1", spec={ + "targetRefs": [{"name": "gw-prod", "sectionName": "http", "kind": "Gateway"}], + "extensionRefs": [], + }, status={ + "descendants": [], + "conditions": [ + {"type": "Resolved", "status": "True", "message": "resolved"}, + {"type": "Programmed", "status": "False", "message": "not programmed"}, + ], + }) + result = _match_net_policies([np], {}, "gw-prod", "http") + assert result[0]["resolved"] is True + assert result[0]["programmed"] is False + assert result[0]["messages"]["programmed"] == "not programmed" + class TestMatchSecPolicies: def test_sec_policy_with_firewall(self): @@ -291,6 +360,23 @@ def test_sec_policy_with_firewall(self): assert len(result[0]["firewallPolicies"]) == 1 assert result[0]["firewallPolicies"][0]["rules"][0]["action"] == "accept" + def test_sec_policy_operational_state(self): + sp = _resource("sp-1", spec={ + "targetRefs": [{"name": "gw-prod", "kind": "Gateway", "sectionName": "http"}], + "extensionRefs": [{"kind": "F5BigFwPolicy", "name": "fw-1"}], + }, status={ + "conditions": [ + {"type": "Resolved", "status": "True", "message": "resolved"}, + {"type": "Programmed", "status": "True"}, + ], + }) + from services.bnk.helpers import make_resource_map + fw_map = make_resource_map([_resource("fw-1", spec={"rule": []})]) + result = _match_sec_policies([sp], fw_map, {}, {}, "gw-prod") + assert result[0]["resolved"] is True + assert result[0]["programmed"] is True + assert result[0]["messages"]["resolved"] == "resolved" + # --------------------------------------------------------------------------- # resolve_list_refs @@ -353,6 +439,15 @@ def test_cne_with_features(self): assert result["features"]["envDiscovery"] is False assert result["containerPlatform"] == "k8s" assert result["phase"] == "Running" + assert result["ready"] is False + + def test_cne_ready_when_programmed(self): + cne = _resource("cne-1", spec={"containerPlatform": "k8s"}, status={ + "phase": "Running", + "conditions": [{"type": "Programmed", "status": "True"}], + }) + result = _build_cne_instance(cne) + assert result["ready"] is True class TestBuildEgress: diff --git a/backend/tests/unit/test_bnk_traffic_stats.py b/backend/tests/unit/test_bnk_traffic_stats.py new file mode 100644 index 0000000..edc1f07 --- /dev/null +++ b/backend/tests/unit/test_bnk_traffic_stats.py @@ -0,0 +1,325 @@ +""" +Unit tests for services.bnk.traffic_stats — TMM traffic stat mapping. + +Tests the pure ``analyze_traffic_stats`` function with constructed TMM +output. No mocking / no DB / no Kubernetes access. +""" + +import pytest + +from services.bnk.traffic_stats import ( + _build_configview_index, + _build_egress_index, + _build_listener_index, + _fetch_configview_mappings, + _match_virtual_server_row, + _parse_configview_uuid_output, + _pick_tmm_pod, + analyze_traffic_stats, + fetch_tmm_traffic_stats, +) + +# --------------------------------------------------------------------------- +# Test data builders +# --------------------------------------------------------------------------- + + +def _topology() -> list[dict]: + return [ + { + "name": "gw-prod", + "namespace": "f5-bnk", + "gatewayClassName": "f5-bnk", + "addresses": ["10.0.0.1"], + "listeners": [ + {"name": "http", "protocol": "HTTP", "port": 80}, + {"name": "https", "protocol": "HTTPS", "port": 443}, + ], + "securityPolicies": [], + }, + ] + + +def _egresses() -> list[dict]: + return [ + { + "name": "egress-demo", + "namespace": "f5-bnk", + "snatType": "auto", + "egressSnatpool": None, + "firewallEnforcedPolicy": None, + "logProfile": None, + "capturedNamespaces": ["app"], + "vxlan": None, + "ready": True, + }, + ] + + +def _data(topology=None, egresses=None) -> dict: + return { + "topology": topology or _topology(), + "dataPlane": {"egresses": egresses or _egresses()}, + "resources": { + "f5bigfwpolicy": [ + { + "metadata": {"name": "fw-deny", "namespace": "f5-bnk"}, + "spec": { + "rule": [ + {"name": "deny-ssh", "action": "drop", "ipProtocol": "tcp", "logging": False}, + ], + }, + }, + ], + }, + } + + +def _raw_stats( + vs_rows=None, + fw_rows=None, + configview_mappings=None, + error=None, +) -> dict: + return { + "source": "tmctl", + "podName": "f5-tmm-abc123", + "namespace": "f5-bnk", + "virtualServerStat": { + "columns": ["name", "clientside.bytes_in", "clientside.bytes_out", + "clientside.cur_conns", "clientside.tot_conns"], + "rows": vs_rows or [], + "exit_code": 0, + }, + "fwRuleStat": { + "columns": ["name", "hit_count", "action"], + "rows": fw_rows or [], + "exit_code": 0, + }, + "configviewMappings": configview_mappings or [], + "error": error, + } + + +# --------------------------------------------------------------------------- +# analyze_traffic_stats +# --------------------------------------------------------------------------- + + +class TestAnalyzeTrafficStats: + def test_returns_empty_envelope_when_no_raw_stats(self): + result = analyze_traffic_stats(_data(), raw_stats=None) + + assert result["available"] is False + assert result["listeners"] == [] + assert result["egresses"] == [] + assert result["firewallRules"] == [] + + def test_returns_empty_envelope_on_tmm_error(self): + raw = _raw_stats(error="debug sidecar unreachable") + result = analyze_traffic_stats(_data(), raw) + + assert result["available"] is False + assert result["error"] == "debug sidecar unreachable" + + def test_maps_virtual_server_stat_to_listener(self): + raw = _raw_stats(vs_rows=[ + ["gw-prod_http", "1024", "2048", "5", "100"], + ]) + result = analyze_traffic_stats(_data(), raw) + + assert result["available"] is True + assert len(result["listeners"]) == 1 + listener = result["listeners"][0] + assert listener["gatewayName"] == "gw-prod" + assert listener["listenerName"] == "http" + assert listener["clientsideTotConns"] == 100 + assert listener["clientsideCurConns"] == 5 + + def test_sums_virtual_server_stat_for_same_listener(self): + raw = _raw_stats(vs_rows=[ + ["gw-prod_http", "1024", "2048", "1", "10"], + ["gw-prod_http", "100", "200", "2", "20"], + ]) + result = analyze_traffic_stats(_data(), raw) + + assert len(result["listeners"]) == 1 + assert result["listeners"][0]["clientsideTotConns"] == 30 + + def test_maps_virtual_server_stat_to_egress(self): + raw = _raw_stats(vs_rows=[ + ["egress-demo", "512", "256", "1", "42"], + ]) + result = analyze_traffic_stats(_data(), raw) + + assert len(result["egresses"]) == 1 + egress = result["egresses"][0] + assert egress["egressName"] == "egress-demo" + assert egress["clientsideTotConns"] == 42 + + def test_maps_firewall_rule_hits(self): + raw = _raw_stats(fw_rows=[ + ["fw-deny_deny-ssh", "7", "drop"], + ]) + result = analyze_traffic_stats(_data(), raw) + + assert len(result["firewallRules"]) == 1 + rule = result["firewallRules"][0] + assert rule["policyName"] == "fw-deny" + assert rule["ruleName"] == "deny-ssh" + assert rule["hitCount"] == 7 + + def test_keeps_unmatched_firewall_rule_for_observability(self): + raw = _raw_stats(fw_rows=[ + ["some-unknown-rule", "3", "accept"], + ]) + result = analyze_traffic_stats(_data(), raw) + + assert len(result["firewallRules"]) == 1 + assert result["firewallRules"][0]["ruleName"] == "some-unknown-rule" + assert result["firewallRules"][0]["hitCount"] == 3 + + def test_configview_hints_override_name_matching(self): + raw = _raw_stats( + vs_rows=[ + ["vs-custom-name", "100", "200", "1", "10"], + ], + configview_mappings=[{ + "uuid": "uuid-1", + "virtual_server_name": "vs-custom-name", + "gateway_name": "gw-prod", + "listener_name": "https", + "namespace": "f5-bnk", + }], + ) + result = analyze_traffic_stats(_data(), raw) + + assert len(result["listeners"]) == 1 + assert result["listeners"][0]["listenerName"] == "https" + + +# --------------------------------------------------------------------------- +# Index builders +# --------------------------------------------------------------------------- + + +class TestIndexBuilders: + def test_build_listener_index(self): + topology = _topology() + index = _build_listener_index(topology) + + assert _normalize_key("gw-prod_http") in index + assert index[_normalize_key("gw-prod_http")]["listenerName"] == "http" + + def test_build_egress_index(self): + index = _build_egress_index(_egresses()) + + assert _normalize_key("egress-demo") in index + assert index[_normalize_key("egress-demo")]["egressName"] == "egress-demo" + + +# --------------------------------------------------------------------------- +# configview parsing +# --------------------------------------------------------------------------- + + +class TestConfigviewParsing: + def test_parse_line_oriented_output(self): + raw = """ + name: vs-custom-name + gateway: gw-prod + listener: https + namespace: f5-bnk + """ + hints = _parse_configview_uuid_output(raw) + assert hints["virtual_server_name"] == "vs-custom-name" + assert hints["gateway_name"] == "gw-prod" + assert hints["listener_name"] == "https" + + def test_parse_json_output(self): + raw = '{"name": "vs-json", "gateway": "gw-prod", "listener": "http"}' + hints = _parse_configview_uuid_output(raw) + assert hints["virtual_server_name"] == "vs-json" + + def test_build_configview_index(self): + mappings = [ + {"uuid": "a", "virtual_server_name": "vs-one", "gateway_name": "gw"}, + {"uuid": "b", "virtual_server_name": "vs-two", "egress_name": "eg"}, + ] + index = _build_configview_index(mappings) + assert _normalize_key("vs-one") in index + assert _normalize_key("vs-two") in index + + +# --------------------------------------------------------------------------- +# Virtual server row matching +# --------------------------------------------------------------------------- + + +class TestMatchVirtualServerRow: + def test_matches_listener_by_name(self): + listener_index = _build_listener_index(_topology()) + kind, matched = _match_virtual_server_row( + {"name": "gw-prod_http"}, listener_index, {}, {}, + ) + assert kind == "listener" + assert matched["listenerName"] == "http" + + def test_prefers_configview_hint(self): + listener_index = _build_listener_index(_topology()) + configview_index = _build_configview_index([{ + "virtual_server_name": "gw-prod_http", + "egress_name": "egress-demo", + "namespace": "f5-bnk", + }]) + kind, matched = _match_virtual_server_row( + {"name": "gw-prod_http"}, listener_index, {}, configview_index, + ) + assert kind == "egress" + assert matched["egressName"] == "egress-demo" + + +# --------------------------------------------------------------------------- +# TMM pod selection +# --------------------------------------------------------------------------- + + +class TestPickTmmPod: + def test_picks_running_pod_with_debug_container(self): + pods = [ + {"name": "f5-tmm-a", "namespace": "f5-bnk", "phase": "Running", + "containers": [{"name": "tmm"}, {"name": "debug"}]}, + {"name": "f5-tmm-b", "namespace": "f5-bnk", "phase": "Pending", + "containers": [{"name": "tmm"}, {"name": "debug"}]}, + ] + assert _pick_tmm_pod(pods) == pods[0] + + def test_skips_pod_without_debug_container(self): + pods = [ + {"name": "f5-tmm-a", "namespace": "f5-bnk", "phase": "Running", + "containers": [{"name": "tmm"}]}, + ] + assert _pick_tmm_pod(pods) is None + + +# --------------------------------------------------------------------------- +# fetch_tmm_traffic_stats — thin wrapper around exec helpers +# --------------------------------------------------------------------------- + + +class TestFetchTmmTrafficStats: + def test_returns_error_when_no_tmm_pods(self): + result = fetch_tmm_traffic_stats(None, {"tmm": []}) # type: ignore[arg-type] + assert result["error"] == "No TMM pods with debug sidecar found" + assert result["podName"] is None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_key(name: str) -> str: + """Use the module's normalization logic directly.""" + from services.bnk.traffic_stats import _normalize_name + return _normalize_name(name) diff --git a/backend/tests/unit/test_cluster_discovery_service.py b/backend/tests/unit/test_cluster_discovery_service.py new file mode 100644 index 0000000..932379a --- /dev/null +++ b/backend/tests/unit/test_cluster_discovery_service.py @@ -0,0 +1,284 @@ +"""Unit tests for credential-template-driven cluster discovery.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.encryption import encrypt_value +from models import CloudCredentialTemplate +from services.cluster_discovery_service import ClusterDiscoveryService + + +def _aws_template() -> CloudCredentialTemplate: + return CloudCredentialTemplate( + name="aws-default", + provider="aws", + aws_auth_method="access_keys", + aws_access_key_id="AKIA_TEST", + aws_secret_access_key_encrypted=encrypt_value("secret"), + region="us-east-1", + is_default=True, + ) + + +def _ibm_template() -> CloudCredentialTemplate: + return CloudCredentialTemplate( + name="ibm-default", + provider="ibm", + ibmcloud_api_key_encrypted=encrypt_value("ibm-api-key"), + region="us-south", + is_default=True, + ) + + +class TestDetectClustersFromCredentials: + """Credential-template discovery dispatches per provider and registers clusters.""" + + def test_no_templates_returns_empty(self, db, make_project): + project = make_project() + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert result["registered"] == [] + assert result["skipped"] == [] + assert result["errors"] == [] + assert "No cloud credential templates" in result["message"] + + @patch("services.cluster_discovery_service.list_eks_clusters_from_template") + def test_registers_aws_cluster(self, mock_list, db, make_project): + project = make_project() + template = _aws_template() + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_list.return_value = [ + { + "name": "eks-prod", + "endpoint": "https://ABC123.eks.amazonaws.com", + "certificate_authority_data": "LS0tLS1CRUdJTi...", + "region": "us-east-1", + "version": "1.29", + "arn": "arn:aws:eks:us-east-1:123456789012:cluster/eks-prod", + "account_id": "123456789012", + } + ] + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert len(result["registered"]) == 1 + assert result["registered"][0]["name"] == "eks-prod" + assert result["registered"][0]["provider"] == "aws" + assert result["registered"][0]["status"] == "registered" + assert len(result["errors"]) == 0 + + @patch("services.cluster_discovery_service.list_eks_clusters_from_template") + def test_skips_already_registered_aws_cluster(self, mock_list, db, make_project, make_k8s_cluster): + project = make_project() + make_k8s_cluster(project=project, name="eks-prod", cloud_provider="aws") + template = _aws_template() + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_list.return_value = [ + { + "name": "eks-prod", + "endpoint": "https://ABC123.eks.amazonaws.com", + "certificate_authority_data": "LS0tLS1CRUdJTi...", + "region": "us-east-1", + } + ] + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["registered"] == [] + assert len(result["skipped"]) == 1 + assert result["skipped"][0]["name"] == "eks-prod" + assert result["skipped"][0]["reason"] == "already_registered" + + @patch("services.cluster_discovery_service.list_eks_clusters_from_template") + def test_captures_provider_error(self, mock_list, db, make_project): + project = make_project() + template = _aws_template() + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_list.side_effect = RuntimeError("AWS API unreachable") + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert result["registered"] == [] + assert len(result["errors"]) == 1 + assert result["errors"][0]["provider"] == "aws" + assert "AWS API unreachable" in result["errors"][0]["error"] + + @patch("services.cluster_discovery_service.list_eks_clusters_from_template") + def test_uses_default_template_when_project_has_none(self, mock_list, db, make_project): + project = make_project() + template = _aws_template() + db.add(template) + db.flush() + + mock_list.return_value = [] + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert "No clusters found" in result["message"] + mock_list.assert_called_once() + + @patch("services.cluster_discovery_service.list_roks_clusters_from_template") + @patch("services.cluster_discovery_service.describe_roks_cluster") + @patch("services.cluster_discovery_service.IBMCloudService") + def test_registers_ibm_cluster( + self, + mock_ibm_svc_cls, + mock_describe, + mock_list, + db, + make_project, + ): + project = make_project() + template = _ibm_template() + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_ibm_svc = MagicMock() + mock_ibm_svc._exchange_api_key.return_value = "fresh-ibm-token" + mock_ibm_svc_cls.return_value = mock_ibm_svc + + mock_list.return_value = [ + { + "name": "roks-prod", + "id": "roks-id-1", + "region": "us-south", + "resource_group": "default", + } + ] + mock_describe.return_value = { + "serverURL": "https://roks-prod.example.com:6443", + "caCert": "LS0tLS1CRUdJTi...", + } + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert len(result["registered"]) == 1 + assert result["registered"][0]["name"] == "roks-prod" + assert result["registered"][0]["provider"] == "ibm" + + @patch("services.cluster_discovery_service.list_aks_clusters_from_template") + @patch("services.cluster_discovery_service.fetch_aks_cluster_credentials") + @patch("services.cluster_discovery_service.fetch_aks_bearer_token") + def test_registers_azure_cluster( + self, + mock_token, + mock_fetch, + mock_list, + db, + make_project, + ): + mock_token.return_value = "test-aad-token" + from core.encryption import encrypt_value + + project = make_project() + template = CloudCredentialTemplate( + name="azure-default", + provider="azure", + azure_subscription_id="sub-123", + azure_tenant_id="tenant-456", + azure_credentials_encrypted=encrypt_value( + '{"client_id": "cid", "client_secret": "csecret"}' + ), + is_default=True, + ) + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_list.return_value = [ + { + "name": "aks-prod", + "resource_group": "rg-prod", + "subscription_id": "sub-123", + "tenant_id": "tenant-456", + "location": "eastus", + "version": "1.29", + } + ] + mock_fetch.return_value = { + "server": "aks-prod.hcp.eastus.azmk8s.io", + "certificate_authority_data": "LS0tLS1CRUdJTi...", + } + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert len(result["registered"]) == 1 + assert result["registered"][0]["name"] == "aks-prod" + assert result["registered"][0]["provider"] == "azure" + + @patch("services.cluster_discovery_service.list_gke_clusters_from_template") + @patch("services.cluster_discovery_service.fetch_gke_cluster_credentials") + def test_registers_gcp_cluster( + self, + mock_fetch, + mock_list, + db, + make_project, + ): + from core.encryption import encrypt_value + + project = make_project() + template = CloudCredentialTemplate( + name="gcp-default", + provider="gcp", + gcp_project_id="my-gcp-project", + gcp_credentials_encrypted=encrypt_value( + '{"type": "service_account", "project_id": "my-gcp-project"}' + ), + is_default=True, + ) + db.add(template) + db.flush() + project.credential_template_id = template.id + db.flush() + + mock_list.return_value = [ + { + "name": "gke-prod", + "project_id": "my-gcp-project", + "location": "us-central1", + "full_name": "projects/my-gcp-project/locations/us-central1/clusters/gke-prod", + "version": "1.29", + } + ] + mock_fetch.return_value = { + "server": "https://1.2.3.4", + "certificate_authority_data": "LS0tLS1CRUdJTi...", + } + + svc = ClusterDiscoveryService(db) + result = svc.detect_clusters_from_credentials(project.id) + + assert result["success"] is True + assert len(result["registered"]) == 1 + assert result["registered"][0]["name"] == "gke-prod" + assert result["registered"][0]["provider"] == "gcp" diff --git a/backend/tests/unit/test_gcp_service.py b/backend/tests/unit/test_gcp_service.py new file mode 100644 index 0000000..5813597 --- /dev/null +++ b/backend/tests/unit/test_gcp_service.py @@ -0,0 +1,37 @@ +"""Unit tests for GCP service helpers.""" + +import yaml + +from services.gcp_service import generate_gke_kubeconfig + + +def test_generate_gke_kubeconfig(): + """GKE kubeconfig uses the gke-gcloud-auth-plugin exec plugin.""" + kubeconfig_yaml = generate_gke_kubeconfig( + cluster_name="gke-prod", + project_id="my-gcp-project", + location="us-central1", + server="https://1.2.3.4", + certificate_authority_data="LS0tLS1CRUdJTi4u.", + ) + + cfg = yaml.safe_load(kubeconfig_yaml) + assert cfg["apiVersion"] == "v1" + assert cfg["kind"] == "Config" + assert cfg["current-context"] == "gke-prod" + + cluster = cfg["clusters"][0] + assert cluster["name"] == "gke-prod" + assert cluster["cluster"]["server"] == "https://1.2.3.4" + assert cluster["cluster"]["certificate-authority-data"] == "LS0tLS1CRUdJTi4u." + + user = cfg["users"][0] + assert user["name"] == "gke-prod" + exec_cfg = user["user"]["exec"] + assert exec_cfg["command"] == "gke-gcloud-auth-plugin" + assert exec_cfg["provideClusterInfo"] is True + env_project = next( + (e for e in exec_cfg["env"] if e["name"] == "CLOUDSDK_CORE_PROJECT"), None + ) + assert env_project is not None + assert env_project["value"] == "my-gcp-project" diff --git a/backend/tests/unit/test_schemas_k8s_negative.py b/backend/tests/unit/test_schemas_k8s_negative.py index b3793ce..0b6765e 100644 --- a/backend/tests/unit/test_schemas_k8s_negative.py +++ b/backend/tests/unit/test_schemas_k8s_negative.py @@ -43,9 +43,13 @@ def test_name_wrong_type_rejected(self): with pytest.raises(ValidationError): ClusterCreateRequest(name=123, kubeconfig="data") # type: ignore[arg-type] - def test_invalid_aws_region_rejected(self): + def test_invalid_aws_region_type_rejected(self): with pytest.raises(ValidationError): - ClusterCreateRequest(name="dev", kubeconfig="data", cloud_provider="aws", region="xx-fake-1") + ClusterCreateRequest(name="dev", kubeconfig="data", cloud_provider="aws", region=123) # type: ignore[arg-type] + + def test_aws_shaped_region_accepted(self): + req = ClusterCreateRequest(name="dev", kubeconfig="data", cloud_provider="aws", region="xx-fake-1") + assert req.region == "xx-fake-1" class TestClusterUpdateRequestNegative: @@ -53,9 +57,13 @@ def test_all_optional(self): req = ClusterUpdateRequest() assert req.name is None - def test_invalid_region_rejected(self): + def test_invalid_region_type_rejected(self): with pytest.raises(ValidationError): - ClusterUpdateRequest(cloud_provider="aws", region="xx-fake-1") + ClusterUpdateRequest(cloud_provider="aws", region=123) # type: ignore[arg-type] + + def test_aws_shaped_region_accepted(self): + req = ClusterUpdateRequest(cloud_provider="aws", region="xx-fake-1") + assert req.region == "xx-fake-1" def test_ssh_port_wrong_type_rejected(self): with pytest.raises(ValidationError): diff --git a/backend/tests/unit/test_schemas_projects.py b/backend/tests/unit/test_schemas_projects.py index 5a04331..7dfeeed 100644 --- a/backend/tests/unit/test_schemas_projects.py +++ b/backend/tests/unit/test_schemas_projects.py @@ -75,14 +75,14 @@ def test_ibm_uppercase_name_rejected(self): # Error should suggest a slugified form. assert "prod-ibm" in str(exc_info.value) - def test_invalid_ibm_region_rejected(self): - """IBM-shaped regions outside the canonical MZR set are rejected.""" + def test_invalid_ibm_region_type_rejected(self): + """Region must be a string.""" with pytest.raises(ValidationError): ProjectCreate( name="prod-ibm", project_type="cloud-ibm", cloud_provider="ibm", - region="us-bogus", + region=123, # type: ignore[arg-type] ) def test_empty_name_rejected(self): @@ -97,9 +97,15 @@ def test_valid_aws_region_accepted(self): req = ProjectCreate(name="Test", region="eu-west-1") assert req.region == "eu-west-1" - def test_invalid_aws_region_rejected(self): + def test_invalid_aws_region_type_rejected(self): + """Region must be a string.""" with pytest.raises(ValidationError): - ProjectCreate(name="Test", cloud_provider="aws", region="xx-fake-1") + ProjectCreate(name="Test", cloud_provider="aws", region=123) # type: ignore[arg-type] + + def test_aws_shaped_region_accepted(self): + """Any AWS-shaped region is accepted (new/private regions).""" + req = ProjectCreate(name="Test", cloud_provider="aws", region="xx-fake-1") + assert req.region == "xx-fake-1" def test_freeform_region_accepted(self): """Non-AWS-pattern regions like 'on-prem' should pass validation.""" @@ -123,9 +129,13 @@ def test_partial_update(self): assert req.name == "Updated" assert req.environment == "staging" - def test_invalid_region_rejected(self): + def test_invalid_region_type_rejected(self): with pytest.raises(ValidationError): - ProjectUpdate(cloud_provider="aws", region="xx-fake-1") + ProjectUpdate(cloud_provider="aws", region=123) # type: ignore[arg-type] + + def test_aws_shaped_region_accepted(self): + req = ProjectUpdate(cloud_provider="aws", region="xx-fake-1") + assert req.region == "xx-fake-1" def test_empty_name_rejected(self): with pytest.raises(ValidationError): diff --git a/backend/tests/unit/test_utils_validators.py b/backend/tests/unit/test_utils_validators.py index 6b77372..d316fb3 100644 --- a/backend/tests/unit/test_utils_validators.py +++ b/backend/tests/unit/test_utils_validators.py @@ -10,8 +10,11 @@ from utils.validators import ( VALID_AWS_REGIONS, validate_aws_region, + validate_azure_region, validate_cidr, validate_cidr_fields, + validate_gcp_region, + validate_ibm_region, ) # ── CIDR Validation ─────────────────────────────────────────────────── @@ -106,18 +109,16 @@ def test_none_allowed(self): def test_empty_string_allowed(self): validate_aws_region("") - def test_invalid_aws_region_raises(self): - with pytest.raises(ValueError, match="Invalid AWS region"): - validate_aws_region("us-narnia-1") + def test_unknown_aws_shaped_region_accepted(self): + """Any AWS-shaped region should be accepted (new/private regions).""" + validate_aws_region("us-narnia-1") + validate_aws_region("ap-southeast-99") def test_freeform_label_passes_through(self): - """Non-AWS-pattern strings (e.g., 'on-prem') should not raise.""" + """Non-AWS-pattern strings (e.g., 'on-prem', 'eu-fr2') should not raise.""" validate_aws_region("on-prem") validate_aws_region("datacenter-nyc") - - def test_custom_field_name_in_error(self): - with pytest.raises(ValueError, match="cloud_region"): - validate_aws_region("xx-fake-1", field_name="cloud_region") + validate_aws_region("eu-fr2") def test_valid_regions_set_is_not_empty(self): """Sanity check that the region set is populated.""" @@ -126,3 +127,58 @@ def test_valid_regions_set_is_not_empty(self): def test_govcloud_regions_included(self): assert "us-gov-west-1" in VALID_AWS_REGIONS assert "us-gov-east-1" in VALID_AWS_REGIONS + + +class TestValidateIbmRegion: + """Tests for validate_ibm_region().""" + + def test_valid_ibm_regions_accepted(self): + for region in ["us-south", "eu-de", "jp-tok", "br-sao"]: + validate_ibm_region(region) + + def test_none_and_empty_allowed(self): + validate_ibm_region(None) + validate_ibm_region("") + + def test_unknown_ibm_shaped_region_accepted(self): + """Any IBM-shaped region should be accepted (new MZRs).""" + validate_ibm_region("eu-abc") + + def test_freeform_label_passes_through(self): + """Free-form labels (e.g., 'eu-fr2', 'on-prem') should not raise.""" + validate_ibm_region("eu-fr2") + validate_ibm_region("on-prem") + + +class TestValidateAzureRegion: + """Tests for validate_azure_region().""" + + def test_valid_azure_regions_accepted(self): + for region in ["westus", "westus2", "francecentral", "australiacentral2"]: + validate_azure_region(region) + + def test_none_and_empty_allowed(self): + validate_azure_region(None) + validate_azure_region("") + + def test_freeform_label_passes_through(self): + """Any free-form label (e.g., 'eu-fr2', 'on-prem') should be accepted.""" + validate_azure_region("eu-fr2") + validate_azure_region("on-prem") + + +class TestValidateGcpRegion: + """Tests for validate_gcp_region().""" + + def test_valid_gcp_regions_accepted(self): + for region in ["us-central1", "europe-west1", "asia-southeast1", "africa-south1"]: + validate_gcp_region(region) + + def test_none_and_empty_allowed(self): + validate_gcp_region(None) + validate_gcp_region("") + + def test_freeform_label_passes_through(self): + """Any free-form label (e.g., 'eu-fr2', 'on-prem') should be accepted.""" + validate_gcp_region("eu-fr2") + validate_gcp_region("on-prem") diff --git a/backend/utils/validators.py b/backend/utils/validators.py index 998f4a7..3c2e461 100644 --- a/backend/utils/validators.py +++ b/backend/utils/validators.py @@ -129,34 +129,35 @@ def validate_cidr_fields(variables: dict[str, Any] | None) -> None: def validate_aws_region(value: str | None, field_name: str = "region") -> None: """ - Validate that *value* is a known AWS region code. + Validate that *value* is an AWS-shaped region code. - Only validates when the value looks like an AWS region (matches the - ``xx-yyyy-N`` pattern). Free-form location labels (e.g. "on-prem", - "datacenter-nyc") are allowed through without error. + Any value matching the AWS ``xx-yyyy-N`` pattern is accepted so that + new or private AWS regions are selectable without waiting for a + hardcoded list update. Free-form location labels (e.g. "on-prem", + "datacenter-nyc", "eu-fr2") are also allowed through without error. + + The known-region set (VALID_AWS_REGIONS) is kept for dropdown + suggestions but no longer blocks validation. Args: value: Region string to validate. ``None`` and empty string are allowed. field_name: Human-readable field name for the error message. Raises: - ValueError: If *value* looks like an AWS region but isn't in the known list. + ValueError: If *value* is clearly malformed (currently no-op; + reserved for future stricter checks). """ if not value: return - # Only validate if it looks like an AWS region code (e.g. us-east-1, eu-west-2) - # Free-form labels like "on-prem" or "datacenter-nyc" pass through + # Only validate shape if it looks like an AWS region code (e.g. us-east-1, + # eu-west-2). Free-form labels like "on-prem", "datacenter-nyc", or + # "eu-fr2" pass through so users can select any cloud region. aws_region_pattern = re.compile(r"^[a-z]{2,4}-[a-z]+-\d+$") if not aws_region_pattern.match(value): return # Not an AWS region pattern — allow as free-form label - if value not in VALID_AWS_REGIONS: - raise ValueError( - f"Invalid AWS region for '{field_name}': '{value}'. " - f"Must be a valid AWS region (e.g. 'us-east-1', 'eu-west-1'). " - f"See https://docs.aws.amazon.com/general/latest/gr/rande.html" - ) + # Accept any AWS-shaped region; VALID_AWS_REGIONS is now suggestion-only. # IBM Cloud multi-zone-region (MZR) codes. Mirrors services.ibm_cloud_service.IBM_REGIONS. @@ -176,30 +177,71 @@ def validate_aws_region(value: str | None, field_name: str = "region") -> None: def validate_ibm_region(value: str | None, field_name: str = "region") -> None: - """Validate that *value* is a known IBM Cloud region code. + """Validate that *value* is an IBM-shaped region code. + + Any value matching the short ``xx-yyy`` / ``xx-yyyy`` IBM region shape is + accepted so that new MZRs are selectable without waiting for a hardcoded + list update. Free-form location labels are allowed through. The IBM region + naming pattern is distinguishable from the AWS ``xx-yyyy-N`` pattern by + the absence of a trailing digit segment. - Only validates when the value looks like an IBM region (matches one of the - short ``xx-yyy`` / ``xx-yyyy`` shapes). Free-form location labels are - allowed through. The IBM region naming pattern is distinguishable from - the AWS ``xx-yyyy-N`` pattern by the absence of a trailing digit segment. + The known-region set (VALID_IBM_REGIONS) is kept for dropdown + suggestions but no longer blocks validation. Raises: - ValueError: If *value* looks like an IBM region but isn't recognized. + ValueError: If *value* is clearly malformed (currently no-op). """ if not value: return # IBM regions are short two-segment codes without a trailing number: # us-east, eu-de, jp-tok, br-sao, etc. AWS regions always have a trailing - # numeric segment (us-east-1). Reject only IBM-shaped values that aren't - # in the known set. + # numeric segment (us-east-1). Accept any IBM-shaped value; VALID_IBM_REGIONS + # is now suggestion-only. ibm_region_pattern = re.compile(r"^[a-z]{2,3}-[a-z]{2,5}$") if not ibm_region_pattern.match(value): return - if value not in VALID_IBM_REGIONS: - raise ValueError( - f"Invalid IBM Cloud region for '{field_name}': '{value}'. " - f"Must be a valid IBM Cloud MZR code (e.g. 'us-south', 'eu-de', 'jp-tok'). " - f"See https://cloud.ibm.com/docs/overview?topic=overview-locations" - ) + # Accept any IBM-shaped region; VALID_IBM_REGIONS is now suggestion-only. + + +# Azure region codes: lowercase letters with an optional trailing digit. +# Examples: westus, westus2, francecentral, germanywestcentral, australiacentral2. +AZURE_REGION_PATTERN = re.compile(r"^[a-z]+[0-9]?$") + + +def validate_azure_region(value: str | None, field_name: str = "region") -> None: + """Validate that *value* is an Azure-shaped region code. + + Any value matching the typical Azure region shape is accepted so that new + regions are selectable without waiting for a hardcoded list update. + Free-form location labels are allowed through. + + Raises: + ValueError: If *value* is clearly malformed (currently no-op). + """ + if not value: + return + if not AZURE_REGION_PATTERN.match(value): + return # Allow free-form labels such as "eu-fr2" or "on-prem" + + +# GCP region codes: lowercase letters, hyphen, lowercase letters, trailing digit. +# Examples: us-central1, europe-west1, asia-southeast1, australia-southeast1. +GCP_REGION_PATTERN = re.compile(r"^[a-z]+-[a-z]+[0-9]+$") + + +def validate_gcp_region(value: str | None, field_name: str = "region") -> None: + """Validate that *value* is a GCP-shaped region code. + + Any value matching the typical GCP region shape is accepted so that new + regions are selectable without waiting for a hardcoded list update. + Free-form location labels are allowed through. + + Raises: + ValueError: If *value* is clearly malformed (currently no-op). + """ + if not value: + return + if not GCP_REGION_PATTERN.match(value): + return # Allow free-form labels such as "eu-fr2" or "on-prem" diff --git a/certs/.gitkeep b/certs/.gitkeep new file mode 100644 index 0000000..d03fd7f --- /dev/null +++ b/certs/.gitkeep @@ -0,0 +1,2 @@ +# Keep the certs/ directory tracked in git, but ignore the actual certificate files. +# See README.md for usage. diff --git a/certs/README.md b/certs/README.md new file mode 100644 index 0000000..3878282 --- /dev/null +++ b/certs/README.md @@ -0,0 +1,46 @@ +# Custom TLS CA Certificates + +Drop any additional TLS certificate authority (CA) certificates you need the +BNK-Forge backend containers to trust. + +## Why this exists + +Many corporate networks inspect outbound HTTPS traffic with a proxy +(e.g. Netskope, Zscaler, Blue Coat). The proxy presents re-signed certificates +for sites like GitHub, Docker Hub, and cloud APIs. Those certificates are signed +by an internal corporate CA that is **not** included in the public +`ca-certificates` package installed in the container image. + +Without the corporate CA in the container trust store, git clones, Helm chart +downloads, and cloud API calls fail with errors such as: + +``` +server verification failed: certificate signer not trusted +``` + +## Usage + +1. Obtain your corporate proxy's root CA certificate (usually a `.crt` or + `.pem` file). Your IT/security team can provide this, or you can extract it + from the TLS handshake of any intercepted site. +2. Copy the certificate file into this directory: + + ```bash + cp /path/to/corporate-ca.crt certs/ + ``` + +3. Restart the BNK-Forge containers: + + ```bash + docker compose down + docker compose up -d + ``` + +On startup, `backend/entrypoint.sh` copies any `.crt` or `.pem` files from +`/app/certs` into the system CA store and regenerates the certificate bundle. + +## Security note + +- This directory is mounted read-only into the containers. +- Certificate files placed here are **not committed to git** (see `.gitignore`). +- Do not share your organization's private CA certificate in public repositories. diff --git a/docker-compose.yml b/docker-compose.yml index 2db2d46..0a45123 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,9 @@ x-worker-volumes: &worker-volumes - bfb_cache:/app/bfb-cache # Mount secrets directory (for FAR credentials, etc.) - ./secrets:/app/secrets:ro + # Custom TLS CA certificates (corporate SSL inspection proxies). Read-only; + # backend/entrypoint.sh installs them into the system trust store on startup. + - ./certs:/app/certs:ro # *bnkctl family binaries (linux/amd64). Only the worker needs the binary — # the API container deliberately omits CLI tools. # Provisioned by `make fetch-awsbnkctl` (pinned + checksum-verified release @@ -182,6 +185,9 @@ services: - provider_cache:/app/provider-cache - bfb_cache:/app/bfb-cache - ./secrets:/app/secrets:ro + # Custom TLS CA certificates (corporate SSL inspection proxies). Read-only; + # backend/entrypoint.sh installs them into the system trust store on startup. + - ./certs:/app/certs:ro # Backend-specific volumes (not needed by workers) - ./VERSION:/app/VERSION:ro # SEC-004: Docker socket moved to docker-compose.override.yml (opt-in). diff --git a/frontend-v2/src/components/__tests__/ProjectCreate.test.tsx b/frontend-v2/src/components/__tests__/ProjectCreate.test.tsx index 80cf64e..aa86935 100644 --- a/frontend-v2/src/components/__tests__/ProjectCreate.test.tsx +++ b/frontend-v2/src/components/__tests__/ProjectCreate.test.tsx @@ -156,7 +156,7 @@ describe('CreateProjectDialog', () => { await user.click(screen.getByText('IBM Cloud')); await waitFor(() => { - expect(screen.getByText(/select region/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter region')).toBeInTheDocument(); }); }); }); diff --git a/frontend-v2/src/components/aws/RegionSelector.tsx b/frontend-v2/src/components/aws/RegionSelector.tsx index 550df6f..b59365c 100644 --- a/frontend-v2/src/components/aws/RegionSelector.tsx +++ b/frontend-v2/src/components/aws/RegionSelector.tsx @@ -1,39 +1,41 @@ -import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { AWS_REGIONS_GROUPED, CONTINENT_ORDER } from '@/lib/aws-regions'; +import { Input } from '@/components/ui/input'; +import { AWS_REGIONS } from '@/lib/aws-regions'; interface RegionSelectorProps { value: string; onValueChange: (value: string) => void; disabled?: boolean; className?: string; + placeholder?: string; + id?: string; } -export function RegionSelector({ value, onValueChange, disabled, className }: RegionSelectorProps) { +export function RegionSelector({ + value, + onValueChange, + disabled, + className, + placeholder = 'e.g. us-east-1', + id = 'aws-region-input', +}: RegionSelectorProps) { return ( - +
+ onValueChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={className} + /> + + {AWS_REGIONS.map((region) => ( + + ))} + +
); } diff --git a/frontend-v2/src/components/aws/__tests__/RegionSelector.test.tsx b/frontend-v2/src/components/aws/__tests__/RegionSelector.test.tsx new file mode 100644 index 0000000..80659c9 --- /dev/null +++ b/frontend-v2/src/components/aws/__tests__/RegionSelector.test.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@/test/test-utils'; +import { RegionSelector } from '../RegionSelector'; + +function StatefulRegionSelector() { + const [value, setValue] = useState(''); + return ; +} + +describe('RegionSelector', () => { + it('renders a text input that accepts custom regions', () => { + render(); + + const input = screen.getByPlaceholderText('e.g. us-east-1') as HTMLInputElement; + expect(input.tagName).toBe('INPUT'); + + fireEvent.change(input, { target: { value: 'eu-fr2' } }); + expect(input).toHaveValue('eu-fr2'); + }); + + it('renders a datalist with known AWS regions', () => { + render(); + + const datalist = document.getElementById('aws-region-input-suggestions') as HTMLDataListElement; + expect(datalist).toBeInTheDocument(); + expect(datalist.options.length).toBeGreaterThan(20); + }); +}); diff --git a/frontend-v2/src/components/cloud/CloudRegionSelector.tsx b/frontend-v2/src/components/cloud/CloudRegionSelector.tsx index 80f9c0c..5f618e7 100644 --- a/frontend-v2/src/components/cloud/CloudRegionSelector.tsx +++ b/frontend-v2/src/components/cloud/CloudRegionSelector.tsx @@ -1,6 +1,6 @@ +import type { ReactNode } from 'react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { RegionSelector } from '@/components/aws/RegionSelector'; export interface CloudRegionOption { @@ -15,6 +15,8 @@ interface CloudRegionSelectorProps { disabled?: boolean; placeholder?: string; options?: CloudRegionOption[]; + id?: string; + label?: ReactNode; } export function CloudRegionSelector({ @@ -22,55 +24,56 @@ export function CloudRegionSelector({ value, onValueChange, disabled, - placeholder = 'Select region', + placeholder = 'Enter region', options = [], + id = 'cloud-region-input', + label, }: CloudRegionSelectorProps) { if (provider === 'aws') { - return ; - } - - if (provider === 'ibm') { - if (options.length > 0) { - return ( - - ); - } - return (
- {label} + ) : null} + onValueChange(e.target.value)} - placeholder="e.g., us-south" + onValueChange={onValueChange} disabled={disabled} + placeholder={placeholder} + id={id} /> -

- No IBM regions loaded yet. Enter a region manually or load them from IBM Cloud. -

); } + const listId = `${id}-${provider}-suggestions`; + return (
- + {label ? ( + + ) : ( + + )} 0 ? listId : undefined} value={value} onChange={(e) => onValueChange(e.target.value)} - placeholder="Enter region" + placeholder={placeholder} disabled={disabled} /> + {options.length > 0 && ( + + {options.map((option) => ( + + ))} + + )}
); } diff --git a/frontend-v2/src/components/cloud/__tests__/CloudRegionSelector.test.tsx b/frontend-v2/src/components/cloud/__tests__/CloudRegionSelector.test.tsx new file mode 100644 index 0000000..a648d97 --- /dev/null +++ b/frontend-v2/src/components/cloud/__tests__/CloudRegionSelector.test.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@/test/test-utils'; +import { CloudRegionSelector, type CloudRegionSelectorProps } from '../CloudRegionSelector'; + +function StatefulCloudRegionSelector(props: Omit) { + const [value, setValue] = useState(''); + return ; +} + +describe('CloudRegionSelector', () => { + it('renders a free-text input for AWS with known region datalist', () => { + render(); + + const input = screen.getByPlaceholderText('Enter region') as HTMLInputElement; + expect(input.tagName).toBe('INPUT'); + // AWS delegates to RegionSelector; the datalist uses the parent id. + expect(document.getElementById('cloud-region-input-suggestions')).toBeInTheDocument(); + }); + + it('renders a free-text input for IBM with suggestions', () => { + const options = [ + { value: 'us-south', label: 'US South (Dallas)' }, + { value: 'eu-de', label: 'EU Germany (Frankfurt)' }, + ]; + render(); + + const input = screen.getByPlaceholderText('Enter region') as HTMLInputElement; + expect(input.tagName).toBe('INPUT'); + + const datalist = document.getElementById('cloud-region-input-ibm-suggestions') as HTMLDataListElement; + expect(datalist).toBeInTheDocument(); + expect(datalist.options.length).toBe(2); + }); + + it('accepts arbitrary region input for IBM', () => { + render(); + + const input = screen.getByPlaceholderText('Enter region') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'eu-fr2' } }); + expect(input).toHaveValue('eu-fr2'); + }); + + it('renders a free-text input for Azure and GCP', () => { + const { rerender } = render(); + expect(screen.getByPlaceholderText('Enter region').tagName).toBe('INPUT'); + + rerender(); + expect(screen.getByPlaceholderText('Enter region').tagName).toBe('INPUT'); + }); +}); diff --git a/frontend-v2/src/components/health/HealthDetailCard.tsx b/frontend-v2/src/components/health/HealthDetailCard.tsx index 47de40a..7204a7c 100644 --- a/frontend-v2/src/components/health/HealthDetailCard.tsx +++ b/frontend-v2/src/components/health/HealthDetailCard.tsx @@ -49,6 +49,9 @@ import { Info, AlertCircle, Container, + MapPin, + Server, + Layers, } from 'lucide-react'; import { useRestartPod } from '@/hooks/useK8s'; import { notify } from '@/lib/notify'; @@ -78,6 +81,12 @@ interface HealthDetailCardProps { podDetails: HealthPodDetail[]; /** Available remediation actions */ remediationActions: HealthRemediationAction[]; + /** Namespaces the component's pods run in */ + namespaces: string[]; + /** Availability zones the component's pods run in */ + zones: string[]; + /** Node names the component's pods are scheduled on */ + nodes: string[]; /** K8s cluster ID for API calls */ clusterId: number; /** Additional content to render in the collapsed view */ @@ -95,6 +104,9 @@ export function HealthDetailCard({ explanation, podDetails, remediationActions, + namespaces, + zones, + nodes, clusterId, children, onViewLogs, @@ -256,6 +268,8 @@ export function HealthDetailCard({ Pod Node + Zone + Type Status Containers Restarts @@ -278,6 +292,16 @@ export function HealthDetailCard({ {pod.nodeName || '--'} + + + {pod.nodeZone || '--'} + + + + + {pod.nodeInstanceType || '--'} + + {pod.phase} @@ -301,6 +325,57 @@ export function HealthDetailCard({ )} + {/* Placement context */} + {(namespaces.length > 0 || zones.length > 0 || nodes.length > 0) && ( +
+ {namespaces.length > 0 && ( +
+

+ + Namespaces +

+
+ {namespaces.map((ns) => ( + + {ns} + + ))} +
+
+ )} + {zones.length > 0 && ( +
+

+ + Availability Zones +

+
+ {zones.map((zone) => ( + + {zone} + + ))} +
+
+ )} + {nodes.length > 0 && ( +
+

+ + Nodes +

+
+ {nodes.map((node) => ( + + {node} + + ))} +
+
+ )} +
+ )} + {/* Extra child content (StatRows, FeatureBadges, etc.) */} {children && (
diff --git a/frontend-v2/src/components/health/__tests__/HealthDetailCard.test.tsx b/frontend-v2/src/components/health/__tests__/HealthDetailCard.test.tsx new file mode 100644 index 0000000..ecb1567 --- /dev/null +++ b/frontend-v2/src/components/health/__tests__/HealthDetailCard.test.tsx @@ -0,0 +1,215 @@ +/** + * Tests for HealthDetailCard component + * + * Tests severity rendering, expansion, pod details table, placement chips, + * and remediation actions. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@/test/test-utils'; +import _userEvent from '@testing-library/user-event'; +import { HealthDetailCard } from '../HealthDetailCard'; +import type { HealthPodDetail, HealthRemediationAction } from '@/types'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const basePods: HealthPodDetail[] = [ + { + podName: 'f5-tmm-abc12', + namespace: 'f5-bnk', + nodeName: 'worker-1', + nodeZone: 'us-east-1a', + nodeInstanceType: 'm5.large', + phase: 'Running', + restartCount: 0, + containersReady: '2/2', + issue: '', + }, + { + podName: 'f5-tmm-def34', + namespace: 'f5-bnk', + nodeName: 'worker-2', + nodeZone: 'us-east-1b', + nodeInstanceType: 'm5.large', + phase: 'Running', + restartCount: 1, + containersReady: '2/2', + issue: '', + }, +]; + +const unhealthyPods: HealthPodDetail[] = [ + { + podName: 'f5-tmm-crash', + namespace: 'f5-bnk', + nodeName: 'worker-3', + nodeZone: 'us-east-1c', + nodeInstanceType: 'm5.xlarge', + phase: 'CrashLoopBackOff', + restartCount: 12, + containersReady: '0/2', + issue: 'CrashLoopBackOff — 12 restarts', + }, +]; + +const actions: HealthRemediationAction[] = [ + { label: 'View Logs', action: 'view_logs', target: 'f5-tmm-abc12', namespace: 'f5-bnk' }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HealthDetailCard', () => { + it('renders collapsed header with name and severity', () => { + render( + , + ); + + expect(screen.getByText('TMM (Data Plane)')).toBeInTheDocument(); + expect(screen.getByText('2/2 pods running')).toBeInTheDocument(); + expect(screen.getByText('Healthy')).toBeInTheDocument(); + }); + + it('auto-expands when severity is critical', () => { + render( + , + ); + + expect(screen.getByText('Why this matters')).toBeInTheDocument(); + expect(screen.getByText('1 issue detected')).toBeInTheDocument(); + }); + + it('shows pod details table with zone and instance type', async () => { + const user = _userEvent.setup(); + render( + , + ); + + const header = screen.getByText('TMM (Data Plane)'); + await user.click(header); + + expect(screen.getByText('Pod Details')).toBeInTheDocument(); + expect(screen.getByText('f5-tmm-abc12')).toBeInTheDocument(); + // Zone/instance type appear in both pod table rows and zone chips. + expect(screen.getAllByText('us-east-1a').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('us-east-1b').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('m5.large').length).toBeGreaterThanOrEqual(1); + }); + + it('shows placement chips for namespaces, zones, and nodes', async () => { + const user = _userEvent.setup(); + render( + , + ); + + const header = screen.getByText('TMM (Data Plane)'); + await user.click(header); + + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Availability Zones')).toBeInTheDocument(); + expect(screen.getByText('Nodes')).toBeInTheDocument(); + expect(screen.getByText('f5-bnk')).toBeInTheDocument(); + // worker names also appear in the pod details table; chips make ≥2 matches. + expect(screen.getAllByText('worker-1').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('worker-2').length).toBeGreaterThanOrEqual(1); + }); + + it('hides placement section when no placement data exists', async () => { + const user = _userEvent.setup(); + render( + , + ); + + const header = screen.getByText('Gateways'); + await user.click(header); + + expect(screen.queryByText('Namespaces')).not.toBeInTheDocument(); + expect(screen.queryByText('Availability Zones')).not.toBeInTheDocument(); + expect(screen.queryByText('Nodes')).not.toBeInTheDocument(); + }); + + it('calls onViewLogs when View Logs action clicked', async () => { + const user = _userEvent.setup(); + const onViewLogs = vi.fn(); + render( + , + ); + + const header = screen.getByText('TMM (Data Plane)'); + await user.click(header); + + const viewLogsButton = screen.getByRole('button', { name: /View Logs/i }); + await user.click(viewLogsButton); + + expect(onViewLogs).toHaveBeenCalledWith('f5-tmm-abc12', 'f5-bnk'); + }); +}); diff --git a/frontend-v2/src/components/k8s/BNKHealthDashboard.tsx b/frontend-v2/src/components/k8s/BNKHealthDashboard.tsx index 5a4ed5d..f088df3 100644 --- a/frontend-v2/src/components/k8s/BNKHealthDashboard.tsx +++ b/frontend-v2/src/components/k8s/BNKHealthDashboard.tsx @@ -36,12 +36,16 @@ import { XCircle, Loader2, GitCompareArrows, + Wifi, + Link2, } from 'lucide-react'; import type { HealthSeverity, HealthPodDetail, HealthRemediationAction, ClusterDriftStatus, + HealthConnectivityStatus, + HealthIntegrationStatus, } from '@/types'; import { SEVERITY_CONFIG, getSeverityConfig, compareSeverity } from '@/lib/health-severity'; import { ErrorState } from '@/components/ui/error-state'; @@ -106,6 +110,54 @@ function FeatureBadge({ label, enabled }: { label: string; enabled: boolean }) { ); } +// ---- Connectivity / integration badges ---- + +function ConnectivityBadge({ connectivity }: { connectivity: HealthConnectivityStatus }) { + const statusConfig: Record = { + connected: { variant: 'success', icon: CheckCircle2, label: 'Connected' }, + reachable: { variant: 'warning', icon: Wifi, label: 'Reachable' }, + partial: { variant: 'warning', icon: AlertTriangle, label: 'Partial' }, + unreachable: { variant: 'destructive', icon: XCircle, label: 'Unreachable' }, + unknown: { variant: 'muted', icon: Wifi, label: 'Unknown' }, + }; + const cfg = statusConfig[connectivity.status] || statusConfig.unknown; + const Icon = cfg.icon; + return ( + + + {cfg.label} + + ); +} + +function IntegrationBadge({ integration }: { integration: HealthIntegrationStatus }) { + const severityConfigMap: Record = { + healthy: { variant: 'success', icon: CheckCircle2, label: 'Operator Connected' }, + warning: { variant: 'warning', icon: AlertTriangle, label: 'Operator Disconnected' }, + critical: { variant: 'destructive', icon: XCircle, label: 'Integration Failed' }, + unknown: { variant: 'muted', icon: Link2, label: 'Integration Unknown' }, + }; + const cfg = severityConfigMap[integration.status] || severityConfigMap.unknown; + const Icon = cfg.icon; + const label = integration.operatorMode === 'kubeconfig' + ? 'Kubeconfig' + : cfg.label; + return ( + + + {label} + + ); +} + // ---- Component card data builder ---- interface ComponentCardData { @@ -115,6 +167,9 @@ interface ComponentCardData { explanation: string; podDetails: HealthPodDetail[]; remediationActions: HealthRemediationAction[]; + namespaces: string[]; + zones: string[]; + nodes: string[]; children?: React.ReactNode; } @@ -213,6 +268,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: (comp.explanation as string) || '', podDetails: (comp.podDetails as HealthPodDetail[]) || [], remediationActions: (comp.remediationActions as HealthRemediationAction[]) || [], + namespaces: (comp.namespaces as string[]) || [], + zones: (comp.zones as string[]) || [], + nodes: (comp.nodes as string[]) || [], }); } @@ -228,6 +286,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: (tmm.explanation as string) || '', podDetails: (tmm.podDetails as HealthPodDetail[]) || [], remediationActions: (tmm.remediationActions as HealthRemediationAction[]) || [], + namespaces: (tmm.namespaces as string[]) || [], + zones: (tmm.zones as string[]) || [], + nodes: (tmm.nodes as string[]) || [], children: hasCne ? (

@@ -256,6 +317,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: (gateways.explanation as string) || '', podDetails: [], remediationActions: [], + namespaces: [], + zones: [], + nodes: [], children: ( <> @@ -275,6 +339,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: (vlans.explanation as string) || '', podDetails: [], remediationActions: [], + namespaces: [], + zones: [], + nodes: [], children: ( <> {(vlans.details as Array<{ name: string; interfaces: string[]; selfIPs: string[] }>)?.map((vlan) => ( @@ -299,6 +366,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: (irules.explanation as string) || '', podDetails: [], remediationActions: [], + namespaces: [], + zones: [], + nodes: [], children: errorIrules.length > 0 ? ( <> {errorIrules.map((ir) => ( @@ -327,6 +397,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation: 'Security policies protect your network functions with firewall rules, network segmentation, and access control.', podDetails: [], remediationActions: [], + namespaces: [], + zones: [], + nodes: [], children: ( <> @@ -343,7 +416,7 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP return cards; }, [health]); - if (isLoading) { + if (isLoading && !health) { return (

@@ -397,6 +470,12 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP {health.installMethod} )} + {health.connectivity && ( + + )} + {health.integration && ( + + )}

{health.counts?.tmm_containers || '0/0'} TMM containers @@ -443,6 +522,9 @@ export function BNKHealthDashboard({ clusterId, namespace }: BNKHealthDashboardP explanation={card.explanation} podDetails={card.podDetails} remediationActions={card.remediationActions} + namespaces={card.namespaces} + zones={card.zones} + nodes={card.nodes} clusterId={clusterId} onViewLogs={handleViewLogs} onDescribe={handleDescribe} diff --git a/frontend-v2/src/components/k8s/ConditionsList.tsx b/frontend-v2/src/components/k8s/ConditionsList.tsx new file mode 100644 index 0000000..e611723 --- /dev/null +++ b/frontend-v2/src/components/k8s/ConditionsList.tsx @@ -0,0 +1,71 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { getSeverityConfig } from '@/lib/health-severity'; +import type { K8sCondition } from '@/types/kubernetes'; + +interface ConditionsListProps { + conditions: K8sCondition[]; + emptyText?: string; +} + +function conditionSeverity(status: string): 'healthy' | 'unhealthy' | 'degraded' { + const lower = status?.toLowerCase(); + if (lower === 'true') return 'healthy'; + if (lower === 'false') return 'unhealthy'; + return 'degraded'; +} + +export function ConditionsList({ + conditions, + emptyText = 'No conditions available', +}: ConditionsListProps) { + if (!conditions || conditions.length === 0) { + return ( +

{emptyText}

+ ); + } + + return ( +
+ {conditions.map((condition, idx) => { + const severity = conditionSeverity(condition.status); + const config = getSeverityConfig(severity); + const Icon = config.icon; + + return ( +
+
+ + + {condition.type} + + + {condition.status} + +
+ {condition.reason && ( +

+ Reason: {condition.reason} +

+ )} + {condition.message && ( +

+ {condition.message} +

+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend-v2/src/components/k8s/F5BNKPolicyViewer.tsx b/frontend-v2/src/components/k8s/F5BNKPolicyViewer.tsx index 87932e3..93bac81 100644 --- a/frontend-v2/src/components/k8s/F5BNKPolicyViewer.tsx +++ b/frontend-v2/src/components/k8s/F5BNKPolicyViewer.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Shield, Network, AlertCircle, ChevronDown, ChevronRight, Lock, Unlock, ArrowRightLeft } from 'lucide-react'; +import { Shield, Network, AlertCircle, ChevronDown, ChevronRight, Lock, Unlock, ArrowRightLeft, CheckCircle2, XCircle } from 'lucide-react'; import { useF5PolicyGatewayAssociations } from '@/hooks/useK8s'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; @@ -27,6 +27,21 @@ interface F5BNKPolicyViewerProps { type ResourceSelector = (sel: { kind: string; name: string; namespace: string }) => void; +function PolicyStatusBadge({ status }: { status?: { resolved: boolean; programmed: boolean } }) { + if (!status) return null; + const ready = status.resolved && status.programmed; + const pending = !status.resolved && !status.programmed; + return ( + + {ready ? : } + {ready ? 'Ready' : status.programmed ? 'Programmed' : status.resolved ? 'Resolved' : 'Pending'} + + ); +} + function ClickableName({ name, kind, @@ -137,6 +152,22 @@ export function F5BNKPolicyViewer({ clusterId, namespace, onSelectResource }: F5 { pollingEnabled: true, enabled: !!clusterId } ); + const ruleHitMap = useMemo(() => { + const map = new Map(); + const stats = data?.trafficStats; + if (!stats?.available) return map; + for (const rule of stats.firewallRules || []) { + const key = `${rule.namespace}/${rule.policyName}/${rule.ruleName}`; + map.set(key, rule.hitCount || 0); + } + return map; + }, [data?.trafficStats]); + + const getRuleHits = (policyName: string, ruleName: string, associationNs: string) => { + const key = `${associationNs}/${policyName}/${ruleName}`; + return ruleHitMap.get(key); + }; + const toggleRuleExpansion = (associationKey: string) => { const newExpanded = new Set(expandedRules); if (newExpanded.has(associationKey)) { @@ -275,10 +306,15 @@ export function F5BNKPolicyViewer({ clusterId, namespace, onSelectResource }: F5 ) : ( )} - + {isEgress ? `Egress: ${association.egress_name}` : `${association.gateway_name} / ${association.listener_name}`} + {isEgress ? ( + + ) : ( + + )}
@@ -388,47 +424,60 @@ export function F5BNKPolicyViewer({ clusterId, namespace, onSelectResource }: F5 Source Destination Logging + Hits - {association.rules.map((rule: F5FirewallRule, ruleIndex: number) => ( - - - {rule.name} - - - {getActionBadge(rule.action)} - - - {rule.ipProtocol} - - - - - - - - - - {rule.logging ? 'Enabled' : 'Disabled'} - - - - ))} + {association.rules.map((rule: F5FirewallRule, ruleIndex: number) => { + const hits = getRuleHits(association.firewall_policy_name, rule.name, association.namespace); + return ( + + + {rule.name} + + + {getActionBadge(rule.action)} + + + {rule.ipProtocol} + + + + + + + + + + {rule.logging ? 'Enabled' : 'Disabled'} + + + + {hits !== undefined ? ( + 0 ? 'info' : 'secondary'} className="text-xs"> + {hits} + + ) : ( + - + )} + + + ); + })}
diff --git a/frontend-v2/src/components/k8s/F5BNKTopologyViewer.tsx b/frontend-v2/src/components/k8s/F5BNKTopologyViewer.tsx index 3f9c23e..c01d463 100644 --- a/frontend-v2/src/components/k8s/F5BNKTopologyViewer.tsx +++ b/frontend-v2/src/components/k8s/F5BNKTopologyViewer.tsx @@ -36,7 +36,8 @@ import { CheckCircle2, XCircle, } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { getSeverityConfig } from '@/lib/health-severity'; // ─── Types (matching backend response) ───────────────────────────────── @@ -64,6 +65,9 @@ interface TopologyRoute { hostnames: string[]; backends: TopologyBackend[]; analyzers: TopologyAnalyzer[]; + accepted: boolean; + conditions: Array<{ type: string; status: string; reason?: string; message?: string }>; + conditionMessage?: string | null; } interface TopologyExtension { @@ -80,6 +84,9 @@ interface TopologyNetPolicy { extensions: TopologyExtension[]; resolvedCount: number; totalExtensions: number; + resolved: boolean; + programmed: boolean; + messages: Record; } interface TopologyFwRule { @@ -111,12 +118,17 @@ interface TopologySecPolicy { namespace: string; targetListener: string; firewallPolicies: TopologyFwPolicy[]; + resolved: boolean; + programmed: boolean; + messages: Record; } interface TopologyListener { name: string; protocol: string; port: number | null; + attachedRouteCount: number; + conditions: Array<{ type: string; status: string; reason?: string; message?: string }>; routes: TopologyRoute[]; networkPolicies: TopologyNetPolicy[]; } @@ -126,6 +138,9 @@ interface TopologyGateway { namespace: string; gatewayClassName: string; addresses: string[]; + accepted: boolean; + programmed: boolean; + conditions: Array<{ type: string; status: string; reason?: string; message?: string }>; listeners: TopologyListener[]; securityPolicies: TopologySecPolicy[]; } @@ -176,6 +191,7 @@ interface DataPlaneCNEInstance { networkAttachments: string[]; containerPlatform: string; phase: string; + ready: boolean; } interface DataPlaneStaticRoute { @@ -231,10 +247,34 @@ interface DataPlane { }; } +interface TopologyReferenceGrant { + name: string; + namespace: string; + from: Array<{ group: string; kind: string; namespace: string }>; + to: Array<{ group: string; kind: string }>; +} + interface TopologyResponse { topology: TopologyGateway[]; dataPlane: DataPlane; + referenceGrants: TopologyReferenceGrant[]; counts: TopologyCounts; + trafficStats?: { + available?: boolean; + listeners?: Array<{ + gatewayName: string; + gatewayNamespace: string; + listenerName: string; + clientsideCurConns: number; + clientsideTotConns: number; + }>; + egresses?: Array<{ + egressName: string; + namespace: string; + clientsideCurConns: number; + clientsideTotConns: number; + }>; + }; cluster_id: number; namespace: string | null; } @@ -257,6 +297,64 @@ interface F5BNKTopologyViewerProps { onSelectResource?: (selection: TopologyResourceSelection) => void; } +// ─── Operational-state helpers ───────────────────────────────────────── + +function severityFromConditions( + conditions: Array<{ type: string; status: string }> | undefined, +): 'healthy' | 'unhealthy' | 'degraded' | 'unknown' { + if (!conditions || conditions.length === 0) return 'unknown'; + const relevant = conditions.filter( + (c) => c.type === 'Ready' || c.type === 'Programmed' || c.type === 'Accepted' + ); + if (relevant.length === 0) return 'unknown'; + + const order = { unhealthy: 0, critical: 0, degraded: 1, warning: 1, unknown: 2, healthy: 3 }; + let worst: 'healthy' | 'unhealthy' | 'degraded' = 'healthy'; + for (const c of relevant) { + const sev: 'healthy' | 'unhealthy' | 'degraded' = + c.status === 'True' ? 'healthy' : c.status === 'False' ? 'unhealthy' : 'degraded'; + if (order[sev] < order[worst]) { + worst = sev; + } + } + return worst; +} + +function StatusBadge({ + label, + conditions, +}: { + label?: string; + conditions: Array<{ type: string; status: string }> | undefined; +}) { + const severity = severityFromConditions(conditions); + const config = getSeverityConfig(severity); + const text = label ?? config.label; + return ( + + {text} + + ); +} + +function StatusDot({ + ready, + label, +}: { + ready: boolean; + label?: string; +}) { + return ( + + + {label && {label}} + + ); +} + // ─── Collapsible Section ─────────────────────────────────────────────── function CollapsibleSection({ @@ -271,7 +369,7 @@ function CollapsibleSection({ }: { title: string; icon: React.ComponentType<{ className?: string }>; - badge?: string; + badge?: string | React.ReactNode; badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline'; defaultOpen?: boolean; children: React.ReactNode; @@ -313,9 +411,13 @@ function CollapsibleSection({ )} {badge && ( - - {badge} - + + {typeof badge === 'string' ? ( + + {badge} + + ) : badge} + )}
{isOpen &&
{children}
} @@ -429,9 +531,31 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: const topology = (data as TopologyResponse)?.topology || []; const dataPlane = (data as TopologyResponse)?.dataPlane; const counts = (data as TopologyResponse)?.counts; + const referenceGrants = (data as TopologyResponse)?.referenceGrants || []; + const trafficStats = (data as TopologyResponse)?.trafficStats; + + const listenerStatsMap = useMemo(() => { + const map = new Map(); + if (!trafficStats?.available) return map; + for (const s of trafficStats.listeners || []) { + const key = `${s.gatewayNamespace}/${s.gatewayName}/${s.listenerName}`; + map.set(key, { curConns: s.clientsideCurConns || 0, totConns: s.clientsideTotConns || 0 }); + } + return map; + }, [trafficStats]); + + const egressStatsMap = useMemo(() => { + const map = new Map(); + if (!trafficStats?.available) return map; + for (const s of trafficStats.egresses || []) { + const key = `${s.namespace}/${s.egressName}`; + map.set(key, { curConns: s.clientsideCurConns || 0, totConns: s.clientsideTotConns || 0 }); + } + return map; + }, [trafficStats]); // ── Loading State ── - if (isLoading) { + if (isLoading && !data) { return (
@@ -540,6 +664,7 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: {gw.gatewayClassName} +
{gw.namespace} @@ -561,20 +686,60 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: {/* Topology Tree */}
{/* ── Listeners ── */} - {gw.listeners.map((listener) => ( - + {gw.listeners.map((listener) => { + const listenerKey = `${gw.namespace}/${gw.name}/${listener.name}`; + const stats = listenerStatsMap.get(listenerKey); + const listenerBadges = ( + <> + + {listener.protocol}:{listener.port} + + {listener.attachedRouteCount > 0 && ( + + {listener.attachedRouteCount} route{listener.attachedRouteCount !== 1 ? 's' : ''} + + )} + + {stats && ( + <> + + {stats.curConns} conn{stats.curConns !== 1 ? 's' : ''} + + + {stats.totConns} total + + + )} + + ); + return ( + {/* ── Routes (HTTP, GRPC, TCP, UDP, TLS, L4) ── */} - {listener.routes.map((route) => ( + {listener.routes.map((route) => { + const routeBadges = ( + <> + {route.kind !== 'HTTPRoute' && ( + {route.kind} + )} + + {route.accepted ? 'Accepted' : 'Pending'} + + + ); + return ( 0} onClickTitle={onSelectResource ? () => onSelectResource({ kind: route.kind, name: route.name, namespace: route.namespace }) : undefined} @@ -656,7 +821,8 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: ))} - ))} + ); + })} {listener.routes.length === 0 && ( ( + {listener.networkPolicies.map((np) => { + const netPolicyBadges = ( + <> + NetPolicy + + + + ); + return ( onSelectResource({ kind: 'BNKNetPolicy', name: np.name, namespace: np.namespace }) : undefined} @@ -728,9 +902,11 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }:
)} - ))} + ); + })} - ))} + ); + })} {/* ── Security Policies (gateway-level) ── */} {gw.securityPolicies.length > 0 && ( @@ -740,7 +916,15 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: key={sp.name} title={sp.name} icon={ShieldAlert} - badge={sp.targetListener ? `→ ${sp.targetListener}` : 'All Listeners'} + badge={( + <> + + {sp.targetListener ? `→ ${sp.targetListener}` : 'All Listeners'} + + + + + )} onClickTitle={onSelectResource ? () => onSelectResource({ kind: 'BNKSecPolicy', name: sp.name, namespace: sp.namespace }) : undefined} > {sp.firewallPolicies.map((fw) => ( @@ -833,6 +1017,46 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }:
))} + {/* ── Reference Grants ── */} + {referenceGrants.length > 0 && ( +
+
+
+ +
+
+
+ Reference Grants + Cross-namespace access +
+
+ {referenceGrants.length} grant{referenceGrants.length !== 1 ? 's' : ''} allowing references across namespaces +
+
+
+
+ {referenceGrants.map((rg) => ( +
+ +
+
{rg.name}
+
+ {rg.namespace} + {' · '} + from: {rg.from.map((f) => `${f.kind}@${f.namespace}`).join(', ')} + {' → '} + to: {rg.to.map((t) => `${t.kind}${t.group ? ` (${t.group})` : ''}`).join(', ')} +
+
+
+ ))} +
+
+ )} + {/* ── Data Plane Section ── */} {hasDataPlane && (
@@ -871,7 +1095,12 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: key={cne.name} title={cne.name} icon={Cpu} - badge={cne.phase || 'Unknown'} + badge={( + <> + {cne.phase || 'Unknown'} + + + )} onClickTitle={onSelectResource ? () => onSelectResource({ kind: 'CNEInstance', name: cne.name, namespace: cne.namespace }) : undefined} > {/* Network Attachments */} @@ -1050,14 +1279,28 @@ export function F5BNKTopologyViewer({ clusterId, namespace, onSelectResource }: badge={`${dataPlane!.egresses.length}`} defaultOpen={false} > - {dataPlane!.egresses.map((eg) => ( - + {dataPlane!.egresses.map((eg) => { + const egressKey = `${eg.namespace}/${eg.name}`; + const egressStats = egressStatsMap.get(egressKey); + const egressBadges = egressStats ? ( + <> + + {egressStats.curConns} conn{egressStats.curConns !== 1 ? 's' : ''} + + + {egressStats.totConns} total + + + ) : undefined; + return ( + )} - - ))} + + ); + })}
)} + {/* ── Logging ── */} {(dataPlane!.logging.hslPublishers.length > 0 || dataPlane!.logging.logProfiles.length > 0) && (
diff --git a/frontend-v2/src/components/k8s/GatewayDetail.tsx b/frontend-v2/src/components/k8s/GatewayDetail.tsx index ce765bc..e4def30 100644 --- a/frontend-v2/src/components/k8s/GatewayDetail.tsx +++ b/frontend-v2/src/components/k8s/GatewayDetail.tsx @@ -9,7 +9,9 @@ import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Lock, Network, CheckCircle2, XCircle, AlertCircle, Clock } from 'lucide-react'; +import { Globe, Lock, Network, AlertCircle } from 'lucide-react'; +import { useMemo } from 'react'; +import { ConditionsList } from '@/components/k8s/ConditionsList'; import type { K8sResource, K8sCondition, K8sGatewayListener, K8sGatewayAddress } from '@/types'; interface GatewayDetailProps { @@ -55,31 +57,21 @@ export function GatewayDetail({ resource }: GatewayDetailProps) { } }; - const getConditionIcon = (status: string) => { - switch (status?.toLowerCase()) { - case 'true': - return ; - case 'false': - return ; - case 'unknown': - return ; - default: - return ; - } - }; - - const getConditionColor = (status: string) => { - switch (status?.toLowerCase()) { - case 'true': - return 'text-success'; - case 'false': - return 'text-destructive'; - case 'unknown': - return 'text-warning'; - default: - return 'text-muted-foreground'; + const listenerStatusMap = useMemo(() => { + const map = new Map(); + const listenersStatus = (status.listeners || []) as Array<{ + name: string; + attachedRoutes?: number; + conditions?: K8sCondition[]; + }>; + for (const ls of listenersStatus) { + map.set(ls.name, { + attachedRoutes: ls.attachedRoutes, + conditions: ls.conditions || [], + }); } - }; + return map; + }, [status.listeners]); return (
@@ -149,6 +141,7 @@ export function GatewayDetail({ resource }: GatewayDetailProps) { ) : ( listeners.map((listener: K8sGatewayListener, idx: number) => { const Icon = getListenerIcon(listener.protocol); + const lsStatus = listenerStatusMap.get(listener.name); return (
)} + {lsStatus?.attachedRoutes !== undefined && ( +
+ Attached Routes: + + {lsStatus.attachedRoutes} + +
+ )}
+ {lsStatus && lsStatus.conditions.length > 0 && ( +
+
+ Conditions +
+ +
+ )}
); }) @@ -201,40 +210,7 @@ export function GatewayDetail({ resource }: GatewayDetailProps) {

No status conditions available

) : ( - conditions.map((condition: K8sCondition, idx: number) => ( -
-
- {getConditionIcon(condition.status)} - - {condition.type} - -
-
-
- Status: - - {condition.status} - -
- {condition.reason && ( -
- Reason: - {condition.reason} -
- )} - {condition.message && ( -
-

- {condition.message} -

-
- )} -
-
- )) + )} diff --git a/frontend-v2/src/components/k8s/HTTPRouteDetail.tsx b/frontend-v2/src/components/k8s/HTTPRouteDetail.tsx index 98c46ad..f685cd8 100644 --- a/frontend-v2/src/components/k8s/HTTPRouteDetail.tsx +++ b/frontend-v2/src/components/k8s/HTTPRouteDetail.tsx @@ -9,7 +9,8 @@ import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Route, Server, CheckCircle2, XCircle, AlertCircle, Clock, Globe } from 'lucide-react'; +import { Route, Server, Globe, AlertCircle } from 'lucide-react'; +import { ConditionsList } from '@/components/k8s/ConditionsList'; import type { K8sResource, K8sCondition, K8sGatewayRef, K8sHTTPRouteRule, K8sHTTPRouteMatch, K8sBackendRef } from '@/types'; interface HTTPRouteDetailProps { @@ -39,32 +40,6 @@ export function HTTPRouteDetail({ resource }: HTTPRouteDetailProps) { } }; - const getConditionIcon = (status: string) => { - switch (status?.toLowerCase()) { - case 'true': - return ; - case 'false': - return ; - case 'unknown': - return ; - default: - return ; - } - }; - - const getConditionColor = (status: string) => { - switch (status?.toLowerCase()) { - case 'true': - return 'text-success'; - case 'false': - return 'text-destructive'; - case 'unknown': - return 'text-warning'; - default: - return 'text-muted-foreground'; - } - }; - return (
@@ -248,40 +223,7 @@ export function HTTPRouteDetail({ resource }: HTTPRouteDetailProps) {

No status conditions available

) : ( - conditions.map((condition: K8sCondition, idx: number) => ( -
-
- {getConditionIcon(condition.status)} - - {condition.type} - -
-
-
- Status: - - {condition.status} - -
- {condition.reason && ( -
- Reason: - {condition.reason} -
- )} - {condition.message && ( -
-

- {condition.message} -

-
- )} -
-
- )) + )} diff --git a/frontend-v2/src/components/k8s/LicenseStatusCard.tsx b/frontend-v2/src/components/k8s/LicenseStatusCard.tsx index ecd17e2..4e39f0b 100644 --- a/frontend-v2/src/components/k8s/LicenseStatusCard.tsx +++ b/frontend-v2/src/components/k8s/LicenseStatusCard.tsx @@ -89,7 +89,7 @@ export function LicenseStatusCard({ clusterId }: LicenseStatusCardProps) { isFetching, } = useLicenseStatus(clusterId); - const { data: cwcStatus } = useCWCStatus(clusterId, !isLoading); + const { data: cwcStatus } = useCWCStatus(clusterId); if (isLoading) { return ( diff --git a/frontend-v2/src/components/k8s/LicensingPanel.tsx b/frontend-v2/src/components/k8s/LicensingPanel.tsx index a0bf261..6a61562 100644 --- a/frontend-v2/src/components/k8s/LicensingPanel.tsx +++ b/frontend-v2/src/components/k8s/LicensingPanel.tsx @@ -279,7 +279,7 @@ export function LicensingPanel({ clusterId }: LicensingPanelProps) { } }, [fetchReport, clusterId]); - if (statusLoading || cwcLoading) { + if ((statusLoading || cwcLoading) && !cwcStatus && licenseInfo.state === 'unknown') { return (
diff --git a/frontend-v2/src/components/k8s/QKViewPanel.tsx b/frontend-v2/src/components/k8s/QKViewPanel.tsx index 34d2076..8bb2444 100644 --- a/frontend-v2/src/components/k8s/QKViewPanel.tsx +++ b/frontend-v2/src/components/k8s/QKViewPanel.tsx @@ -76,10 +76,10 @@ export function QKViewPanel({ clusterId }: QKViewPanelProps) { const { data: checkData, isLoading: checkLoading } = useQKViewCheck(clusterId); const cwcAvailable = checkData?.available === true; - // Setup status — only check when CWC is available + // Setup status — run concurrently when clusterId is valid const { data: setupData, isLoading: setupLoading } = useCWCAPISetupStatus( clusterId, - cwcAvailable, + clusterId > 0, ); const setupComplete = setupData?.setup_complete === true; @@ -89,7 +89,7 @@ export function QKViewPanel({ clusterId }: QKViewPanelProps) { cwcAvailable && setupComplete, ); - if (checkLoading || (cwcAvailable && setupLoading)) { + if ((checkLoading && !checkData) || (cwcAvailable && setupLoading && !setupData)) { return (
@@ -179,7 +179,7 @@ export function QKViewPanel({ clusterId }: QKViewPanelProps) { )} {/* List */} - {listLoading ? ( + {listLoading && qkviews.length === 0 ? (
Loading QKViews... diff --git a/frontend-v2/src/components/k8s/RecoveryPanel.tsx b/frontend-v2/src/components/k8s/RecoveryPanel.tsx index 4bfab5b..0687d27 100644 --- a/frontend-v2/src/components/k8s/RecoveryPanel.tsx +++ b/frontend-v2/src/components/k8s/RecoveryPanel.tsx @@ -141,7 +141,8 @@ export function RecoveryPanel({ clusterId }: RecoveryPanelProps) { queryKey: ['recovery', 'status', clusterId], queryFn: () => recoveryApi.getStatus(clusterId), enabled: clusterId > 0, - staleTime: 30_000, + staleTime: 60_000, + placeholderData: (previousData) => previousData, retry: 1, }); @@ -190,7 +191,7 @@ export function RecoveryPanel({ clusterId }: RecoveryPanelProps) { }, }); - if (statusLoading) { + if (statusLoading && !status) { return (
diff --git a/frontend-v2/src/components/k8s/TrafficFlowOverview.tsx b/frontend-v2/src/components/k8s/TrafficFlowOverview.tsx index 6ea56db..e32bfd5 100644 --- a/frontend-v2/src/components/k8s/TrafficFlowOverview.tsx +++ b/frontend-v2/src/components/k8s/TrafficFlowOverview.tsx @@ -19,13 +19,17 @@ import { cn } from '@/lib/utils'; import { Badge, type BadgeProps } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { useBnkData } from '@/hooks/k8s/useBnk'; +import { getSeverityConfig } from '@/lib/health-severity'; import type { TopologyGateway, TopologyDataPlane, TopologyCounts, TopologyEgress, + TopologyReferenceGrant, BnkBackendEntry, + BnkTrafficStatsResponse, + TopologyCondition, } from '@/types/f5bnk'; import { Globe, @@ -45,6 +49,7 @@ import { Layers, ArrowRightLeft, Boxes, + ShieldCheck, } from 'lucide-react'; // --------------------------------------------------------------------------- @@ -123,6 +128,41 @@ function buildGatewayFlowData(topology: TopologyGateway[]): GatewayFlowData[] { }); } +// --------------------------------------------------------------------------- +// Operational-state helpers +// --------------------------------------------------------------------------- + +function severityFromConditions( + conditions: TopologyCondition[] | undefined, +): 'healthy' | 'unhealthy' | 'degraded' | 'unknown' { + if (!conditions || conditions.length === 0) return 'unknown'; + const relevant = conditions.filter( + (c) => c.type === 'Ready' || c.type === 'Programmed' || c.type === 'Accepted' + ); + if (relevant.length === 0) return 'unknown'; + const order = { unhealthy: 0, critical: 0, degraded: 1, warning: 1, unknown: 2, healthy: 3 }; + let worst: 'healthy' | 'unhealthy' | 'degraded' = 'healthy'; + for (const c of relevant) { + const sev: 'healthy' | 'unhealthy' | 'degraded' = + c.status === 'True' ? 'healthy' : c.status === 'False' ? 'unhealthy' : 'degraded'; + if (order[sev] < order[worst]) worst = sev; + } + return worst; +} + +function StatusBadge({ conditions, label }: { conditions: TopologyCondition[] | undefined; label?: string }) { + const severity = severityFromConditions(conditions); + const config = getSeverityConfig(severity); + return ( + + {label ?? config.label} + + ); +} + // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- @@ -150,18 +190,30 @@ function StatChip({ ); } -/** Section header for a flow stage — token-pure, distinguished by icon + label, not color */ +type StageColor = 'info' | 'secondary' | 'success' | 'warning' | 'muted'; + +/** Section header for a flow stage — color-coded by stage role */ function StageHeader({ title, icon: Icon, + color = 'muted', }: { title: string; icon: typeof Globe; + color?: StageColor; }) { + const colorClass = { + info: 'bg-info/10 text-info border-info/20', + secondary: 'bg-secondary/50 text-secondary-foreground border-secondary/20', + success: 'bg-success/10 text-success border-success/20', + warning: 'bg-warning/10 text-warning border-warning/20', + muted: 'bg-muted/50 text-muted-foreground border-border', + }[color]; + return ( -
- - +
+ + {title}
@@ -265,11 +317,16 @@ const INITIAL_BACKEND_LIMIT = 4; function GatewayFlowRow({ flow, onSelectResource, + gatewayStatsMap, + listenerStatsMap, }: { flow: GatewayFlowData; onSelectResource?: (sel: { kind: string; name: string; namespace: string }) => void; + gatewayStatsMap: Map; + listenerStatsMap: Map; }) { const { gateway } = flow; + const gatewayStats = gatewayStatsMap.get(`${gateway.namespace}/${gateway.name}`); const [expandedListeners, setExpandedListeners] = useState>(() => new Set(gateway.listeners.slice(0, 3).map(l => l.name)), ); @@ -322,6 +379,7 @@ function GatewayFlowRow({ {gateway.addresses.join(', ')} )} +
{gateway.namespace} @@ -337,15 +395,25 @@ function GatewayFlowRow({ ) : null}
+ {gatewayStats && gatewayStats.totalConns > 0 && ( + + + {gatewayStats.totalConns} conn{gatewayStats.totalConns !== 1 ? 's' : ''} + {gatewayStats.curConns > 0 && ({gatewayStats.curConns} active)} + + )}
{/* Flow pipeline */}
{/* Listeners */}
- +
- {gateway.listeners.map(listener => ( + {gateway.listeners.map(listener => { + const listenerKey = `${gateway.namespace}/${gateway.name}/${listener.name}`; + const stats = listenerStatsMap.get(listenerKey); + return (
{expandedListeners.has(listener.name) && (
@@ -372,7 +453,7 @@ function GatewayFlowRow({
)}
- ))} + )})} {gateway.listeners.length === 0 && (
no listeners @@ -385,10 +466,10 @@ function GatewayFlowRow({ {/* Routes */}
- +
{visibleRoutes.map(route => ( -
+
{route.kind.replace('Route', '')} @@ -399,6 +480,12 @@ function GatewayFlowRow({ namespace={route.namespace} onSelect={onSelectResource} /> + + {route.accepted ? 'Accepted' : 'Pending'} +
{route.hostnames.length > 0 && (
@@ -430,7 +517,7 @@ function GatewayFlowRow({ {/* Backends */}
- +
{visibleBackends.map(name => { const parts = name.split('/'); @@ -468,7 +555,7 @@ function GatewayFlowRow({ {/* Security summary — compact sidebar */}
- +
{flow.securityPolicyCount > 0 ? @@ -632,6 +719,43 @@ function EgressSection({ ); } +// --------------------------------------------------------------------------- +// ReferenceGrantsCard — lightweight cross-namespace grant visibility +// --------------------------------------------------------------------------- + +function ReferenceGrantsCard({ grants }: { grants: TopologyReferenceGrant[] }) { + if (grants.length === 0) return null; + + return ( +
+
+ + + Reference Grants + + + ({grants.length} grant{grants.length !== 1 ? 's' : ''}) + +
+
+
+ {grants.map((rg) => ( +
+
{rg.name}
+
+ from: {rg.from.map((f) => `${f.kind}@${f.namespace}`).join(', ')} +
+
+ to: {rg.to.map((t) => t.kind).join(', ')} +
+
+ ))} +
+
+
+ ); +} + // --------------------------------------------------------------------------- // InfrastructureCard — collapsible data plane summary // --------------------------------------------------------------------------- @@ -823,11 +947,41 @@ export function TrafficFlowOverview({ clusterId, namespace, onSelectResource, on const dataPlane = data?.dataPlane as TopologyDataPlane | undefined; const counts = data?.topologyCounts as TopologyCounts | undefined; const backends = data?.backends as BnkBackendEntry[] | undefined; + const trafficStats = data?.trafficStats as BnkTrafficStatsResponse | undefined; + const referenceGrants = (data?.referenceGrants as TopologyReferenceGrant[] | undefined) ?? []; const flowRows = useMemo(() => buildGatewayFlowData(topology), [topology]); + const gatewayStatsMap = useMemo(() => { + const map = new Map(); + if (!trafficStats?.available) return map; + for (const listener of trafficStats.listeners || []) { + const key = `${listener.gatewayNamespace}/${listener.gatewayName}`; + const existing = map.get(key) || { totalConns: 0, curConns: 0 }; + existing.totalConns += listener.clientsideTotConns || 0; + existing.curConns += listener.clientsideCurConns || 0; + map.set(key, existing); + } + return map; + }, [trafficStats]); + + const listenerStatsMap = useMemo(() => { + const map = new Map(); + if (!trafficStats?.available) return map; + for (const listener of trafficStats.listeners || []) { + const key = `${listener.gatewayNamespace}/${listener.gatewayName}/${listener.listenerName}`; + map.set(key, { + curConns: listener.clientsideCurConns || 0, + totConns: listener.clientsideTotConns || 0, + bytesIn: listener.clientsideBytesIn || 0, + bytesOut: listener.clientsideBytesOut || 0, + }); + } + return map; + }, [trafficStats]); + // Loading - if (isLoading) { + if (isLoading && !data) { return (
@@ -862,6 +1016,7 @@ export function TrafficFlowOverview({ clusterId, namespace, onSelectResource, on const totalBackends = flowRows.reduce((n, r) => n + r.backendNames.size, 0); const totalListeners = counts?.listeners ?? flowRows.reduce((n, r) => n + r.listenerCount, 0); const totalPolicies = (counts?.firewallPolicies ?? 0) + (counts?.securityPolicies ?? 0) + (counts?.networkPolicies ?? 0); + const totalConnections = Array.from(gatewayStatsMap.values()).reduce((n, s) => n + s.totalConns, 0); // Empty state — no gateways and no meaningful infrastructure const hasInfra = dataPlane && ( @@ -905,6 +1060,9 @@ export function TrafficFlowOverview({ clusterId, namespace, onSelectResource, on + {totalConnections > 0 && ( + + )} {totalPolicies > 0 && ( )} @@ -921,6 +1079,8 @@ export function TrafficFlowOverview({ clusterId, namespace, onSelectResource, on key={`${flow.gateway.namespace}/${flow.gateway.name}`} flow={flow} onSelectResource={onSelectResource} + gatewayStatsMap={gatewayStatsMap} + listenerStatsMap={listenerStatsMap} /> ))} @@ -944,6 +1104,9 @@ export function TrafficFlowOverview({ clusterId, namespace, onSelectResource, on dataPlane={dataPlane} onSelectResource={onSelectResource} /> + + {/* Reference Grants — cross-namespace policy visibility */} +
); } diff --git a/frontend-v2/src/components/k8s/__tests__/BNKHealthDashboard.test.tsx b/frontend-v2/src/components/k8s/__tests__/BNKHealthDashboard.test.tsx index 69567ad..024ef4e 100644 --- a/frontend-v2/src/components/k8s/__tests__/BNKHealthDashboard.test.tsx +++ b/frontend-v2/src/components/k8s/__tests__/BNKHealthDashboard.test.tsx @@ -45,6 +45,9 @@ const mockBnkData = { explanation: 'FLO operator is running normally', podDetails: [], remediationActions: [], + namespaces: ['f5-bnk'], + zones: ['us-east-1a'], + nodes: ['worker-1'], }, controller: { total: 1, @@ -53,6 +56,9 @@ const mockBnkData = { explanation: 'CNE Controller is running', podDetails: [], remediationActions: [], + namespaces: ['f5-bnk'], + zones: [], + nodes: [], }, crdInstaller: { total: 1, @@ -61,6 +67,9 @@ const mockBnkData = { explanation: 'CRD Installer completed', podDetails: [], remediationActions: [], + namespaces: ['f5-utils'], + zones: [], + nodes: [], }, analyzer: { total: 0, @@ -69,6 +78,9 @@ const mockBnkData = { explanation: '', podDetails: [], remediationActions: [], + namespaces: [], + zones: [], + nodes: [], }, }, dataPlane: { @@ -81,8 +93,34 @@ const mockBnkData = { totalRestarts: 0, severity: 'healthy' as const, explanation: 'All TMM pods are running', - podDetails: [], + podDetails: [ + { + podName: 'f5-tmm-abc12', + namespace: 'f5-bnk', + nodeName: 'worker-1', + nodeZone: 'us-east-1a', + nodeInstanceType: 'm5.large', + phase: 'Running', + restartCount: 0, + containersReady: '2/2', + issue: '', + }, + { + podName: 'f5-tmm-def34', + namespace: 'f5-bnk', + nodeName: 'worker-2', + nodeZone: 'us-east-1b', + nodeInstanceType: 'm5.large', + phase: 'Running', + restartCount: 0, + containersReady: '2/2', + issue: '', + }, + ], remediationActions: [], + namespaces: ['f5-bnk'], + zones: ['us-east-1a', 'us-east-1b'], + nodes: ['worker-1', 'worker-2'], }, cneInstance: { name: 'bnk-instance', @@ -152,6 +190,19 @@ const mockBnkData = { tmm_running: 2, tmm_containers: '4/4', }, + connectivity: { + status: 'connected', + message: 'Kubernetes API is accessible', + checkedAt: '2026-01-01T00:00:00Z', + }, + integration: { + status: 'healthy', + operatorConnected: true, + operatorMode: 'direct_ws', + operatorVersion: '1.2.3', + lastSeen: '2026-01-01T00:00:00Z', + message: 'Operator op-1 is connected', + }, }, // Minimal topology/policy data that the unified endpoint includes topology: [], @@ -180,16 +231,35 @@ const mockDriftStatus = { // Setup // --------------------------------------------------------------------------- +function mockHealthHandler(healthData: Record | null | undefined, status = 200) { + return http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/(health|data)/, ({ request }) => { + if (status >= 400) { + return HttpResponse.json(healthData, { status }); + } + if (request.url.includes('/f5bnk/data')) { + return HttpResponse.json({ + health: healthData, + topology: [], + dataPlane: [], + policyAssociations: [], + trafficStats: { source: 'tmctl', available: false }, + }); + } + return HttpResponse.json(healthData); + }); +} + beforeEach(() => { vi.clearAllMocks(); server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(mockBnkData); - }), + mockHealthHandler(mockBnkData.health), http.get(/\/api\/clusters\/\d+\/drift\/status/, () => { return HttpResponse.json(mockDriftStatus); }), + http.get(/\/api\/licensing\/\d+\/cwc-status/, () => { + return HttpResponse.json({ status: 'unknown', expiry: null }); + }), ); }); @@ -203,9 +273,18 @@ describe('BNKHealthDashboard', () => { describe('loading state', () => { it('shows loading spinner while fetching health data', () => { server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, async () => { + http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/(health|data)/, async ({ request }) => { await new Promise((r) => setTimeout(r, 10000)); - return HttpResponse.json(mockBnkData); + if (request.url.includes('/f5bnk/data')) { + return HttpResponse.json({ + health: mockBnkData.health, + topology: [], + dataPlane: [], + policyAssociations: [], + trafficStats: { source: 'tmctl', available: false }, + }); + } + return HttpResponse.json(mockBnkData.health); }), ); @@ -219,9 +298,7 @@ describe('BNKHealthDashboard', () => { describe('error state', () => { it('shows error message when API fails', async () => { server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json({ error: 'Connection refused' }, { status: 500 }); - }), + mockHealthHandler({ error: 'Connection refused' }, 500), ); render(); @@ -233,9 +310,7 @@ describe('BNKHealthDashboard', () => { it('shows Retry button on error', async () => { server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json({ error: 'Timeout' }, { status: 500 }); - }), + mockHealthHandler({ error: 'Timeout' }, 500), ); render(); @@ -291,12 +366,7 @@ describe('BNKHealthDashboard', () => { it('shows "BNK Platform Critical" for critical overall status', async () => { server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json({ - ...mockBnkData, - health: { ...mockBnkData.health, overall: 'critical' }, - }); - }), + mockHealthHandler({ ...mockBnkData.health, overall: 'critical' }), ); render(); @@ -387,27 +457,25 @@ describe('BNKHealthDashboard', () => { it('renders Analyzer card when analyzer count > 0', async () => { const dataWithAnalyzer = { - ...mockBnkData, - health: { - ...mockBnkData.health, - platform: { - ...mockBnkData.health.platform, - analyzer: { - total: 1, - running: 1, - severity: 'healthy' as const, - explanation: 'Analyzer is running', - podDetails: [], - remediationActions: [], - }, + ...mockBnkData.health, + platform: { + ...mockBnkData.health.platform, + analyzer: { + total: 1, + running: 1, + severity: 'healthy' as const, + explanation: 'Analyzer is running', + podDetails: [], + remediationActions: [], + namespaces: ['f5-bnk'], + zones: [], + nodes: [], }, }, }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(dataWithAnalyzer); - }), + mockHealthHandler(dataWithAnalyzer), ); render(); @@ -430,15 +498,10 @@ describe('BNKHealthDashboard', () => { }); it('hides FLO Operator card when installShape is absent', async () => { - const noShapeData = { - ...mockBnkData, - health: { ...mockBnkData.health, installShape: undefined }, - }; + const noShapeData = { ...mockBnkData.health, installShape: undefined }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(noShapeData); - }), + mockHealthHandler(noShapeData), ); render(); @@ -450,15 +513,10 @@ describe('BNKHealthDashboard', () => { }); it('hides FLO Operator card when installShape is "unknown" (e.g. transient cluster-connectivity issue)', async () => { - const unknownShapeData = { - ...mockBnkData, - health: { ...mockBnkData.health, installShape: 'unknown' }, - }; + const unknownShapeData = { ...mockBnkData.health, installShape: 'unknown' }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(unknownShapeData); - }), + mockHealthHandler(unknownShapeData), ); render(); @@ -471,18 +529,13 @@ describe('BNKHealthDashboard', () => { it('hides FLO Operator card when installShape is "helm"', async () => { const helmData = { - ...mockBnkData, - health: { - ...mockBnkData.health, - installShape: 'helm', - installMethod: 'Helm / manual', - }, + ...mockBnkData.health, + installShape: 'helm', + installMethod: 'Helm / manual', }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(helmData); - }), + mockHealthHandler(helmData), ); render(); @@ -495,18 +548,13 @@ describe('BNKHealthDashboard', () => { it('shows the install method badge when installMethod is present', async () => { const helmData = { - ...mockBnkData, - health: { - ...mockBnkData.health, - installShape: 'helm', - installMethod: 'Helm / manual', - }, + ...mockBnkData.health, + installShape: 'helm', + installMethod: 'Helm / manual', }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(helmData); - }), + mockHealthHandler(helmData), ); render(); @@ -557,25 +605,20 @@ describe('BNKHealthDashboard', () => { describe('severity sorting', () => { it('sorts critical cards before healthy cards', async () => { const dataWithCritical = { - ...mockBnkData, - health: { - ...mockBnkData.health, - overall: 'critical' as const, - platform: { - ...mockBnkData.health.platform, - flo: { - ...mockBnkData.health.platform.flo, - severity: 'critical' as const, - explanation: 'FLO pod is crash-looping', - }, + ...mockBnkData.health, + overall: 'critical' as const, + platform: { + ...mockBnkData.health.platform, + flo: { + ...mockBnkData.health.platform.flo, + severity: 'critical' as const, + explanation: 'FLO pod is crash-looping', }, }, }; server.use( - http.get(/\/api\/k8s\/clusters\/\d+\/f5bnk\/data/, () => { - return HttpResponse.json(dataWithCritical); - }), + mockHealthHandler(dataWithCritical), ); render(); @@ -725,5 +768,118 @@ describe('BNKHealthDashboard', () => { }); }); + // ─── Placement Enrichment ────────────────────────────────────────── + + describe('placement enrichment', () => { + it('shows namespace, zone, and node chips when expanded', async () => { + const user = _userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('TMM (Data Plane)')).toBeInTheDocument(); + }); + + // Expand the TMM card + const tmmHeader = screen.getByLabelText(/TMM.*health.*Healthy/i); + await user.click(tmmHeader); + + // Namespace chips + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('f5-bnk')).toBeInTheDocument(); + // Zone chips + expect(screen.getByText('Availability Zones')).toBeInTheDocument(); + expect(screen.getAllByText('us-east-1a').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('us-east-1b').length).toBeGreaterThanOrEqual(1); + // Node chips + expect(screen.getByText('Nodes')).toBeInTheDocument(); + expect(screen.getAllByText('worker-1').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('worker-2').length).toBeGreaterThanOrEqual(1); + }); + + it('shows node zone and instance type in pod details table', async () => { + const user = _userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('TMM (Data Plane)')).toBeInTheDocument(); + }); + + const tmmHeader = screen.getByLabelText(/TMM.*health.*Healthy/i); + await user.click(tmmHeader); + + await waitFor(() => { + expect(screen.getByText('Pod Details')).toBeInTheDocument(); + }); + + expect(screen.getAllByText('us-east-1a').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('m5.large').length).toBeGreaterThanOrEqual(1); + }); + }); + + // ─── Connectivity / Integration Indicators ───────────────────────── + + describe('connectivity and integration indicators', () => { + it('shows Connected badge when connectivity status is connected', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + }); + + it('shows Operator Connected badge for healthy direct_ws integration', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Operator Connected')).toBeInTheDocument(); + }); + }); + + it('shows Kubeconfig badge when integration mode is kubeconfig', async () => { + const kubeconfigData = { + ...mockBnkData.health, + integration: { + status: 'healthy', + operatorConnected: false, + operatorMode: 'kubeconfig', + operatorVersion: null, + lastSeen: null, + message: 'Cluster managed via kubeconfig', + }, + }; + + server.use( + mockHealthHandler(kubeconfigData), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Kubeconfig')).toBeInTheDocument(); + }); + }); + + it('shows Unreachable badge when connectivity status is unreachable', async () => { + const unreachableData = { + ...mockBnkData.health, + connectivity: { + status: 'unreachable', + message: 'Kubernetes API is unreachable', + checkedAt: '2026-01-01T00:00:00Z', + }, + }; + + server.use( + mockHealthHandler(unreachableData), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Unreachable')).toBeInTheDocument(); + }); + }); + }); + // AI card is intentionally omitted from severity-bearing dashboard cards. }); diff --git a/frontend-v2/src/components/k8s/__tests__/ConditionsList.test.tsx b/frontend-v2/src/components/k8s/__tests__/ConditionsList.test.tsx new file mode 100644 index 0000000..dd13b25 --- /dev/null +++ b/frontend-v2/src/components/k8s/__tests__/ConditionsList.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@/test/test-utils'; +import { ConditionsList } from '../ConditionsList'; + +describe('ConditionsList', () => { + it('renders empty text when no conditions provided', () => { + render(); + expect(screen.getByText('No conditions available')).toBeInTheDocument(); + }); + + it('renders condition type, status, reason and message', () => { + render( + + ); + expect(screen.getByText('Accepted')).toBeInTheDocument(); + expect(screen.getByText('True')).toBeInTheDocument(); + expect(screen.getByText(/Gateway accepted/)).toBeInTheDocument(); + }); + + it('color-codes false conditions as destructive', () => { + render( + + ); + expect(screen.getByText('Programmed')).toBeInTheDocument(); + expect(screen.getByText('False')).toBeInTheDocument(); + expect(screen.getByText(/address conflict/)).toBeInTheDocument(); + }); +}); diff --git a/frontend-v2/src/components/k8s/__tests__/F5BNKPolicyViewer.test.tsx b/frontend-v2/src/components/k8s/__tests__/F5BNKPolicyViewer.test.tsx index 7e6674e..94e3227 100644 --- a/frontend-v2/src/components/k8s/__tests__/F5BNKPolicyViewer.test.tsx +++ b/frontend-v2/src/components/k8s/__tests__/F5BNKPolicyViewer.test.tsx @@ -15,10 +15,12 @@ const emptyBnkData = { egresses: [], logging: { hslPublishers: [], logProfiles: [] }, }, + referenceGrants: [], topologyCounts: { - gateways: 0, listeners: 0, httpRoutes: 0, securityPolicies: 0, - networkPolicies: 0, firewallPolicies: 0, iRules: 0, analyzers: 0, - vlans: 0, cneInstances: 0, staticRoutes: 0, snatPools: 0, + gateways: 0, listeners: 0, httpRoutes: 0, grpcRoutes: 0, tcpRoutes: 0, + udpRoutes: 0, tlsRoutes: 0, l4Routes: 0, totalRoutes: 0, referenceGrants: 0, + securityPolicies: 0, networkPolicies: 0, firewallPolicies: 0, iRules: 0, + analyzers: 0, vlans: 0, cneInstances: 0, staticRoutes: 0, snatPools: 0, egresses: 0, hslPublishers: 0, logProfiles: 0, }, policyAssociations: [], @@ -59,6 +61,7 @@ describe('F5BNKPolicyViewer', () => { firewall_policy_name: 'fw-policy-1', rules_count: 2, rules: [], + bnk_policy_status: { resolved: true, programmed: true, messages: {} }, }, ], policyCount: 1, @@ -90,6 +93,7 @@ describe('F5BNKPolicyViewer', () => { firewall_policy_name: 'egress-demo-fw', rules_count: 1, rules: [], + egress_status: { resolved: true, programmed: false, messages: { programmed: 'pending' } }, }, ], policyCount: 1, @@ -255,6 +259,114 @@ describe('F5BNKPolicyViewer', () => { }); }); + it('shows hit count column for firewall rules when traffic stats available', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...emptyBnkData, + policyAssociations: [ + { + namespace: 'bnk-demo', + gateway_name: 'my-gw', + listener_name: 'http', + gateway_ip: '10.1.1.100', + port: 80, + protocol: 'HTTP', + bnk_policy_name: 'sec-policy-1', + firewall_policy_name: 'fw-policy-1', + rules_count: 1, + rules: [ + { + name: 'allow-http', + action: 'accept', + ipProtocol: 'tcp', + source: { addresses: [], ports: [], addressLists: [], portLists: [] }, + destination: { addresses: [], ports: [], addressLists: [], portLists: [] }, + logging: false, + }, + ], + }, + ], + policyCount: 1, + trafficStats: { + source: 'tmctl', + podName: 'f5-tmm-abc', + sampledAt: '2026-09-01T00:00:00Z', + available: true, + error: null, + listeners: [], + egresses: [], + firewallRules: [{ + policyName: 'fw-policy-1', + namespace: 'bnk-demo', + ruleName: 'allow-http', + action: 'accept', + ipProtocol: 'tcp', + hitCount: 42, + }], + }, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('my-gw / http')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Show Firewall Rules')); + + await waitFor(() => { + expect(screen.getByText('Hits')).toBeInTheDocument(); + }); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('shows policy status badge next to policy name', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...emptyBnkData, + policyAssociations: [ + { + namespace: 'bnk-demo', + gateway_name: 'my-gw', + listener_name: 'http', + gateway_ip: '10.1.1.100', + port: 80, + protocol: 'HTTP', + bnk_policy_name: 'sec-policy-1', + firewall_policy_name: 'fw-policy-1', + rules_count: 0, + rules: [], + bnk_policy_status: { resolved: true, programmed: true, messages: {} }, + }, + { + kind: 'egress', + namespace: 'f5-cne-system', + egress_name: 'bnk-egress-demo', + snat_type: 'SRC_TRANS_AUTOMAP', + captured_namespaces: ['bnk-egress-demo'], + firewall_policy_name: 'egress-demo-fw', + rules_count: 0, + rules: [], + egress_status: { resolved: true, programmed: true, messages: {} }, + }, + ], + policyCount: 2, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('my-gw / http')).toBeInTheDocument(); + }); + expect(screen.getAllByText('Ready').length).toBeGreaterThanOrEqual(2); + }); + it('shows error state on API failure', async () => { server.use( http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { diff --git a/frontend-v2/src/components/k8s/__tests__/F5BNKTopologyViewer.test.tsx b/frontend-v2/src/components/k8s/__tests__/F5BNKTopologyViewer.test.tsx index 8c6a602..8765c66 100644 --- a/frontend-v2/src/components/k8s/__tests__/F5BNKTopologyViewer.test.tsx +++ b/frontend-v2/src/components/k8s/__tests__/F5BNKTopologyViewer.test.tsx @@ -15,10 +15,12 @@ const emptyBnkData = { egresses: [], logging: { hslPublishers: [], logProfiles: [] }, }, + referenceGrants: [], topologyCounts: { - gateways: 0, listeners: 0, httpRoutes: 0, securityPolicies: 0, - networkPolicies: 0, firewallPolicies: 0, iRules: 0, analyzers: 0, - vlans: 0, cneInstances: 0, staticRoutes: 0, snatPools: 0, + gateways: 0, listeners: 0, httpRoutes: 0, grpcRoutes: 0, tcpRoutes: 0, + udpRoutes: 0, tlsRoutes: 0, l4Routes: 0, totalRoutes: 0, referenceGrants: 0, + securityPolicies: 0, networkPolicies: 0, firewallPolicies: 0, iRules: 0, + analyzers: 0, vlans: 0, cneInstances: 0, staticRoutes: 0, snatPools: 0, egresses: 0, hslPublishers: 0, logProfiles: 0, }, policyAssociations: [], @@ -52,10 +54,15 @@ describe('F5BNKTopologyViewer', () => { namespace: 'bnk-demo', gatewayClassName: 'f5-bnk', addresses: ['10.1.1.100'], + accepted: true, + programmed: true, + conditions: [{ type: 'Accepted', status: 'True' }], listeners: [{ name: 'http', protocol: 'HTTP', port: 80, + attachedRouteCount: 0, + conditions: [{ type: 'Accepted', status: 'True' }], routes: [], networkPolicies: [], }], @@ -91,4 +98,188 @@ describe('F5BNKTopologyViewer', () => { expect(screen.getByText('Failed to load topology')).toBeInTheDocument(); }); }); + + it('shows operational status badges on gateway and listener nodes', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...emptyBnkData, + topology: [{ + name: 'bnk-gateway', + namespace: 'bnk-demo', + gatewayClassName: 'f5-bnk', + addresses: ['10.1.1.100'], + accepted: true, + programmed: false, + conditions: [ + { type: 'Accepted', status: 'True' }, + { type: 'Programmed', status: 'False', message: 'address conflict' }, + ], + listeners: [{ + name: 'http', + protocol: 'HTTP', + port: 80, + attachedRouteCount: 2, + conditions: [{ type: 'Accepted', status: 'True' }], + routes: [], + networkPolicies: [], + }], + securityPolicies: [], + }], + topologyCounts: { + ...emptyBnkData.topologyCounts, + gateways: 1, + listeners: 1, + }, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('bnk-gateway')).toBeInTheDocument(); + }); + expect(screen.getByText('2 routes')).toBeInTheDocument(); + expect(screen.getByText('Healthy')).toBeInTheDocument(); + }); + + it('shows reference grants section when grants exist', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...emptyBnkData, + topology: [{ + name: 'bnk-gateway', + namespace: 'bnk-demo', + gatewayClassName: 'f5-bnk', + addresses: ['10.1.1.100'], + accepted: true, + programmed: true, + conditions: [{ type: 'Accepted', status: 'True' }], + listeners: [{ + name: 'http', + protocol: 'HTTP', + port: 80, + attachedRouteCount: 0, + conditions: [], + routes: [], + networkPolicies: [], + }], + securityPolicies: [], + }], + referenceGrants: [{ + name: 'rg-1', + namespace: 'bnk-demo', + from: [{ group: 'gateway.networking.k8s.io', kind: 'HTTPRoute', namespace: 'app' }], + to: [{ group: '', kind: 'Service' }], + }], + topologyCounts: { + ...emptyBnkData.topologyCounts, + gateways: 1, + listeners: 1, + referenceGrants: 1, + }, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('bnk-gateway')).toBeInTheDocument(); + }); + expect(screen.getByText('Reference Grants')).toBeInTheDocument(); + expect(screen.getByText('rg-1')).toBeInTheDocument(); + }); + + it('shows traffic stat badges on listeners and egresses', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...emptyBnkData, + topology: [{ + name: 'bnk-gateway', + namespace: 'bnk-demo', + gatewayClassName: 'f5-bnk', + addresses: ['10.1.1.100'], + accepted: true, + programmed: true, + conditions: [{ type: 'Accepted', status: 'True' }], + listeners: [{ + name: 'http', + protocol: 'HTTP', + port: 80, + attachedRouteCount: 0, + conditions: [{ type: 'Accepted', status: 'True' }], + routes: [], + networkPolicies: [], + }], + securityPolicies: [], + }], + topologyCounts: { + ...emptyBnkData.topologyCounts, + gateways: 1, + listeners: 1, + egresses: 1, + }, + dataPlane: { + ...emptyBnkData.dataPlane, + egresses: [{ + name: 'default-egress', + namespace: 'bnk-demo', + snatType: 'SRC_TRANS_AUTOMAP', + egressSnatpool: null, + firewallEnforcedPolicy: null, + logProfile: null, + capturedNamespaces: [], + vxlan: null, + ready: true, + }], + }, + trafficStats: { + source: 'tmctl', + podName: 'f5-tmm-abc', + sampledAt: '2026-09-01T00:00:00Z', + available: true, + error: null, + listeners: [{ + gatewayName: 'bnk-gateway', + gatewayNamespace: 'bnk-demo', + listenerName: 'http', + clientsideBytesIn: 1024, + clientsideBytesOut: 2048, + clientsideCurConns: 5, + clientsideTotConns: 100, + serversideBytesIn: 0, + serversideBytesOut: 0, + serversideCurConns: 0, + serversideTotConns: 0, + }], + egresses: [{ + egressName: 'default-egress', + namespace: 'bnk-demo', + clientsideBytesIn: 512, + clientsideBytesOut: 256, + clientsideCurConns: 2, + clientsideTotConns: 42, + serversideBytesIn: 0, + serversideBytesOut: 0, + serversideCurConns: 0, + serversideTotConns: 0, + }], + firewallRules: [], + }, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('bnk-gateway')).toBeInTheDocument(); + }); + expect(screen.getByText('5 conns')).toBeInTheDocument(); + expect(screen.getByText('100 total')).toBeInTheDocument(); + }); }); diff --git a/frontend-v2/src/components/k8s/__tests__/GatewayDetail.test.tsx b/frontend-v2/src/components/k8s/__tests__/GatewayDetail.test.tsx index cc007a3..56cb376 100644 --- a/frontend-v2/src/components/k8s/__tests__/GatewayDetail.test.tsx +++ b/frontend-v2/src/components/k8s/__tests__/GatewayDetail.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { render, screen } from '@/test/test-utils'; +import { render, screen, waitFor } from '@/test/test-utils'; +import userEvent from '@testing-library/user-event'; import { GatewayDetail } from '../GatewayDetail'; const mockGateway = { @@ -18,6 +19,14 @@ const mockGateway = { conditions: [ { type: 'Accepted', status: 'True', reason: 'Accepted', message: 'Gateway accepted' }, ], + listeners: [ + { + name: 'http', + attachedRoutes: 3, + conditions: [{ type: 'Accepted', status: 'True', reason: 'Accepted', message: 'Listener accepted' }], + }, + { name: 'https', attachedRoutes: 1, conditions: [] }, + ], }, }; @@ -37,4 +46,13 @@ describe('GatewayDetail', () => { render(); expect(screen.getByText('10.1.1.100')).toBeInTheDocument(); }); + + it('shows listener attached routes and conditions in listeners tab', async () => { + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole('tab', { name: 'Listeners (2)' })); + expect(screen.getAllByText('Attached Routes:').length).toBe(2); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Listener accepted')).toBeInTheDocument(); + }); }); diff --git a/frontend-v2/src/components/k8s/__tests__/TrafficFlowOverview.test.tsx b/frontend-v2/src/components/k8s/__tests__/TrafficFlowOverview.test.tsx index fad31f1..08755b8 100644 --- a/frontend-v2/src/components/k8s/__tests__/TrafficFlowOverview.test.tsx +++ b/frontend-v2/src/components/k8s/__tests__/TrafficFlowOverview.test.tsx @@ -22,6 +22,7 @@ const emptyBnkData = { egresses: [], logging: { hslPublishers: [], logProfiles: [] }, }, + referenceGrants: [], topologyCounts: { gateways: 0, listeners: 0, httpRoutes: 0, grpcRoutes: 0, tcpRoutes: 0, udpRoutes: 0, tlsRoutes: 0, l4Routes: 0, totalRoutes: 0, referenceGrants: 0, @@ -41,10 +42,15 @@ const populatedBnkData = { namespace: 'bnk-demo', gatewayClassName: 'f5-bnk', addresses: ['10.1.1.100'], + accepted: true, + programmed: true, + conditions: [{ type: 'Accepted', status: 'True' }], listeners: [{ name: 'http', protocol: 'HTTP', port: 80, + attachedRouteCount: 1, + conditions: [{ type: 'Accepted', status: 'True' }], routes: [{ name: 'api-route', namespace: 'bnk-demo', @@ -54,6 +60,8 @@ const populatedBnkData = { { name: 'api-svc', namespace: 'bnk-demo', port: 8080, weight: 100, kind: 'Service', group: '' }, ], analyzers: [], + accepted: true, + conditions: [{ type: 'Accepted', status: 'True' }], }], networkPolicies: [], }], @@ -61,6 +69,9 @@ const populatedBnkData = { name: 'prod-sec-policy', namespace: 'bnk-demo', targetListener: 'http', + resolved: true, + programmed: true, + messages: {}, firewallPolicies: [{ name: 'fw-policy-1', rules: [ @@ -93,6 +104,7 @@ const populatedBnkData = { networkAttachments: [], containerPlatform: 'kubernetes', phase: 'Running', + ready: true, }], vlans: [{ name: 'external-vlan', @@ -252,6 +264,84 @@ describe('TrafficFlowOverview', () => { const ruleMatches = screen.getAllByText(/1 rule/); expect(ruleMatches.length).toBeGreaterThanOrEqual(1); }); + + it('shows operational status chips on gateway and route boxes', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json(populatedBnkData); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('prod-gateway')).toBeInTheDocument(); + }); + expect(screen.getAllByText('Healthy').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Accepted').length).toBeGreaterThanOrEqual(1); + }); + + it('shows reference grants card when grants exist', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...populatedBnkData, + referenceGrants: [{ + name: 'rg-1', + namespace: 'bnk-demo', + from: [{ group: 'gateway.networking.k8s.io', kind: 'HTTPRoute', namespace: 'app' }], + to: [{ group: '', kind: 'Service' }], + }], + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Reference Grants')).toBeInTheDocument(); + }); + expect(screen.getByText('rg-1')).toBeInTheDocument(); + }); + + it('shows traffic stats summary chips when trafficStats available', async () => { + server.use( + http.get('*/api/k8s/clusters/:id/f5bnk/data', () => { + return HttpResponse.json({ + ...populatedBnkData, + trafficStats: { + source: 'tmctl', + podName: 'f5-tmm-abc', + sampledAt: '2026-09-01T00:00:00Z', + available: true, + error: null, + listeners: [{ + gatewayName: 'prod-gateway', + gatewayNamespace: 'bnk-demo', + listenerName: 'http', + clientsideBytesIn: 1024, + clientsideBytesOut: 2048, + clientsideCurConns: 5, + clientsideTotConns: 100, + serversideBytesIn: 0, + serversideBytesOut: 0, + serversideCurConns: 0, + serversideTotConns: 0, + }], + egresses: [], + firewallRules: [], + }, + }); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('connections')).toBeInTheDocument(); + }); + expect(screen.getByText('100')).toBeInTheDocument(); + }); }); describe('Egress (outbound) lane', () => { diff --git a/frontend-v2/src/components/k8s/f5bnk-details/ServiceDetail.tsx b/frontend-v2/src/components/k8s/f5bnk-details/ServiceDetail.tsx new file mode 100644 index 0000000..d6f26fa --- /dev/null +++ b/frontend-v2/src/components/k8s/f5bnk-details/ServiceDetail.tsx @@ -0,0 +1,66 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Server, Network } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { formatAge } from '@/lib/time-utils'; +import type { K8sServicePort } from '@/types'; +import { InfoRow, Section, ConditionsTab, type DetailPanelProps } from './shared'; + +export function ServiceDetail({ resource }: DetailPanelProps) { + const spec = resource.spec || {}; + const status = resource.status || {}; + const conditions = status.conditions || []; + const ports: K8sServicePort[] = spec.ports || []; + const selector = spec.selector || {}; + + return ( +
+ + + Summary + Status + + + +
+ + + + +
+ + {ports.length > 0 && ( +
+ {ports.map((port: K8sServicePort, idx: number) => ( +
+ + + {port.port} + {port.targetPort !== undefined && ` → ${port.targetPort}`} + {port.nodePort !== undefined && ` (node:${port.nodePort})`} + + {port.name && {port.name}} + {port.protocol && {port.protocol}} +
+ ))} +
+ )} + + {Object.keys(selector).length > 0 && ( +
+ {Object.entries(selector).map(([key, value]) => ( +
+ + {key}={String(value)} +
+ ))} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/frontend-v2/src/components/k8s/f5bnk-details/__tests__/f5bnk-details.test.tsx b/frontend-v2/src/components/k8s/f5bnk-details/__tests__/f5bnk-details.test.tsx index aed8bd5..e88406e 100644 --- a/frontend-v2/src/components/k8s/f5bnk-details/__tests__/f5bnk-details.test.tsx +++ b/frontend-v2/src/components/k8s/f5bnk-details/__tests__/f5bnk-details.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@/test/test-utils'; +import userEvent from '@testing-library/user-event'; import type { K8sResource } from '@/types'; // Import all detail components @@ -23,6 +24,7 @@ import { GatewayClassDetail } from '../GatewayClassDetail'; import { L4RouteDetail } from '../L4RouteDetail'; import { IpamRangeDetail } from '../IpamRangeDetail'; import { BnkGatewayDetail } from '../BnkGatewayDetail'; +import { ServiceDetail } from '../ServiceDetail'; // Shared helpers import { InfoRow, Section, ConditionsTab, getConditionIcon, getConditionColor } from '../shared'; @@ -655,3 +657,55 @@ describe('BnkGatewayDetail', () => { expect(screen.getByText('my-gateway')).toBeInTheDocument(); }); }); + +// ============================================================================ +// ServiceDetail +// ============================================================================ + +describe('ServiceDetail', () => { + const resource = makeResource({ + kind: 'Service', + spec: { + type: 'ClusterIP', + clusterIP: '10.0.0.1', + ports: [ + { port: 80, targetPort: 8080, protocol: 'TCP', name: 'http' }, + { port: 443, targetPort: 8443, protocol: 'TCP', name: 'https' }, + ], + selector: { app: 'api', version: 'v1' }, + }, + status: { + conditions: [{ type: 'Ready', status: 'True', reason: 'Ready', message: 'Endpoints ready' }], + }, + }); + + it('renders service type and cluster IP', () => { + render(); + expect(screen.getByText('Service')).toBeInTheDocument(); + expect(screen.getByText('ClusterIP')).toBeInTheDocument(); + expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); + }); + + it('renders ports with target ports and protocols', () => { + render(); + expect(screen.getByText('80 → 8080')).toBeInTheDocument(); + expect(screen.getByText('443 → 8443')).toBeInTheDocument(); + expect(screen.getByText('http')).toBeInTheDocument(); + expect(screen.getByText('https')).toBeInTheDocument(); + expect(screen.getAllByText('TCP').length).toBe(2); + }); + + it('renders selector labels', () => { + render(); + expect(screen.getByText('app=api')).toBeInTheDocument(); + expect(screen.getByText('version=v1')).toBeInTheDocument(); + }); + + it('shows status conditions in status tab', async () => { + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole('tab', { name: 'Status' })); + expect(screen.getAllByText('Ready').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Endpoints ready')).toBeInTheDocument(); + }); +}); diff --git a/frontend-v2/src/components/k8s/f5bnk-details/index.ts b/frontend-v2/src/components/k8s/f5bnk-details/index.ts index 3a427ee..4b8ee53 100644 --- a/frontend-v2/src/components/k8s/f5bnk-details/index.ts +++ b/frontend-v2/src/components/k8s/f5bnk-details/index.ts @@ -27,6 +27,7 @@ export { L4RouteDetail } from './L4RouteDetail'; export { IpamRangeDetail } from './IpamRangeDetail'; export { BnkGatewayDetail } from './BnkGatewayDetail'; export { FirewallRuleListDetail } from './FirewallRuleListDetail'; +export { ServiceDetail } from './ServiceDetail'; // Re-export shared types for consumers that need them export type { DetailPanelProps } from './shared'; diff --git a/frontend-v2/src/components/layout/Sidebar.tsx b/frontend-v2/src/components/layout/Sidebar.tsx index 7d26943..f2168fd 100644 --- a/frontend-v2/src/components/layout/Sidebar.tsx +++ b/frontend-v2/src/components/layout/Sidebar.tsx @@ -28,7 +28,6 @@ import { Globe, Users, BarChart3, - Bot, HardDrive, Activity, } from 'lucide-react'; @@ -72,6 +71,7 @@ const navigationSections: { items: [ { name: 'Dashboard', href: '/observability/ai-gateway', icon: Activity, minRole: 'viewer' }, { name: 'LLM Logs', href: '/observability/ai-gateway/logs', icon: ScrollText, minRole: 'viewer' }, + { name: 'Benchmarks', href: '/benchmarks', icon: BarChart3 }, ], }, { @@ -94,8 +94,6 @@ const navigationSections: { { name: 'Kubernetes', href: '/kubernetes', icon: Box, showCount: 'clusters' }, { name: 'F5 BNK', href: '/bnk', icon: Shield }, { name: 'CNF Resources', href: '/cnf', icon: Layers }, - { name: 'Benchmarks', href: '/benchmarks', icon: BarChart3 }, - { name: 'MCP Server', href: '/mcp-server', icon: Bot }, ], }, { diff --git a/frontend-v2/src/components/settings/SystemDefaults.tsx b/frontend-v2/src/components/settings/SystemDefaults.tsx index 9f8bd56..83a9a99 100644 --- a/frontend-v2/src/components/settings/SystemDefaults.tsx +++ b/frontend-v2/src/components/settings/SystemDefaults.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Loader2, Save, Globe, FolderGit2 } from 'lucide-react'; import { useSystemDefaults } from '@/hooks/useSettings'; -import { AWS_REGIONS } from '@/lib/aws-regions'; +import { CloudRegionSelector } from '@/components/cloud/CloudRegionSelector'; // Azure Regions (commonly used) const AZURE_REGIONS = [ @@ -205,105 +205,61 @@ export default function SystemDefaults() {

- {/* AWS Region */} -
- - -
- - {/* Azure Region */} -
- - -
- - {/* GCP Region */} -
- - -
- - {/* IBM Region */} -
- - -
+ } + />
diff --git a/frontend-v2/src/components/settings/__tests__/SystemDefaults.test.tsx b/frontend-v2/src/components/settings/__tests__/SystemDefaults.test.tsx index 4958a1d..bf5d3ee 100644 --- a/frontend-v2/src/components/settings/__tests__/SystemDefaults.test.tsx +++ b/frontend-v2/src/components/settings/__tests__/SystemDefaults.test.tsx @@ -2,7 +2,7 @@ * Tests for SystemDefaults component */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@/test/test-utils'; +import { render, screen, waitFor, fireEvent } from '@/test/test-utils'; import { http, HttpResponse } from 'msw'; import { server } from '@/test/mocks/server'; import SystemDefaults from '../SystemDefaults'; @@ -70,6 +70,51 @@ describe('SystemDefaults', () => { expect(saveBtn).toBeDisabled(); }); + it('renders free-text region inputs and accepts custom regions', async () => { + server.use( + http.get('*/api/system/defaults', () => + HttpResponse.json({ + project: { + default_type: { key: 'project.default_type', raw_value: 'cloud-aws' }, + }, + cloud: { + aws_region: { key: 'cloud.aws.default_region', raw_value: '' }, + azure_region: { key: 'cloud.azure.default_region', raw_value: '' }, + gcp_region: { key: 'cloud.gcp.default_region', raw_value: '' }, + ibm_region: { key: 'cloud.ibm.default_region', raw_value: '' }, + }, + }) + ), + ); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/AWS Region/i)).toBeInTheDocument(); + }); + + const awsInput = screen.getByLabelText(/AWS Region/i) as HTMLInputElement; + const azureInput = screen.getByLabelText(/Azure Default Region/i) as HTMLInputElement; + const gcpInput = screen.getByLabelText(/GCP Default Region/i) as HTMLInputElement; + const ibmInput = screen.getByLabelText(/IBM Default Region/i) as HTMLInputElement; + + expect(awsInput.tagName).toBe('INPUT'); + expect(azureInput.tagName).toBe('INPUT'); + expect(gcpInput.tagName).toBe('INPUT'); + expect(ibmInput.tagName).toBe('INPUT'); + + // Custom regions such as eu-fr2 should be accepted in any provider field. + fireEvent.change(awsInput, { target: { value: 'eu-fr2' } }); + fireEvent.change(azureInput, { target: { value: 'eu-fr2' } }); + fireEvent.change(gcpInput, { target: { value: 'eu-fr2' } }); + fireEvent.change(ibmInput, { target: { value: 'eu-fr2' } }); + + expect(awsInput).toHaveValue('eu-fr2'); + expect(azureInput).toHaveValue('eu-fr2'); + expect(gcpInput).toHaveValue('eu-fr2'); + expect(ibmInput).toHaveValue('eu-fr2'); + }); + it('renders execution settings section', async () => { server.use( http.get('*/api/system/defaults', () => diff --git a/frontend-v2/src/components/stacks/ImportedBlueprintDeployDialog.tsx b/frontend-v2/src/components/stacks/ImportedBlueprintDeployDialog.tsx index 9969dcd..5bf002e 100644 --- a/frontend-v2/src/components/stacks/ImportedBlueprintDeployDialog.tsx +++ b/frontend-v2/src/components/stacks/ImportedBlueprintDeployDialog.tsx @@ -412,16 +412,16 @@ export function ImportedBlueprintDeployDialog({ slug, open, onOpenChange, onSucc

Source: {template.source_path}

) : null}
-
+
{template.estimated_time && (
- + {template.estimated_time}
)} {template.estimated_cost && (
- + {template.estimated_cost}
)} diff --git a/frontend-v2/src/components/stacks/__tests__/ImportedBlueprintDeployDialog.test.tsx b/frontend-v2/src/components/stacks/__tests__/ImportedBlueprintDeployDialog.test.tsx index e103b84..ba036d0 100644 --- a/frontend-v2/src/components/stacks/__tests__/ImportedBlueprintDeployDialog.test.tsx +++ b/frontend-v2/src/components/stacks/__tests__/ImportedBlueprintDeployDialog.test.tsx @@ -669,7 +669,7 @@ describe('ImportedBlueprintDeployDialog', () => { await waitFor(() => { expect(screen.getByDisplayValue('default')).toBeInTheDocument(); - expect(screen.getByDisplayValue('us-south')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter region')).toHaveValue('us-south'); expect(screen.getByText(/Inherited from the selected IBM Cloud Credential Template/i)).toBeInTheDocument(); }); }); diff --git a/frontend-v2/src/components/system/BnkResourcesPanel.tsx b/frontend-v2/src/components/system/BnkResourcesPanel.tsx new file mode 100644 index 0000000..a0e1e12 --- /dev/null +++ b/frontend-v2/src/components/system/BnkResourcesPanel.tsx @@ -0,0 +1,332 @@ +/** + * BNK Resources panel for the System page. + * + * Shows fleet-wide BNK consumption: overview tiles, per-cluster table, + * control-plane vs data-plane breakdown, and top consumers. + */ + +import { Box, Cpu, Database, Gauge, Server } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { SectionCard } from '@/components/ui/section-card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { formatBytes } from '@/lib/utils'; +import type { BnkConsumptionResponse, BnkPlaneConsumption } from '@/types/system'; + +interface BnkResourcesPanelProps { + data: BnkConsumptionResponse | undefined; + isLoading: boolean; + error: Error | null; +} + +function formatCPU(millicores: number): string { + if (millicores < 1000) { + return `${millicores}m`; + } + return `${(millicores / 1000).toFixed(2)} cores`; +} + +function formatMemory(bytes: number): string { + return formatBytes(bytes, 1); +} + +function PlaneBreakdown({ + label, + plane, +}: { + label: string; + plane: BnkPlaneConsumption; +}) { + return ( +
+
+
+ +
+
+

{label}

+

{plane.count} pods

+
+
+
+

{formatCPU(plane.cpu_millicores)}

+

{formatMemory(plane.memory_bytes)}

+
+
+ ); +} + +function OverviewTile({ + icon: Icon, + label, + value, + subtext, +}: { + icon: React.ElementType; + label: string; + value: React.ReactNode; + subtext?: string; +}) { + return ( + + + {label} + + + +
{value}
+ {subtext &&

{subtext}

} +
+
+ ); +} + +export function BnkResourcesPanel({ data, isLoading, error }: BnkResourcesPanelProps) { + if (isLoading) { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ ); + } + + if (error) { + return ( + +

+ Failed to load BNK consumption: {error.message} +

+
+ ); + } + + if (!data) { + return null; + } + + const { fleet_summary, clusters } = data; + const topPods = clusters.flatMap((c) => + c.top_pods.map((p) => ({ ...p, cluster_name: c.cluster_name })) + ); + topPods.sort((a, b) => b.cpu_millicores - a.cpu_millicores); + const topFive = topPods.slice(0, 5); + + return ( +
+ {/* Overview tiles */} +
+ + + 0 + ? formatCPU(fleet_summary.total_cpu_millicores) + : formatCPU(fleet_summary.node_capacity_cpu_millicores) + } + subtext={ + fleet_summary.total_cpu_millicores > 0 + ? "Across all BNK pods" + : "Node capacity (metrics-server unavailable)" + } + /> + 0 + ? formatMemory(fleet_summary.total_memory_bytes) + : formatMemory(fleet_summary.node_capacity_memory_bytes) + } + subtext={ + fleet_summary.total_memory_bytes > 0 + ? "Across all BNK pods" + : "Node capacity (metrics-server unavailable)" + } + /> +
+ + {fleet_summary.dpf_detected_clusters > 0 && ( +
+ + + DPF detected on {fleet_summary.dpf_detected_clusters} cluster + {fleet_summary.dpf_detected_clusters > 1 ? 's' : ''} · {fleet_summary.dpu_count} DPU + {fleet_summary.dpu_count > 1 ? 's' : ''} + +
+ )} + + {/* Cluster consumption table */} + + {clusters.length === 0 ? ( +

No clusters registered.

+ ) : ( +
+ + + + Cluster + Status + Nodes + BNK Pods + Control Plane + Data Plane + CPU + Memory + + + + {clusters.map((cluster) => ( + + + {cluster.cluster_name} + {cluster.bnk_version && ( + v{cluster.bnk_version} + )} + + + {cluster.reachable ? ( + cluster.bnk_installed ? ( + BNK installed + ) : ( + No BNK + ) + ) : ( + Offline + )} + + {cluster.node_count ?? '-'} + {cluster.total.count} + {cluster.control_plane.count} + {cluster.data_plane.count} + + {cluster.metrics_available ? ( + formatCPU(cluster.total.cpu_millicores) + ) : cluster.node_capacity.cpu_millicores > 0 ? ( + + {formatCPU(cluster.node_capacity.cpu_millicores)} + * + + ) : ( + - + )} + + + {cluster.metrics_available ? ( + formatMemory(cluster.total.memory_bytes) + ) : cluster.node_capacity.memory_bytes > 0 ? ( + + {formatMemory(cluster.node_capacity.memory_bytes)} + * + + ) : ( + - + )} + + + ))} + +
+
+ )} + {!fleet_summary.total_bnk_pods && clusters.length > 0 && ( +

+ No BNK workloads detected. Install BNK on a cluster to see resource usage. +

+ )} + {clusters.some((c) => !c.metrics_available && c.node_capacity.cpu_millicores > 0) && ( +

+ * CPU/Memory values marked with * are node allocatable capacity, not live BNK pod usage. + Install metrics-server in each cluster to see actual BNK pod consumption. +

+ )} +
+ +
+ {/* Plane breakdown */} + +
+ sum + c.control_plane.cpu_millicores, 0), + memory_bytes: clusters.reduce((sum, c) => sum + c.control_plane.memory_bytes, 0), + }} + /> + sum + c.data_plane.cpu_millicores, 0), + memory_bytes: clusters.reduce((sum, c) => sum + c.data_plane.memory_bytes, 0), + }} + /> +
+
+ + {/* Top consumers */} + + {topFive.length === 0 ? ( +

No BNK pod metrics available.

+ ) : ( +
+ + + + Pod + Role + CPU + Memory + + + + {topFive.map((pod) => ( + + +
{pod.name}
+
{pod.cluster_name}
+
+ + + {pod.role} + + + {formatCPU(pod.cpu_millicores)} + {formatMemory(pod.memory_bytes)} +
+ ))} +
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend-v2/src/pages/MCP.tsx b/frontend-v2/src/components/system/McpPanel.tsx similarity index 95% rename from frontend-v2/src/pages/MCP.tsx rename to frontend-v2/src/components/system/McpPanel.tsx index d2ded14..dc4f524 100644 --- a/frontend-v2/src/pages/MCP.tsx +++ b/frontend-v2/src/components/system/McpPanel.tsx @@ -1,15 +1,12 @@ /** - * MCP Server page — D-020 redesign. + * MCP Server panel — tab body for System Administration. * - * Bold heading + subtitle, calm KPI strip for status, side-by-side panels - * (setup guides + tool catalog) in SectionCards. Status conveyed via Badge - * variants only; code blocks use muted surface tokens. + * KPI strip for status, side-by-side panels (setup guides + tool catalog). + * Status conveyed via Badge variants only; code blocks use muted surface tokens. */ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { PageHeader } from '@/components/layout/PageHeader'; -import { usePageRefresh } from '@/hooks/usePageRefresh'; import { queryKeys } from '@/lib/queryKeys'; import { systemApi } from '@/lib/api/system'; import type { MCPStatusResponse, MCPToolCategory } from '@/lib/api/system'; @@ -384,10 +381,10 @@ function ToolCatalog({ catalog }: { catalog: MCPToolCategory[] }) { } // ────────────────────────────────────────────────────────────────────────────── -// Main page +// Main panel // ────────────────────────────────────────────────────────────────────────────── -export default function MCP() { +export function McpPanel() { const { data, isLoading } = useQuery({ queryKey: queryKeys.mcp.status(), queryFn: systemApi.getMCPStatus, @@ -395,20 +392,10 @@ export default function MCP() { refetchInterval: 60_000, }); - const { refresh, isRefreshing } = usePageRefresh(); - const baseUrl = `${window.location.protocol}//${window.location.host}`; return ( -
- {/* Header */} - - +
diff --git a/frontend-v2/src/hooks/__tests__/useK8sBnk.test.ts b/frontend-v2/src/hooks/__tests__/useK8sBnk.test.ts index 56861f0..534c5f9 100644 --- a/frontend-v2/src/hooks/__tests__/useK8sBnk.test.ts +++ b/frontend-v2/src/hooks/__tests__/useK8sBnk.test.ts @@ -34,6 +34,21 @@ const mockBnkData = { policyCount: 1, }; +const mockBnkHealth = { + overall: 'healthy', + installShape: 'flo', + installMethod: 'FLO deploy flow', + connectivity: { status: 'connected', message: 'Kubernetes API is accessible', checkedAt: '2026-09-01T00:00:00Z' }, + integration: { status: 'healthy', operatorConnected: false, operatorMode: 'kubeconfig', operatorVersion: null, lastSeen: null, message: 'Cluster managed via kubeconfig' }, + platform: { severity: 'healthy' }, + dataPlane: { severity: 'healthy' }, + networking: { severity: 'healthy' }, + security: { severity: 'healthy' }, + ai: { severity: 'healthy', analyzers: 0, analyzerDetails: [] }, + counts: { tmm_containers: 1, gateways: 0, httpRoutes: 0, vlans: 0 }, + cluster_id: 1, +}; + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -51,6 +66,9 @@ function setupBnkDataHandler() { server.use( http.get('*/api/k8s/clusters/:clusterId/f5bnk/data', () => { return HttpResponse.json(mockBnkData); + }), + http.get('*/api/k8s/clusters/:clusterId/f5bnk/health', () => { + return HttpResponse.json(mockBnkHealth); }) ); } @@ -101,7 +119,7 @@ describe('useBnkData', () => { // ======================================================================== describe('useF5BNKHealth', () => { - it('returns health slice of BNK data', async () => { + it('fetches health data directly from /f5bnk/health', async () => { setupBnkDataHandler(); const { result } = renderHook( @@ -113,7 +131,7 @@ describe('useF5BNKHealth', () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toMatchObject({ overall_status: 'healthy' }); + expect(result.current.data).toMatchObject({ overall_status: 'healthy', cluster_id: 1 }); }); }); diff --git a/frontend-v2/src/hooks/__tests__/useK8sClusters.test.ts b/frontend-v2/src/hooks/__tests__/useK8sClusters.test.ts index 8ca8fc3..0580764 100644 --- a/frontend-v2/src/hooks/__tests__/useK8sClusters.test.ts +++ b/frontend-v2/src/hooks/__tests__/useK8sClusters.test.ts @@ -310,14 +310,14 @@ describe('useTestClusterConnection', () => { }); }); -describe('useDetectEKSClusters', () => { - it('detects EKS clusters for a project', async () => { +describe('useDetectClusters', () => { + it('detects clusters from credential templates for a project', async () => { server.use( - http.post('*/api/projects/:projectId/k8s/clusters/detect-eks', () => { + http.post('*/api/projects/:projectId/k8s/clusters/detect-credentials', () => { return HttpResponse.json({ success: true, - message: 'Found 2 EKS clusters', - registered: [{ id: 10, name: 'eks-prod', module_id: 1, status: 'connected' }], + message: 'Discovered 2 cluster(s) from credential templates, registered 1 new cluster(s)', + registered: [{ id: 10, name: 'eks-prod', provider: 'aws', status: 'registered' }], skipped: [], errors: [], }); @@ -333,6 +333,7 @@ describe('useDetectEKSClusters', () => { }); expect(result.current.data!.registered).toHaveLength(1); + expect(result.current.data!.registered[0].provider).toBe('aws'); }); }); diff --git a/frontend-v2/src/hooks/k8s/__tests__/useBnk.test.ts b/frontend-v2/src/hooks/k8s/__tests__/useBnk.test.ts index 22df3c5..62ded41 100644 --- a/frontend-v2/src/hooks/k8s/__tests__/useBnk.test.ts +++ b/frontend-v2/src/hooks/k8s/__tests__/useBnk.test.ts @@ -50,6 +50,31 @@ const mockBnkData = { topologyCounts: { gateways: 1, routes: 1 }, policyAssociations: [{ policy: 'p-1', gateways: ['gw-1'] }], policyCount: 1, + trafficStats: { + source: 'tmctl', + podName: 'f5-tmm-abc', + sampledAt: '2026-09-01T00:00:00Z', + available: true, + error: null, + listeners: [], + egresses: [], + firewallRules: [], + }, +}; + +const mockBnkHealth = { + overall: 'healthy', + installShape: 'flo', + installMethod: 'FLO deploy flow', + connectivity: { status: 'connected', message: 'Kubernetes API is accessible', checkedAt: '2026-09-01T00:00:00Z' }, + integration: { status: 'healthy', operatorConnected: false, operatorMode: 'kubeconfig', operatorVersion: null, lastSeen: null, message: 'Cluster managed via kubeconfig' }, + platform: { severity: 'healthy' }, + dataPlane: { severity: 'healthy' }, + networking: { severity: 'healthy' }, + security: { severity: 'healthy' }, + ai: { severity: 'healthy', analyzers: 0, analyzerDetails: [] }, + counts: { tmm_containers: 1, gateways: 0, httpRoutes: 0, vlans: 0 }, + cluster_id: 1, }; // Register BNK data handler @@ -58,6 +83,9 @@ function setupBnkHandlers() { http.get('*/api/k8s/clusters/:clusterId/f5bnk/data', () => { return HttpResponse.json(mockBnkData); }), + http.get('*/api/k8s/clusters/:clusterId/f5bnk/health', () => { + return HttpResponse.json(mockBnkHealth); + }), http.get('*/api/k8s/clusters/:clusterId/bnk/upgrade/versions', () => { return HttpResponse.json({ versions: ['2.1.0', '2.0.1', '2.0.0'], @@ -155,13 +183,13 @@ describe('useBnkData', () => { // ============================================================================ describe('useF5BNKHealth', () => { - it('returns health slice from unified data', async () => { + it('fetches health data directly from /f5bnk/health', async () => { setupBnkHandlers(); const { result } = renderHook(() => useF5BNKHealth(1), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toMatchObject({ status: 'healthy' }); + expect(result.current.data).toMatchObject({ status: 'healthy', cluster_id: 1 }); }); }); @@ -176,6 +204,7 @@ describe('useF5GatewayTopology', () => { topology: expect.any(Array), dataPlane: expect.any(Array), counts: { gateways: 1, routes: 1 }, + trafficStats: { source: 'tmctl', available: true }, cluster_id: 1, }); }); @@ -191,6 +220,7 @@ describe('useF5PolicyGatewayAssociations', () => { expect(result.current.data).toMatchObject({ associations: expect.any(Array), count: 1, + trafficStats: { source: 'tmctl', available: true }, cluster_id: 1, }); }); diff --git a/frontend-v2/src/hooks/k8s/__tests__/useClusterCRUD.test.ts b/frontend-v2/src/hooks/k8s/__tests__/useClusterCRUD.test.ts index cb2d6ca..6e5962b 100644 --- a/frontend-v2/src/hooks/k8s/__tests__/useClusterCRUD.test.ts +++ b/frontend-v2/src/hooks/k8s/__tests__/useClusterCRUD.test.ts @@ -280,17 +280,17 @@ describe('useTestClusterConnection', () => { }); // ============================================================================ -// useDetectEKSClusters +// useDetectClusters // ============================================================================ -describe('useDetectEKSClusters', () => { - it('detects and registers EKS clusters', async () => { +describe('useDetectClusters', () => { + it('detects and registers clusters from credential templates', async () => { server.use( - http.post('*/api/projects/:projectId/k8s/clusters/detect-eks', () => { + http.post('*/api/projects/:projectId/k8s/clusters/detect-credentials', () => { return HttpResponse.json({ success: true, - message: 'Found 2 EKS clusters', - registered: [{ id: 10, name: 'eks-1', module_id: 1, status: 'connected' }], + message: 'Discovered 2 cluster(s) from credential templates, registered 1 new cluster(s)', + registered: [{ id: 10, name: 'eks-1', provider: 'aws', status: 'registered' }], skipped: [], errors: [], }); @@ -307,6 +307,7 @@ describe('useDetectEKSClusters', () => { expect(result.current.data!.registered).toHaveLength(1); expect(result.current.data!.registered[0].name).toBe('eks-1'); + expect(result.current.data!.registered[0].provider).toBe('aws'); }); }); diff --git a/frontend-v2/src/hooks/k8s/useBnk.ts b/frontend-v2/src/hooks/k8s/useBnk.ts index 3ddebae..30c2904 100644 --- a/frontend-v2/src/hooks/k8s/useBnk.ts +++ b/frontend-v2/src/hooks/k8s/useBnk.ts @@ -1,11 +1,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import type { - BnkHealthResponse, GatewayTopologyResponse, F5PolicyGatewayAssociationsResponse, + BnkTrafficStatsResponse, + BnkHealthEndpointResponse, } from '@/types'; -import { POLL_INTERVALS } from '@/lib/constants'; +import { POLL_INTERVALS, QUERY_STALE_TIME } from '@/lib/constants'; import { notify } from '@/lib/notify'; import { queryKeys } from '@/lib/queryKeys'; import { useAppMutation } from '@/hooks/lib/useAppMutation'; @@ -27,6 +28,7 @@ export function useBnkData( queryKey: queryKeys.k8s.clusters.bnkData(clusterId, params), queryFn: () => api.getBnkData(clusterId, params), enabled: options?.enabled !== false && !!clusterId, + staleTime: QUERY_STALE_TIME.DEFAULT, refetchInterval: options?.pollingEnabled !== false ? POLL_INTERVALS.SLOW : false, placeholderData: (previousData) => previousData, }); @@ -41,7 +43,12 @@ export function useF5BNKHealth( const query = useBnkData(clusterId, params, options); return { ...query, - data: query.data?.health as BnkHealthResponse | undefined, + data: query.data + ? ({ + ...query.data.health, + cluster_id: clusterId, + } as BnkHealthEndpointResponse) + : undefined, }; } @@ -58,9 +65,10 @@ export function useF5GatewayTopology( dataPlane: query.data.dataPlane, referenceGrants: query.data.referenceGrants ?? [], counts: query.data.topologyCounts, + trafficStats: query.data.trafficStats, cluster_id: clusterId, namespace: params?.namespace ?? null, - } satisfies GatewayTopologyResponse : undefined, + } satisfies GatewayTopologyResponse & { trafficStats?: BnkTrafficStatsResponse } : undefined, }; } @@ -77,7 +85,8 @@ export function useF5PolicyGatewayAssociations( count: query.data.policyCount, cluster_id: clusterId, namespace: params?.namespace, - } as F5PolicyGatewayAssociationsResponse : undefined, + trafficStats: query.data.trafficStats, + } as F5PolicyGatewayAssociationsResponse & { trafficStats?: BnkTrafficStatsResponse } : undefined, }; } diff --git a/frontend-v2/src/hooks/k8s/useClusterCRUD.ts b/frontend-v2/src/hooks/k8s/useClusterCRUD.ts index a101391..df5b772 100644 --- a/frontend-v2/src/hooks/k8s/useClusterCRUD.ts +++ b/frontend-v2/src/hooks/k8s/useClusterCRUD.ts @@ -113,19 +113,20 @@ export function useTestClusterConnection() { }); } -export function useDetectEKSClusters() { +export function useDetectClusters() { const queryClient = useQueryClient(); return useAppMutation({ - mutationFn: (projectId: number) => api.detectEKSClusters(projectId), + mutationFn: (projectId: number) => api.detectClustersFromCredentials(projectId), onSuccess: (data, projectId) => { queryClient.invalidateQueries({ queryKey: queryKeys.k8s.clusters.byProject(projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.k8s.clusters.batchConnectivity() }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); if (data.registered.length > 0) { - notify.success(data.message, `Registered ${data.registered.length} EKS cluster(s)`, { category: 'cluster' }); + notify.success(data.message, `Registered ${data.registered.length} cluster(s)`, { category: 'cluster' }); } else if (data.skipped.length > 0) { - notify.info(data.message, 'All EKS clusters are already registered', { category: 'cluster' }); + notify.info(data.message, 'All discovered clusters are already registered', { category: 'cluster' }); } else { notify.info(data.message, undefined, { category: 'cluster' }); } @@ -137,6 +138,9 @@ export function useDetectEKSClusters() { }); } +/** @deprecated Use useDetectClusters() for credential-template-driven discovery. */ +export const useDetectEKSClusters = useDetectClusters; + export function useRefreshClusterKubeconfig() { const queryClient = useQueryClient(); @@ -196,6 +200,7 @@ export function useClusterResources( queryKey: queryKeys.k8s.clusters.resources(clusterId, resourceType, params), queryFn: () => api.getClusterResources(clusterId, resourceType, params), enabled: options?.enabled !== false && !!clusterId && !!resourceType, + staleTime: QUERY_STALE_TIME.DEFAULT, refetchInterval: options?.pollingEnabled ? POLL_INTERVALS.MEDIUM : false, placeholderData: (previousData) => previousData, }); diff --git a/frontend-v2/src/hooks/useK8sBnk.ts b/frontend-v2/src/hooks/useK8sBnk.ts index 11f5d92..bc2e2ac 100644 --- a/frontend-v2/src/hooks/useK8sBnk.ts +++ b/frontend-v2/src/hooks/useK8sBnk.ts @@ -1,88 +1,25 @@ /** - * F5 BNK data, health, topology, policy, and upgrade hooks (IMP-011: split from useK8s.ts). + * F5 BNK release-registry hooks. + * + * IMP-011: data/health/topology/policy hooks live in `hooks/k8s/useBnk.ts` + * and are re-exported here for backward compatibility. Release-registry + * hooks remain here because they are the only remaining callers of this + * module. */ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; -import type { - BnkHealthResponse, - BnkReleaseRegistryResponse, - GatewayTopologyResponse, - F5PolicyGatewayAssociationsResponse, -} from '@/types'; +import type { BnkReleaseRegistryResponse } from '@/types'; import { notify } from '@/lib/notify'; import { queryKeys } from '@/lib/queryKeys'; import { useAppMutation } from '@/hooks/lib/useAppMutation'; -// ======================================================================== -// F5 BNK Unified Data Hook -// -// Single fetch for all BNK insight views. Returns health, topology, -// and policy data in one response. All BNK insight tabs share this -// cache key, so switching tabs is instant (no re-fetch). -// ======================================================================== - -export function useBnkData( - clusterId: number, - params?: { namespace?: string }, - options?: { pollingEnabled?: boolean; enabled?: boolean } -) { - return useQuery({ - queryKey: queryKeys.k8s.clusters.bnkData(clusterId, params), - queryFn: () => api.getBnkData(clusterId, params), - enabled: options?.enabled !== false && !!clusterId, - refetchInterval: options?.pollingEnabled !== false ? 30000 : false, - placeholderData: (previousData) => previousData, - }); -} - -// Convenience selectors — each returns a slice of the unified data -export function useF5BNKHealth( - clusterId: number, - params?: { namespace?: string }, - options?: { pollingEnabled?: boolean; enabled?: boolean } -) { - const query = useBnkData(clusterId, params, options); - return { - ...query, - data: query.data?.health as BnkHealthResponse | undefined, - }; -} - -export function useF5GatewayTopology( - clusterId: number, - params?: { namespace?: string }, - options?: { pollingEnabled?: boolean; enabled?: boolean } -) { - const query = useBnkData(clusterId, params, options); - return { - ...query, - data: query.data ? { - topology: query.data.topology, - dataPlane: query.data.dataPlane, - referenceGrants: query.data.referenceGrants ?? [], - counts: query.data.topologyCounts, - cluster_id: clusterId, - namespace: params?.namespace ?? null, - } satisfies GatewayTopologyResponse : undefined, - }; -} - -export function useF5PolicyGatewayAssociations( - clusterId: number, - params?: { namespace?: string }, - options?: { pollingEnabled?: boolean; enabled?: boolean } -) { - const query = useBnkData(clusterId, params, options); - return { - ...query, - data: query.data ? { - associations: query.data.policyAssociations, - count: query.data.policyCount, - cluster_id: clusterId, - namespace: params?.namespace, - } as F5PolicyGatewayAssociationsResponse : undefined, - }; -} +// Re-export canonical BNK insight hooks from the k8s domain folder. +export { + useBnkData, + useF5BNKHealth, + useF5GatewayTopology, + useF5PolicyGatewayAssociations, +} from './k8s/useBnk'; // ======================================================================== // BNK Upgrade Workflow diff --git a/frontend-v2/src/hooks/useK8sClusters.ts b/frontend-v2/src/hooks/useK8sClusters.ts index 6b76ac8..46ee419 100644 --- a/frontend-v2/src/hooks/useK8sClusters.ts +++ b/frontend-v2/src/hooks/useK8sClusters.ts @@ -105,31 +105,34 @@ export function useTestClusterConnection() { }); } -export function useDetectEKSClusters() { +export function useDetectClusters() { const queryClient = useQueryClient(); return useAppMutation({ - mutationFn: (projectId: number) => api.detectEKSClusters(projectId), + mutationFn: (projectId: number) => api.detectClustersFromCredentials(projectId), onSuccess: (data, projectId) => { queryClient.invalidateQueries({ queryKey: queryKeys.k8s.clusters.byProject(projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); if (data.registered.length > 0) { - notify.success(data.message, `Registered ${data.registered.length} EKS cluster(s)`, { category: 'cluster' }); + notify.success(data.message, `Registered ${data.registered.length} cluster(s)`, { category: 'cluster' }); } else if (data.skipped.length > 0) { - notify.info(data.message, 'All EKS clusters are already registered', { category: 'cluster' }); + notify.info(data.message, 'All discovered clusters are already registered', { category: 'cluster' }); } else { notify.info(data.message, undefined, { category: 'cluster' }); } if (data.errors.length > 0) { notify.warning(`${data.errors.length} cluster(s) failed to register`, 'Check console for details', { category: 'cluster' }); - logger.error('EKS detection errors:', data.errors); + logger.error('Credential-driven cluster detection errors:', data.errors); } }, }); } +/** @deprecated Use useDetectClusters() for credential-template-driven discovery. */ +export const useDetectEKSClusters = useDetectClusters; + export function useRefreshClusterKubeconfig() { const queryClient = useQueryClient(); diff --git a/frontend-v2/src/hooks/useK8sResources.ts b/frontend-v2/src/hooks/useK8sResources.ts index 197428d..feb3684 100644 --- a/frontend-v2/src/hooks/useK8sResources.ts +++ b/frontend-v2/src/hooks/useK8sResources.ts @@ -35,6 +35,7 @@ export function useClusterResources( queryKey: queryKeys.k8s.clusters.resources(clusterId, resourceType, params), queryFn: () => api.getClusterResources(clusterId, resourceType, params), enabled: options?.enabled !== false && !!clusterId && !!resourceType && reachable, + staleTime: QUERY_STALE_TIME.DEFAULT, refetchInterval: options?.pollingEnabled ? 20000 : false, // Poll every 20 seconds when enabled (reduced from 5s) placeholderData: (previousData) => previousData, }); diff --git a/frontend-v2/src/hooks/useQKView.ts b/frontend-v2/src/hooks/useQKView.ts index 4f80f76..8e27952 100644 --- a/frontend-v2/src/hooks/useQKView.ts +++ b/frontend-v2/src/hooks/useQKView.ts @@ -23,6 +23,7 @@ export function useQKViewCheck(clusterId: number) { queryFn: () => qkviewApi.checkAvailability(clusterId), enabled: clusterId > 0, staleTime: 60_000, // 1 minute — CWC availability doesn't change often + placeholderData: (previousData) => previousData, retry: 1, }); } @@ -33,7 +34,9 @@ export function useQKViewList(clusterId: number, enabled = true) { queryKey: QKVIEW_KEYS.list(clusterId), queryFn: () => qkviewApi.listQKViews(clusterId), enabled: enabled && clusterId > 0, + staleTime: 30_000, refetchInterval: 30_000, // Poll every 30s — each poll execs into a K8s pod + placeholderData: (previousData) => previousData, }); } @@ -43,7 +46,9 @@ export function useQKViewStatus(clusterId: number, qkviewId: string, enabled = t queryKey: QKVIEW_KEYS.status(clusterId, qkviewId), queryFn: () => qkviewApi.getQKViewStatus(qkviewId, clusterId), enabled: enabled && clusterId > 0 && !!qkviewId, + staleTime: 15_000, refetchInterval: 15_000, // Poll every 15s while active — execs into K8s pod + placeholderData: (previousData) => previousData, }); } @@ -115,7 +120,8 @@ export function useCWCAPISetupStatus(clusterId: number, enabled = true) { queryKey: QKVIEW_KEYS.cwcApiSetupStatus(clusterId), queryFn: () => qkviewApi.getCWCAPISetupStatus(clusterId), enabled: enabled && clusterId > 0, - staleTime: 30_000, + staleTime: 60_000, + placeholderData: (previousData) => previousData, retry: 1, }); } diff --git a/frontend-v2/src/hooks/useSystem.ts b/frontend-v2/src/hooks/useSystem.ts index 61a1a50..af3e089 100644 --- a/frontend-v2/src/hooks/useSystem.ts +++ b/frontend-v2/src/hooks/useSystem.ts @@ -12,7 +12,7 @@ import { useState, useEffect } from 'react'; import { api } from '@/lib/api'; import { queryKeys } from '@/lib/queryKeys'; import { QUERY_STALE_TIME, POLL_INTERVALS } from '@/lib/constants'; -import type { CleanupType, VersionInfo, UpgradeResponse } from '@/types/system'; +import type { CleanupType, VersionInfo, UpgradeResponse, BnkConsumptionResponse } from '@/types/system'; import { useAppMutation } from '@/hooks/lib/useAppMutation'; /** @@ -183,6 +183,25 @@ export function useRestartContainers() { }); } +/** + * Fetch fleet-wide BNK resource consumption. + * + * PERFORMANCE: 60s polling, disabled when tab hidden, 2 min stale time. + * The backend aggregates multi-cluster data and caches for 20 seconds. + */ +export function useBnkConsumption(options?: { enabled?: boolean }) { + const isVisible = useDocumentVisibility(); + + return useQuery({ + queryKey: queryKeys.system.bnkConsumption(), + queryFn: () => api.getBnkConsumption(), + enabled: options?.enabled !== false, + refetchInterval: isVisible && options?.enabled !== false ? POLL_INTERVALS.VERY_SLOW : false, + staleTime: QUERY_STALE_TIME.SYSTEM, + placeholderData: (previousData) => previousData, + }); +} + /** * UP-013: Fetch system version info (current, latest, update availability) */ diff --git a/frontend-v2/src/hooks/useTMMDebug.ts b/frontend-v2/src/hooks/useTMMDebug.ts index 02cc6f5..832a0b3 100644 --- a/frontend-v2/src/hooks/useTMMDebug.ts +++ b/frontend-v2/src/hooks/useTMMDebug.ts @@ -38,7 +38,8 @@ export function useTMMDebugPods(clusterId: number, enabled = true) { queryKey: TMM_DEBUG_KEYS.pods(clusterId), queryFn: () => tmmDebugApi.listPods(clusterId), enabled: enabled && clusterId > 0, - staleTime: 30_000, // 30s — pod list doesn't change often + staleTime: 60_000, // 60s — pod list doesn't change often + placeholderData: (prev) => prev, retry: 1, }); } diff --git a/frontend-v2/src/lib/api/kubernetes.ts b/frontend-v2/src/lib/api/kubernetes.ts index 0d7ed7d..7ede84d 100644 --- a/frontend-v2/src/lib/api/kubernetes.ts +++ b/frontend-v2/src/lib/api/kubernetes.ts @@ -116,6 +116,15 @@ export const kubernetesApi = { errors: Array<{ module_id: number; error: string }>; }>(`/api/projects/${projectId}/k8s/clusters/detect-eks`).then((res) => res.data), + detectClustersFromCredentials: (projectId: number) => + apiClient.post<{ + success: boolean; + message: string; + registered: Array<{ id: number; name: string; provider: string; status: string }>; + skipped: Array<{ name: string; provider: string; reason: string }>; + errors: Array<{ provider: string; name: string | null; error: string }>; + }>(`/api/projects/${projectId}/k8s/clusters/detect-credentials`).then((res) => res.data), + getClusterResources: (clusterId: number, resourceType: string, params?: { namespace?: string; label_selector?: string }) => apiClient.get(`/api/k8s/clusters/${clusterId}/resources/${resourceType}`, { params }).then((res) => res.data), diff --git a/frontend-v2/src/lib/api/system.ts b/frontend-v2/src/lib/api/system.ts index 7e050a6..de7bc6f 100644 --- a/frontend-v2/src/lib/api/system.ts +++ b/frontend-v2/src/lib/api/system.ts @@ -16,6 +16,7 @@ import type { UpgradeResponse, UpgradeState, UpgradeVerification, + BnkConsumptionResponse, } from '@/types/system'; export const systemApi = { @@ -69,6 +70,10 @@ export const systemApi = { getUpgradeStatus: () => apiClient.get('/api/system/upgrade/status').then((res) => res.data), + // BNK Resource Consumption Dashboard + getBnkConsumption: () => + apiClient.get('/api/system/bnk-consumption').then((res) => res.data), + // MCP Server getMCPStatus: () => apiClient.get('/api/system/mcp/status').then((res) => res.data), diff --git a/frontend-v2/src/lib/queryClient.ts b/frontend-v2/src/lib/queryClient.ts index f3ff0f2..20c1ae4 100644 --- a/frontend-v2/src/lib/queryClient.ts +++ b/frontend-v2/src/lib/queryClient.ts @@ -14,14 +14,14 @@ import { QUERY_STALE_TIME } from './constants'; export const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: QUERY_STALE_TIME.MEDIUM, // 10 seconds + staleTime: QUERY_STALE_TIME.DEFAULT, // 30 seconds retry: (failureCount, error) => { // Never retry auth errors — the interceptor handles logout const status = (error as { response?: { status?: number } })?.response?.status; if (status === 401 || status === 403) return false; return failureCount < 1; }, - refetchOnWindowFocus: true, + refetchOnWindowFocus: false, refetchOnReconnect: true, refetchOnMount: true, }, diff --git a/frontend-v2/src/lib/queryKeys.ts b/frontend-v2/src/lib/queryKeys.ts index 3cdb007..61ace03 100644 --- a/frontend-v2/src/lib/queryKeys.ts +++ b/frontend-v2/src/lib/queryKeys.ts @@ -108,6 +108,8 @@ export const queryKeys = { // BNK data bnkData: (clusterId: number, params?: Record) => ['k8s', 'clusters', clusterId, 'f5bnk', 'data', params] as const, + bnkHealth: (clusterId: number, params?: Record) => + ['k8s', 'clusters', clusterId, 'f5bnk', 'health', params] as const, // A2A agent discovery a2aAgents: (clusterId: number, params?: Record) => ['k8s', 'clusters', clusterId, 'f5bnk', 'a2a', 'agents', params] as const, @@ -272,6 +274,7 @@ export const queryKeys = { defaultsStatus: () => ['defaults-status'] as const, backupStatus: () => [...queryKeys.system.all, 'backup', 'status'] as const, maintenanceStatus: () => [...queryKeys.system.all, 'maintenance'] as const, + bnkConsumption: () => [...queryKeys.system.all, 'bnk-consumption'] as const, }, // Registry hierarchy diff --git a/frontend-v2/src/pages/CNF.tsx b/frontend-v2/src/pages/CNF.tsx index ac09337..5e1e4a9 100644 --- a/frontend-v2/src/pages/CNF.tsx +++ b/frontend-v2/src/pages/CNF.tsx @@ -423,7 +423,10 @@ export default function CNF() { subtitle="Discovery-driven CRD browser — read-only metadata, conditions, and YAML" projects={projects || []} selectedProjectId={selectedProject} - onProjectChange={setSelectedProject} + onProjectChange={(id) => { + setSelectedProject(id); + setSelectedCluster(null); + }} clusters={visibleClusters} selectedClusterId={selectedCluster} onClusterChange={setSelectedCluster} diff --git a/frontend-v2/src/pages/F5BNK.tsx b/frontend-v2/src/pages/F5BNK.tsx index 8f078fa..8227b57 100644 --- a/frontend-v2/src/pages/F5BNK.tsx +++ b/frontend-v2/src/pages/F5BNK.tsx @@ -29,6 +29,7 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { useProjectClusters, useClusterNamespaces } from '@/hooks/useK8s'; +import { useBnkData } from '@/hooks/k8s/useBnk'; import { useAllClusters } from '@/hooks/useK8sClusters'; import { useProjects } from '@/hooks/useProjects'; import { parseApiError } from '@/lib/error-handler'; @@ -484,6 +485,7 @@ export default function F5BNK() { }), enabled: !!selectedCluster && !!selectedResourceType && !isSpecialView(selectedResourceType) && clusterReachable, staleTime: 30000, + placeholderData: (previousData) => previousData, }); const { data: namespacesResponse } = useClusterNamespaces(selectedCluster || 0, { @@ -798,6 +800,16 @@ export default function F5BNK() { const resolvedNamespace = selectedNamespace === 'all' ? undefined : selectedNamespace; + // Prefetch the unified BNK data bundle in the background while the user is + // on any BNK tab. This warms the cache for Traffic Flow / Topology / Policy + // so tab switching feels instant; the lightweight /f5bnk/health endpoint + // still drives the Health Dashboard landing view. + useBnkData( + selectedCluster ?? 0, + { namespace: resolvedNamespace }, + { enabled: !!selectedCluster, pollingEnabled: false } + ); + return ( {/* Header */} @@ -806,7 +818,10 @@ export default function F5BNK() { subtitle="BIG-IP Next for Kubernetes — gateways, policies, and traffic flow" projects={projects || []} selectedProjectId={selectedProject} - onProjectChange={setSelectedProject} + onProjectChange={(id) => { + setSelectedProject(id); + setSelectedCluster(null); + }} clusters={visibleClusters} selectedClusterId={selectedCluster} onClusterChange={setSelectedCluster} diff --git a/frontend-v2/src/pages/Fleet.tsx b/frontend-v2/src/pages/Fleet.tsx index aeb908e..8c72b41 100644 --- a/frontend-v2/src/pages/Fleet.tsx +++ b/frontend-v2/src/pages/Fleet.tsx @@ -47,6 +47,7 @@ import { AlertCircle, Play, Square, + Gauge, } from 'lucide-react'; import { useFleetHealth, @@ -90,6 +91,8 @@ import { import { AddClusterFlowDialog } from '@/components/k8s/AddClusterFlowDialog'; import { PageHeader } from '@/components/layout/PageHeader'; import { usePageRefresh } from '@/hooks/usePageRefresh'; +import { useBnkConsumption } from '@/hooks/useSystem'; +import { BnkResourcesPanel } from '@/components/system/BnkResourcesPanel'; import { queryKeys } from '@/lib/queryKeys'; // DPFInfrastructurePanel is now rendered under /infrastructure (D-022 P6 IA). @@ -3305,7 +3308,7 @@ function FleetsView() { // ────────────────────────────────────────────────────────────────────────────── // D-022 P6 IA: 'dpf' removed — DPU Infrastructure relocated to /infrastructure. -type FleetView = 'overview' | 'inventory' | 'bulkops' | 'compliance' | 'fleets'; +type FleetView = 'overview' | 'inventory' | 'bulkops' | 'compliance' | 'fleets' | 'bnk'; export default function Fleet() { const [searchParams, setSearchParams] = useSearchParams(); @@ -3316,7 +3319,7 @@ export default function Fleet() { // backward compat with ?view=overview etc. (dpf deep-links now redirect to /infrastructure) const urlView = searchParams.get('view') ?? searchParams.get('tab'); - const validViews: FleetView[] = ['fleets', 'inventory', 'bulkops', 'compliance', 'overview']; + const validViews: FleetView[] = ['fleets', 'bnk', 'inventory', 'bulkops', 'compliance', 'overview']; const initialView: FleetView = urlView && validViews.includes(urlView as FleetView) ? (urlView as FleetView) : 'fleets'; const [activeView, setActiveView] = useState(initialView); @@ -3343,6 +3346,11 @@ export default function Fleet() { ); const { refresh, isRefreshing } = usePageRefresh(); + const { + data: bnkConsumption, + isLoading: bnkLoading, + error: bnkError, + } = useBnkConsumption({ enabled: activeView === 'bnk' }); const subtitle = 'Group clusters into fleets and operate them at scale — health, policy, compliance, and staged operations.'; @@ -3362,7 +3370,7 @@ export default function Fleet() { ) : ( {/* D-022 P6 IA: 'DPU Infrastructure' tab removed — relocated to /infrastructure. - Fleets is the only top-level tab; all per-fleet views live in FleetDetailShell. */} + Top-level tabs: Fleets list + fleet-wide BNK Resources. */} handleSelectView(key)} tabs={[ { key: 'fleets', label: 'Fleets', icon: Flag }, + { key: 'bnk', label: 'BNK Resources', icon: Gauge }, ]} /> @@ -3377,6 +3386,10 @@ export default function Fleet() { + + + + {/* Legacy deep-links: ?view=inventory|bulkops|compliance|overview are forwarded to the Fleets list tab (the views now live inside FleetDetailShell). */} diff --git a/frontend-v2/src/pages/KubernetesV2.tsx b/frontend-v2/src/pages/KubernetesV2.tsx index 3e6acf1..04a0ade 100644 --- a/frontend-v2/src/pages/KubernetesV2.tsx +++ b/frontend-v2/src/pages/KubernetesV2.tsx @@ -770,27 +770,27 @@ export default function KubernetesV2() { icon={Server} title="No cluster selected" description="Select a cluster from the dropdown above to view and manage Kubernetes resources" - action={{ - label: 'Auto-detect Kubernetes Clusters', - onClick: async () => { - if (!selectedProject) return; - try { - const data = await api.detectEKSClusters(selectedProject); - queryClient.invalidateQueries({ - queryKey: queryKeys.k8s.clusters.byProject(selectedProject), - }); - notify.success( - data.registered?.length - ? `Found ${data.registered.length} cluster(s)` - : 'No new clusters found', - undefined, - { category: 'system' }, - ); - } catch (error) { - notifyError(error, 'detecting clusters'); - } - }, - }} + action={{ + label: 'Auto-detect Kubernetes Clusters', + onClick: async () => { + if (!selectedProject) return; + try { + const data = await api.detectClustersFromCredentials(selectedProject); + queryClient.invalidateQueries({ + queryKey: queryKeys.k8s.clusters.byProject(selectedProject), + }); + notify.success( + data.registered?.length + ? `Found ${data.registered.length} cluster(s)` + : 'No new clusters found', + undefined, + { category: 'system' }, + ); + } catch (error) { + notifyError(error, 'detecting clusters'); + } + }, + }} /> ) : viewMode === 'migration' ? ( /* Migration mode: proxy/CIS migration surface (D-022 P6 Slice A). diff --git a/frontend-v2/src/pages/System.tsx b/frontend-v2/src/pages/System.tsx index 47e99aa..85cf8e8 100644 --- a/frontend-v2/src/pages/System.tsx +++ b/frontend-v2/src/pages/System.tsx @@ -16,6 +16,7 @@ import SystemUpgrade from '@/components/settings/SystemUpgrade'; import AuditLog from '@/components/settings/AuditLog'; import { AlertChannels } from '@/components/settings/AlertChannels'; import { BackupPanel } from '@/components/settings/BackupPanel'; +import { McpPanel } from '@/components/system/McpPanel'; import { Tabs, TabsContent } from '@/components/ui/tabs'; import { ResourceViewTabs } from '@/components/layout/ResourceViewTabs'; import { Label } from '@/components/ui/label'; @@ -33,6 +34,7 @@ import { ScrollText, Bell, HardDrive, + Bot, } from 'lucide-react'; import { useUIStore } from '@/stores/uiStore'; import { PageHeader } from '@/components/layout/PageHeader'; @@ -46,7 +48,7 @@ const URL_TAB_REDIRECTS: Record = { 'helm-repos': '/catalog?tab=helm-repos', }; -const VALID_TABS = ['monitor', 'audit', 'alerts', 'defaults', 'appearance', 'backup'] as const; +const VALID_TABS = ['monitor', 'mcp', 'audit', 'alerts', 'defaults', 'appearance', 'backup'] as const; export default function System() { const { theme, setTheme } = useUIStore(); @@ -90,6 +92,7 @@ export default function System() { onChange={handleTabChange} tabs={[ { key: 'monitor', label: 'System Monitor', icon: Monitor }, + { key: 'mcp', label: 'MCP Server', icon: Bot }, { key: 'audit', label: 'Audit Log', icon: ScrollText }, { key: 'alerts', label: 'Alerts', icon: Bell }, { key: 'defaults', label: 'Defaults', icon: Settings2 }, @@ -105,6 +108,10 @@ export default function System() { + + + + diff --git a/frontend-v2/src/pages/__tests__/Fleet.test.tsx b/frontend-v2/src/pages/__tests__/Fleet.test.tsx index 44110f3..e1dd974 100644 --- a/frontend-v2/src/pages/__tests__/Fleet.test.tsx +++ b/frontend-v2/src/pages/__tests__/Fleet.test.tsx @@ -9,6 +9,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@/test/test-utils'; +import userEvent from '@testing-library/user-event'; import Fleet from '@/pages/Fleet'; import { useFleetHealth, useFleetMembersByFleet, useFleetRollups, useFleetTargets } from '@/hooks/useFleet'; import { useAllClusters as _useAllClusters, useBatchConnectivity } from '@/hooks/useK8s'; @@ -77,6 +78,14 @@ vi.mock('@/hooks/useProjects', () => ({ useProjects: vi.fn(() => ({ data: [], isLoading: false })), })); +vi.mock('@/hooks/useSystem', () => ({ + useBnkConsumption: vi.fn(() => ({ data: undefined, isLoading: false, error: null })), +})); + +vi.mock('@/components/system/BnkResourcesPanel', () => ({ + BnkResourcesPanel: () =>
BnkResourcesPanel
, +})); + vi.mock('@/components/fleet/ConfigPromotionWizard', () => ({ ConfigPromotionWizard: () => null, })); @@ -225,18 +234,27 @@ describe('Fleet', () => { ).toBeInTheDocument(); }); - it('renders only the Fleets top-level tab; no DPU Infrastructure, Cluster Health, or Migration tab', () => { + it('renders Fleets and BNK Resources top-level tabs; no DPU Infrastructure, Cluster Health, or Migration tab', () => { // D-022 P6 IA: DPU Infrastructure relocated to /infrastructure. - // Landing is the Fleets list — the only top-level Fleet tab. + // Top-level tabs: Fleets list + fleet-wide BNK Resources. // Cluster Health is a per-fleet sub-tab; Migration moved to K8s page (D-022 P6 Slice A). setFleetHealthMock(); render(); expect(screen.getByRole('tab', { name: /fleets/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /bnk resources/i })).toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /dpu infrastructure/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /cluster health/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /migration/i })).not.toBeInTheDocument(); }); + it('switches to BNK Resources tab and renders the panel', async () => { + const user = userEvent.setup(); + setFleetHealthMock(); + render(); + await user.click(screen.getByRole('tab', { name: /bnk resources/i })); + expect(screen.getByTestId('bnk-resources-panel')).toBeInTheDocument(); + }); + it('renders aggregate stats in the per-fleet Health facet', () => { setFleetHealthMock(); setFleetScopedMocks(); diff --git a/frontend-v2/src/pages/__tests__/System.test.tsx b/frontend-v2/src/pages/__tests__/System.test.tsx index f39d289..0db6d2d 100644 --- a/frontend-v2/src/pages/__tests__/System.test.tsx +++ b/frontend-v2/src/pages/__tests__/System.test.tsx @@ -42,6 +42,10 @@ vi.mock('@/components/settings/AlertChannels', () => ({ AlertChannels: () =>
AlertChannels
, })); +vi.mock('@/components/system/McpPanel', () => ({ + McpPanel: () =>
McpPanel
, +})); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -56,6 +60,7 @@ describe('System', () => { it('renders all tab labels', () => { render(, { initialRoute: '/system' }); expect(screen.getByText('System Monitor')).toBeInTheDocument(); + expect(screen.getByText('MCP Server')).toBeInTheDocument(); expect(screen.getByText('Audit Log')).toBeInTheDocument(); expect(screen.getByText('Alerts')).toBeInTheDocument(); expect(screen.getByText('Defaults')).toBeInTheDocument(); @@ -81,4 +86,13 @@ describe('System', () => { expect(screen.getByTestId('sys-upgrade')).toBeInTheDocument(); }); + it('switches to MCP Server tab and renders the panel', async () => { + const user = userEvent.setup(); + render(, { initialRoute: '/system' }); + await user.click(screen.getByText('MCP Server')); + await waitFor(() => { + expect(screen.getByTestId('mcp-panel')).toBeInTheDocument(); + }); + }); + }); diff --git a/frontend-v2/src/pages/f5bnk-parts/__tests__/resource-registry.test.ts b/frontend-v2/src/pages/f5bnk-parts/__tests__/resource-registry.test.ts new file mode 100644 index 0000000..afad5bd --- /dev/null +++ b/frontend-v2/src/pages/f5bnk-parts/__tests__/resource-registry.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { getRegistryEntry, getDetailComponent, getContextActions, getResourceIcon } from '../resource-registry'; +import { ServiceDetail } from '@/components/k8s/f5bnk-details'; + +describe('resource registry', () => { + it('returns Service detail component and context actions', () => { + const entry = getRegistryEntry('Service'); + expect(entry.detailComponent).toBe(ServiceDetail); + expect(entry.contextActions).toHaveLength(1); + expect(entry.contextActions[0].label).toBe('View Service Details'); + }); + + it('getDetailComponent returns ServiceDetail for Service kind', () => { + expect(getDetailComponent('Service')).toBe(ServiceDetail); + }); + + it('getContextActions returns actions for Service kind', () => { + const actions = getContextActions('Service'); + expect(actions.map((a) => a.label)).toContain('View Service Details'); + }); + + it('getResourceIcon returns an icon for Service kind', () => { + expect(getResourceIcon('Service')).toBeTruthy(); + }); + + it('returns default entry for unknown kinds', () => { + const entry = getRegistryEntry('UnknownKind'); + expect(entry.detailComponent).toBeNull(); + expect(entry.contextActions).toEqual([]); + }); +}); diff --git a/frontend-v2/src/pages/f5bnk-parts/resource-registry.ts b/frontend-v2/src/pages/f5bnk-parts/resource-registry.ts index e7eab1d..f49e0be 100644 --- a/frontend-v2/src/pages/f5bnk-parts/resource-registry.ts +++ b/frontend-v2/src/pages/f5bnk-parts/resource-registry.ts @@ -38,6 +38,7 @@ import { IpamRangeDetail, BnkGatewayDetail, FirewallRuleListDetail, + ServiceDetail, } from '@/components/k8s/f5bnk-details'; import { VIEW_POLICY_MAP, VIEW_AI_ANALYZERS } from './bnk-constants'; @@ -237,6 +238,13 @@ const registry: Record = { ], icon: Activity, }, + Service: { + detailComponent: ServiceDetail, + contextActions: [ + { label: 'View Service Details', icon: Server, type: 'select' }, + ], + icon: Server, + }, }; // --------------------------------------------------------------------------- diff --git a/frontend-v2/src/router.tsx b/frontend-v2/src/router.tsx index fe2ef93..09450f7 100644 --- a/frontend-v2/src/router.tsx +++ b/frontend-v2/src/router.tsx @@ -21,7 +21,6 @@ const System = lazy(() => import('@/pages/System')); const Fleet = lazy(() => import('@/pages/Fleet')); const UserManagement = lazy(() => import('@/pages/UserManagement')); const Benchmarks = lazy(() => import('@/pages/Benchmarks')); -const MCP = lazy(() => import('@/pages/MCP')); const Infrastructure = lazy(() => import('@/pages/Infrastructure')); const LlmDashboard = lazy(() => import('@/pages/observability/LlmDashboard')); const LlmLogs = lazy(() => import('@/pages/observability/LlmLogs')); @@ -159,7 +158,7 @@ export const router = createBrowserRouter([ }, { path: 'mcp-server', - element: , + element: , }, { path: 'users', diff --git a/frontend-v2/src/test/mocks/handlers.ts b/frontend-v2/src/test/mocks/handlers.ts index c464273..e182ce1 100644 --- a/frontend-v2/src/test/mocks/handlers.ts +++ b/frontend-v2/src/test/mocks/handlers.ts @@ -28,6 +28,7 @@ import { mockSystemVersion, mockBackupStatus, mockMaintenanceStatus, + mockBnkConsumption, mockModules, mockModuleLibrary, mockModuleSources, @@ -77,6 +78,7 @@ export { mockMaintenanceStatus, mockBackupStatusInProgress, mockMaintenanceActive, + mockBnkConsumption, } from '../test-fixtures'; // ============================================================================ @@ -1455,6 +1457,10 @@ export const handlers = [ return HttpResponse.json(mockSystemVersion); }), + http.get('*/api/system/bnk-consumption', () => { + return HttpResponse.json(mockBnkConsumption); + }), + http.post('*/api/system/upgrade', () => { return HttpResponse.json({ status: 'upgrading', message: 'Upgrade started: 2.10.49 -> 2.10.50', old_version: '2.10.49', new_version: '2.10.50' }); }), diff --git a/frontend-v2/src/test/test-fixtures.ts b/frontend-v2/src/test/test-fixtures.ts index 66aed16..95a648b 100644 --- a/frontend-v2/src/test/test-fixtures.ts +++ b/frontend-v2/src/test/test-fixtures.ts @@ -790,6 +790,59 @@ export const mockMaintenanceActive = { started_at: '2026-04-13T12:00:00Z', }; +export const mockBnkConsumption = { + timestamp: '2026-09-01T12:00:00Z', + fleet_summary: { + total_clusters: 2, + reachable_clusters: 2, + bnk_installed_clusters: 1, + total_bnk_pods: 5, + control_plane_pods: 2, + data_plane_pods: 3, + total_cpu_millicores: 1850, + total_memory_bytes: 3900000000, + dpf_detected_clusters: 0, + dpu_count: 0, + }, + clusters: [ + { + cluster_id: 1, + cluster_name: 'dev-cluster', + reachable: true, + bnk_installed: true, + bnk_version: '2.5.0', + status: 'connected', + node_count: 3, + control_plane: { count: 1, cpu_millicores: 200, memory_bytes: 500000000 }, + data_plane: { count: 2, cpu_millicores: 1000, memory_bytes: 2000000000 }, + total: { count: 3, cpu_millicores: 1200, memory_bytes: 2500000000 }, + metrics_available: true, + metrics_error: null, + dpf: { detected: false, dpu_count: 0 }, + top_pods: [ + { name: 'f5-tmm-abc', namespace: 'f5-bnk', role: 'tmm', cpu_millicores: 600, memory_bytes: 1100000000 }, + { name: 'f5ingress-ctrl', namespace: 'f5-bnk', role: 'controller', cpu_millicores: 200, memory_bytes: 500000000 }, + ], + }, + { + cluster_id: 2, + cluster_name: 'prod-cluster', + reachable: true, + bnk_installed: false, + bnk_version: null, + status: 'connected', + node_count: 6, + control_plane: { count: 0, cpu_millicores: 0, memory_bytes: 0 }, + data_plane: { count: 0, cpu_millicores: 0, memory_bytes: 0 }, + total: { count: 0, cpu_millicores: 0, memory_bytes: 0 }, + metrics_available: true, + metrics_error: null, + dpf: { detected: false, dpu_count: 0 }, + top_pods: [], + }, + ], +}; + // ============================================================================ // Alert Channels // ============================================================================ diff --git a/frontend-v2/src/types/api-generated.ts b/frontend-v2/src/types/api-generated.ts index 44a03e8..f14695f 100644 --- a/frontend-v2/src/types/api-generated.ts +++ b/frontend-v2/src/types/api-generated.ts @@ -482,6 +482,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project_id}/k8s/clusters/detect-credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Detect And Register Clusters From Credentials + * @description Discover Kubernetes clusters via the project's cloud credential templates. + */ + post: operations["detect_and_register_clusters_from_credentials_api_projects__project_id__k8s_clusters_detect_credentials_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/projects/{project_id}/k8s/clusters": { parameters: { query?: never; @@ -546,6 +566,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project_id}/connectivity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Project Batch Connectivity Check + * @description Probe connectivity for all clusters in a project in parallel. + */ + get: operations["project_batch_connectivity_check_api_projects__project_id__connectivity_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/k8s/clusters/{cluster_id}": { parameters: { query?: never; @@ -1446,6 +1486,9 @@ export interface paths { * Returns health analysis, topology graph, and policy associations * in a single response. The frontend caches this under one query key * so switching between Health, Topology, and Policy Map tabs is instant. + * + * Query parameters: + * - force: bypass the 15-second BNK data / TMM traffic-stats cache. */ get: operations["get_bnk_data_api_k8s_clusters__cluster_id__f5bnk_data_get"]; put?: never; @@ -6081,6 +6124,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/system/bnk-consumption": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Bnk Consumption + * @description Get fleet-wide BNK resource consumption. + */ + get: operations["get_bnk_consumption_api_system_bnk_consumption_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/system/queue-metrics": { parameters: { query?: never; @@ -14369,6 +14432,47 @@ export interface components { */ dpu_ids?: number[]; }; + /** + * BnkClusterConsumption + * @description Per-cluster BNK resource consumption breakdown. + */ + BnkClusterConsumption: { + /** Cluster Id */ + cluster_id: number; + /** Cluster Name */ + cluster_name: string; + /** Reachable */ + reachable: boolean; + /** Bnk Installed */ + bnk_installed: boolean; + /** Bnk Version */ + bnk_version?: string | null; + /** Status */ + status: string; + /** Node Count */ + node_count?: number | null; + control_plane: components["schemas"]["BnkPlaneConsumption"]; + data_plane: components["schemas"]["BnkPlaneConsumption"]; + total: components["schemas"]["BnkPlaneConsumption"]; + node_capacity?: components["schemas"]["BnkNodeCapacity"]; + /** Metrics Available */ + metrics_available: boolean; + /** Metrics Error */ + metrics_error?: string | null; + dpf: components["schemas"]["BnkClusterDpfSummary"]; + /** Top Pods */ + top_pods?: components["schemas"]["BnkTopPod"][]; + }; + /** + * BnkClusterDpfSummary + * @description Lightweight DPF/DPU summary for a single cluster. + */ + BnkClusterDpfSummary: { + /** Detected */ + detected: boolean; + /** Dpu Count */ + dpu_count: number; + }; /** BnkClusterMemberAssignRequest */ BnkClusterMemberAssignRequest: { /** @@ -14406,6 +14510,393 @@ export interface components { }[]; bnk_config: components["schemas"]["BnkClusterConfigSummary"]; }; + /** + * BnkConsumptionResponse + * @description Response for GET /api/system/bnk-consumption. + */ + BnkConsumptionResponse: { + /** Timestamp */ + timestamp: string; + fleet_summary: components["schemas"]["BnkFleetSummary"]; + /** Clusters */ + clusters: components["schemas"]["BnkClusterConsumption"][]; + }; + /** + * BnkDataResponse + * @description Wrapper for the unified /f5bnk/data endpoint. + * + * The ``health`` and ``trafficStats`` keys are strongly typed; the remaining + * keys are kept as loose dicts because their schemas are large and already + * typed manually in the frontend. This lets OpenAPI capture new fields + * without coupling the whole topology/palette response to Pydantic. + */ + BnkDataResponse: { + health: components["schemas"]["BnkHealthResponse"]; + /** Topology */ + topology: { + [key: string]: unknown; + }[]; + /** Dataplane */ + dataPlane: { + [key: string]: unknown; + }; + /** Referencegrants */ + referenceGrants: { + [key: string]: unknown; + }[]; + /** Topologycounts */ + topologyCounts: { + [key: string]: unknown; + }; + /** Policyassociations */ + policyAssociations: { + [key: string]: unknown; + }[]; + /** Policycount */ + policyCount: number; + /** Backends */ + backends?: { + [key: string]: unknown; + }[] | null; + /** Palette */ + palette?: { + [key: string]: unknown; + } | null; + trafficStats?: components["schemas"]["BnkTrafficStatsResponse"] | null; + /** Cluster Id */ + cluster_id: number; + /** Namespace */ + namespace?: string | null; + } & { + [key: string]: unknown; + }; + /** BnkEgressTrafficStats */ + BnkEgressTrafficStats: { + /** Egressname */ + egressName: string; + /** Namespace */ + namespace: string; + /** + * Clientsidebytesin + * @default 0 + */ + clientsideBytesIn: number; + /** + * Clientsidebytesout + * @default 0 + */ + clientsideBytesOut: number; + /** + * Clientsidecurconns + * @default 0 + */ + clientsideCurConns: number; + /** + * Clientsidetotconns + * @default 0 + */ + clientsideTotConns: number; + /** + * Serversidebytesin + * @default 0 + */ + serversideBytesIn: number; + /** + * Serversidebytesout + * @default 0 + */ + serversideBytesOut: number; + /** + * Serversidecurconns + * @default 0 + */ + serversideCurConns: number; + /** + * Serversidetotconns + * @default 0 + */ + serversideTotConns: number; + }; + /** BnkFirewallRuleTrafficStats */ + BnkFirewallRuleTrafficStats: { + /** Policyname */ + policyName: string; + /** Namespace */ + namespace: string; + /** Rulename */ + ruleName: string; + /** + * Action + * @default + */ + action: string; + /** + * Ipprotocol + * @default + */ + ipProtocol: string; + /** + * Hitcount + * @default 0 + */ + hitCount: number; + }; + /** + * BnkFleetSummary + * @description Fleet-wide BNK consumption rollup. + */ + BnkFleetSummary: { + /** Total Clusters */ + total_clusters: number; + /** Reachable Clusters */ + reachable_clusters: number; + /** Bnk Installed Clusters */ + bnk_installed_clusters: number; + /** Total Bnk Pods */ + total_bnk_pods: number; + /** Control Plane Pods */ + control_plane_pods: number; + /** Data Plane Pods */ + data_plane_pods: number; + /** Total Cpu Millicores */ + total_cpu_millicores: number; + /** Total Memory Bytes */ + total_memory_bytes: number; + /** + * Node Capacity Cpu Millicores + * @default 0 + */ + node_capacity_cpu_millicores: number; + /** + * Node Capacity Memory Bytes + * @default 0 + */ + node_capacity_memory_bytes: number; + /** Dpf Detected Clusters */ + dpf_detected_clusters: number; + /** Dpu Count */ + dpu_count: number; + }; + /** BnkHealthAISection */ + BnkHealthAISection: { + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + /** Analyzers */ + analyzers: number; + /** Analyzerdetails */ + analyzerDetails: components["schemas"]["HealthAnalyzerDetail"][]; + }; + /** BnkHealthDataPlaneSection */ + BnkHealthDataPlaneSection: { + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + tmm: components["schemas"]["HealthTmmComponent"]; + /** Cneinstance */ + cneInstance: components["schemas"]["HealthCneInstance"] | { + [key: string]: unknown; + }; + }; + /** BnkHealthEndpointResponse */ + BnkHealthEndpointResponse: { + /** + * Overall + * @enum {string} + */ + overall: "healthy" | "warning" | "critical" | "unknown"; + /** + * Installshape + * @default unknown + */ + installShape: string; + /** + * Installmethod + * @default Unknown + */ + installMethod: string; + connectivity: components["schemas"]["HealthConnectivityStatus"]; + integration: components["schemas"]["HealthIntegrationStatus"]; + platform: components["schemas"]["BnkHealthPlatformSection"]; + dataPlane: components["schemas"]["BnkHealthDataPlaneSection"]; + networking: components["schemas"]["BnkHealthNetworkingSection"]; + security: components["schemas"]["BnkHealthSecuritySection"]; + ai: components["schemas"]["BnkHealthAISection"]; + counts: components["schemas"]["HealthCounts"]; + /** Cluster Id */ + cluster_id: number; + } & { + [key: string]: unknown; + }; + /** BnkHealthNetworkingSection */ + BnkHealthNetworkingSection: { + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + gateways: components["schemas"]["HealthGatewayComponent"]; + vlans: components["schemas"]["HealthVlanComponent"]; + /** Listeners */ + listeners: number; + /** Httproutes */ + httpRoutes: number; + /** Staticroutes */ + staticRoutes: number; + /** Snatpools */ + snatPools: number; + }; + /** BnkHealthPlatformSection */ + BnkHealthPlatformSection: { + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + flo: components["schemas"]["HealthPlatformComponent"]; + controller: components["schemas"]["HealthPlatformComponent"]; + crdInstaller: components["schemas"]["HealthPlatformComponent"]; + analyzer: components["schemas"]["HealthPlatformComponent"]; + }; + /** BnkHealthResponse */ + BnkHealthResponse: { + /** + * Overall + * @enum {string} + */ + overall: "healthy" | "warning" | "critical" | "unknown"; + /** + * Installshape + * @default unknown + */ + installShape: string; + /** + * Installmethod + * @default Unknown + */ + installMethod: string; + connectivity: components["schemas"]["HealthConnectivityStatus"]; + integration: components["schemas"]["HealthIntegrationStatus"]; + platform: components["schemas"]["BnkHealthPlatformSection"]; + dataPlane: components["schemas"]["BnkHealthDataPlaneSection"]; + networking: components["schemas"]["BnkHealthNetworkingSection"]; + security: components["schemas"]["BnkHealthSecuritySection"]; + ai: components["schemas"]["BnkHealthAISection"]; + counts: components["schemas"]["HealthCounts"]; + } & { + [key: string]: unknown; + }; + /** BnkHealthSecuritySection */ + BnkHealthSecuritySection: { + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + /** Firewallpolicies */ + firewallPolicies: number; + /** Securitypolicies */ + securityPolicies: number; + /** Networkpolicies */ + networkPolicies: number; + /** Addresslists */ + addressLists: number; + /** Portlists */ + portLists: number; + irules: components["schemas"]["HealthIRulesComponent"]; + }; + /** BnkListenerTrafficStats */ + BnkListenerTrafficStats: { + /** Gatewayname */ + gatewayName: string; + /** Gatewaynamespace */ + gatewayNamespace: string; + /** Listenername */ + listenerName: string; + /** + * Clientsidebytesin + * @default 0 + */ + clientsideBytesIn: number; + /** + * Clientsidebytesout + * @default 0 + */ + clientsideBytesOut: number; + /** + * Clientsidecurconns + * @default 0 + */ + clientsideCurConns: number; + /** + * Clientsidetotconns + * @default 0 + */ + clientsideTotConns: number; + /** + * Serversidebytesin + * @default 0 + */ + serversideBytesIn: number; + /** + * Serversidebytesout + * @default 0 + */ + serversideBytesOut: number; + /** + * Serversidecurconns + * @default 0 + */ + serversideCurConns: number; + /** + * Serversidetotconns + * @default 0 + */ + serversideTotConns: number; + }; + /** + * BnkNodeCapacity + * @description Node allocatable CPU/memory capacity for a cluster. + */ + BnkNodeCapacity: { + /** + * Cpu Millicores + * @description Aggregated node allocatable CPU in millicores + * @default 0 + */ + cpu_millicores: number; + /** + * Memory Bytes + * @description Aggregated node allocatable memory in bytes + * @default 0 + */ + memory_bytes: number; + }; + /** + * BnkPlaneConsumption + * @description CPU/memory/pod count for a single BNK plane (control-plane or data-plane). + */ + BnkPlaneConsumption: { + /** + * Count + * @description Number of BNK pods in this plane + */ + count: number; + /** + * Cpu Millicores + * @description Aggregated CPU usage in millicores + */ + cpu_millicores: number; + /** + * Memory Bytes + * @description Aggregated memory usage in bytes + */ + memory_bytes: number; + }; /** BnkReleaseListResponse */ BnkReleaseListResponse: { /** Releases */ @@ -14424,6 +14915,47 @@ export interface components { /** Upserted */ upserted: number; }; + /** + * BnkTopPod + * @description A single BNK pod ranked by resource consumption. + */ + BnkTopPod: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Role */ + role: string; + /** Cpu Millicores */ + cpu_millicores: number; + /** Memory Bytes */ + memory_bytes: number; + }; + /** + * BnkTrafficStatsResponse + * @description Traffic statistics mapped from TMM dataplane counters. + */ + BnkTrafficStatsResponse: { + /** Source */ + source?: string | null; + /** Podname */ + podName?: string | null; + /** Sampledat */ + sampledAt?: string | null; + /** + * Available + * @default false + */ + available: boolean; + /** Error */ + error?: string | null; + /** Listeners */ + listeners?: components["schemas"]["BnkListenerTrafficStats"][]; + /** Egresses */ + egresses?: components["schemas"]["BnkEgressTrafficStats"][]; + /** Firewallrules */ + firewallRules?: components["schemas"]["BnkFirewallRuleTrafficStats"][]; + }; /** Body_create_file_secret_api_projects__project_id__secrets_file_post */ Body_create_file_secret_api_projects__project_id__secrets_file_post: { /** File */ @@ -14969,6 +15501,20 @@ export interface components { meta_data?: { [key: string]: unknown; } | null; + /** Node Count */ + node_count?: number | null; + /** Account Id */ + account_id?: string | null; + /** Discovery Status */ + discovery_status?: string | null; + /** Connectivity Status */ + connectivity_status?: string | null; + /** Integration Status */ + integration_status?: string | null; + /** Zones */ + zones?: string[]; + /** Access Method */ + access_method?: string | null; /** Deployable Release Id */ deployable_release_id?: number | null; /** Running Release Id */ @@ -15162,6 +15708,18 @@ export interface components { bnk_config?: components["schemas"]["BnkClusterConfigSummary"] | null; /** Node Count */ node_count?: number | null; + /** Account Id */ + account_id?: string | null; + /** Discovery Status */ + discovery_status?: string | null; + /** Connectivity Status */ + connectivity_status?: string | null; + /** Integration Status */ + integration_status?: string | null; + /** Zones */ + zones?: string[]; + /** Access Method */ + access_method?: string | null; /** Deployable Release Id */ deployable_release_id?: number | null; /** Running Release Id */ @@ -17121,6 +17679,95 @@ export interface components { /** Verify Https Cert */ verify_https_cert?: boolean | null; }; + /** F5EgressPolicyAssociation */ + F5EgressPolicyAssociation: { + /** + * Kind + * @default egress + * @constant + */ + kind: "egress"; + /** Egress Name */ + egress_name?: string | null; + /** Namespace */ + namespace: string; + /** Captured Namespaces */ + captured_namespaces?: string[]; + /** Snat Type */ + snat_type?: string | null; + /** Firewall Policy Name */ + firewall_policy_name: string; + /** Rules Count */ + rules_count?: number | null; + /** Rules */ + rules?: components["schemas"]["F5FirewallRule"][]; + egress_status?: components["schemas"]["PolicyStatus"]; + }; + /** F5FirewallRule */ + F5FirewallRule: { + /** Name */ + name: string; + /** Action */ + action: string; + /** Ipprotocol */ + ipProtocol: string; + source: components["schemas"]["F5FirewallRuleEndpoint"]; + destination: components["schemas"]["F5FirewallRuleEndpoint"]; + /** Logging */ + logging: boolean; + }; + /** F5FirewallRuleEndpoint */ + F5FirewallRuleEndpoint: { + /** Addresses */ + addresses: unknown[]; + /** Ports */ + ports: string[]; + /** Addresslists */ + addressLists: string[]; + /** Portlists */ + portLists: string[]; + }; + /** F5GatewayPolicyAssociation */ + F5GatewayPolicyAssociation: { + /** + * Kind + * @default gateway + * @constant + */ + kind: "gateway"; + /** Bnk Policy Name */ + bnk_policy_name: string; + /** Namespace */ + namespace: string; + /** Gateway Name */ + gateway_name?: string | null; + /** Listener Name */ + listener_name?: string | null; + /** Firewall Policy Name */ + firewall_policy_name: string; + /** Gateway Ip */ + gateway_ip?: string | null; + /** Port */ + port?: number | null; + /** Protocol */ + protocol?: string | null; + /** Rules Count */ + rules_count?: number | null; + /** Rules */ + rules?: components["schemas"]["F5FirewallRule"][]; + bnk_policy_status?: components["schemas"]["PolicyStatus"]; + }; + /** F5PolicyGatewayAssociationsResponse */ + F5PolicyGatewayAssociationsResponse: { + /** Associations */ + associations: (components["schemas"]["F5GatewayPolicyAssociation"] | components["schemas"]["F5EgressPolicyAssociation"])[]; + /** Count */ + count: number; + /** Cluster Id */ + cluster_id: number; + /** Namespace */ + namespace?: string | null; + }; /** * FailedTag * @description A single tag that could not be added to the Catalog. @@ -17444,6 +18091,27 @@ export interface components { detected_platform_profile: string; /** Detected Platform Provider */ detected_platform_provider?: string | null; + /** Cloud Provider */ + cloud_provider?: string | null; + /** Region */ + region?: string | null; + /** Account Id */ + account_id?: string | null; + /** Discovery Status */ + discovery_status?: string | null; + }; + /** GatewayTopologyResponse */ + GatewayTopologyResponse: { + /** Topology */ + topology: components["schemas"]["TopologyGateway"][]; + dataPlane: components["schemas"]["TopologyDataPlane"]; + /** Referencegrants */ + referenceGrants: components["schemas"]["TopologyReferenceGrant"][]; + counts: components["schemas"]["TopologyCounts"]; + /** Cluster Id */ + cluster_id: number; + /** Namespace */ + namespace?: string | null; }; /** * GitSourceValidation @@ -17463,6 +18131,190 @@ export interface components { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** HealthAnalyzerDetail */ + HealthAnalyzerDetail: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Schedule */ + schedule: string; + }; + /** HealthCneInstance */ + HealthCneInstance: { + /** Name */ + name: string; + } & { + [key: string]: unknown; + }; + /** HealthConnectivityStatus */ + HealthConnectivityStatus: { + /** + * Status + * @enum {string} + */ + status: "connected" | "reachable" | "partial" | "unreachable" | "unknown"; + /** Message */ + message: string; + /** Checkedat */ + checkedAt: string; + }; + /** HealthCounts */ + HealthCounts: { + /** Gateways */ + gateways: number; + /** Listeners */ + listeners: number; + /** Httproutes */ + httpRoutes: number; + /** Vlans */ + vlans: number; + /** Firewallpolicies */ + firewallPolicies: number; + /** Irules */ + irules: number; + /** Analyzers */ + analyzers: number; + /** Cneinstances */ + cneInstances: number; + /** Tmm Pods */ + tmm_pods: number; + /** Tmm Running */ + tmm_running: number; + /** Tmm Containers */ + tmm_containers: string; + }; + /** HealthGatewayComponent */ + HealthGatewayComponent: { + /** Total */ + total: number; + /** Programmed */ + programmed: number; + /** Accepted */ + accepted: number; + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + /** Explanation */ + explanation: string; + /** Addresses */ + addresses: string[]; + }; + /** HealthIRuleDetail */ + HealthIRuleDetail: { + /** Name */ + name: string; + /** Accepted */ + accepted: boolean; + /** Programmed */ + programmed: boolean; + /** Error */ + error?: string | null; + }; + /** HealthIRulesComponent */ + HealthIRulesComponent: { + /** Total */ + total: number; + /** Accepted */ + accepted: number; + /** Programmed */ + programmed: number; + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + /** Explanation */ + explanation: string; + /** Details */ + details: components["schemas"]["HealthIRuleDetail"][]; + }; + /** HealthIntegrationStatus */ + HealthIntegrationStatus: { + /** + * Status + * @enum {string} + */ + status: "healthy" | "warning" | "critical" | "unknown"; + /** Operatorconnected */ + operatorConnected: boolean; + /** + * Operatormode + * @enum {string} + */ + operatorMode: "direct_ws" | "polling" | "kubeconfig"; + /** Operatorversion */ + operatorVersion?: string | null; + /** Lastseen */ + lastSeen?: string | null; + /** Message */ + message: string; + }; + /** HealthPlatformComponent */ + HealthPlatformComponent: { + /** Explanation */ + explanation: string; + /** Poddetails */ + podDetails: components["schemas"]["HealthPodDetail"][]; + /** Remediationactions */ + remediationActions: components["schemas"]["HealthRemediationAction"][]; + /** Namespaces */ + namespaces?: string[]; + /** Zones */ + zones?: string[]; + /** Nodes */ + nodes?: string[]; + /** Total */ + total: number; + /** Running */ + running?: number | null; + /** Completed */ + completed?: number | null; + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + }; + /** HealthPodDetail */ + HealthPodDetail: { + /** Podname */ + podName: string; + /** Namespace */ + namespace: string; + /** Nodename */ + nodeName?: string | null; + /** Nodezone */ + nodeZone?: string | null; + /** Nodeinstancetype */ + nodeInstanceType?: string | null; + /** Hostip */ + hostIP?: string | null; + /** Phase */ + phase: string; + /** Restartcount */ + restartCount: number; + /** Containersready */ + containersReady: string; + /** Issue */ + issue: string; + }; + /** HealthRemediationAction */ + HealthRemediationAction: { + /** Label */ + label: string; + /** + * Action + * @enum {string} + */ + action: "view_logs" | "restart_pod" | "describe" | "diagnostics"; + /** Target */ + target: string; + /** Namespace */ + namespace: string; + }; /** HealthSubmission */ HealthSubmission: { /** Cluster */ @@ -17474,6 +18326,65 @@ export interface components { [key: string]: unknown; } | null; }; + /** HealthTmmComponent */ + HealthTmmComponent: { + /** Explanation */ + explanation: string; + /** Poddetails */ + podDetails: components["schemas"]["HealthPodDetail"][]; + /** Remediationactions */ + remediationActions: components["schemas"]["HealthRemediationAction"][]; + /** Namespaces */ + namespaces?: string[]; + /** Zones */ + zones?: string[]; + /** Nodes */ + nodes?: string[]; + /** Pods */ + pods: number; + /** Running */ + running: number; + /** Containerstotal */ + containersTotal: number; + /** Containersready */ + containersReady: number; + /** Totalrestarts */ + totalRestarts: number; + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + }; + /** HealthVlanComponent */ + HealthVlanComponent: { + /** Total */ + total: number; + /** Programmed */ + programmed: number; + /** + * Severity + * @enum {string} + */ + severity: "healthy" | "warning" | "critical" | "unknown"; + /** Explanation */ + explanation: string; + /** Details */ + details: components["schemas"]["HealthVlanDetail"][]; + }; + /** HealthVlanDetail */ + HealthVlanDetail: { + /** Name */ + name: string; + /** Programmed */ + programmed: boolean; + /** Interfaces */ + interfaces: string[]; + /** Selfips */ + selfIPs: string[]; + /** Mtu */ + mtu?: number | null; + }; /** HeartbeatSubmission */ HeartbeatSubmission: { /** Operator Version */ @@ -19141,6 +20052,23 @@ export interface components { /** Created At */ created_at: string | null; }; + /** PolicyStatus */ + PolicyStatus: { + /** + * Resolved + * @default false + */ + resolved: boolean; + /** + * Programmed + * @default false + */ + programmed: boolean; + /** Messages */ + messages?: { + [key: string]: string | null; + }; + }; /** PreviewMemberOut */ PreviewMemberOut: { /** Id */ @@ -22312,6 +23240,121 @@ export interface components { /** Task Ids */ task_ids: number[]; }; + /** TopologyAddressList */ + TopologyAddressList: { + /** Name */ + name: string; + /** Addresses */ + addresses: unknown[]; + }; + /** TopologyAnalyzer */ + TopologyAnalyzer: { + /** Name */ + name: string; + /** Schedule */ + schedule: string; + /** Scripttype */ + scriptType: string; + /** Datasources */ + dataSources: string[]; + /** Parameters */ + parameters: { + [key: string]: string; + }; + }; + /** TopologyCneInstance */ + TopologyCneInstance: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Features */ + features: { + [key: string]: boolean; + }; + /** Networkattachments */ + networkAttachments: unknown[]; + /** Containerplatform */ + containerPlatform: string; + /** Phase */ + phase: string; + /** Ready */ + ready: boolean; + }; + /** TopologyCondition */ + TopologyCondition: { + /** Type */ + type: string; + /** Status */ + status: string; + /** Reason */ + reason?: string | null; + /** Message */ + message?: string | null; + /** Lasttransitiontime */ + lastTransitionTime?: string | null; + }; + /** TopologyCounts */ + TopologyCounts: { + /** Gateways */ + gateways: number; + /** Listeners */ + listeners: number; + /** Httproutes */ + httpRoutes: number; + /** Grpcroutes */ + grpcRoutes: number; + /** Tcproutes */ + tcpRoutes: number; + /** Udproutes */ + udpRoutes: number; + /** Tlsroutes */ + tlsRoutes: number; + /** L4Routes */ + l4Routes: number; + /** Totalroutes */ + totalRoutes: number; + /** Referencegrants */ + referenceGrants: number; + /** Securitypolicies */ + securityPolicies: number; + /** Networkpolicies */ + networkPolicies: number; + /** Firewallpolicies */ + firewallPolicies: number; + /** Irules */ + iRules: number; + /** Analyzers */ + analyzers: number; + /** Vlans */ + vlans: number; + /** Cneinstances */ + cneInstances: number; + /** Staticroutes */ + staticRoutes: number; + /** Snatpools */ + snatPools: number; + /** Egresses */ + egresses: number; + /** Hslpublishers */ + hslPublishers: number; + /** Logprofiles */ + logProfiles: number; + }; + /** TopologyDataPlane */ + TopologyDataPlane: { + /** Vlans */ + vlans: components["schemas"]["TopologyVlan"][]; + /** Cneinstances */ + cneInstances: components["schemas"]["TopologyCneInstance"][]; + /** Staticroutes */ + staticRoutes: components["schemas"]["TopologyStaticRoute"][]; + /** Snatpools */ + snatPools: components["schemas"]["TopologySnatPool"][]; + /** Egresses */ + egresses: components["schemas"]["TopologyEgress"][]; + logging: components["schemas"]["TopologyLogging"]; + }; /** * TopologyEdge * @description A directed edge in the topology graph. @@ -22326,6 +23369,78 @@ export interface components { /** Kind */ kind: string; }; + /** TopologyEgress */ + TopologyEgress: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Snattype */ + snatType: string; + /** Egresssnatpool */ + egressSnatpool?: string | null; + /** Firewallenforcedpolicy */ + firewallEnforcedPolicy?: string | null; + /** Logprofile */ + logProfile?: string | null; + /** Capturednamespaces */ + capturedNamespaces: string[]; + /** Vxlan */ + vxlan?: { + [key: string]: string; + } | null; + /** Ready */ + ready: boolean; + }; + /** TopologyFirewallPolicy */ + TopologyFirewallPolicy: { + /** Name */ + name: string; + /** Rules */ + rules: components["schemas"]["TopologyFwRule"][]; + /** Addresslists */ + addressLists: components["schemas"]["TopologyAddressList"][]; + /** Portlists */ + portLists: components["schemas"]["TopologyPortList"][]; + }; + /** TopologyFwRule */ + TopologyFwRule: { + /** Name */ + name: string; + /** Action */ + action: string; + /** Ipprotocol */ + ipProtocol: string; + /** Logging */ + logging: boolean; + }; + /** TopologyGateway */ + TopologyGateway: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Gatewayclassname */ + gatewayClassName: string; + /** Addresses */ + addresses: string[]; + /** + * Accepted + * @default false + */ + accepted: boolean; + /** + * Programmed + * @default false + */ + programmed: boolean; + /** Conditions */ + conditions?: components["schemas"]["TopologyCondition"][]; + /** Listeners */ + listeners: components["schemas"]["TopologyListener"][]; + /** Securitypolicies */ + securityPolicies: components["schemas"]["TopologySecurityPolicy"][]; + }; /** * TopologyGraphResponse * @description Response for GET /api/k8s/clusters/{cluster_id}/topology. @@ -22342,6 +23457,77 @@ export interface components { /** Info */ info?: string | null; }; + /** TopologyListener */ + TopologyListener: { + /** Name */ + name: string; + /** Protocol */ + protocol: string; + /** Port */ + port?: number | null; + /** + * Attachedroutecount + * @default 0 + */ + attachedRouteCount: number; + /** Conditions */ + conditions?: components["schemas"]["TopologyCondition"][]; + /** Routes */ + routes: components["schemas"]["TopologyRoute"][]; + /** Networkpolicies */ + networkPolicies: components["schemas"]["TopologyNetworkPolicy"][]; + }; + /** TopologyLogging */ + TopologyLogging: { + /** Hslpublishers */ + hslPublishers: { + [key: string]: unknown; + }[]; + /** Logprofiles */ + logProfiles: { + [key: string]: unknown; + }[]; + }; + /** TopologyNetworkPolicy */ + TopologyNetworkPolicy: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Extensions */ + extensions: components["schemas"]["TopologyNetworkPolicyExtension"][]; + /** Resolvedcount */ + resolvedCount: number; + /** Totalextensions */ + totalExtensions: number; + /** + * Resolved + * @default false + */ + resolved: boolean; + /** + * Programmed + * @default false + */ + programmed: boolean; + /** Messages */ + messages?: { + [key: string]: string | null; + }; + }; + /** TopologyNetworkPolicyExtension */ + TopologyNetworkPolicyExtension: { + /** Kind */ + kind: string; + /** Name */ + name: string; + /** Group */ + group: string; + /** Linecount */ + lineCount?: number | null; + /** Eventhandlers */ + eventHandlers?: string[]; + }; /** * TopologyNode * @description A single node in the topology graph (Service, Pod, or owning workload). @@ -22367,6 +23553,151 @@ export interface components { [key: string]: unknown; }; }; + /** TopologyPortList */ + TopologyPortList: { + /** Name */ + name: string; + /** Ports */ + ports: unknown[]; + }; + /** TopologyReferenceGrant */ + TopologyReferenceGrant: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** From */ + from: components["schemas"]["TopologyReferenceGrantFrom"][]; + /** To */ + to: components["schemas"]["TopologyReferenceGrantTo"][]; + }; + /** TopologyReferenceGrantFrom */ + TopologyReferenceGrantFrom: { + /** Group */ + group: string; + /** Kind */ + kind: string; + /** Namespace */ + namespace: string; + }; + /** TopologyReferenceGrantTo */ + TopologyReferenceGrantTo: { + /** Group */ + group: string; + /** Kind */ + kind: string; + }; + /** TopologyRoute */ + TopologyRoute: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Kind */ + kind: string; + /** Hostnames */ + hostnames: string[]; + /** Backends */ + backends: components["schemas"]["TopologyRouteBackend"][]; + /** Analyzers */ + analyzers: components["schemas"]["TopologyAnalyzer"][]; + /** + * Accepted + * @default false + */ + accepted: boolean; + /** Conditions */ + conditions?: components["schemas"]["TopologyCondition"][]; + /** Conditionmessage */ + conditionMessage?: string | null; + }; + /** TopologyRouteBackend */ + TopologyRouteBackend: { + /** Name */ + name: string; + /** Namespace */ + namespace?: string | null; + /** Port */ + port?: number | null; + /** Weight */ + weight?: number | null; + /** + * Kind + * @default Service + */ + kind: string; + /** + * Group + * @default + */ + group: string; + }; + /** TopologySecurityPolicy */ + TopologySecurityPolicy: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Targetlistener */ + targetListener: string; + /** Firewallpolicies */ + firewallPolicies: components["schemas"]["TopologyFirewallPolicy"][]; + /** + * Resolved + * @default false + */ + resolved: boolean; + /** + * Programmed + * @default false + */ + programmed: boolean; + /** Messages */ + messages?: { + [key: string]: string | null; + }; + }; + /** TopologySnatPool */ + TopologySnatPool: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Addresses */ + addresses: unknown[]; + }; + /** TopologyStaticRoute */ + TopologyStaticRoute: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Destination */ + destination: string; + /** Gateway */ + gateway: string; + }; + /** TopologyVlan */ + TopologyVlan: { + /** Name */ + name: string; + /** Namespace */ + namespace: string; + /** Interfaces */ + interfaces: unknown[]; + /** Selfipv4S */ + selfipV4s: string[]; + /** Prefixlen */ + prefixLen?: number | string | null; + /** Mtu */ + mtu?: number | null; + /** Internal */ + internal: boolean; + /** Autolasthop */ + autoLasthop: string; + /** Ready */ + ready: boolean; + }; /** * TransferOwnershipRequest * @description MU-009: Request body for transferring project ownership. @@ -23867,6 +25198,37 @@ export interface operations { }; }; }; + detect_and_register_clusters_from_credentials_api_projects__project_id__k8s_clusters_detect_credentials_post: { + parameters: { + query?: never; + header?: never; + path: { + project_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_project_clusters_api_projects__project_id__k8s_clusters_get: { parameters: { query?: never; @@ -23973,6 +25335,37 @@ export interface operations { }; }; }; + project_batch_connectivity_check_api_projects__project_id__connectivity_get: { + parameters: { + query?: never; + header?: never; + path: { + project_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchConnectivityResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_cluster_details_api_k8s_clusters__cluster_id__get: { parameters: { query?: never; @@ -25432,6 +26825,7 @@ export interface operations { parameters: { query?: { namespace?: string | null; + force?: boolean; }; header?: never; path: { @@ -25447,7 +26841,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["BnkDataResponse"]; }; }; /** @description Validation Error */ @@ -25480,7 +26874,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["BnkHealthEndpointResponse"]; }; }; /** @description Validation Error */ @@ -25513,7 +26907,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["GatewayTopologyResponse"]; }; }; /** @description Validation Error */ @@ -25546,7 +26940,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["F5PolicyGatewayAssociationsResponse"]; }; }; /** @description Validation Error */ @@ -29093,7 +30487,9 @@ export interface operations { }; get_recovery_status_api_k8s_clusters__cluster_id__recovery_status_get: { parameters: { - query?: never; + query?: { + force?: boolean; + }; header?: never; path: { cluster_id: number; @@ -33101,6 +34497,26 @@ export interface operations { }; }; }; + get_bnk_consumption_api_system_bnk_consumption_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BnkConsumptionResponse"]; + }; + }; + }; + }; get_queue_metrics_api_system_queue_metrics_get: { parameters: { query?: never; @@ -39654,7 +41070,9 @@ export interface operations { }; get_license_status_endpoint_api_licensing__cluster_id__status_get: { parameters: { - query?: never; + query?: { + force?: boolean; + }; header?: never; path: { cluster_id: number; @@ -39685,7 +41103,9 @@ export interface operations { }; get_license_report_endpoint_api_licensing__cluster_id__report_get: { parameters: { - query?: never; + query?: { + force?: boolean; + }; header?: never; path: { cluster_id: number; diff --git a/frontend-v2/src/types/f5bnk.ts b/frontend-v2/src/types/f5bnk.ts index 5cae9c9..bc1f6fc 100644 --- a/frontend-v2/src/types/f5bnk.ts +++ b/frontend-v2/src/types/f5bnk.ts @@ -33,6 +33,7 @@ export interface F5GatewayPolicyAssociation { protocol?: string; rules_count?: number; rules?: F5FirewallRule[]; + bnk_policy_status?: PolicyStatus; egress_name?: string; captured_namespaces?: string[]; snat_type?: string; @@ -47,6 +48,7 @@ export interface F5EgressPolicyAssociation { snat_type?: string; rules_count?: number; rules?: F5FirewallRule[]; + egress_status?: PolicyStatus; bnk_policy_name?: string; gateway_name?: string; listener_name?: string; @@ -85,6 +87,8 @@ export interface HealthPodDetail { podName: string; namespace: string; nodeName?: string | null; + nodeZone?: string | null; + nodeInstanceType?: string | null; hostIP?: string | null; phase: string; restartCount: number; @@ -96,6 +100,9 @@ export interface HealthComponentEnrichment { explanation: string; podDetails: HealthPodDetail[]; remediationActions: HealthRemediationAction[]; + namespaces: string[]; + zones: string[]; + nodes: string[]; } export interface HealthPlatformComponent extends HealthComponentEnrichment { @@ -216,6 +223,23 @@ export interface BnkHealthResponse { tmm_running: number; tmm_containers: string; }; + connectivity: HealthConnectivityStatus; + integration: HealthIntegrationStatus; +} + +export interface HealthConnectivityStatus { + status: 'connected' | 'reachable' | 'partial' | 'unreachable' | 'unknown'; + message: string; + checkedAt: string | null; +} + +export interface HealthIntegrationStatus { + status: HealthSeverity; + operatorConnected: boolean; + operatorMode: 'direct_ws' | 'polling' | 'kubeconfig'; + operatorVersion: string | null; + lastSeen: string | null; + message: string; } // BNK Upgrade Types @@ -367,6 +391,20 @@ export interface BnkUpgradeRollbackResponse { // ─── BNK Gateway Topology Types ────────────────────────────────────── +export interface TopologyCondition { + type: string; + status: string; + reason?: string | null; + message?: string | null; + lastTransitionTime?: string | null; +} + +export interface PolicyStatus { + resolved: boolean; + programmed: boolean; + messages: Record; +} + export interface TopologyRouteBackend { name: string; namespace?: string | null; @@ -391,6 +429,9 @@ export interface TopologyRoute { hostnames: string[]; backends: TopologyRouteBackend[]; analyzers: TopologyAnalyzer[]; + accepted: boolean; + conditions: TopologyCondition[]; + conditionMessage?: string | null; } export interface TopologyNetworkPolicyExtension { @@ -407,6 +448,9 @@ export interface TopologyNetworkPolicy { extensions: TopologyNetworkPolicyExtension[]; resolvedCount: number; totalExtensions: number; + resolved: boolean; + programmed: boolean; + messages: Record; } export interface TopologyFirewallPolicy { @@ -432,12 +476,17 @@ export interface TopologySecurityPolicy { namespace: string; targetListener: string; firewallPolicies: TopologyFirewallPolicy[]; + resolved: boolean; + programmed: boolean; + messages: Record; } export interface TopologyListener { name: string; protocol: string; port: number | null; + attachedRouteCount: number; + conditions: TopologyCondition[]; routes: TopologyRoute[]; networkPolicies: TopologyNetworkPolicy[]; } @@ -447,6 +496,9 @@ export interface TopologyGateway { namespace: string; gatewayClassName: string; addresses: string[]; + accepted: boolean; + programmed: boolean; + conditions: TopologyCondition[]; listeners: TopologyListener[]; securityPolicies: TopologySecurityPolicy[]; } @@ -470,6 +522,7 @@ export interface TopologyCneInstance { networkAttachments: unknown[]; containerPlatform: string; phase: string; + ready: boolean; } export interface TopologyStaticRoute { @@ -590,6 +643,55 @@ export interface BnkBackendEntry { createdAt?: string | null; } +// ─── Traffic Statistics Types ──────────────────────────────────────── + +export interface BnkListenerTrafficStats { + gatewayName: string; + gatewayNamespace: string; + listenerName: string; + clientsideBytesIn: number; + clientsideBytesOut: number; + clientsideCurConns: number; + clientsideTotConns: number; + serversideBytesIn: number; + serversideBytesOut: number; + serversideCurConns: number; + serversideTotConns: number; +} + +export interface BnkEgressTrafficStats { + egressName: string; + namespace: string; + clientsideBytesIn: number; + clientsideBytesOut: number; + clientsideCurConns: number; + clientsideTotConns: number; + serversideBytesIn: number; + serversideBytesOut: number; + serversideCurConns: number; + serversideTotConns: number; +} + +export interface BnkFirewallRuleTrafficStats { + policyName: string; + namespace: string; + ruleName: string; + action: string; + ipProtocol: string; + hitCount: number; +} + +export interface BnkTrafficStatsResponse { + source: string | null; + podName: string | null; + sampledAt: string | null; + available: boolean; + error: string | null; + listeners: BnkListenerTrafficStats[]; + egresses: BnkEgressTrafficStats[]; + firewallRules: BnkFirewallRuleTrafficStats[]; +} + // BNK unified data response (getBnkData) export interface BnkDataResponse { health: BnkHealthResponse; @@ -601,6 +703,7 @@ export interface BnkDataResponse { policyCount: number; backends?: BnkBackendEntry[]; palette?: BnkPaletteData; + trafficStats?: BnkTrafficStatsResponse; cluster_id: number; namespace: string | null; } diff --git a/frontend-v2/src/types/system.ts b/frontend-v2/src/types/system.ts index 7c3efe5..797df34 100644 --- a/frontend-v2/src/types/system.ts +++ b/frontend-v2/src/types/system.ts @@ -176,3 +176,70 @@ export interface UpgradeVerification { checks: Record; timestamp: string; } + +// ============================================================================ +// BNK Resource Consumption Dashboard +// ============================================================================ + +export interface BnkPlaneConsumption { + count: number; + cpu_millicores: number; + memory_bytes: number; +} + +export interface BnkTopPod { + name: string; + namespace: string; + role: string; + cpu_millicores: number; + memory_bytes: number; +} + +export interface BnkClusterDpfSummary { + detected: boolean; + dpu_count: number; +} + +export interface BnkNodeCapacity { + cpu_millicores: number; + memory_bytes: number; +} + +export interface BnkClusterConsumption { + cluster_id: number; + cluster_name: string; + reachable: boolean; + bnk_installed: boolean; + bnk_version: string | null; + status: string; + node_count: number | null; + control_plane: BnkPlaneConsumption; + data_plane: BnkPlaneConsumption; + total: BnkPlaneConsumption; + node_capacity: BnkNodeCapacity; + metrics_available: boolean; + metrics_error: string | null; + dpf: BnkClusterDpfSummary; + top_pods: BnkTopPod[]; +} + +export interface BnkFleetSummary { + total_clusters: number; + reachable_clusters: number; + bnk_installed_clusters: number; + total_bnk_pods: number; + control_plane_pods: number; + data_plane_pods: number; + total_cpu_millicores: number; + total_memory_bytes: number; + node_capacity_cpu_millicores: number; + node_capacity_memory_bytes: number; + dpf_detected_clusters: number; + dpu_count: number; +} + +export interface BnkConsumptionResponse { + timestamp: string; + fleet_summary: BnkFleetSummary; + clusters: BnkClusterConsumption[]; +}