diff --git a/locales/en/plugin__networking-console-plugin.json b/locales/en/plugin__networking-console-plugin.json index 2cac627d..a68aec22 100644 --- a/locales/en/plugin__networking-console-plugin.json +++ b/locales/en/plugin__networking-console-plugin.json @@ -22,6 +22,7 @@ "Accessible outside the cluster": "Accessible outside the cluster", "Accessible within the cluster only": "Accessible within the cluster only", "Actions": "Actions", + "Add": "Add", "Add allowed destination": "Add allowed destination", "Add allowed source": "Add allowed source", "Add alternate Service": "Add alternate Service", @@ -37,6 +38,7 @@ "Add port": "Add port", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.", "Add subnet": "Add subnet", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "All incoming traffic is denied to Pods in {{namespace}}", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.", "All outgoing traffic is denied from Pods in {{namespace}}": "All outgoing traffic is denied from Pods in {{namespace}}", @@ -62,6 +64,8 @@ "Any peer": "Any peer", "Any pod": "Any pod", "Any port": "Any port", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "Are you sure?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "Assigns IP addresses from this subnet to Pods and VirtualMachines.", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.", @@ -154,6 +158,7 @@ "Enable {{kind}}": "Enable {{kind}}", "Error": "Error", "Error details": "Error details", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "Example: 100", "Exceptions": "Exceptions", "External IP": "External IP", @@ -188,6 +193,7 @@ "Ingresses": "Ingresses", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}", "Insecure traffic": "Insecure traffic", + "Interface": "Interface", "Invalid IP address or subnet format": "Invalid IP address or subnet format", "Invalid subnet format": "Invalid subnet format", "Invalid YAML cannot be persisted": "Invalid YAML cannot be persisted", @@ -266,6 +272,7 @@ "No results found for \"{{inputValue}}\"": "No results found for \"{{inputValue}}\"", "No route status": "No route status", "No selector": "No selector", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "Node port", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -318,9 +325,11 @@ "Remove all": "Remove all", "Remove alternate Service": "Remove alternate Service", "Remove exception": "Remove exception", + "Remove from network": "Remove from network", "Remove peer": "Remove peer", "Remove port": "Remove port", "Remove subnets?": "Remove subnets?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "Resource name", "Restarts": "Restarts", "Restricted Access": "Restricted Access", @@ -337,11 +346,13 @@ "Rules": "Rules", "Running": "Running", "Save": "Save", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.", "Secure Route": "Secure Route", "Security": "Security", "Select a Project": "Select a Project", "Select a Service": "Select a Service", + "Select all": "Select all", "Select an operator": "Select an operator", "Select ClusterUserDefinedNetwork": "Select ClusterUserDefinedNetwork", "Select default ingress and egress deny rules": "Select default ingress and egress deny rules", @@ -351,6 +362,7 @@ "Select role": "Select role", "Select target port": "Select target port", "Select termination type": "Select termination type", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "Service", "Service address": "Service address", "Service details": "Service details", @@ -426,6 +438,7 @@ "View events": "View events", "View logs": "View logs", "View naming information": "View naming information", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "VLAN tag number", "Weight": "Weight", diff --git a/locales/es/plugin__networking-console-plugin.json b/locales/es/plugin__networking-console-plugin.json index 9e24d3a5..b28649ab 100644 --- a/locales/es/plugin__networking-console-plugin.json +++ b/locales/es/plugin__networking-console-plugin.json @@ -24,6 +24,7 @@ "Accessible outside the cluster": "Accesible fuera del clúster", "Accessible within the cluster only": "Accesible solo dentro del clúster", "Actions": "Acciones", + "Add": "Add", "Add allowed destination": "Agregar destino permitido", "Add allowed source": "Agregar fuente permitida", "Add alternate Service": "Agregar servicio alternativo", @@ -39,6 +40,7 @@ "Add port": "Agregar puerto", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "Agregue puertos para restringir el tráfico a través de ellos. Si no se proporcionan puertos, su política hará que todos los puertos sean accesibles al tráfico.", "Add subnet": "Agregar subred", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "Se rechaza todo el tráfico entrante a los Pods en {{namespace}}", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "Todo el tráfico saliente está permitido de forma predeterminada. Se pueden utilizar reglas de salida para restringir el tráfico saliente si el proveedor de red del clúster lo permite. Cuando se utiliza el proveedor de red del clúster OpenShift SDN, no se admite la política de red de salida.", "All outgoing traffic is denied from Pods in {{namespace}}": "Se deniega todo el tráfico saliente desde los Pods en {{namespace}}", @@ -64,6 +66,8 @@ "Any peer": "Cualquier par", "Any pod": "Cualquier pod", "Any port": "Cualquier puerto", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "¿Está seguro?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "Asigna direcciones IP de esta subred a pods y máquinas virtuales.", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "En el momento de la creación, el espacio de nombre debe configurarse con la etiqueta <2>{{label}}. Vaya a<5> Espacios de nombre para crear un nuevo espacio de nombre.", @@ -156,6 +160,7 @@ "Enable {{kind}}": "Permitir {{kind}}", "Error": "Error", "Error details": "Error de detalles", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "Ejemplo: 100", "Exceptions": "Excepciones", "External IP": "IP externa", @@ -190,6 +195,7 @@ "Ingresses": "Ingresos", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "Error de entrada: los selectores deben comenzar y terminar con una letra o número, y solo pueden contener -, _, / o . Valor ofensivo: {{offendingSelector}}", "Insecure traffic": "Tráfico inseguro", + "Interface": "Interface", "Invalid IP address or subnet format": "Dirección IP o formato de subred no válidos", "Invalid subnet format": "Formato de subred no válido", "Invalid YAML cannot be persisted": "YAML no válido no se puede conservar", @@ -268,6 +274,7 @@ "No results found for \"{{inputValue}}\"": "No se encontraron resultados para \"{{inputValue}}\"", "No route status": "Sin estado de ruta", "No selector": "Sin selector", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "Puerto de nodo", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -320,9 +327,11 @@ "Remove all": "Quitar todo", "Remove alternate Service": "Quitar servicio alternativo", "Remove exception": "Quitar excepción", + "Remove from network": "Remove from network", "Remove peer": "Quitar par", "Remove port": "Quitar puerto", "Remove subnets?": "¿Desea eliminar subredes?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "Nombre del recurso", "Restarts": "Restarts", "Restricted Access": "Acceso restringido", @@ -339,11 +348,13 @@ "Rules": "Normas", "Running": "En ejecución", "Save": "Guardar", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "La red secundaria solo se asigna a los pods que usan la anotación k8s.v1.cni.cncf.io/networks para seleccionar la red determinada.", "Secure Route": "Ruta segura", "Security": "Seguridad", "Select a Project": "Seleccionar un proyecto", "Select a Service": "Seleccionar un servicio", + "Select all": "Select all", "Select an operator": "Seleccionar un operador", "Select ClusterUserDefinedNetwork": "Seleccionar ClusterUserDefinedNetwork", "Select default ingress and egress deny rules": "Seleccione reglas predeterminadas de denegación de entrada y salida", @@ -353,6 +364,7 @@ "Select role": "Seleccionar rol", "Select target port": "Seleccionar puerto de destino", "Select termination type": "Seleccionar tipo de terminación", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "Servicio", "Service address": "Dirección de servicio", "Service details": "Detalles del servicio", @@ -428,6 +440,7 @@ "View events": "Ver eventos", "View logs": "Ver registros", "View naming information": "Ver información de nombres", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "Número de etiqueta VLAN", "Weight": "Peso", diff --git a/locales/fr/plugin__networking-console-plugin.json b/locales/fr/plugin__networking-console-plugin.json index da93bd48..e607c6e2 100644 --- a/locales/fr/plugin__networking-console-plugin.json +++ b/locales/fr/plugin__networking-console-plugin.json @@ -24,6 +24,7 @@ "Accessible outside the cluster": "Accessible en dehors du cluster", "Accessible within the cluster only": "Accessible au sein du cluster uniquement", "Actions": "Actions", + "Add": "Add", "Add allowed destination": "Ajouter une destination autorisée", "Add allowed source": "Ajouter une source autorisée", "Add alternate Service": "Ajouter un service alternatif", @@ -39,6 +40,7 @@ "Add port": "Ajouter un port", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "Ajoutez les ports pour lesquels restreindre le trafic. Si aucun port n’est indiqué dans votre stratégie, tous les ports seront accessibles au trafic.", "Add subnet": "Ajouter un sous-réseau", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "Tout le trafic entrant est refusé aux pods dans {{namespace}}", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "Tout le trafic sortant est autorisé par défaut. Les règles de sortie peuvent être utilisées pour restreindre le trafic sortant si le fournisseur de réseau du cluster le permet. Lors de l’utilisation du fournisseur de réseau de cluster OpenShift SDN, la stratégie réseau de sortie n’est pas prise en charge.", "All outgoing traffic is denied from Pods in {{namespace}}": "Tout le trafic sortant issu des pods dans {{namespace}} est refusé", @@ -64,6 +66,8 @@ "Any peer": "N’importe quel homologue", "Any pod": "N’importe quel pod", "Any port": "N’importe quel port", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "Confirmez-vous cette action\u00a0?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "Attribue des adresses IP de ce sous-réseau aux pods et aux machines virtuelles.", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "Lors de sa création, l'espace de noms doit être configuré avec<2>{{label}} étiquette. Aller à<5> Espaces de noms pour créer un nouvel espace de noms.", @@ -156,6 +160,7 @@ "Enable {{kind}}": "Activer {{kind}}", "Error": "Erreur", "Error details": "Détails de l’erreur", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "Exemple: 100", "Exceptions": "Exceptions", "External IP": "IP externe", @@ -190,6 +195,7 @@ "Ingresses": "Entrées", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "Erreur de saisie\u00a0: les sélecteurs doivent commencer et se terminer par une lettre ou un chiffre, et ne peuvent contenir que les caractères -, _, / ou . Valeur erronée\u00a0: {{offendingSelector}}", "Insecure traffic": "Trafic non sécurisé", + "Interface": "Interface", "Invalid IP address or subnet format": "Adresse IP ou format de sous-réseau non valide", "Invalid subnet format": "Format de sous-réseau non valide", "Invalid YAML cannot be persisted": "Un code YAML non valide ne peut pas être rendu persistant", @@ -268,6 +274,7 @@ "No results found for \"{{inputValue}}\"": "Aucun résultat trouvé pour \"{{inputValue}} \"", "No route status": "Aucun statut d’itinéraire", "No selector": "Aucun sélecteur", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "Port du nœud", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -320,9 +327,11 @@ "Remove all": "Supprimer tout", "Remove alternate Service": "Supprimer un service alternatif", "Remove exception": "Supprimer l’exception", + "Remove from network": "Remove from network", "Remove peer": "Supprimer l’homologue", "Remove port": "Supprimer le port", "Remove subnets?": "Supprimer les sous-réseaux\u00a0?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "Nom de la ressource", "Restarts": "Restarts", "Restricted Access": "Accès restreint", @@ -339,11 +348,13 @@ "Rules": "Règles", "Running": "En cours d’exécution", "Save": "Enregistrer", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "Le réseau secondaire n'est attribué qu'aux pods qui utilisent l'annotation k8s.v1.cni.cncf.io/networks pour sélectionner un réseau donné.", "Secure Route": "Sécuriser l’itinéraire", "Security": "Sécurité", "Select a Project": "Sélectionnez un projet", "Select a Service": "Sélectionner un service", + "Select all": "Select all", "Select an operator": "Sélectionnez un opérateur", "Select ClusterUserDefinedNetwork": "Sélectionnez ClusterUserDefinedNetwork", "Select default ingress and egress deny rules": "Sélectionner les règles de refus d’entrée et de sortie par défaut", @@ -353,6 +364,7 @@ "Select role": "Sélectionner un rôle", "Select target port": "Sélectionner le port cible", "Select termination type": "Sélectionner le type de terminaison", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "Service", "Service address": "Adresse de service", "Service details": "Détails du service", @@ -428,6 +440,7 @@ "View events": "Afficher les événements", "View logs": "Afficher les journaux", "View naming information": "Afficher les informations sur l’attribution de nom", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "Numéro de balise VLAN", "Weight": "Poids", diff --git a/locales/ja/plugin__networking-console-plugin.json b/locales/ja/plugin__networking-console-plugin.json index b6a5a790..6cfdfa42 100644 --- a/locales/ja/plugin__networking-console-plugin.json +++ b/locales/ja/plugin__networking-console-plugin.json @@ -22,6 +22,7 @@ "Accessible outside the cluster": "クラスター外からアクセス可能", "Accessible within the cluster only": "クラスター内でのみアクセス可能", "Actions": "アクション", + "Add": "Add", "Add allowed destination": "許可される宛先の追加", "Add allowed source": "許可されるソースの追加", "Add alternate Service": "代替 Service の追加", @@ -37,6 +38,7 @@ "Add port": "ポートの追加", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "トラフィックを制限するポートを追加します。ポートが指定されていない場合、ポリシーによりすべてのポートでトラフィックへのアクセスが可能になります。", "Add subnet": "サブネットの追加", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "すべての受信トラフィックが {{namespace}} の Pod に対して拒否されます", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "すべての送信トラフィックはデフォルトで許可されます。Egress ルールは、クラスターネットワークプロバイダーが許可する場合に、送信トラフィックを制限するために使用できます。OpenShift SDN クラスターネットワークプロバイダーを使用する場合、Egress ネットワークポリシーはサポートされません。", "All outgoing traffic is denied from Pods in {{namespace}}": "すべての送信トラフィックは {{namespace}} の Pod で拒否されます", @@ -62,6 +64,8 @@ "Any peer": "任意のピア", "Any pod": "任意の Pod", "Any port": "任意のポート", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "操作を実行してもよろしいですか?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "このサブネットから Pod と VirtualMachine に IP アドレスを割り当てます。", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "作成時に、namespace に <2>{{label}} ラベルを設定する必要があります。<5>Namespaces に移動して、新しい namespace を作成してください。", @@ -154,6 +158,7 @@ "Enable {{kind}}": "{{kind}} の有効化", "Error": "エラー", "Error details": "エラーの詳細", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "例: 100", "Exceptions": "例外", "External IP": "外部 IP", @@ -188,6 +193,7 @@ "Ingresses": "Ingresses", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "入力エラー: セレクターは文字または数字で開始および終了する必要があり、-、_、/、または . しか含めることができません。無効な値: {{offendingSelector}}", "Insecure traffic": "非セキュアなトラフィック", + "Interface": "Interface", "Invalid IP address or subnet format": "無効な IP アドレスまたはサブネット形式", "Invalid subnet format": "無効なサブネット形式", "Invalid YAML cannot be persisted": "無効な YAML を永続化できません", @@ -266,6 +272,7 @@ "No results found for \"{{inputValue}}\"": "\"{{inputValue}}\" の結果が見つかりません", "No route status": "ルートステータスがありません", "No selector": "セレクターがありません", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "ノードポート", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -318,9 +325,11 @@ "Remove all": "すべてを削除", "Remove alternate Service": "代替 Service の削除", "Remove exception": "例外の削除", + "Remove from network": "Remove from network", "Remove peer": "ピアの削除", "Remove port": "ポートの削除", "Remove subnets?": "サブネットを削除しますか?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "リソース名", "Restarts": "Restarts", "Restricted Access": "制限されたアクセス", @@ -337,11 +346,13 @@ "Rules": "ルール", "Running": "実行中", "Save": "保存", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "セカンダリーネットワークは、k8s.v1.cni.cncf.io/networks アノテーションを使用して特定のネットワークを選択する Pod にのみ割り当てられます。", "Secure Route": "セキュアなルート", "Security": "セキュリティー", "Select a Project": "プロジェクトの選択", "Select a Service": "Service の選択", + "Select all": "Select all", "Select an operator": "演算子の選択", "Select ClusterUserDefinedNetwork": "ClusterUserDefinedNetwork の選択", "Select default ingress and egress deny rules": "デフォルトの Ingress および Egress 拒否ルールの選択", @@ -351,6 +362,7 @@ "Select role": "ロールの選択", "Select target port": "ターゲットポートの選択", "Select termination type": "終端タイプの選択", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "Service", "Service address": "Service アドレス", "Service details": "Service の詳細", @@ -426,6 +438,7 @@ "View events": "イベントの表示", "View logs": "ログの表示", "View naming information": "命名情報の表示", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "VLAN タグ番号", "Weight": "重み", diff --git a/locales/ko/plugin__networking-console-plugin.json b/locales/ko/plugin__networking-console-plugin.json index df42432a..49013a86 100644 --- a/locales/ko/plugin__networking-console-plugin.json +++ b/locales/ko/plugin__networking-console-plugin.json @@ -22,6 +22,7 @@ "Accessible outside the cluster": "클러스터 외부에서 액세스 가능", "Accessible within the cluster only": "클러스터 내에서만 액세스 가능", "Actions": "작업", + "Add": "Add", "Add allowed destination": "허용된 대상 추가", "Add allowed source": "허용된 소스 추가", "Add alternate Service": "대체 서비스 추가", @@ -37,6 +38,7 @@ "Add port": "포트 추가", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "포트를 추가하여 포트를 통한 트래픽을 제한합니다. 포트가 제공되지 않으면 정책에 따라 모든 포트가 트래픽에 액세스할 수 있습니다.", "Add subnet": "서브넷 추가", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "수신되는 모든 트래픽은 {{namespace}}의 Pod에 거부됩니다.", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "모든 나가는 트래픽은 기본적으로 허용됩니다. 클러스터 네트워크 공급자가 송신 트래픽을 허용하는 경우 송신 규칙을 사용하여 나가는 트래픽을 제한할 수 있습니다. OpenShift SDN 클러스터 네트워크 공급자를 사용하는 경우 송신 네트워크 정책이 지원되지 않습니다.", "All outgoing traffic is denied from Pods in {{namespace}}": "모든 나가는 트래픽은 {{namespace}}의 Pod에서 거부됩니다.", @@ -62,6 +64,8 @@ "Any peer": "모든 사용자", "Any pod": "모든 pod", "Any port": "모든 포트", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "정말로 실행하시겠습니까?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "이 서브넷의 IP 주소를 Pod 및 VirtualMachines에 할당합니다.", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "생성 시 <2>{{label}} 라벨을 사용하여 네임스페이스를 구성해야 합니다. 새 네임스페이스를 생성하려면 <5>Namespaces 로 이동하십시오.", @@ -154,6 +158,7 @@ "Enable {{kind}}": "{{kind}} 활성화", "Error": "오류", "Error details": "오류 정보", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "예: 100", "Exceptions": "예외", "External IP": "외부 IP", @@ -188,6 +193,7 @@ "Ingresses": "Ingress", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "입력 오류: 선택기는 문자나 숫자로 시작하고 끝나야 하며 -, _, / 또는 . 만 포함할 수 있습니다. 잘못된 값: {{offendingSelector}}", "Insecure traffic": "안전하지 않은 트래픽", + "Interface": "Interface", "Invalid IP address or subnet format": "잘못된 IP 주소 또는 서브넷 형식", "Invalid subnet format": "잘못된 서브넷 형식", "Invalid YAML cannot be persisted": "잘못된 YAML을 유지할 수 없습니다.", @@ -266,6 +272,7 @@ "No results found for \"{{inputValue}}\"": "\"{{inputValue}}\"의 결과를 찾을 수 없음", "No route status": "라우팅 상태 없음", "No selector": "선택기 없음", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "노드 포트", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -318,9 +325,11 @@ "Remove all": "모두 삭제", "Remove alternate Service": "대체 서비스 삭제", "Remove exception": "예외 제거", + "Remove from network": "Remove from network", "Remove peer": "피어 제거", "Remove port": "포트 제거", "Remove subnets?": "서브넷을 제거하시겠습니까?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "리소스 이름", "Restarts": "Restarts", "Restricted Access": "제한된 액세스", @@ -337,11 +346,13 @@ "Rules": "규칙", "Running": "실행 중", "Save": "저장", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "보조 네트워크는 k8s.v1.cni.cncf.io/networks 주석을 사용하여 지정된 네트워크를 선택하는 Pod에만 할당됩니다.", "Secure Route": "안전한 경로", "Security": "보안", "Select a Project": "프로젝트 선택", "Select a Service": "서비스 선택", + "Select all": "Select all", "Select an operator": "Operator 선택", "Select ClusterUserDefinedNetwork": "ClusterUserDefinedNetwork 선택", "Select default ingress and egress deny rules": "기본 Ingress 및 Egress 거부 규칙 선택", @@ -351,6 +362,7 @@ "Select role": "역할 선택", "Select target port": "대상 포트 선택", "Select termination type": "종료 유형 선택", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "서비스", "Service address": "서비스 주소", "Service details": "서비스 내용", @@ -426,6 +438,7 @@ "View events": "이벤트보기", "View logs": "로그보기", "View naming information": "이름 지정 정보 보기", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "VLAN 태그 번호", "Weight": "가중치", diff --git a/locales/zh/plugin__networking-console-plugin.json b/locales/zh/plugin__networking-console-plugin.json index 5a7e10cc..2f87039e 100644 --- a/locales/zh/plugin__networking-console-plugin.json +++ b/locales/zh/plugin__networking-console-plugin.json @@ -22,6 +22,7 @@ "Accessible outside the cluster": "在集群外可以访问", "Accessible within the cluster only": "仅在集群内可访问", "Actions": "行动", + "Add": "Add", "Add allowed destination": "添加允许的目的地", "Add allowed source": "添加允许的源", "Add alternate Service": "添加备用服务", @@ -37,6 +38,7 @@ "Add port": "添加端口", "Add ports to restrict traffic through them. If no ports are provided, your policy will make all ports accessible to traffic.": "添加端口以限制通过它们的流量。如果没有提供端口,您的策略将允许所有端口对流量进行访问。", "Add subnet": "添加子网", + "Add virtual machines": "Add virtual machines", "All incoming traffic is denied to Pods in {{namespace}}": "所有传入的流量都被 {{namespace}} 中的 Pod 拒绝", "All outgoing traffic is allowed by default. Egress rules can be used to restrict outgoing traffic if the cluster network provider allows it. When using the OpenShift SDN cluster network provider, egress network policy is not supported.": "所有外向流量都是默认允许的。如果集群网络供应商允许,可以使用 egress 规则来限制外向流量。在使用 OpenShift SDN 集群网络供应商时,不支持 egress 网络策略。", "All outgoing traffic is denied from Pods in {{namespace}}": "所有外向流量都会从 {{namespace}} 中的 Pod 拒绝", @@ -62,6 +64,8 @@ "Any peer": "任何对等者", "Any pod": "任何 pod", "Any port": "任何端口", + "Are you sure you want to remove <2> from <5>{nadName}?": "Are you sure you want to remove <2> from <5>{nadName}?", + "Are you sure you want to remove <2> from <5>{udnName}?": "Are you sure you want to remove <2> from <5>{udnName}?", "Are you sure?": "您确定吗?", "Assigns IP addresses from this subnet to Pods and VirtualMachines.": "将这个子网的 IP 地址分配给 Pod 和 VirtualMachines。", "At creation time the namespace must be configured with <2>{{label}} label. Go to <5>Namespaces to create a new namespace.": "在创建时,命名空间必须配置带有 <2>{{label}} 标签。进入 <5>Namespaces 以创建一个新命名空间。", @@ -154,6 +158,7 @@ "Enable {{kind}}": "启用 {{kind}}", "Error": "错误", "Error details": "错误详情", + "Error loading virtual machines": "Error loading virtual machines", "Example: 100": "示例:100", "Exceptions": "例外", "External IP": "外部 IP", @@ -188,6 +193,7 @@ "Ingresses": "Ingresses", "Input error: selectors must start and end by a letter or number, and can only contain -, _, / or . Offending value: {{offendingSelector}}": "输入错误:选择器必须以字母或数字开头和结尾,并且只能包含 -、_、/ 或 . 输出值:{{offendingSelector}}", "Insecure traffic": "不安全的流量", + "Interface": "Interface", "Invalid IP address or subnet format": "无效的 IP 地址或子网格式", "Invalid subnet format": "无效的子网格式", "Invalid YAML cannot be persisted": "无效的 YAML 无法保留", @@ -266,6 +272,7 @@ "No results found for \"{{inputValue}}\"": "没有为 \"{{inputValue}}\" 找到结果", "No route status": "没有路由状态", "No selector": "没有选择器", + "No virtual machines found": "No virtual machines found", "Node": "Node", "Node port": "节点端口", "NodeNetworkConfigurationPolicy": "NodeNetworkConfigurationPolicy", @@ -318,9 +325,11 @@ "Remove all": "删除所有", "Remove alternate Service": "删除备用服务", "Remove exception": "删除异常", + "Remove from network": "Remove from network", "Remove peer": "删除对等点", "Remove port": "删除端口", "Remove subnets?": "删除子网?", + "Remove virtual machine from network?": "Remove virtual machine from network?", "Resource name": "资源名称", "Restarts": "Restarts", "Restricted Access": "限制的访问", @@ -337,11 +346,13 @@ "Rules": "规则", "Running": "运行", "Save": "保存", + "Search by name...": "Search by name...", "Secondary network is only assigned to pods that use k8s.v1.cni.cncf.io/networks annotation to select given network.": "二级网络只分配给使用 k8s.v1.cni.cncf.io/networks 注解来选择给定网络的 pod。", "Secure Route": "安全路由", "Security": "安全性", "Select a Project": "选择一个项目", "Select a Service": "选择一个服务", + "Select all": "Select all", "Select an operator": "选择 operator", "Select ClusterUserDefinedNetwork": "选择 ClusterUserDefinedNetwork", "Select default ingress and egress deny rules": "选择默认入口和出口拒绝规则", @@ -351,6 +362,7 @@ "Select role": "选择角色", "Select target port": "选择目标端口", "Select termination type": "选择终止类型", + "Select virtual machines to connect to this network.": "Select virtual machines to connect to this network.", "Service": "服务", "Service address": "服务地址", "Service details": "服务详情", @@ -426,6 +438,7 @@ "View events": "查看事件", "View logs": "查看日志", "View naming information": "查看命名信息", + "Virtual Machines": "Virtual Machines", "VLAN": "VLAN", "VLAN tag number": "VLAN 标签号", "Weight": "权重", diff --git a/package-lock.json b/package-lock.json index 6ef25bc7..6d79429c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@patternfly/react-charts": "~8.5.1", "@patternfly/react-component-groups": "~6.5.0", "@patternfly/react-core": "~6.5.1", + "@patternfly/react-data-view": "~6.5.0", "@patternfly/react-drag-drop": "~6.5.1", "@patternfly/react-icons": "~6.5.1", "@patternfly/react-styles": "~6.5.1", @@ -3754,6 +3755,25 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-data-view": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-6.5.0.tgz", + "integrity": "sha512-QTj8eg/pwchdxSpapqU3a6MZG7syiwzXy1La2hgAJtvQTR9ltkIg/GJJ4ota2O5wHzdSAVzTTdxiNuP33wmvbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@patternfly/react-component-groups": "^6.1.0", + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-table": "^6.4.0", + "clsx": "^2.1.1", + "react-jss": "^10.10.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-drag-drop": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/@patternfly/react-drag-drop/-/react-drag-drop-6.5.1.tgz", @@ -6424,6 +6444,16 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", diff --git a/package.json b/package.json index 3632871c..fc50e4eb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@patternfly/react-charts": "~8.5.1", "@patternfly/react-component-groups": "~6.5.0", "@patternfly/react-core": "~6.5.1", + "@patternfly/react-data-view": "~6.5.0", "@patternfly/react-drag-drop": "~6.5.1", "@patternfly/react-icons": "~6.5.1", "@patternfly/react-styles": "~6.5.1", @@ -79,8 +80,8 @@ "pluralize": "^8.0.0", "prettier": "^3.2.5", "react": "18.3.1", - "react-dom": "18.3.1", "react-copy-to-clipboard": "^5.1.0", + "react-dom": "18.3.1", "react-i18next": "~16.5.8", "react-router": "~7.13.1", "resolve-url-loader": "^5.0.0", diff --git a/src/utils/components/ConfirmModal/ConfirmModal.tsx b/src/utils/components/ConfirmModal/ConfirmModal.tsx index 84fda239..fbe27283 100644 --- a/src/utils/components/ConfirmModal/ConfirmModal.tsx +++ b/src/utils/components/ConfirmModal/ConfirmModal.tsx @@ -6,6 +6,7 @@ import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation' export type ConfirmModalProps = { btnText: JSX.Element | string; + btnVariant?: ButtonVariant; closeModal?: () => void; executeFn: () => void; message: JSX.Element | string; @@ -14,6 +15,7 @@ export type ConfirmModalProps = { const ConfirmModal: FC = ({ btnText, + btnVariant = ButtonVariant.primary, closeModal, executeFn, message, @@ -27,17 +29,11 @@ const ConfirmModal: FC = ({ }; return ( - + {message} - + ) : ( + t('{{count}} annotation', { + count: _.size(metadata?.annotations), + }) + )} + + + + + + + + + + + + + +
+ + + +
+
+ + + ); +}; + +export default NADDetails; diff --git a/src/views/nads/details/tabs/details/NADTypeDetails.tsx b/src/views/nads/details/tabs/details/NADTypeDetails.tsx index 2d424ae2..51ad6899 100644 --- a/src/views/nads/details/tabs/details/NADTypeDetails.tsx +++ b/src/views/nads/details/tabs/details/NADTypeDetails.tsx @@ -1,6 +1,5 @@ import React, { FC } from 'react'; -import { DetailsItemComponentProps } from '@openshift-console/dynamic-plugin-sdk'; import MutedText from '@utils/components/MutedText/MutedText'; import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import { getConfigAsJSON, getType } from '@utils/resources/nads/selectors'; @@ -8,16 +7,18 @@ import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; import { networkTypes } from '@views/nads/form/utils/types'; import { getLocalnetTopologyType } from '@views/nads/form/utils/utils'; -const NADTypeDetails: FC> = ({ - obj: nad, -}) => { +type NADTypeDetailsProps = { + nad: NetworkAttachmentDefinitionKind; +}; + +const NADTypeDetails: FC = ({ nad }) => { const { t } = useNetworkingTranslation(); const nadConfig = getConfigAsJSON(nad); const type = getLocalnetTopologyType(getType(nadConfig), nadConfig?.topology); if (!type) return ; - return networkTypes?.[type] || type; + return <>{networkTypes?.[type] || type}; }; export default NADTypeDetails; diff --git a/src/views/nads/details/tabs/virtualMachines/NADVirtualMachines.tsx b/src/views/nads/details/tabs/virtualMachines/NADVirtualMachines.tsx new file mode 100644 index 00000000..f9b7f7b4 --- /dev/null +++ b/src/views/nads/details/tabs/virtualMachines/NADVirtualMachines.tsx @@ -0,0 +1,348 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import * as _ from 'lodash'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Bullseye, + Button, + Card, + CardBody, + EmptyState, + Label, + PageSection, + Pagination, + Spinner, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { + DataView, + DataViewFilters, + DataViewTable, + DataViewTextFilter, + DataViewTh, + DataViewToolbar, + useDataViewFilters, + useDataViewPagination, + useDataViewSort, +} from '@patternfly/react-data-view'; +import { Tbody, Td, Tr } from '@patternfly/react-table'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { getVMStatus } from '@utils/resources/vm/selectors'; + +import useNADVirtualMachines, { NADVirtualMachine } from '../../hooks/useNADVirtualMachines'; + +import AddNADVirtualMachinesModal from './components/AddNADVirtualMachinesModal'; +import NADVirtualMachineRowActions from './components/NADVirtualMachineRowActions'; + +type NADVirtualMachineFilters = { + name?: string; +}; + +const COLUMN_COUNT = 5; +const DEFAULT_PER_PAGE = 20; +const SORT_COLUMN_KEYS = ['name', 'namespace', 'status', 'interface'] as const; + +const getVMStatusLabelColor = (status: string) => { + if (!status) { + return 'grey'; + } + + if (status.includes('Error') || status === 'Stopped') { + return 'red'; + } + + if (status === 'Running') { + return 'green'; + } + + return 'orange'; +}; + +const NADVirtualMachines: FC<{ obj?: NetworkAttachmentDefinitionKind }> = ({ obj: nad }) => { + const { t } = useNetworkingTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const { availableVirtualMachines, loaded, loadError, virtualMachines } = + useNADVirtualMachines(nad); + + const { clearAllFilters, filters, onSetFilters } = useDataViewFilters({ + searchParams, + setSearchParams, + }); + + const { onPerPageSelect, onSetPage, page, perPage } = useDataViewPagination({ + page: 1, + perPage: DEFAULT_PER_PAGE, + searchParams, + setSearchParams, + }); + + const { direction, onSort, sortBy } = useDataViewSort({ + searchParams, + setSearchParams, + }); + + const filteredData = useMemo(() => { + const nameFilter = filters.name?.toLowerCase(); + + if (!nameFilter) { + return virtualMachines; + } + + return virtualMachines.filter(({ vm }) => getName(vm)?.toLowerCase().includes(nameFilter)); + }, [filters.name, virtualMachines]); + + const sortedData = useMemo(() => { + if (!sortBy) { + return filteredData; + } + + const getSortValue = (item: NADVirtualMachine) => { + switch (sortBy) { + case 'name': + return getName(item.vm) ?? ''; + case 'namespace': + return getNamespace(item.vm) ?? ''; + case 'status': + return getVMStatus(item.vm); + case 'interface': + return item.interfaceName; + default: + return ''; + } + }; + + return _.orderBy(filteredData, [getSortValue], [direction === 'desc' ? 'desc' : 'asc']); + }, [direction, filteredData, sortBy]); + + const paginatedData = useMemo(() => { + const start = (page - 1) * perPage; + return sortedData.slice(start, start + perPage); + }, [page, perPage, sortedData]); + + const getSortParams = useCallback( + (columnIndex: number) => ({ + columnIndex, + onSort: ( + event: MouseEvent | React.KeyboardEvent | React.MouseEvent, + _index: number | string, + newDirection: 'asc' | 'desc', + ) => onSort(event, SORT_COLUMN_KEYS[columnIndex], newDirection), + sortBy: { direction, index: columnIndex }, + }), + [direction, onSort], + ); + + const columns = useMemo( + () => [ + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: , + }, + ], + [getSortParams, t], + ); + + const rows = useMemo( + () => + paginatedData.map(({ interfaceName, vm }) => { + const name = getName(vm); + const vmNamespace = getNamespace(vm); + const status = getVMStatus(vm); + + return { + id: `${vmNamespace}/${name}`, + row: [ + { + cell: ( + + ), + }, + { + cell: , + }, + { + cell: ( + + ), + }, + { + cell: interfaceName, + }, + { + cell: nad && ( + + ), + props: { isActionCell: true }, + }, + ], + }; + }), + [nad, paginatedData], + ); + + const activeState = useMemo(() => { + if (loadError) { + return 'error'; + } + + if (!loaded) { + return 'loading'; + } + + if (filteredData.length === 0) { + return 'empty'; + } + + return undefined; + }, [filteredData.length, loadError, loaded]); + + const bodyStates = useMemo( + () => ({ + empty: ( + + + + + + + + + + ), + error: ( + + + + + + + + + + ), + loading: ( + + + + + + + + + + ), + }), + [t], + ); + + return ( + + + + + + + + + + onSetFilters(newValues)} + values={filters} + > + + + } + pagination={ + + } + /> + + + + + + + {nad && ( + setIsAddModalOpen(false)} + /> + )} + + ); +}; + +export default NADVirtualMachines; diff --git a/src/views/nads/details/tabs/virtualMachines/components/AddNADVirtualMachinesModal.tsx b/src/views/nads/details/tabs/virtualMachines/components/AddNADVirtualMachinesModal.tsx new file mode 100644 index 00000000..4072dbbb --- /dev/null +++ b/src/views/nads/details/tabs/virtualMachines/components/AddNADVirtualMachinesModal.tsx @@ -0,0 +1,448 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import * as _ from 'lodash'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Bullseye, + Button, + EmptyState, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Pagination, + Spinner, +} from '@patternfly/react-core'; +import { + DataView, + DataViewFilters, + DataViewTable, + DataViewTextFilter, + DataViewTh, + DataViewToolbar, + DataViewTr, + isDataViewTrObject, + useDataViewFilters, + useDataViewPagination, + useDataViewSelection, + useDataViewSort, +} from '@patternfly/react-data-view'; +import { Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { getVMStatus } from '@utils/resources/vm/selectors'; +import { addVMToNAD } from '@utils/resources/vm/utils'; + +type AddNADVirtualMachinesModalProps = { + availableVMs: V1VirtualMachine[]; + isOpen: boolean; + loaded: boolean; + nad: NetworkAttachmentDefinitionKind; + onClose: () => void; +}; + +type AddVMFilters = { + name?: string; +}; + +const COLUMN_COUNT = 4; +const DEFAULT_PER_PAGE = 20; +const SORT_COLUMN_KEYS = ['name', 'namespace', 'status'] as const; + +const getVMStatusLabelColor = (status: string) => { + if (!status) { + return 'grey'; + } + + if (status.includes('Error') || status === 'Stopped') { + return 'red'; + } + + if (status === 'Running') { + return 'green'; + } + + return 'orange'; +}; + +const AddNADVirtualMachinesModal: FC = ({ + availableVMs, + isOpen, + loaded, + nad, + onClose, +}) => { + const { t } = useNetworkingTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { clearAllFilters, filters, onSetFilters } = useDataViewFilters({}); + + const { onPerPageSelect, onSetPage, page, perPage } = useDataViewPagination({ + page: 1, + perPage: DEFAULT_PER_PAGE, + }); + + const { direction, onSort, sortBy } = useDataViewSort({}); + + const { + isSelected: isVMSelected, + onSelect: onVMSelect, + selected, + setSelected, + } = useDataViewSelection({ + matchOption: (item, another) => + getName(item) === getName(another) && getNamespace(item) === getNamespace(another), + }); + + useEffect(() => { + if (isOpen) { + setSelected([]); + } + // Only reset when the modal opens; setSelected is not stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const filteredData = useMemo(() => { + const nameFilter = filters.name?.toLowerCase(); + + if (!nameFilter) { + return availableVMs; + } + + return availableVMs.filter((vm) => getName(vm)?.toLowerCase().includes(nameFilter)); + }, [availableVMs, filters.name]); + + const sortedData = useMemo(() => { + if (!sortBy) { + return filteredData; + } + + const getSortValue = (vm: V1VirtualMachine) => { + switch (sortBy) { + case 'name': + return getName(vm) ?? ''; + case 'namespace': + return getNamespace(vm) ?? ''; + case 'status': + return getVMStatus(vm); + default: + return ''; + } + }; + + return _.orderBy(filteredData, [getSortValue], [direction === 'desc' ? 'desc' : 'asc']); + }, [direction, filteredData, sortBy]); + + const paginatedData = useMemo(() => { + const start = (page - 1) * perPage; + return sortedData.slice(start, start + perPage); + }, [page, perPage, sortedData]); + + const vmByRowId = useMemo( + () => + new Map( + paginatedData.map((vm) => [`${getNamespace(vm)}/${getName(vm)}`, vm]), + ), + [paginatedData], + ); + + const areAllPaginatedSelected = useMemo( + () => paginatedData.length > 0 && paginatedData.every((vm) => isVMSelected(vm)), + [isVMSelected, paginatedData], + ); + + const areSomePaginatedSelected = useMemo( + () => paginatedData.some((vm) => isVMSelected(vm)) && !areAllPaginatedSelected, + [areAllPaginatedSelected, isVMSelected, paginatedData], + ); + + const vmMatches = useCallback( + (a: V1VirtualMachine, b: V1VirtualMachine) => + getName(a) === getName(b) && getNamespace(a) === getNamespace(b), + [], + ); + + const onSelectAll = useCallback( + (_event: React.FormEvent, isSelecting: boolean) => { + if (isSelecting) { + const newSelected = [...selected]; + + paginatedData.forEach((vm) => { + if (!newSelected.some((item) => vmMatches(item, vm))) { + newSelected.push(vm); + } + }); + setSelected(newSelected); + return; + } + + setSelected(selected.filter((item) => !paginatedData.some((vm) => vmMatches(item, vm)))); + }, + [paginatedData, selected, setSelected, vmMatches], + ); + + const isAddDisabled = isSubmitting || availableVMs.length === 0 || selected.length === 0; + + const getSortParams = useCallback( + (columnIndex: number) => ({ + columnIndex, + onSort: ( + event: MouseEvent | React.KeyboardEvent | React.MouseEvent, + _index: number | string, + newDirection: 'asc' | 'desc', + ) => onSort(event, SORT_COLUMN_KEYS[columnIndex], newDirection), + sortBy: { direction, index: columnIndex }, + }), + [direction, onSort], + ); + + const columns = useMemo( + () => [ + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + ], + [getSortParams, t], + ); + + const rows = useMemo( + () => + paginatedData.map((vm) => { + const name = getName(vm); + const vmNamespace = getNamespace(vm); + const status = getVMStatus(vm); + const rowId = `${vmNamespace}/${name}`; + + return { + id: rowId, + row: [ + { + cell: ( + + ), + }, + { + cell: , + }, + { + cell: ( + + ), + }, + ], + }; + }), + [paginatedData], + ); + + const selection = useMemo( + () => ({ + isSelectDisabled: () => isSubmitting, + isSelected: (row: DataViewTr) => { + const rowId = isDataViewTrObject(row) ? row.id : undefined; + const vm = rowId ? vmByRowId.get(rowId) : undefined; + + return vm ? isVMSelected(vm) : false; + }, + onSelect: (isSelecting: boolean, row: DataViewTr | DataViewTr[]) => { + const rowsToProcess = Array.isArray(row) ? row : [row]; + + rowsToProcess.forEach((item) => { + const rowId = isDataViewTrObject(item) ? item.id : undefined; + const vm = rowId ? vmByRowId.get(rowId) : undefined; + + if (vm) { + onVMSelect(isSelecting, vm); + } + }); + }, + }), + [isSubmitting, isVMSelected, onVMSelect, vmByRowId], + ); + + const activeState = useMemo(() => { + if (!loaded) { + return 'loading'; + } + + if (filteredData.length === 0) { + return 'empty'; + } + + return 'data'; + }, [filteredData.length, loaded]); + + const headStates = useMemo( + () => ({ + data: ( + + + + {columns.map((column, index) => ( + {column.cell} + ))} + + + ), + }), + [ + areAllPaginatedSelected, + areSomePaginatedSelected, + columns, + isSubmitting, + onSelectAll, + paginatedData.length, + t, + ], + ); + + const bodyStates = useMemo( + () => ({ + empty: ( + + + + + + + + + + ), + loading: ( + + + + + + + + + + ), + }), + [t], + ); + + const handleAdd = async () => { + if (selected.length === 0) { + return; + } + + setIsSubmitting(true); + + try { + await Promise.all(selected.map((vm) => addVMToNAD(vm, nad))); + onClose(); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + onSetFilters(newValues)} + values={filters} + > + + + } + pagination={ + + } + /> + + + + + + + + + ); +}; + +export default AddNADVirtualMachinesModal; diff --git a/src/views/nads/details/tabs/virtualMachines/components/NADVirtualMachineRowActions.tsx b/src/views/nads/details/tabs/virtualMachines/components/NADVirtualMachineRowActions.tsx new file mode 100644 index 00000000..622a6641 --- /dev/null +++ b/src/views/nads/details/tabs/virtualMachines/components/NADVirtualMachineRowActions.tsx @@ -0,0 +1,73 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { Trans } from 'react-i18next'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { Action, ResourceLink, useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { ButtonVariant } from '@patternfly/react-core'; +import ActionsDropdown from '@utils/components/ActionsDropdown/ActionsDropdown'; +import ConfirmModal, { ConfirmModalProps } from '@utils/components/ConfirmModal/ConfirmModal'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { asAccessReview, getName, getNamespace } from '@utils/resources/shared'; +import { removeVMFromNAD } from '@utils/resources/vm/utils'; +import { networkConsole } from '@utils/utils/helpers'; + +type NADVirtualMachineRowActionsProps = { + interfaceName: string; + nad: NetworkAttachmentDefinitionKind; + vm: V1VirtualMachine; +}; + +const NADVirtualMachineRowActions: FC = ({ + interfaceName, + nad, + vm, +}) => { + const { t } = useNetworkingTranslation(); + const createModal = useModal(); + const vmName = getName(vm); + const vmNamespace = getNamespace(vm); + const nadName = getName(nad); + + const onRemove = useCallback(() => { + createModal(ConfirmModal, { + btnText: t('Remove'), + btnVariant: ButtonVariant.danger, + executeFn: () => { + removeVMFromNAD(vm, interfaceName).catch((error) => { + networkConsole.warn('Failed to remove virtual machine from network', error); + }); + }, + message: ( + + Are you sure you want to remove{' '} + {' '} + from {nadName}? + + ), + title: t('Remove virtual machine from network?'), + }); + }, [createModal, interfaceName, nadName, t, vm, vmName, vmNamespace]); + + const actions = useMemo( + () => [ + { + accessReview: asAccessReview(VirtualMachineModel, vm, 'update'), + cta: onRemove, + id: 'remove-vm-from-nad-network', + label: t('Remove from network'), + }, + ], + [onRemove, t, vm], + ); + + return ; +}; + +export default NADVirtualMachineRowActions; diff --git a/src/views/nads/details/tabs/yaml/NADYAML.tsx b/src/views/nads/details/tabs/yaml/NADYAML.tsx new file mode 100644 index 00000000..88e3b56f --- /dev/null +++ b/src/views/nads/details/tabs/yaml/NADYAML.tsx @@ -0,0 +1,21 @@ +import React, { FC, Suspense } from 'react'; + +import { ResourceYAMLEditor } from '@openshift-console/dynamic-plugin-sdk'; +import Loading from '@utils/components/Loading/Loading'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; + +type NADYAMLProps = { + obj?: NetworkAttachmentDefinitionKind; +}; + +const NADYAML: FC = ({ obj: nad }) => { + return !nad ? ( + + ) : ( + }> + + + ); +}; + +export default NADYAML; diff --git a/src/views/nads/manifest.ts b/src/views/nads/manifest.ts index 0cfede08..5c47cf58 100644 --- a/src/views/nads/manifest.ts +++ b/src/views/nads/manifest.ts @@ -1,6 +1,6 @@ import { - DetailsItem, ResourceActionProvider, + ResourceDetailsPage, ResourceListPage, ResourceNSNavItem, RoutePage, @@ -58,29 +58,6 @@ export const NADsExtensions: EncodedExtension[] = [ }, type: 'console.page/route', } as EncodedExtension, - { - properties: { - column: 'left', - component: { $codeRef: 'NetworkAttachmentDefintionDescriptionDetails' }, - id: 'nad-description-detail-item', - model: NetworkAttachmentDefinitionExtensionModel, - path: 'metadata.annotations.description', - title: 'Description', - }, - type: 'console.resource/details-item', - } as EncodedExtension, - { - properties: { - column: 'right', - component: { $codeRef: 'NetworkAttachmentDefintionTypeDetails' }, - id: 'nad-type-detail-item', - model: NetworkAttachmentDefinitionExtensionModel, - path: 'spec.config.type', - title: 'Type', - }, - type: 'console.resource/details-item', - } as EncodedExtension, - { properties: { model: NetworkAttachmentDefinitionExtensionModel, @@ -100,13 +77,18 @@ export const NADsExtensions: EncodedExtension[] = [ }, type: 'console.action/resource-provider', } as EncodedExtension, + { + properties: { + component: { $codeRef: 'NADDetailsPage' }, + model: NetworkAttachmentDefinitionExtensionModel, + }, + type: 'console.page/resource/details', + } as EncodedExtension, ]; export const NADsExposedModules: ConsolePluginBuildMetadata['exposedModules'] = { + NADDetailsPage: './views/nads/details/NADDetailsPage.tsx', NetworkAttachmentDefinitionFormPage: './views/nads/form/NetworkAttachmentDefinitionFormPage.tsx', NetworkAttachmentDefinitionList: './views/nads/list/NetworkAttachmentDefinitionList.tsx', - NetworkAttachmentDefintionDescriptionDetails: - './views/nads/details/tabs/details/NADDescriptionDetails.tsx', - NetworkAttachmentDefintionTypeDetails: './views/nads/details/tabs/details/NADTypeDetails.tsx', useNADsActions: './views/nads/actions/hooks/useNADsActions.tsx', }; diff --git a/src/views/udns/details/UDNDetailsPage.tsx b/src/views/udns/details/UDNDetailsPage.tsx new file mode 100644 index 00000000..8603f860 --- /dev/null +++ b/src/views/udns/details/UDNDetailsPage.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react'; + +import { HorizontalNav, useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import StatusBox from '@utils/components/StatusBox/StatusBox'; +import { + ClusterUserDefinedNetworkModelGroupVersionKind, + UserDefinedNetworkModelGroupVersionKind, +} from '@utils/models'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; + +import UDNDetailsPageTitle from './components/UDNDetailsPageTitle'; +import { useUDNTabs } from './hooks/useUDNTabs'; +import useUDNVirtualMachines from './hooks/useUDNVirtualMachines'; + +export type UDNDetailsPageProps = { + name: string; + namespace?: string; +}; + +const UDNDetailsPage: FC = ({ name, namespace }) => { + const isNamespaced = Boolean(namespace); + + const [udn, loaded, error] = useK8sWatchResource< + ClusterUserDefinedNetworkKind | UserDefinedNetworkKind + >({ + groupVersionKind: isNamespaced + ? UserDefinedNetworkModelGroupVersionKind + : ClusterUserDefinedNetworkModelGroupVersionKind, + name, + namespace, + }); + + const { virtualMachines } = useUDNVirtualMachines(udn); + const pages = useUDNTabs(virtualMachines.length); + + return ( + + + + + ); +}; + +export default UDNDetailsPage; diff --git a/src/views/udns/details/components/UDNDetailsPageTitle.tsx b/src/views/udns/details/components/UDNDetailsPageTitle.tsx new file mode 100644 index 00000000..73a20a76 --- /dev/null +++ b/src/views/udns/details/components/UDNDetailsPageTitle.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react'; + +import { modelToRef } from '@kubevirt-ui/kubevirt-api/console'; +import { Title } from '@patternfly/react-core'; +import DetailsPageTitle from '@utils/components/DetailsPageTitle/DetailsPageTitle'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { getModel } from '@utils/resources/udns/selectors'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import UDNActions from '@views/udns/actions/UDNActions'; + +type UDNDetailsPageTitleProps = { + udn: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; +}; + +const UDNDetailsPageTitle: FC = ({ udn }) => { + const { t } = useNetworkingTranslation(); + const model = getModel(udn); + + return ( + + + <span className="co-m-resource-icon co-m-resource-icon--lg" title={model.abbr}> + {model.abbr} + </span> + {udn?.metadata?.name} + + {udn && } + + ); +}; + +export default UDNDetailsPageTitle; diff --git a/src/views/udns/details/hooks/useUDNTabs.tsx b/src/views/udns/details/hooks/useUDNTabs.tsx new file mode 100644 index 00000000..72ff5935 --- /dev/null +++ b/src/views/udns/details/hooks/useUDNTabs.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Badge } from '@patternfly/react-core'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; + +import UDNDetailsTab from '../tabs/details/UDNDetailsTab'; +import UDNVirtualMachines from '../tabs/virtualMachines/UDNVirtualMachines'; +import UDNYAML from '../tabs/yaml/UDNYAML'; + +export const useUDNTabs = (vmCount = 0) => { + const { t } = useNetworkingTranslation(); + + return [ + { + component: UDNDetailsTab, + href: '', + name: t('Details'), + }, + { + component: UDNYAML, + href: 'yaml', + name: t('YAML'), + }, + { + component: UDNVirtualMachines, + href: 'virtual-machines', + name: ( + <> + {t('Virtual Machines')} + + {vmCount} + + + ), + }, + ]; +}; diff --git a/src/views/udns/details/hooks/useUDNVirtualMachines.ts b/src/views/udns/details/hooks/useUDNVirtualMachines.ts new file mode 100644 index 00000000..ee5c3ae7 --- /dev/null +++ b/src/views/udns/details/hooks/useUDNVirtualMachines.ts @@ -0,0 +1,97 @@ +import { useCallback, useMemo } from 'react'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import NetworkAttachmentDefinitionModel from '@kubevirt-ui/kubevirt-api/console/models/NetworkAttachmentDefinitionModel'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { V1Network, V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { getInterfaces, getNetworks } from '@utils/resources/vm/selectors'; +import { findNADForUDN, multusNetworkMatchesUDN, UDNResource } from '@utils/resources/vm/utils'; + +export type UDNVirtualMachine = { + interfaceName: string; + nad?: NetworkAttachmentDefinitionKind; + vm: V1VirtualMachine; +}; + +type UseUDNVirtualMachinesResult = { + availableVirtualMachines: V1VirtualMachine[]; + getNADForVM: (vm: V1VirtualMachine) => NetworkAttachmentDefinitionKind | undefined; + loaded: boolean; + loadError: unknown; + virtualMachines: UDNVirtualMachine[]; +}; + +const networkUsesUDN = (network: V1Network, udn: UDNResource, vm: V1VirtualMachine): boolean => { + if (!multusNetworkMatchesUDN(network, udn, vm)) { + return false; + } + + const iface = getInterfaces(vm).find((item) => item.name === network.name); + + return iface?.state !== 'absent'; +}; + +export const vmUsesUDN = (vm: V1VirtualMachine, udn: UDNResource | undefined): boolean => + Boolean(udn) && getNetworks(vm).some((network) => networkUsesUDN(network, udn, vm)); + +const getUDNVirtualMachines = ( + vms: undefined | V1VirtualMachine[], + udn: UDNResource | undefined, + nads: NetworkAttachmentDefinitionKind[] | undefined, +): UDNVirtualMachine[] => { + if (!udn) { + return []; + } + + return (vms ?? []).flatMap((vm) => { + const matchingNetworks = getNetworks(vm).filter((network) => networkUsesUDN(network, udn, vm)); + + if (matchingNetworks.length === 0) { + return []; + } + + return [ + { + interfaceName: matchingNetworks[0].name, + nad: findNADForUDN(nads, udn, vm), + vm, + }, + ]; + }); +}; + +const useUDNVirtualMachines = (udn: UDNResource | undefined): UseUDNVirtualMachinesResult => { + const [vms, vmsLoaded, vmsLoadError] = useK8sWatchResource({ + groupVersionKind: modelToGroupVersionKind(VirtualMachineModel), + isList: true, + }); + + const [nads, nadsLoaded, nadsLoadError] = useK8sWatchResource({ + groupVersionKind: modelToGroupVersionKind(NetworkAttachmentDefinitionModel), + isList: true, + }); + + const getNADForVM = useCallback( + (vm: V1VirtualMachine) => (udn ? findNADForUDN(nads, udn, vm) : undefined), + [nads, udn], + ); + + const virtualMachines = useMemo(() => getUDNVirtualMachines(vms, udn, nads), [nads, udn, vms]); + + const availableVirtualMachines = useMemo( + () => (vms ?? []).filter((vm) => !vmUsesUDN(vm, udn)), + [udn, vms], + ); + + return { + availableVirtualMachines, + getNADForVM, + loaded: vmsLoaded && nadsLoaded, + loadError: vmsLoadError || nadsLoadError, + virtualMachines, + }; +}; + +export default useUDNVirtualMachines; diff --git a/src/views/udns/details/tabs/details/UDNDetailsTab.tsx b/src/views/udns/details/tabs/details/UDNDetailsTab.tsx new file mode 100644 index 00000000..d6dd8189 --- /dev/null +++ b/src/views/udns/details/tabs/details/UDNDetailsTab.tsx @@ -0,0 +1,136 @@ +import React, { FC } from 'react'; +import * as _ from 'lodash'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import { + ResourceLink, + Timestamp, + useAccessReview, + useAnnotationsModal, + useLabelsModal, +} from '@openshift-console/dynamic-plugin-sdk'; +import { + Button, + ButtonVariant, + DescriptionList as DL, + Grid, + GridItem, + PageSection, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import { DetailsItem } from '@utils/components/DetailsItem/DetailsItem'; +import { LabelList } from '@utils/components/DetailsItem/LabelList'; +import DetailsSectionTitle from '@utils/components/DetailsSectionTitle/DetailsSectionTitle'; +import Loading from '@utils/components/Loading/Loading'; +import { OwnerReferences } from '@utils/components/OwnerReference/owner-references'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { getModel, getUDNConditions } from '@utils/resources/udns/selectors'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import UDNConditions from '@views/udns/list/components/UDNConditions'; + +import UDNLayerDetails from './UDNLayerDetails'; +import UDNTopologyDetails from './UDNTopologyDetails'; + +type UDNDetailsTabProps = { + obj?: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; +}; + +const UDNDetailsTab: FC = ({ obj: udn }) => { + const { t } = useNetworkingTranslation(); + const metadata = udn?.metadata; + const model = udn ? getModel(udn) : undefined; + const annotationsModalLauncher = useAnnotationsModal(udn); + const labelsModalLauncher = useLabelsModal(udn); + + const [canUpdate] = useAccessReview({ + group: model?.apiGroup, + name: metadata?.name, + namespace: metadata?.namespace, + resource: model?.plural, + verb: 'patch', + }); + + if (!udn || !model) { + return ( + + + + ); + } + + return ( + + + + +
+ + {metadata?.namespace && ( + + + + )} + + + + + {canUpdate ? ( + + ) : ( + t('{{count}} annotation', { + count: _.size(metadata?.annotations), + }) + )} + + + + + + + +
+
+ +
+ + + + + + + +
+
+
+
+ ); +}; + +export default UDNDetailsTab; diff --git a/src/views/udns/details/tabs/virtualMachines/UDNVirtualMachines.tsx b/src/views/udns/details/tabs/virtualMachines/UDNVirtualMachines.tsx new file mode 100644 index 00000000..1a14f480 --- /dev/null +++ b/src/views/udns/details/tabs/virtualMachines/UDNVirtualMachines.tsx @@ -0,0 +1,350 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import * as _ from 'lodash'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Bullseye, + Button, + Card, + CardBody, + EmptyState, + Label, + PageSection, + Pagination, + Spinner, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { + DataView, + DataViewFilters, + DataViewTable, + DataViewTextFilter, + DataViewTh, + DataViewToolbar, + useDataViewFilters, + useDataViewPagination, + useDataViewSort, +} from '@patternfly/react-data-view'; +import { Tbody, Td, Tr } from '@patternfly/react-table'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import { getVMStatus } from '@utils/resources/vm/selectors'; + +import useUDNVirtualMachines, { UDNVirtualMachine } from '../../hooks/useUDNVirtualMachines'; + +import AddUDNVirtualMachinesModal from './components/AddUDNVirtualMachinesModal'; +import UDNVirtualMachineRowActions from './components/UDNVirtualMachineRowActions'; + +type UDNVirtualMachineFilters = { + name?: string; +}; + +const COLUMN_COUNT = 5; +const DEFAULT_PER_PAGE = 20; +const SORT_COLUMN_KEYS = ['name', 'namespace', 'status', 'interface'] as const; + +const getVMStatusLabelColor = (status: string) => { + if (!status) { + return 'grey'; + } + + if (status.includes('Error') || status === 'Stopped') { + return 'red'; + } + + if (status === 'Running') { + return 'green'; + } + + return 'orange'; +}; + +const UDNVirtualMachines: FC<{ + obj?: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; +}> = ({ obj: udn }) => { + const { t } = useNetworkingTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const { availableVirtualMachines, getNADForVM, loaded, loadError, virtualMachines } = + useUDNVirtualMachines(udn); + + const { clearAllFilters, filters, onSetFilters } = useDataViewFilters({ + searchParams, + setSearchParams, + }); + + const { onPerPageSelect, onSetPage, page, perPage } = useDataViewPagination({ + page: 1, + perPage: DEFAULT_PER_PAGE, + searchParams, + setSearchParams, + }); + + const { direction, onSort, sortBy } = useDataViewSort({ + searchParams, + setSearchParams, + }); + + const filteredData = useMemo(() => { + const nameFilter = filters.name?.toLowerCase(); + + if (!nameFilter) { + return virtualMachines; + } + + return virtualMachines.filter(({ vm }) => getName(vm)?.toLowerCase().includes(nameFilter)); + }, [filters.name, virtualMachines]); + + const sortedData = useMemo(() => { + if (!sortBy) { + return filteredData; + } + + const getSortValue = (item: UDNVirtualMachine) => { + switch (sortBy) { + case 'name': + return getName(item.vm) ?? ''; + case 'namespace': + return getNamespace(item.vm) ?? ''; + case 'status': + return getVMStatus(item.vm); + case 'interface': + return item.interfaceName; + default: + return ''; + } + }; + + return _.orderBy(filteredData, [getSortValue], [direction === 'desc' ? 'desc' : 'asc']); + }, [direction, filteredData, sortBy]); + + const paginatedData = useMemo(() => { + const start = (page - 1) * perPage; + return sortedData.slice(start, start + perPage); + }, [page, perPage, sortedData]); + + const getSortParams = useCallback( + (columnIndex: number) => ({ + columnIndex, + onSort: ( + event: MouseEvent | React.KeyboardEvent | React.MouseEvent, + _index: number | string, + newDirection: 'asc' | 'desc', + ) => onSort(event, SORT_COLUMN_KEYS[columnIndex], newDirection), + sortBy: { direction, index: columnIndex }, + }), + [direction, onSort], + ); + + const columns = useMemo( + () => [ + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: , + }, + ], + [getSortParams, t], + ); + + const rows = useMemo( + () => + paginatedData.map(({ interfaceName, vm }) => { + const name = getName(vm); + const vmNamespace = getNamespace(vm); + const status = getVMStatus(vm); + + return { + id: `${vmNamespace}/${name}`, + row: [ + { + cell: ( + + ), + }, + { + cell: , + }, + { + cell: ( + + ), + }, + { + cell: interfaceName, + }, + { + cell: udn && ( + + ), + props: { isActionCell: true }, + }, + ], + }; + }), + [udn, paginatedData], + ); + + const activeState = useMemo(() => { + if (loadError) { + return 'error'; + } + + if (!loaded) { + return 'loading'; + } + + if (filteredData.length === 0) { + return 'empty'; + } + + return undefined; + }, [filteredData.length, loadError, loaded]); + + const bodyStates = useMemo( + () => ({ + empty: ( + + + + + + + + + + ), + error: ( + + + + + + + + + + ), + loading: ( + + + + + + + + + + ), + }), + [t], + ); + + return ( + + + + + + + + + + onSetFilters(newValues)} + values={filters} + > + + + } + pagination={ + + } + /> + + + + + + + {udn && ( + setIsAddModalOpen(false)} + /> + )} + + ); +}; + +export default UDNVirtualMachines; diff --git a/src/views/udns/details/tabs/virtualMachines/components/AddUDNVirtualMachinesModal.tsx b/src/views/udns/details/tabs/virtualMachines/components/AddUDNVirtualMachinesModal.tsx new file mode 100644 index 00000000..5ed28048 --- /dev/null +++ b/src/views/udns/details/tabs/virtualMachines/components/AddUDNVirtualMachinesModal.tsx @@ -0,0 +1,460 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import * as _ from 'lodash'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { + Bullseye, + Button, + EmptyState, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Pagination, + Spinner, +} from '@patternfly/react-core'; +import { + DataView, + DataViewFilters, + DataViewTable, + DataViewTextFilter, + DataViewTh, + DataViewToolbar, + DataViewTr, + isDataViewTrObject, + useDataViewFilters, + useDataViewPagination, + useDataViewSelection, + useDataViewSort, +} from '@patternfly/react-data-view'; +import { Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { getVMStatus } from '@utils/resources/vm/selectors'; +import { addVMToNAD } from '@utils/resources/vm/utils'; +import { networkConsole } from '@utils/utils/helpers'; + +type AddUDNVirtualMachinesModalProps = { + availableVMs: V1VirtualMachine[]; + getNADForVM: (vm: V1VirtualMachine) => NetworkAttachmentDefinitionKind | undefined; + isOpen: boolean; + loaded: boolean; + onClose: () => void; +}; + +type AddVMFilters = { + name?: string; +}; + +const COLUMN_COUNT = 4; +const DEFAULT_PER_PAGE = 20; +const SORT_COLUMN_KEYS = ['name', 'namespace', 'status'] as const; + +const getVMStatusLabelColor = (status: string) => { + if (!status) { + return 'grey'; + } + + if (status.includes('Error') || status === 'Stopped') { + return 'red'; + } + + if (status === 'Running') { + return 'green'; + } + + return 'orange'; +}; + +const AddUDNVirtualMachinesModal: FC = ({ + availableVMs, + getNADForVM, + isOpen, + loaded, + onClose, +}) => { + const { t } = useNetworkingTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { clearAllFilters, filters, onSetFilters } = useDataViewFilters({}); + + const { onPerPageSelect, onSetPage, page, perPage } = useDataViewPagination({ + page: 1, + perPage: DEFAULT_PER_PAGE, + }); + + const { direction, onSort, sortBy } = useDataViewSort({}); + + const { + isSelected: isVMSelected, + onSelect: onVMSelect, + selected, + setSelected, + } = useDataViewSelection({ + matchOption: (item, another) => + getName(item) === getName(another) && getNamespace(item) === getNamespace(another), + }); + + useEffect(() => { + if (isOpen) { + setSelected([]); + } + // Only reset when the modal opens; setSelected is not stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const filteredData = useMemo(() => { + const nameFilter = filters.name?.toLowerCase(); + + if (!nameFilter) { + return availableVMs; + } + + return availableVMs.filter((vm) => getName(vm)?.toLowerCase().includes(nameFilter)); + }, [availableVMs, filters.name]); + + const sortedData = useMemo(() => { + if (!sortBy) { + return filteredData; + } + + const getSortValue = (vm: V1VirtualMachine) => { + switch (sortBy) { + case 'name': + return getName(vm) ?? ''; + case 'namespace': + return getNamespace(vm) ?? ''; + case 'status': + return getVMStatus(vm); + default: + return ''; + } + }; + + return _.orderBy(filteredData, [getSortValue], [direction === 'desc' ? 'desc' : 'asc']); + }, [direction, filteredData, sortBy]); + + const paginatedData = useMemo(() => { + const start = (page - 1) * perPage; + return sortedData.slice(start, start + perPage); + }, [page, perPage, sortedData]); + + const vmByRowId = useMemo( + () => + new Map( + paginatedData.map((vm) => [`${getNamespace(vm)}/${getName(vm)}`, vm]), + ), + [paginatedData], + ); + + const areAllPaginatedSelected = useMemo( + () => paginatedData.length > 0 && paginatedData.every((vm) => isVMSelected(vm)), + [isVMSelected, paginatedData], + ); + + const areSomePaginatedSelected = useMemo( + () => paginatedData.some((vm) => isVMSelected(vm)) && !areAllPaginatedSelected, + [areAllPaginatedSelected, isVMSelected, paginatedData], + ); + + const vmMatches = useCallback( + (a: V1VirtualMachine, b: V1VirtualMachine) => + getName(a) === getName(b) && getNamespace(a) === getNamespace(b), + [], + ); + + const onSelectAll = useCallback( + (_event: React.FormEvent, isSelecting: boolean) => { + if (isSelecting) { + const newSelected = [...selected]; + + paginatedData.forEach((vm) => { + if (!newSelected.some((item) => vmMatches(item, vm))) { + newSelected.push(vm); + } + }); + setSelected(newSelected); + return; + } + + setSelected(selected.filter((item) => !paginatedData.some((vm) => vmMatches(item, vm)))); + }, + [paginatedData, selected, setSelected, vmMatches], + ); + + const isAddDisabled = isSubmitting || availableVMs.length === 0 || selected.length === 0; + + const getSortParams = useCallback( + (columnIndex: number) => ({ + columnIndex, + onSort: ( + event: MouseEvent | React.KeyboardEvent | React.MouseEvent, + _index: number | string, + newDirection: 'asc' | 'desc', + ) => onSort(event, SORT_COLUMN_KEYS[columnIndex], newDirection), + sortBy: { direction, index: columnIndex }, + }), + [direction, onSort], + ); + + const columns = useMemo( + () => [ + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + { + cell: ( + + ), + }, + ], + [getSortParams, t], + ); + + const rows = useMemo( + () => + paginatedData.map((vm) => { + const name = getName(vm); + const vmNamespace = getNamespace(vm); + const status = getVMStatus(vm); + const rowId = `${vmNamespace}/${name}`; + + return { + id: rowId, + row: [ + { + cell: ( + + ), + }, + { + cell: , + }, + { + cell: ( + + ), + }, + ], + }; + }), + [paginatedData], + ); + + const selection = useMemo( + () => ({ + isSelectDisabled: () => isSubmitting, + isSelected: (row: DataViewTr) => { + const rowId = isDataViewTrObject(row) ? row.id : undefined; + const vm = rowId ? vmByRowId.get(rowId) : undefined; + + return vm ? isVMSelected(vm) : false; + }, + onSelect: (isSelecting: boolean, row: DataViewTr | DataViewTr[]) => { + const rowsToProcess = Array.isArray(row) ? row : [row]; + + rowsToProcess.forEach((item) => { + const rowId = isDataViewTrObject(item) ? item.id : undefined; + const vm = rowId ? vmByRowId.get(rowId) : undefined; + + if (vm) { + onVMSelect(isSelecting, vm); + } + }); + }, + }), + [isSubmitting, isVMSelected, onVMSelect, vmByRowId], + ); + + const activeState = useMemo(() => { + if (!loaded) { + return 'loading'; + } + + if (filteredData.length === 0) { + return 'empty'; + } + + return 'data'; + }, [filteredData.length, loaded]); + + const headStates = useMemo( + () => ({ + data: ( + + + + {columns.map((column, index) => ( + {column.cell} + ))} + + + ), + }), + [ + areAllPaginatedSelected, + areSomePaginatedSelected, + columns, + isSubmitting, + onSelectAll, + paginatedData.length, + t, + ], + ); + + const bodyStates = useMemo( + () => ({ + empty: ( + + + + + + + + + + ), + loading: ( + + + + + + + + + + ), + }), + [t], + ); + + const handleAdd = async () => { + if (selected.length === 0) { + return; + } + + setIsSubmitting(true); + + try { + await Promise.all( + selected.map((vm) => { + const nad = getNADForVM(vm); + + if (!nad) { + networkConsole.warn('Failed to add virtual machine to network: NAD not found', vm); + return Promise.resolve(); + } + + return addVMToNAD(vm, nad); + }), + ); + onClose(); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + onSetFilters(newValues)} + values={filters} + > + + + } + pagination={ + + } + /> + + + + + + + + + ); +}; + +export default AddUDNVirtualMachinesModal; diff --git a/src/views/udns/details/tabs/virtualMachines/components/UDNVirtualMachineRowActions.tsx b/src/views/udns/details/tabs/virtualMachines/components/UDNVirtualMachineRowActions.tsx new file mode 100644 index 00000000..f9417952 --- /dev/null +++ b/src/views/udns/details/tabs/virtualMachines/components/UDNVirtualMachineRowActions.tsx @@ -0,0 +1,73 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { Trans } from 'react-i18next'; + +import { modelToGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { Action, ResourceLink, useModal } from '@openshift-console/dynamic-plugin-sdk'; +import { ButtonVariant } from '@patternfly/react-core'; +import ActionsDropdown from '@utils/components/ActionsDropdown/ActionsDropdown'; +import ConfirmModal, { ConfirmModalProps } from '@utils/components/ConfirmModal/ConfirmModal'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import { asAccessReview, getName, getNamespace } from '@utils/resources/shared'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import { removeVMFromNAD } from '@utils/resources/vm/utils'; +import { networkConsole } from '@utils/utils/helpers'; + +type UDNVirtualMachineRowActionsProps = { + interfaceName: string; + udn: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; + vm: V1VirtualMachine; +}; + +const UDNVirtualMachineRowActions: FC = ({ + interfaceName, + udn, + vm, +}) => { + const { t } = useNetworkingTranslation(); + const createModal = useModal(); + const vmName = getName(vm); + const vmNamespace = getNamespace(vm); + const udnName = getName(udn); + + const onRemove = useCallback(() => { + createModal(ConfirmModal, { + btnText: t('Remove'), + btnVariant: ButtonVariant.danger, + executeFn: () => { + removeVMFromNAD(vm, interfaceName).catch((error) => { + networkConsole.warn('Failed to remove virtual machine from network', error); + }); + }, + message: ( + + Are you sure you want to remove{' '} + {' '} + from {udnName}? + + ), + title: t('Remove virtual machine from network?'), + }); + }, [createModal, interfaceName, t, udnName, vm, vmName, vmNamespace]); + + const actions = useMemo( + () => [ + { + accessReview: asAccessReview(VirtualMachineModel, vm, 'update'), + cta: onRemove, + id: 'remove-vm-from-udn-network', + label: t('Remove from network'), + }, + ], + [onRemove, t, vm], + ); + + return ; +}; + +export default UDNVirtualMachineRowActions; diff --git a/src/views/udns/details/tabs/yaml/UDNYAML.tsx b/src/views/udns/details/tabs/yaml/UDNYAML.tsx new file mode 100644 index 00000000..b6f42c22 --- /dev/null +++ b/src/views/udns/details/tabs/yaml/UDNYAML.tsx @@ -0,0 +1,21 @@ +import React, { FC, Suspense } from 'react'; + +import { ResourceYAMLEditor } from '@openshift-console/dynamic-plugin-sdk'; +import Loading from '@utils/components/Loading/Loading'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; + +type UDNYAMLProps = { + obj?: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; +}; + +const UDNYAML: FC = ({ obj: udn }) => { + return !udn ? ( + + ) : ( + }> + + + ); +}; + +export default UDNYAML; diff --git a/src/views/udns/manifest.ts b/src/views/udns/manifest.ts index 71b018f5..71a5c2a1 100644 --- a/src/views/udns/manifest.ts +++ b/src/views/udns/manifest.ts @@ -1,5 +1,5 @@ import { - DetailsItem, + ResourceDetailsPage, ResourceListPage, ResourceNSNavItem, RoutePage, @@ -77,46 +77,18 @@ export const UserDefinedNetworksExtensions: EncodedExtension[] = [ } as EncodedExtension, { properties: { - column: 'right', - component: { $codeRef: 'UserDefinedNetworkTopologyDetails' }, - id: 'cudn-topology-detail-item', + component: { $codeRef: 'UDNDetailsPage' }, model: ClusterUserDefinedNetworkExtensionModel, - path: 'spec.network.topology', - title: 'Topology', }, - type: 'console.resource/details-item', - } as EncodedExtension, + type: 'console.page/resource/details', + } as EncodedExtension, { properties: { - column: 'right', - component: { $codeRef: 'UserDefinedNetworkLayerDetails' }, - id: 'cudn-layer-detail-item', - model: ClusterUserDefinedNetworkExtensionModel, - title: '', - }, - type: 'console.resource/details-item', - } as EncodedExtension, - { - properties: { - column: 'right', - component: { $codeRef: 'UserDefinedNetworkTopologyDetails' }, - id: 'udn-topology-detail-item', - model: UserDefinedNetworkExtensionModel, - path: 'spec.topology', - title: 'Topology', - }, - type: 'console.resource/details-item', - } as EncodedExtension, - { - properties: { - column: 'right', - component: { $codeRef: 'UserDefinedNetworkLayerDetails' }, - id: 'udn-layer-detail-item', + component: { $codeRef: 'UDNDetailsPage' }, model: UserDefinedNetworkExtensionModel, - title: '', }, - type: 'console.resource/details-item', - } as EncodedExtension, + type: 'console.page/resource/details', + } as EncodedExtension, { properties: { component: { @@ -146,8 +118,7 @@ export const UserDefinedNetworksExtensions: EncodedExtension[] = [ ]; export const UserDefinedNetworksExposedModules: ConsolePluginBuildMetadata['exposedModules'] = { + UDNDetailsPage: './views/udns/details/UDNDetailsPage.tsx', UserDefinedNetworkFormPage: './views/udns/form/UserDefinedNetworkFormPage.tsx', - UserDefinedNetworkLayerDetails: './views/udns/details/tabs/details/UDNLayerDetails.tsx', UserDefinedNetworksList: './views/udns/list/UserDefinedNetworksList.tsx', - UserDefinedNetworkTopologyDetails: './views/udns/details/tabs/details/UDNTopologyDetails.tsx', };