From 1c968ee3b8d944f707f71a2caefd127515a52c86 Mon Sep 17 00:00:00 2001 From: Andrew Lavery Date: Tue, 24 Oct 2023 13:23:16 -0600 Subject: [PATCH] Embedded cluster working group (#4076) * implement 'IsHelmVM' function based on presence of configmap if embedded-cluster-config exists in kube-system it is helmvm add node metrics add node pod capacity and list of pods to node metrics implement per-node metrics endpoint with podlist * generate a node join token (#4072) * generate a node join token * two mutexes, and do not restart successful pods * generate the full node join command * all controllers are also workers * allow arbitrary node roles * role is controller not controller+worker * change node join command API to accept a list of roles * Add view node page, add new add node modal (#4065) * wip: refactor helm cluster page, add view node page * add new add node modal * protect routes * move parenthesis * use test data for now * add material react table * start connecting additional api calls, add test pod data * add material react table to display pods * revert change * uncomment real queries * fix useparams import * fix params/routing, update api route * fix loading/refetch state * update generate add node request * add error handling, add mui react table to cluster manage page * move ts-ignore line * remove delete functionality for now * update tanstack query imports * shorter embedded-cluster join commands (#4075) * shorter commands wip * actually return the token * actually return token * return entire commands * handle error * fix lolgic * imports * update percentages, add pods, fix link, show expiry (#4077) * fix routing, add missing slash (#4079) * remove test data, uncomment route protection, fix redirect after license upload (#4081) * 24, not 60-something, character join tokens (#4080) * node usage metrics not being collected is not a fatal error (#4082) * include pod usage metrics (#4083) add kube-proxy/os/kernel to node metrics return 'used' not 'available' * remove pause and delete columns, update styles (#4085) * remove pause and delete columns until the api code is ready, add loading state, update styles * update variable names, fix redirect to cluster manage * include 'sudo ./' at the beginning of the node join command (#4088) * format pod CPU and memory usage before returning (#4086) * fixes: clipboard, redirect, columns styling/formatting (#4090) * make clipboard work with http, fix redirect to cluster manage page * right align columns, remove placeholders, add namespace column * determine node roles based on their labels (#4089) * fix vet (#4084) * fix vet * fix tests * complete mock handler * more test * mockgen * cleanup the k0s join token creation pod after completion (#4091) * fix redirect, add column types (#4092) * make labels optional (#4094) * check for labels * make labels optional type * remove the --force flag (#4097) * chore: change embedded cluster config map namespace (#4100) * implement 'IsHelmVM' function based on presence of configmap if embedded-cluster-config exists in kube-system it is helmvm * change namespace of embedded cluster config --------- Co-authored-by: Andrew Lavery * update redirect to cluster manage page, comment test data (#4096) * improve logic around initial cluster flow, comment test data * fix types, redirect on unknown or pending config status if helmvm * node role labels (#4093) * node role labels * handle having no labels on the first node * f * include a prefix on the label * = not : * fix config redirect, remove unnecessary code (#4104) * fix config redirect, remove unnecessary code * linting * quite a bit of renaming (#4106) * renaming things to 'embedded-cluster' * rename frontend * import ordering * undo goland's wonderful formatting changes * function naming * undo whitespace change * Apply suggestions from code review Co-authored-by: Salah Al Saleh * address review comments * return to excluding helmvm from some tests * another set of mistaken find-replace --------- Co-authored-by: Star Richardson <67430892+alicenstar@users.noreply.github.com> Co-authored-by: Diamon Wiggins <38189728+diamonwiggins@users.noreply.github.com> Co-authored-by: Salah Al Saleh --- .github/actions/cmx-versions/dist/index.js | 2 +- migrations/tables/embeded_cluster_tokens.yaml | 21 + .../delete_node.go | 2 +- pkg/{helmvm => embeddedcluster}/drain_node.go | 2 +- pkg/{helmvm => embeddedcluster}/exec.go | 2 +- pkg/embeddedcluster/helmvm_node.go | 169 ++++++ pkg/embeddedcluster/helmvm_nodes.go | 108 ++++ pkg/embeddedcluster/node_join.go | 345 +++++++++++ pkg/embeddedcluster/types/types.go | 48 ++ pkg/embeddedcluster/util.go | 54 ++ ...ode.go => embedded_cluster_delete_node.go} | 6 +- ...node.go => embedded_cluster_drain_node.go} | 6 +- pkg/handlers/embedded_cluster_get.go | 45 ++ .../embedded_cluster_node_join_command.go | 114 ++++ pkg/handlers/handlers.go | 27 +- pkg/handlers/handlers_test.go | 25 +- pkg/handlers/helmvm_get.go | 26 - pkg/handlers/helmvm_node_join_command.go | 55 -- pkg/handlers/interface.go | 12 +- pkg/handlers/metadata.go | 14 +- pkg/handlers/mock/mock.go | 84 +-- pkg/helmvm/helmvm_nodes.go | 212 ------- pkg/helmvm/node_join.go | 12 - pkg/helmvm/types/types.go | 33 -- pkg/helmvm/util.go | 13 - pkg/kotsadm/metadata.go | 10 +- pkg/kotsadm/types/metadata.go | 6 +- pkg/store/kotsstore/embedded_cluster_store.go | 69 +++ pkg/store/mock/mock.go | 83 +++ pkg/store/store_interface.go | 6 + pkg/store/types/constants.go | 16 +- pkg/util/image.go | 26 + web/package.json | 5 + web/src/Root.tsx | 72 ++- web/src/components/UploadLicenseFile.tsx | 47 +- web/src/components/apps/AppDetailPage.tsx | 21 +- web/src/components/apps/AppVersionHistory.tsx | 10 +- .../apps/EmbeddedClusterManagement.tsx | 495 ++++++++++++++++ .../apps/EmbeddedClusterViewNode.jsx | 215 +++++++ ....test.js => EmbeddedClustrNodeRow.test.js} | 2 +- .../apps/HelmVMClusterManagement.jsx | 544 ------------------ web/src/components/apps/HelmVMNodeRow.jsx | 278 --------- web/src/components/shared/CodeSnippet.jsx | 28 +- web/src/components/shared/NavBar.tsx | 33 +- .../AppVersionHistoryRow.tsx | 9 +- .../components/DashboardVersionCard.tsx | 6 +- ...nt.scss => EmbeddedClusterManagement.scss} | 31 +- web/src/types/index.ts | 7 +- web/tailwind.config.js | 65 ++- web/yarn.lock | 360 +++++++++++- 50 files changed, 2474 insertions(+), 1407 deletions(-) create mode 100644 migrations/tables/embeded_cluster_tokens.yaml rename pkg/{helmvm => embeddedcluster}/delete_node.go (90%) rename pkg/{helmvm => embeddedcluster}/drain_node.go (88%) rename pkg/{helmvm => embeddedcluster}/exec.go (94%) create mode 100644 pkg/embeddedcluster/helmvm_node.go create mode 100644 pkg/embeddedcluster/helmvm_nodes.go create mode 100644 pkg/embeddedcluster/node_join.go create mode 100644 pkg/embeddedcluster/types/types.go create mode 100644 pkg/embeddedcluster/util.go rename pkg/handlers/{helmvm_delete_node.go => embedded_cluster_delete_node.go} (82%) rename pkg/handlers/{helmvm_drain_node.go => embedded_cluster_drain_node.go} (82%) create mode 100644 pkg/handlers/embedded_cluster_get.go create mode 100644 pkg/handlers/embedded_cluster_node_join_command.go delete mode 100644 pkg/handlers/helmvm_get.go delete mode 100644 pkg/handlers/helmvm_node_join_command.go delete mode 100644 pkg/helmvm/helmvm_nodes.go delete mode 100644 pkg/helmvm/node_join.go delete mode 100644 pkg/helmvm/types/types.go delete mode 100644 pkg/helmvm/util.go create mode 100644 pkg/store/kotsstore/embedded_cluster_store.go create mode 100644 pkg/util/image.go create mode 100644 web/src/components/apps/EmbeddedClusterManagement.tsx create mode 100644 web/src/components/apps/EmbeddedClusterViewNode.jsx rename web/src/components/apps/{HelmVMNodeRow.test.js => EmbeddedClustrNodeRow.test.js} (57%) delete mode 100644 web/src/components/apps/HelmVMClusterManagement.jsx delete mode 100644 web/src/components/apps/HelmVMNodeRow.jsx rename web/src/scss/components/apps/{HelmVMClusterManagement.scss => EmbeddedClusterManagement.scss} (60%) diff --git a/.github/actions/cmx-versions/dist/index.js b/.github/actions/cmx-versions/dist/index.js index 85624a1de8..6a28fb5826 100644 --- a/.github/actions/cmx-versions/dist/index.js +++ b/.github/actions/cmx-versions/dist/index.js @@ -7710,4 +7710,4 @@ getClusterVersions(); module.exports = __webpack_exports__; /******/ })() -; \ No newline at end of file +; diff --git a/migrations/tables/embeded_cluster_tokens.yaml b/migrations/tables/embeded_cluster_tokens.yaml new file mode 100644 index 0000000000..6c233ba4d9 --- /dev/null +++ b/migrations/tables/embeded_cluster_tokens.yaml @@ -0,0 +1,21 @@ +apiVersion: schemas.schemahero.io/v1alpha4 +kind: Table +metadata: + name: embedded-cluster-tokens +spec: + name: embedded_cluster_tokens + requires: [] + schema: + rqlite: + strict: true + primaryKey: + - token + columns: + - name: token + type: text + constraints: + notNull: true + - name: roles + type: text + constraints: + notNull: true diff --git a/pkg/helmvm/delete_node.go b/pkg/embeddedcluster/delete_node.go similarity index 90% rename from pkg/helmvm/delete_node.go rename to pkg/embeddedcluster/delete_node.go index 24d7e5e46d..a50a99eb75 100644 --- a/pkg/helmvm/delete_node.go +++ b/pkg/embeddedcluster/delete_node.go @@ -1,4 +1,4 @@ -package helmvm +package embeddedcluster import ( "context" diff --git a/pkg/helmvm/drain_node.go b/pkg/embeddedcluster/drain_node.go similarity index 88% rename from pkg/helmvm/drain_node.go rename to pkg/embeddedcluster/drain_node.go index b8fa55afbb..0d1439b979 100644 --- a/pkg/helmvm/drain_node.go +++ b/pkg/embeddedcluster/drain_node.go @@ -1,4 +1,4 @@ -package helmvm +package embeddedcluster import ( "context" diff --git a/pkg/helmvm/exec.go b/pkg/embeddedcluster/exec.go similarity index 94% rename from pkg/helmvm/exec.go rename to pkg/embeddedcluster/exec.go index 04f94635de..be71e0a014 100644 --- a/pkg/helmvm/exec.go +++ b/pkg/embeddedcluster/exec.go @@ -1,4 +1,4 @@ -package helmvm +package embeddedcluster import ( corev1client "k8s.io/client-go/kubernetes/typed/core/v1" diff --git a/pkg/embeddedcluster/helmvm_node.go b/pkg/embeddedcluster/helmvm_node.go new file mode 100644 index 0000000000..4443e3cdf8 --- /dev/null +++ b/pkg/embeddedcluster/helmvm_node.go @@ -0,0 +1,169 @@ +package embeddedcluster + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + "github.com/replicatedhq/kots/pkg/embeddedcluster/types" + "github.com/replicatedhq/kots/pkg/k8sutil" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + metricsv "k8s.io/metrics/pkg/client/clientset/versioned" +) + +// GetNode will get a node with stats and podlist +func GetNode(ctx context.Context, client kubernetes.Interface, nodeName string) (*types.Node, error) { + node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get node %s: %w", nodeName, err) + } + + clientConfig, err := k8sutil.GetClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to get cluster config: %w", err) + } + + metricsClient, err := metricsv.NewForConfig(clientConfig) + if err != nil { + return nil, fmt.Errorf("failed to create metrics client: %w", err) + } + + return nodeMetrics(ctx, client, metricsClient, *node) +} + +func podsOnNode(ctx context.Context, client kubernetes.Interface, nodeName string) ([]corev1.Pod, error) { + namespaces, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list namespaces: %w", err) + } + + toReturn := []corev1.Pod{} + + for _, ns := range namespaces.Items { + nsPods, err := client.CoreV1().Pods(ns.Name).List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("spec.nodeName=%s", nodeName)}) + if err != nil { + return nil, fmt.Errorf("list pods on %s in namespace %s: %w", nodeName, ns.Name, err) + } + + toReturn = append(toReturn, nsPods.Items...) + } + return toReturn, nil +} + +// nodeMetrics takes a corev1.Node and gets metrics + status for that node +func nodeMetrics(ctx context.Context, client kubernetes.Interface, metricsClient *metricsv.Clientset, node corev1.Node) (*types.Node, error) { + nodePods, err := podsOnNode(ctx, client, node.Name) + if err != nil { + return nil, fmt.Errorf("pods per node: %w", err) + } + + cpuCapacity := types.CapacityUsed{} + memoryCapacity := types.CapacityUsed{} + podCapacity := types.CapacityUsed{} + + memoryCapacity.Capacity = float64(node.Status.Capacity.Memory().Value()) / math.Pow(2, 30) // capacity in GB + + cpuCapacity.Capacity, err = strconv.ParseFloat(node.Status.Capacity.Cpu().String(), 64) + if err != nil { + return nil, fmt.Errorf("parse CPU capacity %q for node %s: %w", node.Status.Capacity.Cpu().String(), node.Name, err) + } + + podCapacity.Capacity = float64(node.Status.Capacity.Pods().Value()) + + nodeUsageMetrics, err := metricsClient.MetricsV1beta1().NodeMetricses().Get(ctx, node.Name, metav1.GetOptions{}) + if err == nil { + if nodeUsageMetrics.Usage.Memory() != nil { + memoryCapacity.Used = float64(nodeUsageMetrics.Usage.Memory().Value()) / math.Pow(2, 30) + } + + if nodeUsageMetrics.Usage.Cpu() != nil { + cpuCapacity.Used = nodeUsageMetrics.Usage.Cpu().AsApproximateFloat64() + } + } else { + // if we can't get metrics, we'll do nothing for now + // in the future we may decide to retry or log a warning + } + + podCapacity.Used = float64(len(nodePods)) + + podInfo := []types.PodInfo{} + + for _, pod := range nodePods { + newInfo := types.PodInfo{ + Name: pod.Name, + Namespace: pod.Namespace, + Status: string(pod.Status.Phase), + } + + podMetrics, err := metricsClient.MetricsV1beta1().PodMetricses(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{}) + if err == nil { + podTotalMemory := 0.0 + podTotalCPU := 0.0 + for _, container := range podMetrics.Containers { + if container.Usage.Memory() != nil { + podTotalMemory += float64(container.Usage.Memory().Value()) / math.Pow(2, 20) + } + if container.Usage.Cpu() != nil { + podTotalCPU += container.Usage.Cpu().AsApproximateFloat64() * 1000 + } + } + newInfo.Memory = fmt.Sprintf("%.1f MB", podTotalMemory) + newInfo.CPU = fmt.Sprintf("%.1f m", podTotalCPU) + } + + podInfo = append(podInfo, newInfo) + } + + return &types.Node{ + Name: node.Name, + IsConnected: isConnected(node), + IsReady: isReady(node), + IsPrimaryNode: isPrimary(node), + CanDelete: node.Spec.Unschedulable && !isConnected(node), + KubeletVersion: node.Status.NodeInfo.KubeletVersion, + KubeProxyVersion: node.Status.NodeInfo.KubeProxyVersion, + OperatingSystem: node.Status.NodeInfo.OperatingSystem, + KernelVersion: node.Status.NodeInfo.KernelVersion, + CPU: cpuCapacity, + Memory: memoryCapacity, + Pods: podCapacity, + Labels: nodeRolesFromLabels(node.Labels), + Conditions: findNodeConditions(node.Status.Conditions), + PodList: podInfo, + }, nil +} + +// nodeRolesFromLabels parses a map of k8s node labels, and returns the roles of the node +func nodeRolesFromLabels(labels map[string]string) []string { + toReturn := []string{} + + numRolesStr, ok := labels[types.EMBEDDED_CLUSTER_ROLE_LABEL] + if !ok { + // the first node will not initially have a role label, but is a 'controller' + if val, ok := labels["node-role.kubernetes.io/control-plane"]; ok && val == "true" { + return []string{"controller"} + } + return nil + } + numRoles, err := strconv.Atoi(strings.TrimPrefix(numRolesStr, "total-")) + if err != nil { + fmt.Printf("failed to parse role label %q: %s", numRolesStr, err.Error()) + + return nil + } + + for i := 0; i < numRoles; i++ { + roleLabel, ok := labels[fmt.Sprintf("%s-%d", types.EMBEDDED_CLUSTER_ROLE_LABEL, i)] + if !ok { + fmt.Printf("failed to find role label %d", i) + continue + } + toReturn = append(toReturn, roleLabel) + } + + return toReturn +} diff --git a/pkg/embeddedcluster/helmvm_nodes.go b/pkg/embeddedcluster/helmvm_nodes.go new file mode 100644 index 0000000000..7b0eb7729e --- /dev/null +++ b/pkg/embeddedcluster/helmvm_nodes.go @@ -0,0 +1,108 @@ +package embeddedcluster + +import ( + "context" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/embeddedcluster/types" + "github.com/replicatedhq/kots/pkg/k8sutil" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + metricsv "k8s.io/metrics/pkg/client/clientset/versioned" +) + +// GetNodes will get a list of nodes with stats +func GetNodes(ctx context.Context, client kubernetes.Interface) (*types.EmbeddedClusterNodes, error) { + nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, errors.Wrap(err, "list nodes") + } + + clientConfig, err := k8sutil.GetClusterConfig() + if err != nil { + return nil, errors.Wrap(err, "failed to get cluster config") + } + + metricsClient, err := metricsv.NewForConfig(clientConfig) + if err != nil { + return nil, errors.Wrap(err, "failed to create metrics client") + } + + toReturn := types.EmbeddedClusterNodes{} + + for _, node := range nodes.Items { + nodeMet, err := nodeMetrics(ctx, client, metricsClient, node) + if err != nil { + return nil, errors.Wrap(err, "node metrics") + } + + toReturn.Nodes = append(toReturn.Nodes, *nodeMet) + } + + isEmbeddedCluster, err := IsEmbeddedCluster(client) + if err != nil { + return nil, errors.Wrap(err, "is embeddedcluster") + } + toReturn.IsEmbeddedClusterEnabled = isEmbeddedCluster + + isHA, err := IsHA(client) + if err != nil { + return nil, errors.Wrap(err, "is ha") + } + toReturn.HA = isHA + + return &toReturn, nil +} + +func findNodeConditions(conditions []corev1.NodeCondition) types.NodeConditions { + discoveredConditions := types.NodeConditions{} + for _, condition := range conditions { + if condition.Type == "MemoryPressure" { + discoveredConditions.MemoryPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "DiskPressure" { + discoveredConditions.DiskPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "PIDPressure" { + discoveredConditions.PidPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "Ready" { + discoveredConditions.Ready = condition.Status == corev1.ConditionTrue + } + } + return discoveredConditions +} + +func isConnected(node corev1.Node) bool { + for _, taint := range node.Spec.Taints { + if taint.Key == "node.kubernetes.io/unreachable" { + return false + } + } + + return true +} + +func isReady(node corev1.Node) bool { + for _, condition := range node.Status.Conditions { + if condition.Type == "Ready" { + return condition.Status == corev1.ConditionTrue + } + } + + return false +} + +func isPrimary(node corev1.Node) bool { + for label := range node.ObjectMeta.Labels { + if label == "node-role.kubernetes.io/master" { + return true + } + if label == "node-role.kubernetes.io/control-plane" { + return true + } + } + + return false +} diff --git a/pkg/embeddedcluster/node_join.go b/pkg/embeddedcluster/node_join.go new file mode 100644 index 0000000000..b7e85e9b35 --- /dev/null +++ b/pkg/embeddedcluster/node_join.go @@ -0,0 +1,345 @@ +package embeddedcluster + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/replicatedhq/kots/pkg/embeddedcluster/types" + "github.com/replicatedhq/kots/pkg/util" + corev1 "k8s.io/api/core/v1" + kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type joinTokenEntry struct { + Token string + Creation *time.Time + Mut sync.Mutex +} + +var joinTokenMapMut = sync.Mutex{} +var joinTokenMap = map[string]*joinTokenEntry{} + +// GenerateAddNodeToken will generate the embedded cluster node add command for a node with the specified roles +// join commands will last for 24 hours, and will be cached for 1 hour after first generation +func GenerateAddNodeToken(ctx context.Context, client kubernetes.Interface, nodeRole string) (string, error) { + // get the joinToken struct entry for this node role + joinTokenMapMut.Lock() + if _, ok := joinTokenMap[nodeRole]; !ok { + joinTokenMap[nodeRole] = &joinTokenEntry{} + } + joinToken := joinTokenMap[nodeRole] + joinTokenMapMut.Unlock() + + // lock the joinToken struct entry + joinToken.Mut.Lock() + defer joinToken.Mut.Unlock() + + // if the joinToken has been generated in the past hour, return it + if joinToken.Creation != nil && time.Now().Before(joinToken.Creation.Add(time.Hour)) { + return joinToken.Token, nil + } + + newToken, err := runAddNodeCommandPod(ctx, client, nodeRole) + if err != nil { + return "", fmt.Errorf("failed to run add node command pod: %w", err) + } + + now := time.Now() + joinToken.Token = newToken + joinToken.Creation = &now + + return newToken, nil +} + +// run a pod that will generate the add node token +func runAddNodeCommandPod(ctx context.Context, client kubernetes.Interface, nodeRole string) (string, error) { + podName := "k0s-token-generator-" + suffix := strings.Replace(nodeRole, "+", "-", -1) + podName += suffix + + // cleanup the pod if it already exists + err := client.CoreV1().Pods("kube-system").Delete(ctx, podName, metav1.DeleteOptions{}) + if err != nil { + if !kuberneteserrors.IsNotFound(err) { + return "", fmt.Errorf("failed to delete pod: %w", err) + } + } + + // get the kotsadm image, as we know that will always exist + kotsadmImage, err := util.ThisImage(ctx, client) + if err != nil { + return "", fmt.Errorf("failed to get kotsadm image: %w", err) + } + + hostPathFile := corev1.HostPathFile + hostPathDir := corev1.HostPathDirectory + _, err = client.CoreV1().Pods("kube-system").Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: "kube-system", + Labels: map[string]string{ + "replicated.app/embedded-cluster": "true", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + HostNetwork: true, + Volumes: []corev1.Volume{ + { + Name: "bin", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/usr/local/bin/k0s", + Type: &hostPathFile, + }, + }, + }, + { + Name: "lib", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/var/lib/k0s", + Type: &hostPathDir, + }, + }, + }, + { + Name: "etc", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/etc/k0s", + Type: &hostPathDir, + }, + }, + }, + { + Name: "run", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/run/k0s", + Type: &hostPathDir, + }, + }, + }, + }, + Affinity: &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + { + Key: "node.k0sproject.io/role", + Operator: corev1.NodeSelectorOpIn, + Values: []string{ + "control-plane", + }, + }, + }, + }, + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "k0s-token-generator", + Image: kotsadmImage, + Command: []string{"/mnt/k0s"}, + Args: []string{ + "token", + "create", + "--expiry", + "12h", + "--role", + nodeRole, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "bin", + MountPath: "/mnt/k0s", + }, + { + Name: "lib", + MountPath: "/var/lib/k0s", + }, + { + Name: "etc", + MountPath: "/etc/k0s", + }, + { + Name: "run", + MountPath: "/run/k0s", + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create pod: %w", err) + } + + // wait for the pod to complete + for { + pod, err := client.CoreV1().Pods("kube-system").Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get pod: %w", err) + } + + if pod.Status.Phase == corev1.PodSucceeded { + break + } + + if pod.Status.Phase == corev1.PodFailed { + return "", fmt.Errorf("pod failed") + } + + time.Sleep(time.Second) + } + + // get the logs from the completed pod + podLogs, err := client.CoreV1().Pods("kube-system").GetLogs(podName, &corev1.PodLogOptions{}).DoRaw(ctx) + if err != nil { + return "", fmt.Errorf("failed to get pod logs: %w", err) + } + + // delete the completed pod + err = client.CoreV1().Pods("kube-system").Delete(ctx, podName, metav1.DeleteOptions{}) + if err != nil { + return "", fmt.Errorf("failed to delete pod: %w", err) + } + + // the logs are just a join token, which needs to be added to other things to get a join command + return string(podLogs), nil +} + +// GenerateAddNodeCommand returns the command a user should run to add a node with the provided token +// the command will be of the form 'embeddedcluster node join ip:port UUID' +func GenerateAddNodeCommand(ctx context.Context, client kubernetes.Interface, token string) (string, error) { + cm, err := ReadConfigMap(client) + if err != nil { + return "", fmt.Errorf("failed to read configmap: %w", err) + } + + binaryName := cm.Data["embedded-binary-name"] + + // get the IP of a controller node + nodeIP, err := getControllerNodeIP(ctx, client) + if err != nil { + return "", fmt.Errorf("failed to get controller node IP: %w", err) + } + + // get the port of the 'admin-console' service + port, err := getAdminConsolePort(ctx, client) + if err != nil { + return "", fmt.Errorf("failed to get admin console port: %w", err) + } + + return fmt.Sprintf("sudo ./%s node join %s:%d %s", binaryName, nodeIP, port, token), nil +} + +// GenerateK0sJoinCommand returns the k0s node join command, without the token but with all other required flags +// (including node labels generated from the roles etc) +func GenerateK0sJoinCommand(ctx context.Context, client kubernetes.Interface, roles []string) (string, error) { + k0sRole := "worker" + for _, role := range roles { + if role == "controller" { + k0sRole = "controller" + } + } + + cmd := []string{"/usr/local/bin/k0s", "install", k0sRole} + if k0sRole == "controller" { + cmd = append(cmd, "--enable-worker") + } + + labels, err := getRolesNodeLabels(ctx, client, roles) + if err != nil { + return "", fmt.Errorf("failed to get role labels: %w", err) + } + cmd = append(cmd, "--labels", labels) + + return strings.Join(cmd, " "), nil +} + +// gets the port of the 'admin-console' service +func getAdminConsolePort(ctx context.Context, client kubernetes.Interface) (int32, error) { + svc, err := client.CoreV1().Services(util.PodNamespace).Get(ctx, "admin-console", metav1.GetOptions{}) + if err != nil { + return -1, fmt.Errorf("failed to get admin-console service: %w", err) + } + + for _, port := range svc.Spec.Ports { + if port.Name == "http" { + return port.NodePort, nil + } + } + return -1, fmt.Errorf("did not find port 'http' in service 'admin-console'") +} + +// getControllerNodeIP gets the IP of a healthy controller node +func getControllerNodeIP(ctx context.Context, client kubernetes.Interface) (string, error) { + nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list nodes: %w", err) + } + + for _, node := range nodes.Items { + if cp, ok := node.Labels["node-role.kubernetes.io/control-plane"]; !ok || cp != "true" { + continue + } + + for _, condition := range node.Status.Conditions { + if condition.Type == "Ready" && condition.Status == "True" { + for _, address := range node.Status.Addresses { + if address.Type == "InternalIP" { + return address.Address, nil + } + } + } + } + + } + + return "", fmt.Errorf("failed to find healthy controller node") +} + +func getRolesNodeLabels(ctx context.Context, client kubernetes.Interface, roles []string) (string, error) { + roleLabels := getRoleListLabels(roles) + + for _, role := range roles { + labels, err := getRoleNodeLabels(ctx, client, role) + if err != nil { + return "", fmt.Errorf("failed to get node labels for role %s: %w", role, err) + } + roleLabels = append(roleLabels, labels...) + } + + return strings.Join(roleLabels, ","), nil +} + +// TODO: look up role in cluster config, apply additional labels based on role +func getRoleNodeLabels(ctx context.Context, client kubernetes.Interface, role string) ([]string, error) { + toReturn := []string{} + + return toReturn, nil +} + +// getRoleListLabels returns the labels needed to identify the roles of this node in the future +// one label will be the number of roles, and then deterministic label names will be used to store the role names +func getRoleListLabels(roles []string) []string { + toReturn := []string{} + toReturn = append(toReturn, fmt.Sprintf("%s=total-%d", types.EMBEDDED_CLUSTER_ROLE_LABEL, len(roles))) + + for idx, role := range roles { + toReturn = append(toReturn, fmt.Sprintf("%s-%d=%s", types.EMBEDDED_CLUSTER_ROLE_LABEL, idx, role)) + } + + return toReturn +} diff --git a/pkg/embeddedcluster/types/types.go b/pkg/embeddedcluster/types/types.go new file mode 100644 index 0000000000..f9b93a4b8c --- /dev/null +++ b/pkg/embeddedcluster/types/types.go @@ -0,0 +1,48 @@ +package types + +const EMBEDDED_CLUSTER_LABEL = "kots.io/embedded-cluster" +const EMBEDDED_CLUSTER_ROLE_LABEL = EMBEDDED_CLUSTER_LABEL + "-role" + +type EmbeddedClusterNodes struct { + Nodes []Node `json:"nodes"` + HA bool `json:"ha"` + IsEmbeddedClusterEnabled bool `json:"isEmbeddedClusterEnabled"` +} + +type Node struct { + Name string `json:"name"` + IsConnected bool `json:"isConnected"` + IsReady bool `json:"isReady"` + IsPrimaryNode bool `json:"isPrimaryNode"` + CanDelete bool `json:"canDelete"` + KubeletVersion string `json:"kubeletVersion"` + KubeProxyVersion string `json:"kubeProxyVersion"` + OperatingSystem string `json:"operatingSystem"` + KernelVersion string `json:"kernelVersion"` + CPU CapacityUsed `json:"cpu"` + Memory CapacityUsed `json:"memory"` + Pods CapacityUsed `json:"pods"` + Labels []string `json:"labels"` + Conditions NodeConditions `json:"conditions"` + PodList []PodInfo `json:"podList"` +} + +type CapacityUsed struct { + Capacity float64 `json:"capacity"` + Used float64 `json:"used"` +} + +type NodeConditions struct { + MemoryPressure bool `json:"memoryPressure"` + DiskPressure bool `json:"diskPressure"` + PidPressure bool `json:"pidPressure"` + Ready bool `json:"ready"` +} + +type PodInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Namespace string `json:"namespace"` + CPU string `json:"cpu"` + Memory string `json:"memory"` +} diff --git a/pkg/embeddedcluster/util.go b/pkg/embeddedcluster/util.go new file mode 100644 index 0000000000..87f120b7d8 --- /dev/null +++ b/pkg/embeddedcluster/util.go @@ -0,0 +1,54 @@ +package embeddedcluster + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const configMapName = "embedded-cluster-config" +const configMapNamespace = "embedded-cluster" + +// ReadConfigMap will read the Kurl config from a configmap +func ReadConfigMap(client kubernetes.Interface) (*corev1.ConfigMap, error) { + return client.CoreV1().ConfigMaps(configMapNamespace).Get(context.TODO(), configMapName, metav1.GetOptions{}) +} + +func IsEmbeddedCluster(clientset kubernetes.Interface) (bool, error) { + if clientset == nil { + return false, fmt.Errorf("clientset is nil") + } + + configMapExists := false + _, err := ReadConfigMap(clientset) + if err == nil { + configMapExists = true + } else if kuberneteserrors.IsNotFound(err) { + configMapExists = false + } else if kuberneteserrors.IsUnauthorized(err) { + configMapExists = false + } else if kuberneteserrors.IsForbidden(err) { + configMapExists = false + } else if err != nil { + return false, fmt.Errorf("failed to get embedded cluster configmap: %w", err) + } + + return configMapExists, nil +} + +func IsHA(clientset kubernetes.Interface) (bool, error) { + return true, nil +} + +func ClusterID(client kubernetes.Interface) (string, error) { + configMap, err := ReadConfigMap(client) + if err != nil { + return "", fmt.Errorf("failed to read configmap: %w", err) + } + + return configMap.Data["embedded-cluster-id"], nil +} diff --git a/pkg/handlers/helmvm_delete_node.go b/pkg/handlers/embedded_cluster_delete_node.go similarity index 82% rename from pkg/handlers/helmvm_delete_node.go rename to pkg/handlers/embedded_cluster_delete_node.go index 1b732ab07f..ea55401654 100644 --- a/pkg/handlers/helmvm_delete_node.go +++ b/pkg/handlers/embedded_cluster_delete_node.go @@ -5,14 +5,14 @@ import ( "net/http" "github.com/gorilla/mux" - "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DeleteEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -41,7 +41,7 @@ func (h *Handler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { return } - if err := helmvm.DeleteNode(ctx, client, restconfig, node); err != nil { + if err := embeddedcluster.DeleteNode(ctx, client, restconfig, node); err != nil { logger.Error(err) w.WriteHeader(http.StatusInternalServerError) return diff --git a/pkg/handlers/helmvm_drain_node.go b/pkg/handlers/embedded_cluster_drain_node.go similarity index 82% rename from pkg/handlers/helmvm_drain_node.go rename to pkg/handlers/embedded_cluster_drain_node.go index ae0a337f6f..ae59dc5071 100644 --- a/pkg/handlers/helmvm_drain_node.go +++ b/pkg/handlers/embedded_cluster_drain_node.go @@ -5,14 +5,14 @@ import ( "net/http" "github.com/gorilla/mux" - "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DrainEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -36,7 +36,7 @@ func (h *Handler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { // This pod may get evicted and not be able to respond to the request go func() { - if err := helmvm.DrainNode(ctx, client, node); err != nil { + if err := embeddedcluster.DrainNode(ctx, client, node); err != nil { logger.Error(err) return } diff --git a/pkg/handlers/embedded_cluster_get.go b/pkg/handlers/embedded_cluster_get.go new file mode 100644 index 0000000000..b08d02072a --- /dev/null +++ b/pkg/handlers/embedded_cluster_get.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +func (h *Handler) GetEmbeddedClusterNodes(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + nodes, err := embeddedcluster.GetNodes(r.Context(), client) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, nodes) +} + +func (h *Handler) GetEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + nodeName := mux.Vars(r)["nodeName"] + node, err := embeddedcluster.GetNode(r.Context(), client, nodeName) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, node) +} diff --git a/pkg/handlers/embedded_cluster_node_join_command.go b/pkg/handlers/embedded_cluster_node_join_command.go new file mode 100644 index 0000000000..8b61429999 --- /dev/null +++ b/pkg/handlers/embedded_cluster_node_join_command.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/store" +) + +type GenerateEmbeddedClusterNodeJoinCommandResponse struct { + Command []string `json:"command"` +} + +type GetEmbeddedClusterNodeJoinCommandResponse struct { + ClusterID string `json:"clusterID"` + K0sJoinCommand string `json:"k0sJoinCommand"` + K0sToken string `json:"k0sToken"` +} + +type GenerateEmbeddedClusterNodeJoinCommandRequest struct { + Roles []string `json:"roles"` +} + +func (h *Handler) GenerateEmbeddedClusterNodeJoinCommand(w http.ResponseWriter, r *http.Request) { + generateEmbeddedClusterNodeJoinCommandRequest := GenerateEmbeddedClusterNodeJoinCommandRequest{} + if err := json.NewDecoder(r.Body).Decode(&generateEmbeddedClusterNodeJoinCommandRequest); err != nil { + logger.Error(fmt.Errorf("failed to decode request body: %w", err)) + w.WriteHeader(http.StatusBadRequest) + return + } + + token, err := store.GetStore().SetEmbeddedClusterInstallCommandRoles(generateEmbeddedClusterNodeJoinCommandRequest.Roles) + if err != nil { + logger.Error(fmt.Errorf("failed to set k0s install command roles: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(fmt.Errorf("failed to get clientset: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + nodeJoinCommand, err := embeddedcluster.GenerateAddNodeCommand(r.Context(), client, token) + if err != nil { + logger.Error(fmt.Errorf("failed to generate add node command: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + JSON(w, http.StatusOK, GenerateEmbeddedClusterNodeJoinCommandResponse{ + Command: []string{nodeJoinCommand}, + }) +} + +// this function relies on the token being valid for authentication +func (h *Handler) GetEmbeddedClusterNodeJoinCommand(w http.ResponseWriter, r *http.Request) { + // read query string, ensure that the token is valid + token := r.URL.Query().Get("token") + roles, err := store.GetStore().GetEmbeddedClusterInstallCommandRoles(token) + if err != nil { + logger.Error(fmt.Errorf("failed to get k0s install command roles: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // use roles to generate join token etc + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(fmt.Errorf("failed to get clientset: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + k0sRole := "worker" + for _, role := range roles { + if role == "controller" { + k0sRole = "controller" + break + } + } + + k0sToken, err := embeddedcluster.GenerateAddNodeToken(r.Context(), client, k0sRole) + if err != nil { + logger.Error(fmt.Errorf("failed to generate add node token: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + k0sJoinCommand, err := embeddedcluster.GenerateK0sJoinCommand(r.Context(), client, roles) + if err != nil { + logger.Error(fmt.Errorf("failed to generate k0s join command: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + clusterID, err := embeddedcluster.ClusterID(client) + if err != nil { + logger.Error(fmt.Errorf("failed to get cluster id: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + JSON(w, http.StatusOK, GetEmbeddedClusterNodeJoinCommandResponse{ + ClusterID: clusterID, + K0sJoinCommand: k0sJoinCommand, + K0sToken: k0sToken, + }) +} diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 637f1651ab..f8853fc4b1 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -275,18 +275,18 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT r.Name("GetKurlNodes").Path("/api/v1/kurl/nodes").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetKurlNodes)) - // HelmVM - r.Name("HelmVM").Path("/api/v1/helmvm").HandlerFunc(NotImplemented) - r.Name("GenerateHelmVMNodeJoinCommandSecondary").Path("/api/v1/helmvm/generate-node-join-command-secondary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandSecondary)) - r.Name("GenerateHelmVMNodeJoinCommandPrimary").Path("/api/v1/helmvm/generate-node-join-command-primary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandPrimary)) - r.Name("DrainHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}/drain").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainHelmVMNode)) - r.Name("DeleteHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}").Methods("DELETE"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteHelmVMNode)) - r.Name("GetHelmVMNodes").Path("/api/v1/helmvm/nodes").Methods("GET"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetHelmVMNodes)) + // Embedded Cluster + r.Name("EmbeddedCluster").Path("/api/v1/embedded-cluster").HandlerFunc(NotImplemented) + r.Name("GenerateEmbeddedClusterNodeJoinCommand").Path("/api/v1/embedded-cluster/generate-node-join-command").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateEmbeddedClusterNodeJoinCommand)) + r.Name("DrainEmbeddedClusterNode").Path("/api/v1/embedded-cluster/nodes/{nodeName}/drain").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainEmbeddedClusterNode)) + r.Name("DeleteEmbeddedClusterNode").Path("/api/v1/embedded-cluster/nodes/{nodeName}").Methods("DELETE"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteEmbeddedClusterNode)) + r.Name("GetEmbeddedClusterNodes").Path("/api/v1/embedded-cluster/nodes").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetEmbeddedClusterNodes)) + r.Name("GetEmbeddedClusterNode").Path("/api/v1/embedded-cluster/node/{nodeName}").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetEmbeddedClusterNode)) // Prometheus r.Name("SetPrometheusAddress").Path("/api/v1/prometheus").Methods("POST"). @@ -355,6 +355,9 @@ func RegisterUnauthenticatedRoutes(handler *Handler, kotsStore store.Store, debu // These handlers should be called by the application only. loggingRouter.Path("/license/v1/license").Methods("GET").HandlerFunc(handler.GetPlatformLicenseCompatibility) loggingRouter.Path("/api/v1/app/custom-metrics").Methods("POST").HandlerFunc(handler.GetSendCustomAppMetricsHandler(kotsStore)) + + // This handler requires a valid token in the query + loggingRouter.Path("/api/v1/embedded-cluster/join").Methods("GET").HandlerFunc(handler.GetEmbeddedClusterNodeJoinCommand) } func StreamJSON(c *websocket.Conn, payload interface{}) { diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go index 91bd7c0731..c502bb6733 100644 --- a/pkg/handlers/handlers_test.go +++ b/pkg/handlers/handlers_test.go @@ -1209,55 +1209,56 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ }, }, - "HelmVM": {}, // Not implemented - "GenerateHelmVMNodeJoinCommandSecondary": { + "EmbeddedCluster": {}, // Not implemented + "GenerateEmbeddedClusterNodeJoinCommand": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateHelmVMNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateEmbeddedClusterNodeJoinCommand(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateHelmVMNodeJoinCommandPrimary": { + "DrainEmbeddedClusterNode": { { + Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateHelmVMNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) + handlerRecorder.DrainEmbeddedClusterNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DrainHelmVMNode": { + "DeleteEmbeddedClusterNode": { { Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DrainHelmVMNode(gomock.Any(), gomock.Any()) + handlerRecorder.DeleteEmbeddedClusterNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DeleteHelmVMNode": { + "GetEmbeddedClusterNodes": { { - Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DeleteHelmVMNode(gomock.Any(), gomock.Any()) + handlerRecorder.GetEmbeddedClusterNodes(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GetHelmVMNodes": { + "GetEmbeddedClusterNode": { { + Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GetHelmVMNodes(gomock.Any(), gomock.Any()) + handlerRecorder.GetEmbeddedClusterNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, diff --git a/pkg/handlers/helmvm_get.go b/pkg/handlers/helmvm_get.go deleted file mode 100644 index cd440d116f..0000000000 --- a/pkg/handlers/helmvm_get.go +++ /dev/null @@ -1,26 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/replicatedhq/kots/pkg/helmvm" - "github.com/replicatedhq/kots/pkg/k8sutil" - "github.com/replicatedhq/kots/pkg/logger" -) - -func (h *Handler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { - client, err := k8sutil.GetClientset() - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - nodes, err := helmvm.GetNodes(client) - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - JSON(w, http.StatusOK, nodes) -} diff --git a/pkg/handlers/helmvm_node_join_command.go b/pkg/handlers/helmvm_node_join_command.go deleted file mode 100644 index 6604b659d9..0000000000 --- a/pkg/handlers/helmvm_node_join_command.go +++ /dev/null @@ -1,55 +0,0 @@ -package handlers - -import ( - "net/http" - "time" - - "github.com/replicatedhq/kots/pkg/helmvm" - "github.com/replicatedhq/kots/pkg/k8sutil" - "github.com/replicatedhq/kots/pkg/logger" -) - -type GenerateHelmVMNodeJoinCommandResponse struct { - Command []string `json:"command"` - Expiry string `json:"expiry"` -} - -func (h *Handler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { - client, err := k8sutil.GetClientset() - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - command, expiry, err := helmvm.GenerateAddNodeCommand(client, false) - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ - Command: command, - Expiry: expiry.Format(time.RFC3339), - }) -} - -func (h *Handler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { - client, err := k8sutil.GetClientset() - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - command, expiry, err := helmvm.GenerateAddNodeCommand(client, true) - if err != nil { - logger.Error(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ - Command: command, - Expiry: expiry.Format(time.RFC3339), - }) -} diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index 69b3539288..a8fe1bd4dc 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -138,12 +138,12 @@ type KOTSHandler interface { DeleteKurlNode(w http.ResponseWriter, r *http.Request) GetKurlNodes(w http.ResponseWriter, r *http.Request) - // HelmVM - GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) - GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) - DrainHelmVMNode(w http.ResponseWriter, r *http.Request) - DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) - GetHelmVMNodes(w http.ResponseWriter, r *http.Request) + // EmbeddedCLuster + GenerateEmbeddedClusterNodeJoinCommand(w http.ResponseWriter, r *http.Request) + DrainEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) + DeleteEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) + GetEmbeddedClusterNodes(w http.ResponseWriter, r *http.Request) + GetEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) // Prometheus SetPrometheusAddress(w http.ResponseWriter, r *http.Request) diff --git a/pkg/handlers/metadata.go b/pkg/handlers/metadata.go index 81903b26dd..cc7253551e 100644 --- a/pkg/handlers/metadata.go +++ b/pkg/handlers/metadata.go @@ -50,9 +50,9 @@ type MetadataResponseBranding struct { } type AdminConsoleMetadata struct { - IsAirgap bool `json:"isAirgap"` - IsKurl bool `json:"isKurl"` - IsHelmVM bool `json:"isHelmVM"` + IsAirgap bool `json:"isAirgap"` + IsKurl bool `json:"isKurl"` + IsEmbeddedCluster bool `json:"isEmbeddedCluster"` } // GetMetadataHandler helper function that returns a http handler func that returns metadata. It takes a function that @@ -73,7 +73,7 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. if kuberneteserrors.IsNotFound(err) { metadataResponse.AdminConsoleMetadata.IsAirgap = kotsadmMetadata.IsAirgap metadataResponse.AdminConsoleMetadata.IsKurl = kotsadmMetadata.IsKurl - metadataResponse.AdminConsoleMetadata.IsHelmVM = kotsadmMetadata.IsHelmVM + metadataResponse.AdminConsoleMetadata.IsEmbeddedCluster = kotsadmMetadata.IsEmbeddedCluster logger.Info(fmt.Sprintf("config map %q not found", metadataConfigMapName)) JSON(w, http.StatusOK, &metadataResponse) @@ -114,9 +114,9 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. metadataResponse.UpstreamURI = brandingConfigMap.Data[upstreamUriKey] metadataResponse.ConsoleFeatureFlags = application.Spec.ConsoleFeatureFlags metadataResponse.AdminConsoleMetadata = AdminConsoleMetadata{ - IsAirgap: kotsadmMetadata.IsAirgap, - IsKurl: kotsadmMetadata.IsKurl, - IsHelmVM: kotsadmMetadata.IsHelmVM, + IsAirgap: kotsadmMetadata.IsAirgap, + IsKurl: kotsadmMetadata.IsKurl, + IsEmbeddedCluster: kotsadmMetadata.IsEmbeddedCluster, } JSON(w, http.StatusOK, metadataResponse) diff --git a/pkg/handlers/mock/mock.go b/pkg/handlers/mock/mock.go index 7440665850..c122017330 100644 --- a/pkg/handlers/mock/mock.go +++ b/pkg/handlers/mock/mock.go @@ -262,16 +262,16 @@ func (mr *MockKOTSHandlerMockRecorder) DeleteBackup(w, r interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBackup", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteBackup), w, r) } -// DeleteHelmVMNode mocks base method. -func (m *MockKOTSHandler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { +// DeleteEmbeddedClusterNode mocks base method. +func (m *MockKOTSHandler) DeleteEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DeleteHelmVMNode", w, r) + m.ctrl.Call(m, "DeleteEmbeddedClusterNode", w, r) } -// DeleteHelmVMNode indicates an expected call of DeleteHelmVMNode. -func (mr *MockKOTSHandlerMockRecorder) DeleteHelmVMNode(w, r interface{}) *gomock.Call { +// DeleteEmbeddedClusterNode indicates an expected call of DeleteEmbeddedClusterNode. +func (mr *MockKOTSHandlerMockRecorder) DeleteEmbeddedClusterNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteHelmVMNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteEmbeddedClusterNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteEmbeddedClusterNode), w, r) } // DeleteKurlNode mocks base method. @@ -394,16 +394,16 @@ func (mr *MockKOTSHandlerMockRecorder) DownloadSupportBundle(w, r interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadSupportBundle", reflect.TypeOf((*MockKOTSHandler)(nil).DownloadSupportBundle), w, r) } -// DrainHelmVMNode mocks base method. -func (m *MockKOTSHandler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { +// DrainEmbeddedClusterNode mocks base method. +func (m *MockKOTSHandler) DrainEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DrainHelmVMNode", w, r) + m.ctrl.Call(m, "DrainEmbeddedClusterNode", w, r) } -// DrainHelmVMNode indicates an expected call of DrainHelmVMNode. -func (mr *MockKOTSHandlerMockRecorder) DrainHelmVMNode(w, r interface{}) *gomock.Call { +// DrainEmbeddedClusterNode indicates an expected call of DrainEmbeddedClusterNode. +func (mr *MockKOTSHandlerMockRecorder) DrainEmbeddedClusterNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainHelmVMNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainEmbeddedClusterNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainEmbeddedClusterNode), w, r) } // DrainKurlNode mocks base method. @@ -442,28 +442,16 @@ func (mr *MockKOTSHandlerMockRecorder) GarbageCollectImages(w, r interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GarbageCollectImages", reflect.TypeOf((*MockKOTSHandler)(nil).GarbageCollectImages), w, r) } -// GenerateHelmVMNodeJoinCommandPrimary mocks base method. -func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { +// GenerateEmbeddedClusterNodeJoinCommand mocks base method. +func (m *MockKOTSHandler) GenerateEmbeddedClusterNodeJoinCommand(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandPrimary", w, r) + m.ctrl.Call(m, "GenerateEmbeddedClusterNodeJoinCommand", w, r) } -// GenerateHelmVMNodeJoinCommandPrimary indicates an expected call of GenerateHelmVMNodeJoinCommandPrimary. -func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { +// GenerateEmbeddedClusterNodeJoinCommand indicates an expected call of GenerateEmbeddedClusterNodeJoinCommand. +func (mr *MockKOTSHandlerMockRecorder) GenerateEmbeddedClusterNodeJoinCommand(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandPrimary), w, r) -} - -// GenerateHelmVMNodeJoinCommandSecondary mocks base method. -func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandSecondary", w, r) -} - -// GenerateHelmVMNodeJoinCommandSecondary indicates an expected call of GenerateHelmVMNodeJoinCommandSecondary. -func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandSecondary), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateEmbeddedClusterNodeJoinCommand", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateEmbeddedClusterNodeJoinCommand), w, r) } // GenerateKurlNodeJoinCommandMaster mocks base method. @@ -718,6 +706,30 @@ func (mr *MockKOTSHandlerMockRecorder) GetDownstreamOutput(w, r interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDownstreamOutput", reflect.TypeOf((*MockKOTSHandler)(nil).GetDownstreamOutput), w, r) } +// GetEmbeddedClusterNode mocks base method. +func (m *MockKOTSHandler) GetEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetEmbeddedClusterNode", w, r) +} + +// GetEmbeddedClusterNode indicates an expected call of GetEmbeddedClusterNode. +func (mr *MockKOTSHandlerMockRecorder) GetEmbeddedClusterNode(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterNode", reflect.TypeOf((*MockKOTSHandler)(nil).GetEmbeddedClusterNode), w, r) +} + +// GetEmbeddedClusterNodes mocks base method. +func (m *MockKOTSHandler) GetEmbeddedClusterNodes(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetEmbeddedClusterNodes", w, r) +} + +// GetEmbeddedClusterNodes indicates an expected call of GetEmbeddedClusterNodes. +func (mr *MockKOTSHandlerMockRecorder) GetEmbeddedClusterNodes(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterNodes", reflect.TypeOf((*MockKOTSHandler)(nil).GetEmbeddedClusterNodes), w, r) +} + // GetFileSystemSnapshotProviderInstructions mocks base method. func (m *MockKOTSHandler) GetFileSystemSnapshotProviderInstructions(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() @@ -754,18 +766,6 @@ func (mr *MockKOTSHandlerMockRecorder) GetGlobalSnapshotSettings(w, r interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalSnapshotSettings", reflect.TypeOf((*MockKOTSHandler)(nil).GetGlobalSnapshotSettings), w, r) } -// GetHelmVMNodes mocks base method. -func (m *MockKOTSHandler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "GetHelmVMNodes", w, r) -} - -// GetHelmVMNodes indicates an expected call of GetHelmVMNodes. -func (mr *MockKOTSHandlerMockRecorder) GetHelmVMNodes(w, r interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHelmVMNodes", reflect.TypeOf((*MockKOTSHandler)(nil).GetHelmVMNodes), w, r) -} - // GetIdentityServiceConfig mocks base method. func (m *MockKOTSHandler) GetIdentityServiceConfig(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() diff --git a/pkg/helmvm/helmvm_nodes.go b/pkg/helmvm/helmvm_nodes.go deleted file mode 100644 index e00dca2108..0000000000 --- a/pkg/helmvm/helmvm_nodes.go +++ /dev/null @@ -1,212 +0,0 @@ -package helmvm - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "math" - "net/http" - "os" - "strconv" - "time" - - "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/helmvm/types" - "github.com/replicatedhq/kots/pkg/logger" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - statsv1alpha1 "k8s.io/kubelet/pkg/apis/stats/v1alpha1" -) - -// GetNodes will get a list of nodes with stats -func GetNodes(client kubernetes.Interface) (*types.HelmVMNodes, error) { - nodes, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) - if err != nil { - return nil, errors.Wrap(err, "list nodes") - } - - toReturn := types.HelmVMNodes{} - - for _, node := range nodes.Items { - cpuCapacity := types.CapacityAvailable{} - memoryCapacity := types.CapacityAvailable{} - podCapacity := types.CapacityAvailable{} - - memoryCapacity.Capacity = float64(node.Status.Capacity.Memory().Value()) / math.Pow(2, 30) // capacity in GB - - cpuCapacity.Capacity, err = strconv.ParseFloat(node.Status.Capacity.Cpu().String(), 64) - if err != nil { - return nil, errors.Wrapf(err, "parse CPU capacity %q for node %s", node.Status.Capacity.Cpu().String(), node.Name) - } - - podCapacity.Capacity = float64(node.Status.Capacity.Pods().Value()) - - nodeIP := "" - for _, address := range node.Status.Addresses { - if address.Type == corev1.NodeInternalIP { - nodeIP = address.Address - } - } - - if nodeIP == "" { - logger.Infof("Did not find address for node %s, %+v", node.Name, node.Status.Addresses) - } else { - nodeMetrics, err := getNodeMetrics(nodeIP) - if err != nil { - logger.Infof("Got error retrieving stats for node %q: %v", node.Name, err) - } else { - if nodeMetrics.Node.Memory != nil && nodeMetrics.Node.Memory.AvailableBytes != nil { - memoryCapacity.Available = float64(*nodeMetrics.Node.Memory.AvailableBytes) / math.Pow(2, 30) - } - - if nodeMetrics.Node.CPU != nil && nodeMetrics.Node.CPU.UsageNanoCores != nil { - cpuCapacity.Available = cpuCapacity.Capacity - (float64(*nodeMetrics.Node.CPU.UsageNanoCores) / math.Pow(10, 9)) - } - - podCapacity.Available = podCapacity.Capacity - float64(len(nodeMetrics.Pods)) - } - } - - nodeLabelArray := []string{} - for k, v := range node.Labels { - nodeLabelArray = append(nodeLabelArray, fmt.Sprintf("%s:%s", k, v)) - } - - toReturn.Nodes = append(toReturn.Nodes, types.Node{ - Name: node.Name, - IsConnected: isConnected(node), - IsReady: isReady(node), - IsPrimaryNode: isPrimary(node), - CanDelete: node.Spec.Unschedulable && !isConnected(node), - KubeletVersion: node.Status.NodeInfo.KubeletVersion, - CPU: cpuCapacity, - Memory: memoryCapacity, - Pods: podCapacity, - Labels: nodeLabelArray, - Conditions: findNodeConditions(node.Status.Conditions), - }) - } - - isHelmVM, err := IsHelmVM(client) - if err != nil { - return nil, errors.Wrap(err, "is helmvm") - } - toReturn.IsHelmVMEnabled = isHelmVM - - isHA, err := IsHA(client) - if err != nil { - return nil, errors.Wrap(err, "is ha") - } - toReturn.HA = isHA - - return &toReturn, nil -} - -func findNodeConditions(conditions []corev1.NodeCondition) types.NodeConditions { - discoveredConditions := types.NodeConditions{} - for _, condition := range conditions { - if condition.Type == "MemoryPressure" { - discoveredConditions.MemoryPressure = condition.Status == corev1.ConditionTrue - } - if condition.Type == "DiskPressure" { - discoveredConditions.DiskPressure = condition.Status == corev1.ConditionTrue - } - if condition.Type == "PIDPressure" { - discoveredConditions.PidPressure = condition.Status == corev1.ConditionTrue - } - if condition.Type == "Ready" { - discoveredConditions.Ready = condition.Status == corev1.ConditionTrue - } - } - return discoveredConditions -} - -// get kubelet PKI info from /etc/kubernetes/pki/kubelet, use it to hit metrics server at `http://${nodeIP}:10255/stats/summary` -func getNodeMetrics(nodeIP string) (*statsv1alpha1.Summary, error) { - client := http.Client{ - Timeout: time.Second, - } - port := 10255 - - // only use mutual TLS if client cert exists - _, err := os.ReadFile("/etc/kubernetes/pki/kubelet/client.crt") - if err == nil { - cert, err := tls.LoadX509KeyPair("/etc/kubernetes/pki/kubelet/client.crt", "/etc/kubernetes/pki/kubelet/client.key") - if err != nil { - return nil, errors.Wrap(err, "get client keypair") - } - - // this will leak memory - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - InsecureSkipVerify: true, - }, - } - port = 10250 - } - - r, err := client.Get(fmt.Sprintf("https://%s:%d/stats/summary", nodeIP, port)) - if err != nil { - return nil, errors.Wrapf(err, "get node %s stats", nodeIP) - } - defer r.Body.Close() - - body, err := io.ReadAll(r.Body) - if err != nil { - return nil, errors.Wrapf(err, "read node %s stats response", nodeIP) - } - - summary := statsv1alpha1.Summary{} - err = json.Unmarshal(body, &summary) - if err != nil { - return nil, errors.Wrapf(err, "parse node %s stats response", nodeIP) - } - - return &summary, nil -} - -func isConnected(node corev1.Node) bool { - for _, taint := range node.Spec.Taints { - if taint.Key == "node.kubernetes.io/unreachable" { - return false - } - } - - return true -} - -func isReady(node corev1.Node) bool { - for _, condition := range node.Status.Conditions { - if condition.Type == "Ready" { - return condition.Status == corev1.ConditionTrue - } - } - - return false -} - -func isPrimary(node corev1.Node) bool { - for label := range node.ObjectMeta.Labels { - if label == "node-role.kubernetes.io/master" { - return true - } - if label == "node-role.kubernetes.io/control-plane" { - return true - } - } - - return false -} - -func internalIP(node corev1.Node) string { - for _, address := range node.Status.Addresses { - if address.Type == corev1.NodeInternalIP { - return address.Address - } - } - return "" -} diff --git a/pkg/helmvm/node_join.go b/pkg/helmvm/node_join.go deleted file mode 100644 index 6aad6255a9..0000000000 --- a/pkg/helmvm/node_join.go +++ /dev/null @@ -1,12 +0,0 @@ -package helmvm - -import ( - "time" - - "k8s.io/client-go/kubernetes" -) - -// GenerateAddNodeCommand will generate the HelmVM node add command for a primary or secondary node -func GenerateAddNodeCommand(client kubernetes.Interface, primary bool) ([]string, *time.Time, error) { - return nil, nil, nil -} diff --git a/pkg/helmvm/types/types.go b/pkg/helmvm/types/types.go deleted file mode 100644 index c298dfbd93..0000000000 --- a/pkg/helmvm/types/types.go +++ /dev/null @@ -1,33 +0,0 @@ -package types - -type HelmVMNodes struct { - Nodes []Node `json:"nodes"` - HA bool `json:"ha"` - IsHelmVMEnabled bool `json:"isHelmVMEnabled"` -} - -type Node struct { - Name string `json:"name"` - IsConnected bool `json:"isConnected"` - IsReady bool `json:"isReady"` - IsPrimaryNode bool `json:"isPrimaryNode"` - CanDelete bool `json:"canDelete"` - KubeletVersion string `json:"kubeletVersion"` - CPU CapacityAvailable `json:"cpu"` - Memory CapacityAvailable `json:"memory"` - Pods CapacityAvailable `json:"pods"` - Labels []string `json:"labels"` - Conditions NodeConditions `json:"conditions"` -} - -type CapacityAvailable struct { - Capacity float64 `json:"capacity"` - Available float64 `json:"available"` -} - -type NodeConditions struct { - MemoryPressure bool `json:"memoryPressure"` - DiskPressure bool `json:"diskPressure"` - PidPressure bool `json:"pidPressure"` - Ready bool `json:"ready"` -} diff --git a/pkg/helmvm/util.go b/pkg/helmvm/util.go deleted file mode 100644 index 7d2817f93e..0000000000 --- a/pkg/helmvm/util.go +++ /dev/null @@ -1,13 +0,0 @@ -package helmvm - -import ( - "k8s.io/client-go/kubernetes" -) - -func IsHelmVM(clientset kubernetes.Interface) (bool, error) { - return false, nil -} - -func IsHA(clientset kubernetes.Interface) (bool, error) { - return false, nil -} diff --git a/pkg/kotsadm/metadata.go b/pkg/kotsadm/metadata.go index 9c9b045cb0..9c2c09b90a 100644 --- a/pkg/kotsadm/metadata.go +++ b/pkg/kotsadm/metadata.go @@ -1,7 +1,7 @@ package kotsadm import ( - "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/kurl" "k8s.io/client-go/kubernetes" @@ -9,12 +9,12 @@ import ( func GetMetadata(clientset kubernetes.Interface) types.Metadata { isKurl, _ := kurl.IsKurl(clientset) - isHelmVM, _ := helmvm.IsHelmVM(clientset) + isEmbeddedCluster, _ := embeddedcluster.IsEmbeddedCluster(clientset) metadata := types.Metadata{ - IsAirgap: IsAirgap(), - IsKurl: isKurl, - IsHelmVM: isHelmVM, + IsAirgap: IsAirgap(), + IsKurl: isKurl, + IsEmbeddedCluster: isEmbeddedCluster, } return metadata diff --git a/pkg/kotsadm/types/metadata.go b/pkg/kotsadm/types/metadata.go index 79e8b142a6..8ff2225f42 100644 --- a/pkg/kotsadm/types/metadata.go +++ b/pkg/kotsadm/types/metadata.go @@ -1,7 +1,7 @@ package types type Metadata struct { - IsAirgap bool - IsKurl bool - IsHelmVM bool + IsAirgap bool + IsKurl bool + IsEmbeddedCluster bool } diff --git a/pkg/store/kotsstore/embedded_cluster_store.go b/pkg/store/kotsstore/embedded_cluster_store.go new file mode 100644 index 0000000000..39cc5cda5b --- /dev/null +++ b/pkg/store/kotsstore/embedded_cluster_store.go @@ -0,0 +1,69 @@ +package kotsstore + +import ( + "encoding/json" + "fmt" + "github.com/rqlite/gorqlite" + + "github.com/replicatedhq/kots/pkg/persistence" + "github.com/replicatedhq/kots/pkg/rand" +) + +func (s *KOTSStore) SetEmbeddedClusterInstallCommandRoles(roles []string) (string, error) { + db := persistence.MustGetDBSession() + + installID := rand.StringWithCharset(24, rand.LOWER_CASE+rand.UPPER_CASE) + + query := `delete from embedded_cluster_tokens where token = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{installID}, + }) + if err != nil { + return "", fmt.Errorf("delete embedded_cluster join token: %v: %v", err, wr.Err) + } + + jsonRoles, err := json.Marshal(roles) + if err != nil { + return "", fmt.Errorf("failed to marshal roles: %w", err) + } + + query = `insert into embedded_cluster_tokens (token, roles) values (?, ?)` + wr, err = db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{installID, string(jsonRoles)}, + }) + if err != nil { + return "", fmt.Errorf("insert embedded_cluster join token: %v: %v", err, wr.Err) + } + + return installID, nil +} + +func (s *KOTSStore) GetEmbeddedClusterInstallCommandRoles(token string) ([]string, error) { + db := persistence.MustGetDBSession() + query := `select roles from embedded_cluster_tokens where token = ?` + rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{token}, + }) + if err != nil { + return nil, fmt.Errorf("failed to query: %v: %v", err, rows.Err) + } + if !rows.Next() { + return nil, ErrNotFound + } + + rolesStr := "" + if err = rows.Scan(&rolesStr); err != nil { + return nil, fmt.Errorf("failed to scan roles: %w", err) + } + + rolesArr := []string{} + err = json.Unmarshal([]byte(rolesStr), &rolesArr) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal roles: %w", err) + } + + return rolesArr, nil +} diff --git a/pkg/store/mock/mock.go b/pkg/store/mock/mock.go index 6ae23ab37b..a71c1b9f57 100644 --- a/pkg/store/mock/mock.go +++ b/pkg/store/mock/mock.go @@ -758,6 +758,21 @@ func (mr *MockStoreMockRecorder) GetEmbeddedClusterAuthToken() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterAuthToken", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterAuthToken)) } +// GetEmbeddedClusterInstallCommandRoles mocks base method. +func (m *MockStore) GetEmbeddedClusterInstallCommandRoles(token string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedClusterInstallCommandRoles", token) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedClusterInstallCommandRoles indicates an expected call of GetEmbeddedClusterInstallCommandRoles. +func (mr *MockStoreMockRecorder) GetEmbeddedClusterInstallCommandRoles(token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterInstallCommandRoles), token) +} + // GetIgnoreRBACErrors mocks base method. func (m *MockStore) GetIgnoreRBACErrors(appID string, sequence int64) (bool, error) { m.ctrl.T.Helper() @@ -1644,6 +1659,21 @@ func (mr *MockStoreMockRecorder) SetEmbeddedClusterAuthToken(token interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterAuthToken", reflect.TypeOf((*MockStore)(nil).SetEmbeddedClusterAuthToken), token) } +// SetEmbeddedClusterInstallCommandRoles mocks base method. +func (m *MockStore) SetEmbeddedClusterInstallCommandRoles(roles []string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetEmbeddedClusterInstallCommandRoles", roles) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetEmbeddedClusterInstallCommandRoles indicates an expected call of SetEmbeddedClusterInstallCommandRoles. +func (mr *MockStoreMockRecorder) SetEmbeddedClusterInstallCommandRoles(roles interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockStore)(nil).SetEmbeddedClusterInstallCommandRoles), roles) +} + // SetIgnorePreflightPermissionErrors mocks base method. func (m *MockStore) SetIgnorePreflightPermissionErrors(appID string, sequence int64) error { m.ctrl.T.Helper() @@ -4432,3 +4462,56 @@ func (mr *MockReportingStoreMockRecorder) SaveReportingInfo(licenseID, reporting mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveReportingInfo", reflect.TypeOf((*MockReportingStore)(nil).SaveReportingInfo), licenseID, reportingInfo) } + +// MockEmbeddedClusterStore is a mock of EmbeddedClusterStore interface. +type MockEmbeddedClusterStore struct { + ctrl *gomock.Controller + recorder *MockEmbeddedClusterStoreMockRecorder +} + +// MockEmbeddedClusterStoreMockRecorder is the mock recorder for MockEmbeddedClusterStore. +type MockEmbeddedClusterStoreMockRecorder struct { + mock *MockEmbeddedClusterStore +} + +// NewMockEmbeddedClusterStore creates a new mock instance. +func NewMockEmbeddedClusterStore(ctrl *gomock.Controller) *MockEmbeddedClusterStore { + mock := &MockEmbeddedClusterStore{ctrl: ctrl} + mock.recorder = &MockEmbeddedClusterStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEmbeddedClusterStore) EXPECT() *MockEmbeddedClusterStoreMockRecorder { + return m.recorder +} + +// GetEmbeddedClusterInstallCommandRoles mocks base method. +func (m *MockEmbeddedClusterStore) GetEmbeddedClusterInstallCommandRoles(token string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedClusterInstallCommandRoles", token) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedClusterInstallCommandRoles indicates an expected call of GetEmbeddedClusterInstallCommandRoles. +func (mr *MockEmbeddedClusterStoreMockRecorder) GetEmbeddedClusterInstallCommandRoles(token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockEmbeddedClusterStore)(nil).GetEmbeddedClusterInstallCommandRoles), token) +} + +// SetEmbeddedClusterInstallCommandRoles mocks base method. +func (m *MockEmbeddedClusterStore) SetEmbeddedClusterInstallCommandRoles(roles []string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetEmbeddedClusterInstallCommandRoles", roles) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetEmbeddedClusterInstallCommandRoles indicates an expected call of SetEmbeddedClusterInstallCommandRoles. +func (mr *MockEmbeddedClusterStoreMockRecorder) SetEmbeddedClusterInstallCommandRoles(roles interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockEmbeddedClusterStore)(nil).SetEmbeddedClusterInstallCommandRoles), roles) +} diff --git a/pkg/store/store_interface.go b/pkg/store/store_interface.go index 1a5df70a01..34b5872db2 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -47,6 +47,7 @@ type Store interface { EmbeddedStore BrandingStore ReportingStore + EmbeddedClusterStore Init() error // this may need options WaitForReady(ctx context.Context) error @@ -253,3 +254,8 @@ type ReportingStore interface { SavePreflightReport(licenseID string, preflightStatus *reportingtypes.PreflightStatus) error SaveReportingInfo(licenseID string, reportingInfo *reportingtypes.ReportingInfo) error } + +type EmbeddedClusterStore interface { + SetEmbeddedClusterInstallCommandRoles(roles []string) (string, error) + GetEmbeddedClusterInstallCommandRoles(token string) ([]string, error) +} diff --git a/pkg/store/types/constants.go b/pkg/store/types/constants.go index a449968b28..1ce8b655d7 100644 --- a/pkg/store/types/constants.go +++ b/pkg/store/types/constants.go @@ -3,12 +3,12 @@ package types type DownstreamVersionStatus string const ( - VersionUnknown DownstreamVersionStatus = "unknown" - VersionPendingConfig DownstreamVersionStatus = "pending_config" - VersionPending DownstreamVersionStatus = "pending" - VersionPendingPreflight DownstreamVersionStatus = "pending_preflight" - VersionPendingDownload DownstreamVersionStatus = "pending_download" - VersionDeploying DownstreamVersionStatus = "deploying" - VersionDeployed DownstreamVersionStatus = "deployed" - VersionFailed DownstreamVersionStatus = "failed" + VersionUnknown DownstreamVersionStatus = "unknown" // we don't know + VersionPendingConfig DownstreamVersionStatus = "pending_config" // needs required configuration + VersionPendingDownload DownstreamVersionStatus = "pending_download" // needs to be downloaded from the upstream source + VersionPendingPreflight DownstreamVersionStatus = "pending_preflight" // waiting for preflights to finish + VersionPending DownstreamVersionStatus = "pending" // can be deployed, but is not yet + VersionDeploying DownstreamVersionStatus = "deploying" // is being deployed + VersionDeployed DownstreamVersionStatus = "deployed" // did deploy successfully + VersionFailed DownstreamVersionStatus = "failed" // did not deploy successfully ) diff --git a/pkg/util/image.go b/pkg/util/image.go new file mode 100644 index 0000000000..d1bd374831 --- /dev/null +++ b/pkg/util/image.go @@ -0,0 +1,26 @@ +package util + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// ThisImage looks for either a deployment 'kotsadm' or a statefulset 'kotsadm' in the current namespace +// it returns the image of the first container in the pod template +func ThisImage(ctx context.Context, client kubernetes.Interface) (string, error) { + deploy, err := client.AppsV1().Deployments(PodNamespace).Get(ctx, "kotsadm", metav1.GetOptions{}) + if err == nil { + return deploy.Spec.Template.Spec.Containers[0].Image, nil + } + + statefulset, err := client.AppsV1().StatefulSets(PodNamespace).Get(ctx, "kotsadm", metav1.GetOptions{}) + if err == nil { + return statefulset.Spec.Template.Spec.Containers[0].Image, nil + } + + return "", fmt.Errorf("failed to find deployment or statefulset") + +} diff --git a/web/package.json b/web/package.json index 21caa76d0c..f58cc7c981 100644 --- a/web/package.json +++ b/web/package.json @@ -121,9 +121,13 @@ "webpack-merge": "5.8.0" }, "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", "@grafana/data": "^8.5.16", "@maji/react-prism": "^1.0.1", "@monaco-editor/react": "^4.4.5", + "@mui/icons-material": "^5.14.14", + "@mui/material": "^5.14.14", "@storybook/addon-storysource": "^6.5.16", "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1", @@ -144,6 +148,7 @@ "js-yaml": "3.14.0", "lodash": "4.17.21", "markdown-it": "^12.3.2", + "material-react-table": "^1.15.1", "monaco-editor": "^0.33.0", "monaco-editor-webpack-plugin": "^7.0.1", "node-polyfill-webpack-plugin": "^1.1.4", diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 1260d96b8c..61fb86c592 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -1,6 +1,6 @@ -import React, { useReducer, useEffect } from "react"; +import React, { useEffect, useReducer } from "react"; import { createBrowserHistory } from "history"; -import { Route, Routes, Navigate, useNavigate } from "react-router-dom"; +import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { Helmet } from "react-helmet"; import Modal from "react-modal"; import find from "lodash/find"; @@ -10,10 +10,10 @@ import PreflightResultPage from "./components/PreflightResultPage"; import AppConfig from "./features/AppConfig/components/AppConfig"; import { AppDetailPage } from "./components/apps/AppDetailPage"; import KurlClusterManagement from "./components/apps/KurlClusterManagement"; -import HelmVMClusterManagement from "./components/apps/HelmVMClusterManagement"; +import EmbeddedClusterManagement from "@components/apps/EmbeddedClusterManagement"; import UnsupportedBrowser from "./components/static/UnsupportedBrowser"; import NotFound from "./components/static/NotFound"; -import { Utilities, parseUpstreamUri } from "./utilities/utilities"; +import { parseUpstreamUri, Utilities } from "./utilities/utilities"; import fetch from "./utilities/fetchWithTimeout"; import { SecureAdminConsole } from "@features/Auth"; import UploadLicenseFile from "./components/UploadLicenseFile"; @@ -58,6 +58,7 @@ import SnapshotDetails from "@components/snapshots/SnapshotDetails"; import SnapshotRestore from "@components/snapshots/SnapshotRestore"; import AppSnapshots from "@components/snapshots/AppSnapshots"; import AppSnapshotRestore from "@components/snapshots/AppSnapshotRestore"; +import EmbeddedClusterViewNode from "@components/apps/EmbeddedClusterViewNode"; // react-query client const queryClient = new QueryClient(); @@ -466,7 +467,9 @@ const Root = () => { refetchAppsList={getAppsList} fetchingMetadata={state.fetchingMetadata} isKurlEnabled={Boolean(state.adminConsoleMetadata?.isKurl)} - isHelmVMEnabled={Boolean(state.adminConsoleMetadata?.isHelmVM)} + isEmbeddedClusterEnabled={Boolean( + state.adminConsoleMetadata?.isEmbeddedCluster + )} isGitOpsSupported={isGitOpsSupported()} isIdentityServiceSupported={isIdentityServiceSupported()} appsList={state.appsList} @@ -531,6 +534,9 @@ const Root = () => { appSlugFromMetadata={state.appSlugFromMetadata || ""} fetchingMetadata={state.fetchingMetadata} onUploadSuccess={getAppsList} + isEmbeddedCluster={Boolean( + state.adminConsoleMetadata?.isEmbeddedCluster + )} /> } /> @@ -573,16 +579,39 @@ const Root = () => { } /> } /> - - ) : ( - - ) - } - /> + {state.adminConsoleMetadata?.isEmbeddedCluster && ( + <> + + } + /> + } + /> + + )} + {(state.adminConsoleMetadata?.isKurl || + state.adminConsoleMetadata?.isEmbeddedCluster) && ( + + ) : ( + + ) + } + /> + )} + {state.adminConsoleMetadata?.isEmbeddedCluster && ( + } + /> + )} } @@ -672,6 +701,9 @@ const Root = () => { snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isHelmManaged={state.isHelmManaged} + isEmbeddedCluster={Boolean( + state.adminConsoleMetadata?.isEmbeddedCluster + )} /> } /> @@ -687,6 +719,9 @@ const Root = () => { snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isHelmManaged={state.isHelmManaged} + isEmbeddedCluster={Boolean( + state.adminConsoleMetadata?.isEmbeddedCluster + )} /> } > @@ -761,12 +796,7 @@ const Root = () => { } /> - } + element={} /> {/* WHERE IS SELECTEDAPP */} {state.app?.isAppIdentityServiceSupported && ( diff --git a/web/src/components/UploadLicenseFile.tsx b/web/src/components/UploadLicenseFile.tsx index 19bc1cf95b..9af2e5e3c4 100644 --- a/web/src/components/UploadLicenseFile.tsx +++ b/web/src/components/UploadLicenseFile.tsx @@ -1,23 +1,23 @@ import React, { useEffect, useReducer } from "react"; -import { useNavigate } from "react-router-dom"; -import { Link } from "react-router-dom"; -import { KotsPageTitle } from "@components/Head"; -// TODO: upgrade this dependency -// @ts-ignore -import Dropzone from "react-dropzone"; +import { Link, useNavigate } from "react-router-dom"; import yaml from "js-yaml"; -import size from "lodash/size"; import isEmpty from "lodash/isEmpty"; import keyBy from "lodash/keyBy"; +import size from "lodash/size"; +// TODO: upgrade this dependency +// @ts-ignore +import Dropzone from "react-dropzone"; import Modal from "react-modal"; import Select from "react-select"; + +import { KotsPageTitle } from "@components/Head"; import { getFileContent } from "../utilities/utilities"; -import CodeSnippet from "./shared/CodeSnippet"; +import Icon from "./Icon"; import LicenseUploadProgress from "./LicenseUploadProgress"; +import CodeSnippet from "./shared/CodeSnippet"; import "../scss/components/troubleshoot/UploadSupportBundleModal.scss"; import "../scss/components/UploadLicenseFile.scss"; -import Icon from "./Icon"; type LicenseYaml = { spec: { @@ -26,17 +26,6 @@ type LicenseYaml = { }; }; -type Props = { - appsListLength: number; - appName: string; - appSlugFromMetadata: string; - fetchingMetadata: boolean; - isBackupRestore?: boolean; - onUploadSuccess: () => Promise; - logo: string | null; - snapshot?: { name: string }; -}; - type SelectedAppToInstall = { label: string; value: string; @@ -68,6 +57,19 @@ type UploadLicenseResponse = { slug: string; success?: boolean; }; + +type Props = { + appsListLength: number; + appName: string; + appSlugFromMetadata: string; + fetchingMetadata: boolean; + isBackupRestore?: boolean; + onUploadSuccess: () => Promise; + logo: string | null; + snapshot?: { name: string }; + isEmbeddedCluster: boolean; +}; + const UploadLicenseFile = (props: Props) => { const [state, setState] = useReducer( (currentState: State, newState: Partial) => ({ @@ -259,6 +261,11 @@ const UploadLicenseFile = (props: Props) => { return; } + if (props.isEmbeddedCluster) { + navigate(`/${data.slug}/cluster/manage`, { replace: true }); + return; + } + if (data.isConfigurable) { navigate(`/${data.slug}/config`, { replace: true }); return; diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index 4aa0d386ee..aa0b2d0ef1 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -1,10 +1,12 @@ -import React, { Fragment, useReducer, useEffect, useState } from "react"; +import React, { Fragment, useEffect, useReducer, useState } from "react"; import classNames from "classnames"; -import { useNavigate, useParams, Outlet } from "react-router-dom"; +import { Outlet, useNavigate, useParams } from "react-router-dom"; import Modal from "react-modal"; import { useTheme } from "@src/components/context/withTheme"; -import { KotsSidebarItem } from "@src/components/watches/WatchSidebarItem"; -import { HelmChartSidebarItem } from "@src/components/watches/WatchSidebarItem"; +import { + HelmChartSidebarItem, + KotsSidebarItem, +} from "@src/components/watches/WatchSidebarItem"; import { isAwaitingResults } from "../../utilities/utilities"; import SubNavBar from "@src/components/shared/SubNavBar"; @@ -15,7 +17,7 @@ import Loader from "../shared/Loader"; import ErrorModal from "../modals/ErrorModal"; // Types -import { App, Metadata, KotsParams, Version } from "@types"; +import { App, KotsParams, Metadata, Version } from "@types"; import { useApps, useSelectedApp } from "@features/App"; type Props = { @@ -30,6 +32,7 @@ type Props = { refetchAppsList: () => void; refetchAppMetadata: () => void; snapshotInProgressApps: string[]; + isEmbeddedCluster: boolean; }; type State = { @@ -321,7 +324,15 @@ function AppDetailPage(props: Props) { const firstVersion = downstream.pendingVersions.find( (version: Version) => version?.sequence === 0 ); + if (firstVersion?.status === "unknown" && props.isEmbeddedCluster) { + navigate(`/${appNeedsConfiguration.slug}/cluster/manage`); + return; + } if (firstVersion?.status === "pending_config") { + if (props.isEmbeddedCluster) { + navigate(`/${appNeedsConfiguration.slug}/cluster/manage`); + return; + } navigate(`/${appNeedsConfiguration.slug}/config`); return; } diff --git a/web/src/components/apps/AppVersionHistory.tsx b/web/src/components/apps/AppVersionHistory.tsx index f39ff953ef..a0a9277c3d 100644 --- a/web/src/components/apps/AppVersionHistory.tsx +++ b/web/src/components/apps/AppVersionHistory.tsx @@ -19,12 +19,12 @@ import DeployWarningModal from "../shared/modals/DeployWarningModal"; import AutomaticUpdatesModal from "@src/components/modals/AutomaticUpdatesModal"; import SkipPreflightsModal from "../shared/modals/SkipPreflightsModal"; import { - Utilities, + getCommitHashFromUrl, + getGitProviderDiffUrl, + getPreflightResultState, isAwaitingResults, secondsAgo, - getPreflightResultState, - getGitProviderDiffUrl, - getCommitHashFromUrl, + Utilities, } from "../../utilities/utilities"; import { Repeater } from "../../utilities/repeater"; import { AirgapUploader } from "../../utilities/airgapUploader"; @@ -59,7 +59,7 @@ type Props = { adminConsoleMetadata: { isAirgap: boolean; isKurl: boolean; - isHelmVM: boolean; + isEmbeddedCluster: boolean; }; app: App; displayErrorModal: boolean; diff --git a/web/src/components/apps/EmbeddedClusterManagement.tsx b/web/src/components/apps/EmbeddedClusterManagement.tsx new file mode 100644 index 0000000000..55834092c9 --- /dev/null +++ b/web/src/components/apps/EmbeddedClusterManagement.tsx @@ -0,0 +1,495 @@ +import { useQuery } from "@tanstack/react-query"; +import classNames from "classnames"; +import MaterialReactTable, { MRT_ColumnDef } from "material-react-table"; +import React, { ChangeEvent, useMemo, useReducer, useState } from "react"; +import Modal from "react-modal"; +import { Link, useParams } from "react-router-dom"; + +import { KotsPageTitle } from "@components/Head"; +import { useApps } from "@features/App"; +import { rbacRoles } from "../../constants/rbac"; +import { Utilities } from "../../utilities/utilities"; +import Icon from "../Icon"; +import CodeSnippet from "../shared/CodeSnippet"; + +import "@src/scss/components/apps/EmbeddedClusterManagement.scss"; + +const testData = { + nodes: undefined, +}; + +type State = { + displayAddNode: boolean; + confirmDeleteNode: string; + deleteNodeError: string; + showConfirmDrainModal: boolean; + nodeNameToDrain: string; + drainingNodeName: string | null; + drainNodeSuccessful: boolean; +}; + +const EmbeddedClusterManagement = ({ + fromLicenseFlow = false, +}: { + fromLicenseFlow?: boolean; +}) => { + const [state, setState] = useReducer( + (prevState: State, newState: Partial) => ({ + ...prevState, + ...newState, + }), + { + displayAddNode: false, + confirmDeleteNode: "", + deleteNodeError: "", + showConfirmDrainModal: false, + nodeNameToDrain: "", + drainingNodeName: null, + drainNodeSuccessful: false, + } + ); + const [selectedNodeTypes, setSelectedNodeTypes] = useState([]); + + const { data: appsData } = useApps(); + // we grab the first app because embeddedcluster users should only ever have one app + const app = appsData?.apps?.[0]; + + const { slug } = useParams(); + + // #region queries + type NodesResponse = { + ha: boolean; + isEmbeddedClusterEnabled: boolean; + nodes: { + name: string; + isConnected: boolean; + isReady: boolean; + isPrimaryNode: boolean; + canDelete: boolean; + kubeletVersion: string; + cpu: { + capacity: number; + used: number; + }; + memory: { + capacity: number; + used: number; + }; + pods: { + capacity: number; + used: number; + }; + labels?: string[]; + conditions: { + memoryPressure: boolean; + diskPressure: boolean; + pidPressure: boolean; + ready: boolean; + }; + }[]; + }; + + const { + data: nodesData, + isInitialLoading: nodesLoading, + error: nodesError, + } = useQuery({ + queryKey: ["embeddedClusterNodes"], + queryFn: async () => { + const res = await fetch( + `${process.env.API_ENDPOINT}/embedded-cluster/nodes`, + { + headers: { + Accept: "application/json", + }, + credentials: "include", + method: "GET", + } + ); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + } + console.log( + "failed to get node status list, unexpected status code", + res.status + ); + try { + const error = await res.json(); + throw new Error( + error?.error?.message || error?.error || error?.message + ); + } catch (err) { + throw new Error("Unable to fetch nodes, please try again later."); + } + } + return res.json(); + }, + refetchInterval: (data) => (data ? 1000 : 0), + retry: false, + }); + + type AddNodeCommandResponse = { + command: string; + expiry: string; + }; + + const { + data: generateAddNodeCommand, + isLoading: generateAddNodeCommandLoading, + error: generateAddNodeCommandError, + } = useQuery({ + queryKey: ["generateAddNodeCommand", selectedNodeTypes], + queryFn: async ({ queryKey }) => { + const [, nodeTypes] = queryKey; + const res = await fetch( + `${process.env.API_ENDPOINT}/embedded-cluster/generate-node-join-command`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + body: JSON.stringify({ + roles: nodeTypes, + }), + } + ); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + } + console.log( + "failed to get generate node command, unexpected status code", + res.status + ); + try { + const error = await res.json(); + throw new Error( + error?.error?.message || error?.error || error?.message + ); + } catch (err) { + throw new Error( + "Unable to generate node join command, please try again later." + ); + } + } + return res.json(); + }, + enabled: selectedNodeTypes.length > 0, + }); + // #endregion + + const onAddNodeClick = () => { + setState({ + displayAddNode: true, + }); + }; + + // #region node type logic + const NODE_TYPES = ["controller"]; + + const determineDisabledState = () => { + return false; + }; + + const handleSelectNodeType = (e: ChangeEvent) => { + let nodeType = e.currentTarget.value; + let types = selectedNodeTypes; + + if (selectedNodeTypes.includes(nodeType)) { + setSelectedNodeTypes(types.filter((type) => type !== nodeType)); + } else { + setSelectedNodeTypes([...types, nodeType]); + } + }; + // #endregion + + type NodeColumns = { + name: string | JSX.Element; + roles: JSX.Element; + status: string; + cpu: string; + memory: string; + pause: JSX.Element; + delete: JSX.Element; + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: "Name", + enableHiding: false, + enableColumnDragging: false, + size: 150, + }, + { + accessorKey: "roles", + header: "Role(s)", + size: 150, + }, + { + accessorKey: "status", + header: "Status", + size: 150, + }, + { + accessorKey: "cpu", + header: "CPU", + size: 150, + muiTableBodyCellProps: { + align: "right", + }, + }, + { + accessorKey: "memory", + header: "Memory", + size: 150, + muiTableBodyCellProps: { + align: "right", + }, + }, + // { + // accessorKey: "pause", + // header: "Pause", + // size: 100, + // }, + // { + // accessorKey: "delete", + // header: "Delete", + // size: 80, + // }, + ], + [] + ); + + const mappedNodes = useMemo(() => { + return ( + (nodesData?.nodes || testData?.nodes)?.map((n) => ({ + name: ( + + {n.name} + + ), + roles: ( +
+ {n?.labels?.map((l) => ( + + {l} + + ))} +
+ ), + status: n.isReady ? "Ready" : "Not Ready", + cpu: `${n.cpu.used.toFixed(2)} / ${n.cpu.capacity.toFixed(2)}`, + memory: `${n.memory.used.toFixed(2)} / ${n.memory.capacity.toFixed( + 2 + )} GB`, + pause: ( + <> + + + ), + delete: ( + <> + + + ), + })) || [] + ); + }, [nodesData?.nodes?.toString()]); + // #endregion + + return ( +
+ +
+

+ Cluster Nodes +

+
+

+ This page lists the nodes that are configured and shows the + status/health of each. +

+ {Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) && ( + + )} +
+
+ {nodesLoading && ( +

+ Loading nodes... +

+ )} + {!nodesData && nodesError && ( +

+ {nodesError?.message} +

+ )} + {(nodesData?.nodes || testData?.nodes) && ( + + )} +
+ {fromLicenseFlow && ( + + Continue + + )} +
+ {/* MODALS */} + setState({ displayAddNode: false })} + contentLabel="Add Node" + className="Modal" + ariaHideApp={false} + > +
+
+

+ Add a Node +

+ setState({ displayAddNode: false })} + /> +
+

+ To add a node to this cluster, select the type of node you'd like to + add. Once you've selected a node type, we will generate a node join + command for you to use in the CLI. When the node successfully joins + the cluster, you will see it appear in the list of nodes on this + page. +

+
+ {NODE_TYPES.map((nodeType) => ( +
+ + +
+ ))} +
+
+ {selectedNodeTypes.length > 0 && generateAddNodeCommandLoading && ( +

+ Generating command... +

+ )} + {!generateAddNodeCommand && generateAddNodeCommandError && ( +

+ {generateAddNodeCommandError?.message} +

+ )} + {!generateAddNodeCommandLoading && generateAddNodeCommand?.command && ( + <> + Copied! + } + > + {generateAddNodeCommand?.command} + +

+ Command expires: {generateAddNodeCommand?.expiry} +

+ + )} +
+ {/* buttons */} +
+ +
+
+
+
+ ); +}; + +export default EmbeddedClusterManagement; diff --git a/web/src/components/apps/EmbeddedClusterViewNode.jsx b/web/src/components/apps/EmbeddedClusterViewNode.jsx new file mode 100644 index 0000000000..c138aa79c5 --- /dev/null +++ b/web/src/components/apps/EmbeddedClusterViewNode.jsx @@ -0,0 +1,215 @@ +import { MaterialReactTable } from "material-react-table"; +import React, { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import Loader from "@components/shared/Loader"; + +const testData = undefined; + +const EmbeddedClusterViewNode = () => { + const { slug, nodeName } = useParams(); + const { data: nodeData, isLoading: nodeLoading } = useQuery({ + queryKey: ["embeddedClusterNode", nodeName], + queryFn: async ({ queryKey }) => { + const [, nodeName] = queryKey; + return ( + await fetch( + `${process.env.API_ENDPOINT}/embedded-cluster/node/${nodeName}`, + { + headers: { + Accept: "application/json", + }, + credentials: "include", + method: "GET", + } + ) + ).json(); + }, + onError: (err) => { + if (err.status === 401) { + Utilities.logoutUser(); + return; + } + console.log( + "failed to get node status list, unexpected status code", + err.status + ); + }, + }); + + const node = nodeData || testData; + + // #region table data + const columns = useMemo( + () => [ + { + accessorKey: "name", + header: "Name", + enableHiding: false, + enableColumnDragging: false, + size: 150, + }, + { + accessorKey: "namespace", + header: "Namespace", + size: 150, + }, + { + accessorKey: "status", + header: "Status", + size: 150, + }, + { + accessorKey: "cpu", + header: "CPU", + size: 150, + muiTableBodyCellProps: { + align: "right", + }, + }, + { + accessorKey: "memory", + header: "Memory", + size: 150, + muiTableBodyCellProps: { + align: "right", + }, + }, + // { + // accessorKey: "delete", + // header: "Delete", + // size: 80, + // }, + ], + [] + ); + + const mappedPods = useMemo(() => { + return node?.podList?.map((p) => ({ + name: p.name, + namespace: p.namespace, + status: p.status, + cpu: p.cpu, + memory: p.memory, + delete: ( + <> + + + ), + })); + }, [node?.podList?.toString()]); + // #endregion + + return ( +
+ {/* Breadcrumbs */} +

+ + Cluster Nodes + {" "} + / {nodeName} +

+ + {nodeLoading && ( +
+ +
+ )} + {!nodeLoading && node && ( + <> + {/* Node Info */} +
+

+ {node?.name} +

+
+
+

+ kubelet version +

+

{node?.kubeletVersion}

+
+
+

+ kube-proxy version +

+

{node?.kubeProxyVersion}

+
+
+

+ kernel version +

+

{node?.kernelVersion}

+
+
+
+ {/* Pods table */} +
+

Pods

+
+ +
+
+ {/* Troubleshooting */} + {/*
+

+ Troubleshooting +

+
*/} + {/* Danger Zone */} + {/*
+

+ Danger Zone +

+ +
*/} + + )} +
+ ); +}; + +export default EmbeddedClusterViewNode; diff --git a/web/src/components/apps/HelmVMNodeRow.test.js b/web/src/components/apps/EmbeddedClustrNodeRow.test.js similarity index 57% rename from web/src/components/apps/HelmVMNodeRow.test.js rename to web/src/components/apps/EmbeddedClustrNodeRow.test.js index b7a4d8324c..18b6d130ad 100644 --- a/web/src/components/apps/HelmVMNodeRow.test.js +++ b/web/src/components/apps/EmbeddedClustrNodeRow.test.js @@ -1,3 +1,3 @@ -describe("HelmVMNodeRow", () => { +describe("EmbeddedClusterNodeRow", () => { it.todo("upgrade to react 18 and add unit tests"); }); diff --git a/web/src/components/apps/HelmVMClusterManagement.jsx b/web/src/components/apps/HelmVMClusterManagement.jsx deleted file mode 100644 index 4d1712f258..0000000000 --- a/web/src/components/apps/HelmVMClusterManagement.jsx +++ /dev/null @@ -1,544 +0,0 @@ -import React, { Component, Fragment } from "react"; -import classNames from "classnames"; -import dayjs from "dayjs"; -import { KotsPageTitle } from "@components/Head"; -import CodeSnippet from "../shared/CodeSnippet"; -import HelmVMNodeRow from "./HelmVMNodeRow"; -import Loader from "../shared/Loader"; -import { rbacRoles } from "../../constants/rbac"; -import { Utilities } from "../../utilities/utilities"; -import { Repeater } from "../../utilities/repeater"; -import ErrorModal from "../modals/ErrorModal"; -import Modal from "react-modal"; - -import "@src/scss/components/apps/HelmVMClusterManagement.scss"; -import Icon from "../Icon"; - -export class HelmVMClusterManagement extends Component { - state = { - generating: false, - command: "", - expiry: null, - displayAddNode: false, - selectedNodeType: "primary", - generateCommandErrMsg: "", - helmvm: null, - getNodeStatusJob: new Repeater(), - deletNodeError: "", - confirmDeleteNode: "", - showConfirmDrainModal: false, - nodeNameToDrain: "", - drainingNodeName: null, - drainNodeSuccessful: false, - }; - - componentDidMount() { - this.getNodeStatus(); - this.state.getNodeStatusJob.start(this.getNodeStatus, 1000); - } - - componentWillUnmount() { - this.state.getNodeStatusJob.stop(); - } - - getNodeStatus = async () => { - try { - const res = await fetch(`${process.env.API_ENDPOINT}/helmvm/nodes`, { - headers: { - Accept: "application/json", - }, - credentials: "include", - method: "GET", - }); - if (!res.ok) { - if (res.status === 401) { - Utilities.logoutUser(); - return; - } - console.log( - "failed to get node status list, unexpected status code", - res.status - ); - return; - } - const response = await res.json(); - this.setState({ - helmvm: response, - // if cluster doesn't support ha, then primary will be disabled. Force into secondary - selectedNodeType: !response.ha - ? "secondary" - : this.state.selectedNodeType, - }); - return response; - } catch (err) { - console.log(err); - throw err; - } - }; - - deleteNode = (name) => { - this.setState({ - confirmDeleteNode: name, - }); - }; - - cancelDeleteNode = () => { - this.setState({ - confirmDeleteNode: "", - }); - }; - - reallyDeleteNode = () => { - const name = this.state.confirmDeleteNode; - this.cancelDeleteNode(); - - fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}`, { - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - method: "DELETE", - }) - .then(async (res) => { - if (!res.ok) { - if (res.status === 401) { - Utilities.logoutUser(); - return; - } - this.setState({ - deleteNodeError: `Delete failed with status ${res.status}`, - }); - } - }) - .catch((err) => { - console.log(err); - }); - }; - - generateWorkerAddNodeCommand = async () => { - this.setState({ - generating: true, - command: "", - expiry: null, - generateCommandErrMsg: "", - }); - - fetch( - `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-secondary`, - { - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - method: "POST", - } - ) - .then(async (res) => { - if (!res.ok) { - this.setState({ - generating: false, - generateCommandErrMsg: `Failed to generate command with status ${res.status}`, - }); - } else { - const data = await res.json(); - this.setState({ - generating: false, - command: data.command, - expiry: data.expiry, - }); - } - }) - .catch((err) => { - console.log(err); - this.setState({ - generating: false, - generateCommandErrMsg: err ? err.message : "Something went wrong", - }); - }); - }; - - onDrainNodeClick = (name) => { - this.setState({ - showConfirmDrainModal: true, - nodeNameToDrain: name, - }); - }; - - drainNode = async (name) => { - this.setState({ showConfirmDrainModal: false, drainingNodeName: name }); - fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}/drain`, { - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - method: "POST", - }) - .then(async (res) => { - this.setState({ drainNodeSuccessful: true }); - setTimeout(() => { - this.setState({ - drainingNodeName: null, - drainNodeSuccessful: false, - }); - }, 3000); - }) - .catch((err) => { - console.log(err); - this.setState({ - drainingNodeName: null, - drainNodeSuccessful: false, - }); - }); - }; - - generatePrimaryAddNodeCommand = async () => { - this.setState({ - generating: true, - command: "", - expiry: null, - generateCommandErrMsg: "", - }); - - fetch( - `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-primary`, - { - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - method: "POST", - } - ) - .then(async (res) => { - if (!res.ok) { - this.setState({ - generating: false, - generateCommandErrMsg: `Failed to generate command with status ${res.status}`, - }); - } else { - const data = await res.json(); - this.setState({ - generating: false, - command: data.command, - expiry: data.expiry, - }); - } - }) - .catch((err) => { - console.log(err); - this.setState({ - generating: false, - generateCommandErrMsg: err ? err.message : "Something went wrong", - }); - }); - }; - - onAddNodeClick = () => { - this.setState( - { - displayAddNode: true, - }, - async () => { - await this.generateWorkerAddNodeCommand(); - } - ); - }; - - onSelectNodeType = (event) => { - const value = event.currentTarget.value; - this.setState( - { - selectedNodeType: value, - }, - async () => { - if (this.state.selectedNodeType === "secondary") { - await this.generateWorkerAddNodeCommand(); - } else { - await this.generatePrimaryAddNodeCommand(); - } - } - ); - }; - - ackDeleteNodeError = () => { - this.setState({ deleteNodeError: "" }); - }; - - render() { - const { helmvm } = this.state; - const { displayAddNode, generateCommandErrMsg } = this.state; - - if (!helmvm) { - return ( -
- -
- ); - } - - return ( -
- -
-
-
-

- Your nodes -

-
- {helmvm?.nodes && - helmvm?.nodes.map((node, i) => ( - - ))} -
-
- {helmvm?.isHelmVMEnabled && - Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) ? ( - !displayAddNode ? ( -
- -
- ) : ( -
-
-

- Add a Node -

-
-
-
- - -
-
- - -
-
- {this.state.generating && ( -
- -
- )} - {!this.state.generating && this.state.command?.length > 0 ? ( - -

- Run this command on the node you wish to join the - cluster -

- - Command has been copied to your clipboard - - } - > - {[this.state.command.join(" \\\n ")]} - - {this.state.expiry && ( - - {`Expires on ${dayjs(this.state.expiry).format( - "MMM Do YYYY, h:mm:ss a z" - )} UTC${(-1 * new Date().getTimezoneOffset()) / 60}`} - - )} -
- ) : ( - - {generateCommandErrMsg && ( -
- - {generateCommandErrMsg} - -
- )} -
- )} -
- ) - ) : null} -
-
- {this.state.deleteNodeError && ( - - )} - -
-

- Deleting this node may cause data loss. Are you sure you want to - proceed? -

-
- - -
-
-
- {this.state.showConfirmDrainModal && ( - - this.setState({ - showConfirmDrainModal: false, - nodeNameToDrain: "", - }) - } - shouldReturnFocusAfterClose={false} - contentLabel="Confirm Drain Node" - ariaHideApp={false} - className="Modal MediumSize" - > -
-

- Are you sure you want to drain {this.state.nodeNameToDrain}? -

-

- Draining this node may cause data loss. If you want to delete{" "} - {this.state.nodeNameToDrain} you must disconnect it after it has - been drained. -

-
- - -
-
-
- )} -
- ); - } -} - -export default HelmVMClusterManagement; diff --git a/web/src/components/apps/HelmVMNodeRow.jsx b/web/src/components/apps/HelmVMNodeRow.jsx deleted file mode 100644 index 93f2c5489c..0000000000 --- a/web/src/components/apps/HelmVMNodeRow.jsx +++ /dev/null @@ -1,278 +0,0 @@ -import React from "react"; -import classNames from "classnames"; -import Loader from "../shared/Loader"; -import { rbacRoles } from "../../constants/rbac"; -import { getPercentageStatus, Utilities } from "../../utilities/utilities"; -import Icon from "../Icon"; - -export default function HelmVMNodeRow(props) { - const { node } = props; - - const DrainDeleteNode = () => { - const { drainNode, drainNodeSuccessful, drainingNodeName } = props; - if (drainNode && Utilities.sessionRolesHasOneOf(rbacRoles.DRAIN_NODE)) { - if ( - !drainNodeSuccessful && - drainingNodeName && - drainingNodeName === node?.name - ) { - return ( -
- - - - - Draining Node - -
- ); - } else if (drainNodeSuccessful && drainingNodeName === node?.name) { - return ( -
- - - Node successfully drained - -
- ); - } else { - return ( -
- -
- ); - } - } - }; - - return ( -
-
-
-

- {node?.name} -

- {node?.isPrimaryNode && ( - - Primary node - - )} -
-
-
-

- - {node?.isConnected ? "Connected" : "Disconnected"} -

-

-   -

-
-
-

- - {node?.pods?.available === -1 - ? `${node?.pods?.capacity} pods` - : `${ - node?.pods?.available === 0 - ? "0" - : node?.pods?.capacity - node?.pods?.available - } pods used`} -

- {node?.pods?.available !== -1 && ( -

- of {node?.pods?.capacity} pods total -

- )} -
-
-

- - {node?.cpu?.available === -1 - ? `${node?.cpu?.capacity} ${ - node?.cpu?.available === "1" ? "core" : "cores" - }` - : `${ - node?.cpu?.available === 0 - ? "0" - : (node?.cpu?.capacity - node?.cpu?.available).toFixed(1) - } ${ - node?.cpu?.available === "1" ? "core used" : "cores used" - }`} -

- {node?.pods?.available !== -1 && ( -

- of {node?.cpu?.capacity}{" "} - {node?.cpu?.available === "1" ? "core total" : "cores total"} -

- )} -
-
-

- - {node?.memory?.available === -1 - ? `${node?.memory?.capacity?.toFixed(1)} GB` - : `${ - node?.memory?.available === 0 - ? "0" - : ( - node?.memory?.capacity - node?.memory?.available - ).toFixed(1) - } GB used`} -

- {node?.pods?.available !== -1 && ( -

- of {node?.memory?.capacity?.toFixed(1)} GB total -

- )} -
-
-
-
-

- - {node?.kubeletVersion} -

-
-
-

- - {node?.conditions?.diskPressure - ? "No Space on Device" - : "No Disk Pressure"} -

-
-
-

- - {node?.conditions?.pidPressure - ? "Pressure on CPU" - : "No CPU Pressure"} -

-
-
-

- - {node?.conditions?.memoryPressure - ? "No Space on Memory" - : "No Memory Pressure"} -

-
-
- {/* LABELS */} -
- {node?.labels.length > 0 - ? node.labels.sort().map((label, i) => { - let labelToShow = label.replace(":", "="); - return ( -
- {labelToShow} -
- ); - }) - : null} -
-
-

- For more details run{" "} - - kubectl describe node {node?.name} - -

-
-
- -
- ); -} diff --git a/web/src/components/shared/CodeSnippet.jsx b/web/src/components/shared/CodeSnippet.jsx index 892c4b746a..9f0738e2fb 100644 --- a/web/src/components/shared/CodeSnippet.jsx +++ b/web/src/components/shared/CodeSnippet.jsx @@ -36,7 +36,7 @@ class CodeSnippet extends Component { const { children, copyDelay } = this.props; const textToCopy = Array.isArray(children) ? children.join("\n") : children; - if (navigator.clipboard) { + if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(textToCopy).then(() => { this.setState({ didCopy: true }); @@ -44,6 +44,29 @@ class CodeSnippet extends Component { this.setState({ didCopy: false }); }, copyDelay); }); + } else { + const textArea = document.createElement("textarea"); + textArea.value = textToCopy; + + textArea.style.position = "absolute"; + textArea.style.opacity = 0; + + document.body.prepend(textArea); + textArea.select(); + + try { + document.execCommand("copy"); + + this.setState({ didCopy: true }); + + setTimeout(() => { + this.setState({ didCopy: false }); + }, copyDelay); + } catch (error) { + console.error(error); + } finally { + textArea.remove(); + } } }; @@ -67,7 +90,6 @@ class CodeSnippet extends Component { language, preText, canCopy, - clipboardEnabled = !!navigator.clipboard, copyText, onCopyText, variant, @@ -94,7 +116,7 @@ class CodeSnippet extends Component { )} {content} - {clipboardEnabled && canCopy && ( + {canCopy && ( void; @@ -144,7 +144,7 @@ export class NavBar extends PureComponent { className, fetchingMetadata, isKurlEnabled, - isHelmVMEnabled, + isEmbeddedClusterEnabled, isGitOpsSupported, isIdentityServiceSupported, appsList, @@ -228,20 +228,21 @@ export class NavBar extends PureComponent { )} - {(isKurlEnabled || isHelmVMEnabled) && ( -
- - Cluster Management - -
- )} + + Cluster Management + + + )} {isSnapshotsSupported && (
{ }; const getCurrentVersionStatus = (version: Version | null) => { - if ( - version?.status === "deployed" || - version?.status === "merged" || - version?.status === "pending" - ) { + if (version?.status === "deployed" || version?.status === "pending") { return ( Currently {version?.status.replace("_", " ")} version diff --git a/web/src/scss/components/apps/HelmVMClusterManagement.scss b/web/src/scss/components/apps/EmbeddedClusterManagement.scss similarity index 60% rename from web/src/scss/components/apps/HelmVMClusterManagement.scss rename to web/src/scss/components/apps/EmbeddedClusterManagement.scss index cd8bc74a01..5430c6b05f 100644 --- a/web/src/scss/components/apps/HelmVMClusterManagement.scss +++ b/web/src/scss/components/apps/EmbeddedClusterManagement.scss @@ -1,38 +1,13 @@ @import "../../variables.scss"; -.HelmVMClusterManagement--wrapper { +.EmbeddedClusterManagement--wrapper { .BoxedCheckbox { height: 85px; width: 200px; } - - .timestamp { - position: relative; - margin-top: -10px; - z-index: -1; - } - - .node-label { - font-size: 12px; - font-weight: 500; - line-height: 12px; - color: #577981; - padding: 4px 6px; - border-radius: 20px; - background-color: #ffffff; - white-space: nowrap; - border: 1px solid #577981; - margin-right: 8px; - display: inline-block; - margin-top: 8px; - - &:last-child { - margin-right: 0; - } - } } -.HelmVMNodeRow--wrapper { +.EmbeddedClusterNodeRow--wrapper { padding: 12px; border-top: 1px solid #dfdfdf; @@ -55,7 +30,7 @@ } } -.HelmVMNodeRow--items p { +.EmbeddedClusterNodeRow--items p { max-width: 180px; .icon { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 269d6d1b35..ab0b6912f9 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -130,7 +130,7 @@ export type Entitlement = { export type Metadata = { isAirgap: boolean; isKurl: boolean; - isHelmVM: boolean; + isEmbeddedCluster: boolean; }; export type PreflightError = { @@ -252,13 +252,12 @@ export type VersionStatus = | "deployed" | "deploying" | "failed" - | "merged" | "pending" | "pending_config" | "pending_download" | "pending_preflight" - | "superseded" - | "waiting"; + | "waiting" + | "unknown"; export type LicenseFile = { preview: string; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 1ea5c980de..d89f3af2a5 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -13,17 +13,60 @@ module.exports = { "teal-muted-dark": "#577981", "teal-medium": "#097992", gray: { - 100: "#dfdfdf", + 100: "#dedede", 200: "#c4c8ca", 300: "#b3b3b3", + 410: "#9b9b9b", 400: "#959595", 500: "#717171", 600: "#585858", 700: "#4f4f4f", - 800: "#323232" + 800: "#323232", + 900: "#2c2c2c", + }, + blue: { + 50: "#ecf4fe", + 75: "#b3d2fc", + 200: "#65a4f8", + 300: "#4591f7", + 400: "#3066ad", + }, + green: { + 50: "#e7f7f3", + 75: "#9cdfcf", + 100: "#73d2bb", + 200: "#37bf9e", + 300: "#0eb28a", + 400: "#0a7d61", + 500: "#096d54", + }, + indigo: { + 100: "#f0f1ff", + 200: "#c2c7fd", + 300: "#a9b0fd", + 400: "#838efc", + 500: "#6a77fb", + 600: "#4a53b0", + 700: "#414999", }, neutral: { - 700: "#4A4A4A" + 700: "#4A4A4A", + }, + teal: { + 300: "#4db9c0", + 400: "#38a3a8", + }, + pink: { + 50: "#fff0f3", + 100: "#ffc1cf", + 200: "#fea7bc", + 300: "#fe819f", + 400: "#fe678b", + 500: "#b24861", + 600: "#9b3f55", + }, + purple: { + 400: "#7242b0", }, error: "#bc4752", "error-xlight": "#fbedeb", @@ -34,26 +77,26 @@ module.exports = { "warning-bright": "#ec8f39", "info-bright": "#76bbca", "disabled-teal": "#76a6cf", - "dark-neon-green": "#38cc97" + "dark-neon-green": "#38cc97", }, extend: { borderRadius: { xs: "0.125rem", sm: "0.187rem", - variants: ["first", "last"] + variants: ["first", "last"], }, fontFamily: { - sans: ["Open Sans", ...defaultTheme.fontFamily.sans] - } - } + sans: ["Open Sans", ...defaultTheme.fontFamily.sans], + }, + }, }, corePlugins: { - preflight: false + preflight: false, }, plugins: [ plugin(function ({ addVariant }) { addVariant("is-enabled", "&:not([disabled])"); addVariant("is-disabled", "&[disabled]"); - }) - ] + }), + ], }; diff --git a/web/yarn.lock b/web/yarn.lock index d8d7d15bd8..e0d72198b0 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -350,6 +350,13 @@ dependencies: "@babel/types" "^7.18.6" +"@babel/helper-module-imports@^7.16.7": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" + integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== + dependencies: + "@babel/types" "^7.22.15" + "@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.20.2", "@babel/helper-module-transforms@^7.21.0": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.0.tgz#89a8f86ad748870e3d024e470b2e8405e869db67" @@ -447,11 +454,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== +"@babel/helper-string-parser@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" + integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== + "@babel/helper-validator-identifier@^7.16.7", "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + "@babel/helper-validator-option@^7.18.6", "@babel/helper-validator-option@^7.21.0": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz#8224c7e13ace4bafdc4004da2cf064ef42673180" @@ -1390,6 +1407,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.23.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.23.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" + integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.3.1": version "7.20.13" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" @@ -1535,6 +1559,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.22.15": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" + integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== + dependencies: + "@babel/helper-string-parser" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -1614,6 +1647,23 @@ resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@emotion/babel-plugin@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" + integrity sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/serialize" "^1.1.2" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + "@emotion/babel-utils@^0.6.4": version "0.6.10" resolved "https://registry.npmjs.org/@emotion/babel-utils/-/babel-utils-0.6.10.tgz" @@ -1626,11 +1676,27 @@ find-root "^1.1.0" source-map "^0.7.2" +"@emotion/cache@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + "@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": version "0.6.6" resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.6.6.tgz" integrity sha512-ojhgxzUHZ7am3D2jHkMzPpsBAiB005GF5YU4ea+8DNPybMk01JJUM9V9YRlF/GE95tcOm8DxQvWA2jq19bGalQ== +"@emotion/hash@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43" + integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== + "@emotion/is-prop-valid@^1.1.0": version "1.1.3" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.1.3.tgz" @@ -1638,6 +1704,13 @@ dependencies: "@emotion/memoize" "^0.7.4" +"@emotion/is-prop-valid@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz#23116cf1ed18bfeac910ec6436561ecb1a3885cc" + integrity sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": version "0.6.6" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.6.6.tgz" @@ -1648,6 +1721,25 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz" integrity sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ== +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + +"@emotion/react@^11.11.1": + version "11.11.1" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.11.1.tgz#b2c36afac95b184f73b08da8c214fdf861fa4157" + integrity sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/cache" "^11.11.0" + "@emotion/serialize" "^1.1.2" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + hoist-non-react-statics "^3.3.1" + "@emotion/serialize@^0.9.1": version "0.9.1" resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.9.1.tgz" @@ -1658,6 +1750,34 @@ "@emotion/unitless" "^0.6.7" "@emotion/utils" "^0.8.2" +"@emotion/serialize@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51" + integrity sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA== + dependencies: + "@emotion/hash" "^0.9.1" + "@emotion/memoize" "^0.8.1" + "@emotion/unitless" "^0.8.1" + "@emotion/utils" "^1.2.1" + csstype "^3.0.2" + +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + +"@emotion/styled@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.11.0.tgz#26b75e1b5a1b7a629d7c0a8b708fbf5a9cdce346" + integrity sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.11.0" + "@emotion/is-prop-valid" "^1.2.1" + "@emotion/serialize" "^1.1.2" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.1" + "@emotion/utils" "^1.2.1" + "@emotion/stylis@^0.7.0": version "0.7.1" resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.7.1.tgz" @@ -1678,11 +1798,31 @@ resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== +"@emotion/unitless@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" + integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== + +"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963" + integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== + "@emotion/utils@^0.8.2": version "0.8.2" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.8.2.tgz" integrity sha512-rLu3wcBWH4P5q1CGoSSH/i9hrXs7SlbRLkoq9IGuoPYNGQvDJ3pt/wmOM+XgYjIDRMVIdkUWt0RsfzF50JfnCw== +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + "@eslint/eslintrc@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.0.tgz" @@ -1698,6 +1838,33 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.4.2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c" + integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg== + dependencies: + "@floating-ui/utils" "^0.1.3" + +"@floating-ui/dom@^1.5.1": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" + integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA== + dependencies: + "@floating-ui/core" "^1.4.2" + "@floating-ui/utils" "^0.1.3" + +"@floating-ui/react-dom@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.2.tgz#fab244d64db08e6bed7be4b5fcce65315ef44d20" + integrity sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ== + dependencies: + "@floating-ui/dom" "^1.5.1" + +"@floating-ui/utils@^0.1.3": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" + integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -2204,6 +2371,97 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@mui/base@5.0.0-beta.20": + version "5.0.0-beta.20" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.20.tgz#14fcdfe0350f2aad06ab6c37c4c91dacaab8f600" + integrity sha512-CS2pUuqxST7ch9VNDCklRYDbJ3rru20Tx7na92QvVVKfu3RL4z/QLuVIc8jYGsdCnauMaeUSlFNLAJNb0yXe6w== + dependencies: + "@babel/runtime" "^7.23.1" + "@floating-ui/react-dom" "^2.0.2" + "@mui/types" "^7.2.6" + "@mui/utils" "^5.14.13" + "@popperjs/core" "^2.11.8" + clsx "^2.0.0" + prop-types "^15.8.1" + +"@mui/core-downloads-tracker@^5.14.14": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.14.tgz#a54894e9b4dc908ab2d59eac543219d9018448e6" + integrity sha512-Rw/xKiTOUgXD8hdKqj60aC6QcGprMipG7ne2giK6Mz7b4PlhL/xog9xLeclY3BxsRLkZQ05egFnIEY1CSibTbw== + +"@mui/icons-material@^5.14.14": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.14.tgz#02d33f51f0b9de238d5c47b0a31ff330144393c4" + integrity sha512-vwuaMsKvI7AWTeYqR8wYbpXijuU8PzMAJWRAq2DDIuOZPxjKyHlr8WQ25+azZYkIXtJ7AqnVb1ZmHdEyB4/kug== + dependencies: + "@babel/runtime" "^7.23.1" + +"@mui/material@^5.14.14": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.14.14.tgz#e47f3992b609002cd57a71f70e829dc2d286028c" + integrity sha512-cAmCwAHFQXxb44kWbVFkhKATN8tACgMsFwrXo8ro6WzYW73U/qsR5AcCiJIhCyYYg+gcftfkmNcpRaV3JjhHCg== + dependencies: + "@babel/runtime" "^7.23.1" + "@mui/base" "5.0.0-beta.20" + "@mui/core-downloads-tracker" "^5.14.14" + "@mui/system" "^5.14.14" + "@mui/types" "^7.2.6" + "@mui/utils" "^5.14.13" + "@types/react-transition-group" "^4.4.7" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + react-is "^18.2.0" + react-transition-group "^4.4.5" + +"@mui/private-theming@^5.14.14": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.14.14.tgz#035dde1eb30c896c69a12b7dee1dce3a323c66e9" + integrity sha512-n77au3CQj9uu16hak2Y+rvbGSBaJKxziG/gEbOLVGrAuqZ+ycVSkorCfN6Y/4XgYOpG/xvmuiY3JwhAEOzY3iA== + dependencies: + "@babel/runtime" "^7.23.1" + "@mui/utils" "^5.14.13" + prop-types "^15.8.1" + +"@mui/styled-engine@^5.14.13": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.14.14.tgz#b0ededf531fff1ef110f7b263c2d3d95a0b8ec9a" + integrity sha512-sF3DS2PVG+cFWvkVHQQaGFpL1h6gSwOW3L91pdxPLQDHDZ5mZ/X0SlXU5XA+WjypoysG4urdAQC7CH/BRvUiqg== + dependencies: + "@babel/runtime" "^7.23.1" + "@emotion/cache" "^11.11.0" + csstype "^3.1.2" + prop-types "^15.8.1" + +"@mui/system@^5.14.14": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.14.14.tgz#f33327e74230523169107ace960e8bb51cbdbab7" + integrity sha512-y4InFmCgGGWXnz+iK4jRTWVikY0HgYnABjz4wgiUgEa2W1H8M4ow+27BegExUWPkj4TWthQ2qG9FOGSMtI+PKA== + dependencies: + "@babel/runtime" "^7.23.1" + "@mui/private-theming" "^5.14.14" + "@mui/styled-engine" "^5.14.13" + "@mui/types" "^7.2.6" + "@mui/utils" "^5.14.13" + clsx "^2.0.0" + csstype "^3.1.2" + prop-types "^15.8.1" + +"@mui/types@^7.2.6": + version "7.2.6" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.6.tgz#d72b9e9eb0032e107e76033932d65c3f731d2608" + integrity sha512-7sjLQrUmBwufm/M7jw/quNiPK/oor2+pGUQP2CULRcFCArYTq78oJ3D5esTaL0UMkXKJvDqXn6Ike69yAOBQng== + +"@mui/utils@^5.14.13": + version "5.14.14" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.14.14.tgz#7b2a0bcfb44c3376fc81f85500f9bd01706682ac" + integrity sha512-3AKp8uksje5sRfVrtgG9Q/2TBsHWVBUtA0NaXliZqGcXo8J+A+Agp0qUW2rJ+ivgPWTCCubz9FZVT2IQZ3bGsw== + dependencies: + "@babel/runtime" "^7.23.1" + "@types/prop-types" "^15.7.7" + prop-types "^15.8.1" + react-is "^18.2.0" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz" @@ -2345,6 +2603,11 @@ resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + "@remix-run/router@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.5.0.tgz#57618e57942a5f0131374a9fdb0167e25a117fdc" @@ -3661,7 +3924,7 @@ regenerator-runtime "^0.13.7" resolve-from "^5.0.0" -"@tanstack/match-sorter-utils@^8.7.0": +"@tanstack/match-sorter-utils@8.8.4", "@tanstack/match-sorter-utils@^8.7.0": version "8.8.4" resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz#0b2864d8b7bac06a9f84cb903d405852cc40a457" integrity sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw== @@ -3690,6 +3953,30 @@ "@tanstack/query-core" "4.36.1" use-sync-external-store "^1.2.0" +"@tanstack/react-table@8.10.7": + version "8.10.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.10.7.tgz#733f4bee8cf5aa19582f944dd0fd3224b21e8c94" + integrity sha512-bXhjA7xsTcsW8JPTTYlUg/FuBpn8MNjiEPhkNhIGCUR6iRQM2+WEco4OBpvDeVcR9SE+bmWLzdfiY7bCbCSVuA== + dependencies: + "@tanstack/table-core" "8.10.7" + +"@tanstack/react-virtual@3.0.0-beta.65": + version "3.0.0-beta.65" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.65.tgz#a29a10c761afd00c8000dc38adf60088656e0e62" + integrity sha512-Q21cUoE0C8Oyzy3RAMV+u4BuB+RwIf2/oQRCWksmIBp1PqLEtvXhAldh7v/wUt7WKEkislKDICZAvbYYs7EAyQ== + dependencies: + "@tanstack/virtual-core" "3.0.0-beta.65" + +"@tanstack/table-core@8.10.7": + version "8.10.7" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.10.7.tgz#577e8a635048875de4c9d6d6a3c21d26ff9f9d08" + integrity sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw== + +"@tanstack/virtual-core@3.0.0-beta.65": + version "3.0.0-beta.65" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.65.tgz#fac199321db7787db9463082903dca23c0850c5c" + integrity sha512-ObP2pvXBdbivinr7BWDbGqYt4TK8wNzYsOWio+qBkDx5AJFuvqcdJxcCCYnv4dzVTe5ELA1MT4tkt8NB/tnEdA== + "@testing-library/dom@^8.3.0": version "8.19.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.0.tgz#bd3f83c217ebac16694329e413d9ad5fdcfd785f" @@ -4183,6 +4470,11 @@ resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== +"@types/prop-types@^15.7.7": + version "15.7.8" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3" + integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ== + "@types/q@1.0.7": version "1.0.7" resolved "https://registry.npmjs.org/@types/q/-/q-1.0.7.tgz" @@ -4252,6 +4544,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.7": + version "4.4.7" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.7.tgz#bf69f269d74aa78b99097673ca6dd6824a68ef1c" + integrity sha512-ICCyBl5mvyqYp8Qeq9B5G/fyBSRC0zx3XM3sCC6KkcMsNeAHqXBKkmat4GqdJET5jtYUpZXrxI5flve5qhi2Eg== + dependencies: + "@types/react" "*" + "@types/react@*": version "18.0.20" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.20.tgz#e4c36be3a55eb5b456ecf501bd4a00fd4fd0c9ab" @@ -5657,7 +5956,7 @@ babel-plugin-macros@^2.0.0: cosmiconfig "^6.0.0" resolve "^1.12.0" -babel-plugin-macros@^3.0.1: +babel-plugin-macros@^3.0.1, babel-plugin-macros@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1" integrity sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg== @@ -6801,6 +7100,11 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz" @@ -7361,6 +7665,11 @@ csstype@^3.0.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz" integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw== +csstype@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -7816,6 +8125,14 @@ dom-helpers@^3.4.0: dependencies: "@babel/runtime" "^7.1.2" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + dom-serializer@^1.0.1: version "1.4.1" resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz" @@ -9768,6 +10085,11 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight-words@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/highlight-words/-/highlight-words-1.2.2.tgz#9875b75d11814d7356b24f23feeb7d77761fa867" + integrity sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ== + highlight.js@^10.4.1, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" @@ -9799,7 +10121,7 @@ hoek@4.2.1: resolved "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz" integrity sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA== -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -11756,6 +12078,16 @@ marked@4.0.12: resolved "https://registry.npmjs.org/marked/-/marked-4.0.12.tgz" integrity sha512-hgibXWrEDNBWgGiK18j/4lkS6ihTe9sxtV4Q1OQppb/0zzyPSzoFANBa5MfsG/zgsWklmNnhm0XACZOH/0HBiQ== +material-react-table@^1.15.1: + version "1.15.1" + resolved "https://registry.yarnpkg.com/material-react-table/-/material-react-table-1.15.1.tgz#c2bdfdd9c9636acbb2e8ffd5553a82395a2d9f4a" + integrity sha512-TXidRV7lGtCV5G/ON9Y38TztRcmpKFodFmyTCjvlKXCl5/9X+KY4waP8U0l16FFslg1f7HGWhfkqV5OfUfEIoA== + dependencies: + "@tanstack/match-sorter-utils" "8.8.4" + "@tanstack/react-table" "8.10.7" + "@tanstack/react-virtual" "3.0.0-beta.65" + highlight-words "1.2.2" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -13869,7 +14201,7 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^18.0.0: +react-is@^18.0.0, react-is@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== @@ -13997,6 +14329,16 @@ react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" +react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-vis@^1.11.7: version "1.11.7" resolved "https://registry.npmjs.org/react-vis/-/react-vis-1.11.7.tgz" @@ -14193,6 +14535,11 @@ regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.2, regenerator-runtime@^ resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== +regenerator-runtime@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45" + integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA== + regenerator-transform@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56" @@ -15485,6 +15832,11 @@ stylis-rule-sheet@^0.0.10: resolved "https://registry.npmjs.org/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz" integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + stylis@^3.5.0: version "3.5.4" resolved "https://registry.npmjs.org/stylis/-/stylis-3.5.4.tgz"