diff --git a/README.md b/README.md index 0c3e3b09..26054844 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Cyclops can either be installed manually by applying the latest manifest, by usi To install Cyclops using `kubectl` into your cluster, run the commands below: ```bash -kubectl apply -f https://raw.githubusercontent.com/cyclops-ui/cyclops/v0.11.1/install/cyclops-install.yaml && kubectl apply -f https://raw.githubusercontent.com/cyclops-ui/cyclops/v0.11.1/install/demo-templates.yaml +kubectl apply -f https://raw.githubusercontent.com/cyclops-ui/cyclops/v0.12.0/install/cyclops-install.yaml && kubectl apply -f https://raw.githubusercontent.com/cyclops-ui/cyclops/v0.12.0/install/demo-templates.yaml ``` It will create a new namespace called `cyclops` and deploy everything you need for your Cyclops instance to run. diff --git a/cyclops-ctrl/internal/controller/modules.go b/cyclops-ctrl/internal/controller/modules.go index 23e972af..1017b9ea 100644 --- a/cyclops-ctrl/internal/controller/modules.go +++ b/cyclops-ctrl/internal/controller/modules.go @@ -158,6 +158,7 @@ func (m *Modules) Manifest(ctx *gin.Context) { request.TemplateRef.URL, request.TemplateRef.Path, request.TemplateRef.Version, + "", ) if err != nil { fmt.Println(err) @@ -204,6 +205,7 @@ func (m *Modules) CurrentManifest(ctx *gin.Context) { module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, module.Spec.TemplateRef.Version, + module.Status.TemplateResolvedVersion, ) if err != nil { fmt.Println(err) @@ -396,6 +398,7 @@ func (m *Modules) ResourcesForModule(ctx *gin.Context) { module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, templateVersion, + module.Status.TemplateResolvedVersion, ) if err != nil { ctx.JSON(http.StatusInternalServerError, dto.NewError("Error fetching template", err.Error())) @@ -440,6 +443,7 @@ func (m *Modules) Template(ctx *gin.Context) { module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, module.Spec.TemplateRef.Version, + module.Status.TemplateResolvedVersion, ) if err != nil { fmt.Println(err) @@ -458,6 +462,7 @@ func (m *Modules) Template(ctx *gin.Context) { module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, module.Spec.TemplateRef.Version, + module.Status.TemplateResolvedVersion, ) if err != nil { fmt.Println(err) @@ -494,6 +499,7 @@ func (m *Modules) HelmTemplate(ctx *gin.Context) { module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, module.Spec.TemplateRef.Version, + module.Status.TemplateResolvedVersion, ) if err != nil { fmt.Println(err) diff --git a/cyclops-ctrl/internal/controller/sse/resources.go b/cyclops-ctrl/internal/controller/sse/resources.go index 5fc4ea6f..29804b3e 100644 --- a/cyclops-ctrl/internal/controller/sse/resources.go +++ b/cyclops-ctrl/internal/controller/sse/resources.go @@ -5,11 +5,85 @@ import ( "net/http" "time" + "github.com/pkg/errors" + "github.com/gin-gonic/gin" "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/cyclops-ui/cyclops/cyclops-ctrl/pkg/cluster/k8sclient" ) func (s *Server) Resources(ctx *gin.Context) { + resources, err := s.k8sClient.GetWorkloadsForModule(ctx.Param("name")) + if err != nil { + ctx.String(http.StatusInternalServerError, err.Error()) + return + } + + watchSpecs := make([]k8sclient.ResourceWatchSpec, 0, len(resources)) + for _, resource := range resources { + if !k8sclient.IsWorkload(resource.GetGroup(), resource.GetVersion(), resource.GetKind()) { + continue + } + + resourceName, err := kindToResource(resource.GetKind()) + if err != nil { + ctx.String(http.StatusInternalServerError, err.Error()) + return + } + + watchSpecs = append(watchSpecs, k8sclient.ResourceWatchSpec{ + GVR: schema.GroupVersionResource{ + Group: resource.GetGroup(), + Version: resource.GetVersion(), + Resource: resourceName, + }, + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + }) + } + + stopCh := make(chan struct{}) + + watchResource, err := s.k8sClient.WatchKubernetesResources(watchSpecs, stopCh) + if err != nil { + ctx.String(http.StatusInternalServerError, err.Error()) + return + } + + ctx.Stream(func(w io.Writer) bool { + for { + select { + case u, ok := <-watchResource: + if !ok { + return false + } + + res, err := s.k8sClient.GetResource( + u.GroupVersionKind().Group, + u.GroupVersionKind().Version, + u.GroupVersionKind().Kind, + u.GetName(), + u.GetNamespace(), + ) + if err != nil { + continue + } + + ctx.SSEvent("resource-update", res) + return true + case <-ctx.Request.Context().Done(): + close(stopCh) + return false + case <-ctx.Done(): + close(stopCh) + return false + } + } + }) +} + +func (s *Server) SingleResource(ctx *gin.Context) { type Ref struct { Group string `json:"group" form:"group"` Version string `json:"version" form:"version"` @@ -73,3 +147,16 @@ func (s *Server) Resources(ctx *gin.Context) { } }) } + +func kindToResource(kind string) (string, error) { + switch kind { + case "Deployment": + return "deployments", nil + case "StatefulSet": + return "statefulsets", nil + case "DaemonSet": + return "daemonsets", nil + default: + return "", errors.Errorf("kind %v is not a workload", kind) + } +} diff --git a/cyclops-ctrl/internal/controller/templates.go b/cyclops-ctrl/internal/controller/templates.go index ed9004ae..95923179 100644 --- a/cyclops-ctrl/internal/controller/templates.go +++ b/cyclops-ctrl/internal/controller/templates.go @@ -59,7 +59,7 @@ func (c *Templates) GetTemplate(ctx *gin.Context) { return } - t, err := c.templatesRepo.GetTemplate(repo, path, commit) + t, err := c.templatesRepo.GetTemplate(repo, path, commit, "") if err != nil { fmt.Println(err) ctx.JSON(http.StatusBadRequest, dto.NewError("Error loading template", err.Error())) @@ -131,7 +131,12 @@ func (c *Templates) CreateTemplatesStore(ctx *gin.Context) { return } - tmpl, err := c.templatesRepo.GetTemplate(templateStore.TemplateRef.URL, templateStore.TemplateRef.Path, templateStore.TemplateRef.Version) + tmpl, err := c.templatesRepo.GetTemplate( + templateStore.TemplateRef.URL, + templateStore.TemplateRef.Path, + templateStore.TemplateRef.Version, + "", + ) if err != nil { fmt.Println(err) ctx.JSON(http.StatusBadRequest, dto.NewError("Error loading template", err.Error())) @@ -169,7 +174,12 @@ func (c *Templates) EditTemplatesStore(ctx *gin.Context) { return } - tmpl, err := c.templatesRepo.GetTemplate(templateStore.TemplateRef.URL, templateStore.TemplateRef.Path, templateStore.TemplateRef.Version) + tmpl, err := c.templatesRepo.GetTemplate( + templateStore.TemplateRef.URL, + templateStore.TemplateRef.Path, + templateStore.TemplateRef.Version, + "", + ) if err != nil { fmt.Println(err) ctx.JSON(http.StatusBadRequest, dto.NewError("Error loading template", err.Error())) diff --git a/cyclops-ctrl/internal/handler/handler.go b/cyclops-ctrl/internal/handler/handler.go index a425e9e4..47c6d8a9 100644 --- a/cyclops-ctrl/internal/handler/handler.go +++ b/cyclops-ctrl/internal/handler/handler.go @@ -52,7 +52,8 @@ func (h *Handler) Start() error { server := sse.NewServer(h.k8sClient) - h.router.POST("/stream/resources", sse.HeadersMiddleware(), server.Resources) + h.router.GET("/stream/resources/:name", sse.HeadersMiddleware(), server.Resources) + h.router.POST("/stream/resources", sse.HeadersMiddleware(), server.SingleResource) h.router.GET("/ping", h.pong()) diff --git a/cyclops-ctrl/internal/mapper/modules.go b/cyclops-ctrl/internal/mapper/modules.go index 189cf1cd..3581442a 100644 --- a/cyclops-ctrl/internal/mapper/modules.go +++ b/cyclops-ctrl/internal/mapper/modules.go @@ -2,6 +2,8 @@ package mapper import ( "fmt" + v1 "k8s.io/api/core/v1" + "strings" json "github.com/json-iterator/go" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -27,7 +29,7 @@ func RequestToModule(req dto.Module) (cyclopsv1alpha1.Module, error) { Name: req.Name, }, Spec: cyclopsv1alpha1.ModuleSpec{ - TargetNamespace: req.Namespace, + TargetNamespace: mapTargetNamespace(req.Namespace), TemplateRef: DtoTemplateRefToK8s(req.Template), Values: apiextensionsv1.JSON{ Raw: data, @@ -41,7 +43,7 @@ func ModuleToDTO(module cyclopsv1alpha1.Module) (dto.Module, error) { return dto.Module{ Name: module.Name, Namespace: module.Namespace, - TargetNamespace: module.Spec.TargetNamespace, + TargetNamespace: mapTargetNamespace(module.Spec.TargetNamespace), Version: module.Spec.TemplateRef.Version, Template: k8sTemplateRefToDTO(module.Spec.TemplateRef, module.Status.TemplateResolvedVersion), Values: module.Spec.Values, @@ -139,3 +141,11 @@ func setValuesRecursive(moduleValues map[string]interface{}, fields map[string]m return values, nil } + +func mapTargetNamespace(targetNamespace string) string { + if len(strings.TrimSpace(targetNamespace)) == 0 { + return v1.NamespaceDefault + } + + return targetNamespace +} diff --git a/cyclops-ctrl/internal/modulecontroller/module_controller.go b/cyclops-ctrl/internal/modulecontroller/module_controller.go index 0841c2d0..38031365 100644 --- a/cyclops-ctrl/internal/modulecontroller/module_controller.go +++ b/cyclops-ctrl/internal/modulecontroller/module_controller.go @@ -133,6 +133,7 @@ func (r *ModuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr module.Spec.TemplateRef.URL, module.Spec.TemplateRef.Path, templateVersion, + module.Status.TemplateResolvedVersion, ) if err != nil { r.logger.Error(err, "error fetching module template", "namespaced name", req.NamespacedName) diff --git a/cyclops-ctrl/internal/template/git.go b/cyclops-ctrl/internal/template/git.go index 0e58a54a..76334b41 100644 --- a/cyclops-ctrl/internal/template/git.go +++ b/cyclops-ctrl/internal/template/git.go @@ -29,15 +29,20 @@ import ( "github.com/cyclops-ui/cyclops/cyclops-ctrl/internal/template/gitproviders" ) -func (r Repo) LoadTemplate(repoURL, path, commit string) (*models.Template, error) { +func (r Repo) LoadTemplate(repoURL, path, commit, resolvedVersion string) (*models.Template, error) { creds, err := r.credResolver.RepoAuthCredentials(repoURL) if err != nil { return nil, err } - commitSHA, err := resolveRef(repoURL, commit, creds) - if err != nil { - return nil, err + commitSHA := resolvedVersion + if len(commitSHA) == 0 { + ref, err := resolveRef(repoURL, commit, creds) + if err != nil { + return nil, err + } + + commitSHA = ref } cached, ok := r.cache.GetTemplate(repoURL, path, commitSHA) diff --git a/cyclops-ctrl/internal/template/helm.go b/cyclops-ctrl/internal/template/helm.go index aab0a783..1c66121b 100644 --- a/cyclops-ctrl/internal/template/helm.go +++ b/cyclops-ctrl/internal/template/helm.go @@ -23,10 +23,12 @@ import ( "github.com/cyclops-ui/cyclops/cyclops-ctrl/internal/models/helm" ) -func (r Repo) LoadHelmChart(repo, chart, version string) (*models.Template, error) { +func (r Repo) LoadHelmChart(repo, chart, version, resolvedVersion string) (*models.Template, error) { var err error strictVersion := version - if !isValidVersion(version) { + if len(resolvedVersion) > 0 { + strictVersion = resolvedVersion + } else if !isValidVersion(version) { if registry.IsOCI(repo) { strictVersion, err = getOCIStrictVersion(repo, chart, version) if err != nil { diff --git a/cyclops-ctrl/internal/template/oci.go b/cyclops-ctrl/internal/template/oci.go index 24e38c8c..4e46d133 100644 --- a/cyclops-ctrl/internal/template/oci.go +++ b/cyclops-ctrl/internal/template/oci.go @@ -13,10 +13,13 @@ import ( "github.com/cyclops-ui/cyclops/cyclops-ctrl/internal/models" ) -func (r Repo) LoadOCIHelmChart(repo, chart, version string) (*models.Template, error) { +func (r Repo) LoadOCIHelmChart(repo, chart, version, resolvedVersion string) (*models.Template, error) { var err error strictVersion := version - if !isValidVersion(version) { + + if len(resolvedVersion) > 0 { + strictVersion = resolvedVersion + } else if !isValidVersion(version) { strictVersion, err = getOCIStrictVersion(repo, chart, version) if err != nil { return nil, err diff --git a/cyclops-ctrl/internal/template/template.go b/cyclops-ctrl/internal/template/template.go index b6171172..3442987d 100644 --- a/cyclops-ctrl/internal/template/template.go +++ b/cyclops-ctrl/internal/template/template.go @@ -29,10 +29,10 @@ func NewRepo(credResolver auth.TemplatesResolver, tc templateCache) *Repo { } } -func (r Repo) GetTemplate(repo, path, version string) (*models.Template, error) { +func (r Repo) GetTemplate(repo, path, version, resolvedVersion string) (*models.Template, error) { // region load OCI chart if registry.IsOCI(repo) { - return r.LoadOCIHelmChart(repo, path, version) + return r.LoadOCIHelmChart(repo, path, version, resolvedVersion) } // endregion @@ -43,12 +43,12 @@ func (r Repo) GetTemplate(repo, path, version string) (*models.Template, error) } if isHelmRepo { - return r.LoadHelmChart(repo, path, version) + return r.LoadHelmChart(repo, path, version, resolvedVersion) } // endregion // fallback to cloning from git - return r.LoadTemplate(repo, path, version) + return r.LoadTemplate(repo, path, version, resolvedVersion) } func (r Repo) GetTemplateInitialValues(repo, path, version string) (map[string]interface{}, error) { @@ -80,7 +80,7 @@ func (r Repo) loadDependencies(metadata *helm.Metadata) ([]*models.Template, err continue } - dep, err := r.GetTemplate(dependency.Repository, dependency.Name, dependency.Version) + dep, err := r.GetTemplate(dependency.Repository, dependency.Name, dependency.Version, "") if err != nil { return nil, err } diff --git a/cyclops-ctrl/pkg/cluster/k8sclient/client.go b/cyclops-ctrl/pkg/cluster/k8sclient/client.go index 3b378853..f1523b0a 100644 --- a/cyclops-ctrl/pkg/cluster/k8sclient/client.go +++ b/cyclops-ctrl/pkg/cluster/k8sclient/client.go @@ -8,8 +8,10 @@ import ( "fmt" "io" networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/tools/cache" "os" "os/exec" "sort" @@ -1112,6 +1114,12 @@ func isNetworkPolicy(group, version, kind string) bool { return group == "networking.k8s.io" && version == "v1" && kind == "NetworkPolicy" } +func IsWorkload(group, version, kind string) bool { + return isDeployment(group, version, kind) || + isStatefulSet(group, version, kind) || + isDaemonSet(group, version, kind) +} + func (k *KubernetesClient) WatchResource(group, version, resource, name, namespace string) (watch.Interface, error) { gvr := schema.GroupVersionResource{ Group: group, @@ -1123,3 +1131,58 @@ func (k *KubernetesClient) WatchResource(group, version, resource, name, namespa FieldSelector: "metadata.name=" + name, }) } + +type ResourceWatchSpec struct { + GVR schema.GroupVersionResource + Namespace string + Name string +} + +func (k *KubernetesClient) WatchKubernetesResources(gvrs []ResourceWatchSpec, stopCh chan struct{}) (chan *unstructured.Unstructured, error) { + if len(gvrs) == 0 { + return nil, errors.New("no gvrs to watch") + } + + eventChan := make(chan *unstructured.Unstructured, 1) + + startWatch := func(spec ResourceWatchSpec) { + go func() { + resourceClient := k.Dynamic.Resource(spec.GVR).Namespace(spec.Namespace) + + informer := cache.NewSharedInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + options.FieldSelector = "metadata.name=" + spec.Name + return resourceClient.List(context.TODO(), options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + options.FieldSelector = "metadata.name=" + spec.Name + return resourceClient.Watch(context.TODO(), options) + }, + }, + &unstructured.Unstructured{}, + 0, + ) + + informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + eventChan <- obj.(*unstructured.Unstructured) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + eventChan <- newObj.(*unstructured.Unstructured) + }, + DeleteFunc: func(obj interface{}) { + eventChan <- obj.(*unstructured.Unstructured) + }, + }) + + informer.Run(stopCh) + }() + } + + for _, gvr := range gvrs { + startWatch(gvr) + } + + return eventChan, nil +} diff --git a/cyclops-ctrl/pkg/cluster/k8sclient/modules.go b/cyclops-ctrl/pkg/cluster/k8sclient/modules.go index a788d7a9..5bd75f78 100644 --- a/cyclops-ctrl/pkg/cluster/k8sclient/modules.go +++ b/cyclops-ctrl/pkg/cluster/k8sclient/modules.go @@ -2,6 +2,7 @@ package k8sclient import ( "context" + "regexp" "sort" "strings" @@ -106,6 +107,63 @@ func (k *KubernetesClient) GetResourcesForModule(name string) ([]dto.Resource, e return out, nil } +func (k *KubernetesClient) GetWorkloadsForModule(name string) ([]dto.Resource, error) { + out := make([]dto.Resource, 0, 0) + + deployments, err := k.clientset.AppsV1().Deployments("").List(context.Background(), metav1.ListOptions{ + LabelSelector: "cyclops.module=" + name, + }) + if err != nil { + return nil, err + } + + for _, item := range deployments.Items { + out = append(out, &dto.Other{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: item.Name, + Namespace: item.Namespace, + }) + } + + statefulset, err := k.clientset.AppsV1().StatefulSets("").List(context.Background(), metav1.ListOptions{ + LabelSelector: "cyclops.module=" + name, + }) + if err != nil { + return nil, err + } + + for _, item := range statefulset.Items { + out = append(out, &dto.Other{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + Name: item.Name, + Namespace: item.Namespace, + }) + } + + daemonsets, err := k.clientset.AppsV1().DaemonSets("").List(context.Background(), metav1.ListOptions{ + LabelSelector: "cyclops.module=" + name, + }) + if err != nil { + return nil, err + } + + for _, item := range daemonsets.Items { + out = append(out, &dto.Other{ + Group: "apps", + Version: "v1", + Kind: "DaemonSet", + Name: item.Name, + Namespace: item.Namespace, + }) + } + + return out, nil +} + func (k *KubernetesClient) getManagedGVRs(moduleName string) ([]schema.GroupVersionResource, error) { module, _ := k.GetModule(moduleName) @@ -242,7 +300,7 @@ func (k *KubernetesClient) GetModuleResourcesHealth(name string) (string, error) resourcesWithHealth += len(deployments.Items) for _, item := range deployments.Items { - if isProgressing(item.Status.Conditions) { + if isDeploymentProgressing(item.Status.Conditions) { return statusProgressing, nil } @@ -262,6 +320,10 @@ func (k *KubernetesClient) GetModuleResourcesHealth(name string) (string, error) resourcesWithHealth += len(statefulsets.Items) for _, item := range statefulsets.Items { + if isStatefulSetProgressing(item.Status, item.Spec.Replicas, item.Generation) { + return statusProgressing, nil + } + if item.Generation != item.Status.ObservedGeneration || item.Status.Replicas != item.Status.UpdatedReplicas || item.Status.Replicas != item.Status.AvailableReplicas { @@ -420,10 +482,16 @@ func (k *KubernetesClient) getPods(deployment appsv1.Deployment) ([]dto.Pod, err return nil, err } + rs, singleRS := deploymentAvailableReplicaSet(deployment.Status.Conditions) + out := make([]dto.Pod, 0, len(pods.Items)) for _, item := range pods.Items { containers := make([]dto.Container, 0, len(item.Spec.Containers)) + if singleRS && len(rs) > 0 && !isPodOwner(item, rs) { + continue + } + for _, cnt := range item.Spec.Containers { env := make(map[string]string) for _, envVar := range cnt.Env { @@ -757,7 +825,7 @@ func containerStatus(status apiv1.ContainerStatus) dto.ContainerStatus { } func getDeploymentStatus(deployment *appsv1.Deployment) string { - if isProgressing(deployment.Status.Conditions) { + if isDeploymentProgressing(deployment.Status.Conditions) { return statusProgressing } @@ -777,6 +845,10 @@ func getStatefulSetStatus(statefulset *appsv1.StatefulSet) string { return statusHealthy } + if isStatefulSetProgressing(statefulset.Status, statefulset.Spec.Replicas, statefulset.Generation) { + return statusProgressing + } + return statusUnhealthy } @@ -800,7 +872,34 @@ func getPodStatus(containers []dto.Container) bool { return true } -func isProgressing(conditions []appsv1.DeploymentCondition) bool { +func deploymentAvailableReplicaSet(conditions []appsv1.DeploymentCondition) (string, bool) { + replicaSetNamePattern := regexp.MustCompile(`ReplicaSet "(.+?)" has successfully progressed`) + + for _, condition := range conditions { + if condition.Type == appsv1.DeploymentProgressing { + match := replicaSetNamePattern.FindStringSubmatch(condition.Message) + if len(match) > 1 { + return match[1], true + } + } + } + + return "", false +} + +func isPodOwner(pod apiv1.Pod, rsName string) bool { + for _, reference := range pod.OwnerReferences { + if reference.APIVersion == "apps/v1" && + reference.Kind == "ReplicaSet" && + reference.Name == rsName { + return true + } + } + + return false +} + +func isDeploymentProgressing(conditions []appsv1.DeploymentCondition) bool { progressingReason := "" availableReason := "" @@ -823,3 +922,19 @@ func isProgressing(conditions []appsv1.DeploymentCondition) bool { progressingReason == "FoundNewReplicaSet" || progressingReason == "ReplicaSetUpdated") } + +func isStatefulSetProgressing(status appsv1.StatefulSetStatus, desiredReplicas *int32, generation int64) bool { + if status.ObservedGeneration == 0 || generation > status.ObservedGeneration { + return true + } + + if status.CurrentRevision != status.UpdateRevision { + return true + } + + if desiredReplicas == nil { + return false + } + + return status.ReadyReplicas < *desiredReplicas || status.UpdatedReplicas < *desiredReplicas +} diff --git a/cyclops-ui/src/components/form/TemplateFormFields.tsx b/cyclops-ui/src/components/form/TemplateFormFields.tsx index 48804658..09a15db9 100644 --- a/cyclops-ui/src/components/form/TemplateFormFields.tsx +++ b/cyclops-ui/src/components/form/TemplateFormFields.tsx @@ -74,6 +74,7 @@ export function mapFields( formItemName={formItemName} arrayField={arrayField} isRequired={isRequired} + isModuleEdit={isModuleEdit} />, ); return; diff --git a/cyclops-ui/src/components/form/fields/string/SelectInput.tsx b/cyclops-ui/src/components/form/fields/string/SelectInput.tsx index 5b09fae8..f1015fea 100644 --- a/cyclops-ui/src/components/form/fields/string/SelectInput.tsx +++ b/cyclops-ui/src/components/form/fields/string/SelectInput.tsx @@ -11,6 +11,7 @@ interface Props { formItemName: string | string[]; arrayField: any; isRequired: boolean; + isModuleEdit: boolean; } export const SelectInputField = ({ @@ -18,6 +19,7 @@ export const SelectInputField = ({ formItemName, arrayField, isRequired, + isModuleEdit, }: Props) => { const selectOptions = (field: any) => { let options: Option[] = []; @@ -62,6 +64,7 @@ export const SelectInputField = ({ (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) } options={selectOptions(field)} + disabled={field.immutable && isModuleEdit} /> ); diff --git a/cyclops-ui/src/components/k8s-resources/DaemonSet.tsx b/cyclops-ui/src/components/k8s-resources/DaemonSet.tsx index 2c1f79f1..e31ab9a4 100644 --- a/cyclops-ui/src/components/k8s-resources/DaemonSet.tsx +++ b/cyclops-ui/src/components/k8s-resources/DaemonSet.tsx @@ -3,15 +3,15 @@ import { Col, Divider, Row, Alert } from "antd"; import axios from "axios"; import { mapResponseError } from "../../utils/api/errors"; import PodTable from "./common/PodTable/PodTable"; -import { resourceStream } from "../../utils/api/sse/resources"; import { isStreamingEnabled } from "../../utils/api/common"; interface Props { name: string; namespace: string; + workload: any; } -const DaemonSet = ({ name, namespace }: Props) => { +const DaemonSet = ({ name, namespace, workload }: Props) => { const [daemonSet, setDaemonSet] = useState({ status: "", pods: [], @@ -22,14 +22,6 @@ const DaemonSet = ({ name, namespace }: Props) => { description: "", }); - useEffect(() => { - if (isStreamingEnabled()) { - resourceStream(`apps`, `v1`, `DaemonSet`, name, namespace, (r: any) => { - setDaemonSet(r); - }); - } - }, [name, namespace]); - const fetchDaemonSet = useCallback(() => { axios .get(`/api/resources`, { @@ -53,7 +45,7 @@ const DaemonSet = ({ name, namespace }: Props) => { fetchDaemonSet(); if (isStreamingEnabled()) { - return () => {}; + return; } const interval = setInterval(() => fetchDaemonSet(), 15000); @@ -62,6 +54,24 @@ const DaemonSet = ({ name, namespace }: Props) => { }; }, [fetchDaemonSet]); + function getPods() { + if (workload && isStreamingEnabled()) { + return workload.pods; + } + + return daemonSet.pods; + } + + function getPodsLength() { + let pods = getPods(); + + if (Array.isArray(pods)) { + return pods.length; + } + + return 0; + } + return (
{error.message.length !== 0 && ( @@ -85,12 +95,12 @@ const DaemonSet = ({ name, namespace }: Props) => { orientationMargin="0" orientation={"left"} > - Pods: {daemonSet.pods.length} + Replicas: {getPodsLength()} diff --git a/cyclops-ui/src/components/k8s-resources/Deployment.tsx b/cyclops-ui/src/components/k8s-resources/Deployment.tsx index c4897309..e94cc92e 100644 --- a/cyclops-ui/src/components/k8s-resources/Deployment.tsx +++ b/cyclops-ui/src/components/k8s-resources/Deployment.tsx @@ -3,15 +3,15 @@ import { Col, Divider, Row, Alert } from "antd"; import axios from "axios"; import { mapResponseError } from "../../utils/api/errors"; import PodTable from "./common/PodTable/PodTable"; -import { resourceStream } from "../../utils/api/sse/resources"; import { isStreamingEnabled } from "../../utils/api/common"; interface Props { name: string; namespace: string; + workload: any; } -const Deployment = ({ name, namespace }: Props) => { +const Deployment = ({ name, namespace, workload }: Props) => { const [deployment, setDeployment] = useState({ status: "", pods: [], @@ -21,14 +21,6 @@ const Deployment = ({ name, namespace }: Props) => { description: "", }); - useEffect(() => { - if (isStreamingEnabled()) { - resourceStream(`apps`, `v1`, `Deployment`, name, namespace, (r: any) => { - setDeployment(r); - }); - } - }, [name, namespace]); - const fetchDeployment = useCallback(() => { axios .get(`/api/resources`, { @@ -52,7 +44,7 @@ const Deployment = ({ name, namespace }: Props) => { fetchDeployment(); if (isStreamingEnabled()) { - return () => {}; + return; } const interval = setInterval(() => fetchDeployment(), 15000); @@ -61,6 +53,24 @@ const Deployment = ({ name, namespace }: Props) => { }; }, [fetchDeployment]); + function getPods() { + if (workload && isStreamingEnabled()) { + return workload.pods; + } + + return deployment.pods; + } + + function getPodsLength() { + let pods = getPods(); + + if (Array.isArray(pods)) { + return pods.length; + } + + return 0; + } + return (
{error.message.length !== 0 && ( @@ -84,13 +94,13 @@ const Deployment = ({ name, namespace }: Props) => { orientationMargin="0" orientation={"left"} > - Replicas: {deployment.pods.length} + Replicas: {getPodsLength()} {}} /> diff --git a/cyclops-ui/src/components/k8s-resources/StatefulSet.tsx b/cyclops-ui/src/components/k8s-resources/StatefulSet.tsx index 8fab853e..a967cfc9 100644 --- a/cyclops-ui/src/components/k8s-resources/StatefulSet.tsx +++ b/cyclops-ui/src/components/k8s-resources/StatefulSet.tsx @@ -3,21 +3,16 @@ import { Col, Divider, Row, Alert } from "antd"; import axios from "axios"; import { mapResponseError } from "../../utils/api/errors"; import PodTable from "./common/PodTable/PodTable"; -import { resourceStream } from "../../utils/api/sse/resources"; import { isStreamingEnabled } from "../../utils/api/common"; interface Props { name: string; namespace: string; + workload: any; } -interface Statefulset { - status: string; - pods: any[]; -} - -const StatefulSet = ({ name, namespace }: Props) => { - const [statefulSet, setStatefulSet] = useState({ +const StatefulSet = ({ name, namespace, workload }: Props) => { + const [statefulSet, setStatefulSet] = useState({ status: "", pods: [], }); @@ -27,14 +22,6 @@ const StatefulSet = ({ name, namespace }: Props) => { description: "", }); - useEffect(() => { - if (isStreamingEnabled()) { - resourceStream(`apps`, `v1`, `StatefulSet`, name, namespace, (r: any) => { - setStatefulSet(r); - }); - } - }, [name, namespace]); - const fetchStatefulSet = useCallback(() => { axios .get(`/api/resources`, { @@ -58,7 +45,7 @@ const StatefulSet = ({ name, namespace }: Props) => { fetchStatefulSet(); if (isStreamingEnabled()) { - return () => {}; + return; } const interval = setInterval(() => fetchStatefulSet(), 15000); @@ -67,6 +54,24 @@ const StatefulSet = ({ name, namespace }: Props) => { }; }, [fetchStatefulSet]); + function getPods() { + if (workload && isStreamingEnabled()) { + return workload.pods; + } + + return statefulSet.pods; + } + + function getPodsLength() { + let pods = getPods(); + + if (Array.isArray(pods)) { + return pods.length; + } + + return 0; + } + return (
{error.message.length !== 0 && ( @@ -90,13 +95,13 @@ const StatefulSet = ({ name, namespace }: Props) => { orientationMargin="0" orientation={"left"} > - Replicas: {statefulSet.pods.length} + Replicas: {getPodsLength()} {}} /> diff --git a/cyclops-ui/src/components/pages/EditModule/EditModule.tsx b/cyclops-ui/src/components/pages/EditModule/EditModule.tsx index b86f6955..be514d8a 100644 --- a/cyclops-ui/src/components/pages/EditModule/EditModule.tsx +++ b/cyclops-ui/src/components/pages/EditModule/EditModule.tsx @@ -12,7 +12,6 @@ import { Typography, } from "antd"; import axios from "axios"; -import { useNavigate } from "react-router"; import { LockFilled, UnlockFilled } from "@ant-design/icons"; import { useParams } from "react-router-dom"; @@ -101,8 +100,6 @@ const EditModule = () => { const [loadValues, setLoadValues] = useState(false); const [loadTemplate, setLoadTemplate] = useState(false); - const history = useNavigate(); - let { moduleName } = useParams(); const mapsToArray = useCallback((fields: any[], values: any): any => { @@ -410,7 +407,7 @@ const EditModule = () => { {" "} diff --git a/cyclops-ui/src/components/pages/ModuleDetails.tsx b/cyclops-ui/src/components/pages/ModuleDetails.tsx index f1b3fd81..612f171b 100644 --- a/cyclops-ui/src/components/pages/ModuleDetails.tsx +++ b/cyclops-ui/src/components/pages/ModuleDetails.tsx @@ -60,6 +60,13 @@ import { RestartButton, } from "../k8s-resources/common/RestartButton"; import YAML from "yaml"; +import { isStreamingEnabled } from "../../utils/api/common"; +import { resourcesStream } from "../../utils/api/sse/resources"; +import { + isWorkload, + ResourceRef, + resourceRefKey, +} from "../../utils/resourceRef"; const languages = [ "javascript", @@ -109,12 +116,9 @@ interface module { iconURL: string; } -interface resourceRef { - group: string; - version: string; - kind: string; - name: string; - namespace: string; +interface workload { + status: string; + pods: any[]; } const ModuleDetails = () => { @@ -138,6 +142,23 @@ const ModuleDetails = () => { const [deleteResourceVerify, setDeleteResourceVerify] = useState(""); const [resources, setResources] = useState([]); + const [workloads, setWorkloads] = useState>(new Map()); + + function getWorkload(ref: ResourceRef): workload | undefined { + let k = resourceRefKey(ref); + + return workloads.get(k); + } + + function putWorkload(ref: ResourceRef, workload: workload) { + let k = resourceRefKey(ref); + + setWorkloads((prev) => { + const s = new Map(prev); + s.set(k, workload); + return s; + }); + } const [module, setModule] = useState({ name: "", @@ -153,7 +174,7 @@ const ModuleDetails = () => { }); const [deleteResourceModal, setDeleteResourceModal] = useState(false); - const [deleteResourceRef, setDeleteResourceRef] = useState({ + const [deleteResourceRef, setDeleteResourceRef] = useState({ group: "", version: "", kind: "", @@ -285,6 +306,22 @@ const ModuleDetails = () => { }; }, [moduleName, fetchModuleResources]); + useEffect(() => { + if (isStreamingEnabled()) { + resourcesStream(moduleName, (r: any) => { + let resourceRef: ResourceRef = { + group: r.group, + version: r.version, + kind: r.kind, + name: r.name, + namespace: r.namespace, + }; + + putWorkload(resourceRef, r); + }); + } + }, [moduleName]); + const getCollapseColor = (fieldName: string) => { if ( activeCollapses.get(fieldName) && @@ -474,11 +511,7 @@ const ModuleDetails = () => { return "#ffcc00"; } - if (status === "healthy" || status === "unknown") { - return "#27D507"; - } - - return "#FF0000"; + return "#27D507"; }; resources.forEach((resource: any, index) => { @@ -487,10 +520,22 @@ const ModuleDetails = () => { let resourceDetails =

; + let resourceRef: ResourceRef = { + group: resource.group, + version: resource.version, + kind: resource.kind, + name: resource.name, + namespace: resource.namespace, + }; + switch (resource.kind) { case "Deployment": resourceDetails = ( - + ); break; case "CronJob": @@ -505,12 +550,20 @@ const ModuleDetails = () => { break; case "DaemonSet": resourceDetails = ( - + ); break; case "StatefulSet": resourceDetails = ( - + ); break; case "Pod": @@ -583,9 +636,14 @@ const ModuleDetails = () => { ); } + let resourceStatus = resource.status; + if (isStreamingEnabled() && isWorkload(resourceRef)) { + resourceStatus = getWorkload(resourceRef)?.status; + } + resourceCollapses.push( { border: "1px solid #E3E3E3", borderLeft: "solid " + - getStatusColor(resource.status, resource.deleted) + + getStatusColor(resourceStatus, resource.deleted) + " 4px", }} > @@ -760,12 +818,17 @@ const ModuleDetails = () => { resourcesWithStatus++; - if (resource.status === "progressing") { + let resourceStatus = resource.status; + if (isStreamingEnabled() && isWorkload(resource)) { + resourceStatus = getWorkload(resource)?.status; + } + + if (resourceStatus === "progressing") { status = "progressing"; continue; } - if (resource.status === "unhealthy") { + if (resourceStatus === "unhealthy") { status = "unhealthy"; break; } diff --git a/cyclops-ui/src/components/pages/Modules/Modules.tsx b/cyclops-ui/src/components/pages/Modules/Modules.tsx index 10167665..c31aff0a 100644 --- a/cyclops-ui/src/components/pages/Modules/Modules.tsx +++ b/cyclops-ui/src/components/pages/Modules/Modules.tsx @@ -13,7 +13,6 @@ import { Popover, Checkbox, } from "antd"; -import { useNavigate } from "react-router"; import axios from "axios"; @@ -26,7 +25,6 @@ import { mapResponseError } from "../../../utils/api/errors"; const { Title } = Typography; const Modules = () => { - const history = useNavigate(); const [allData, setAllData] = useState([]); const [filteredData, setFilteredData] = useState([]); const [loadingModules, setLoadingModules] = useState(false); @@ -45,18 +43,27 @@ const Modules = () => { useEffect(() => { setLoadingModules(true); - axios - .get(`/api/modules/list`) - .then((res) => { - setAllData(res.data); - setFilteredData(res.data); - setLoadingModules(false); - }) - .catch((error) => { - setError(mapResponseError(error)); - setLoadingModules(false); - }); + + function fetchModules() { + axios + .get(`/api/modules/list`) + .then((res) => { + setAllData(res.data); + setLoadingModules(false); + }) + .catch((error) => { + setError(mapResponseError(error)); + setLoadingModules(false); + }); + } + + fetchModules(); + const interval = setInterval(() => fetchModules(), 10000); + return () => { + clearInterval(interval); + }; }, []); + useEffect(() => { var updatedList = [...allData]; updatedList = updatedList.filter((module: any) => { @@ -74,7 +81,7 @@ const Modules = () => { }, [moduleHealthFilter, allData, searchInputFilter]); const handleClick = () => { - history("/modules/new"); + window.location.href = "/modules/new"; }; const handleSelectItem = (selectedItems: any[]) => { setModuleHealthFilter(selectedItems); diff --git a/cyclops-ui/src/components/pages/NewModule/NewModule.tsx b/cyclops-ui/src/components/pages/NewModule/NewModule.tsx index 8f8e38e1..7b914ce5 100644 --- a/cyclops-ui/src/components/pages/NewModule/NewModule.tsx +++ b/cyclops-ui/src/components/pages/NewModule/NewModule.tsx @@ -15,7 +15,6 @@ import { notification, } from "antd"; import axios from "axios"; -import { useNavigate } from "react-router"; import { findMaps, flattenObjectKeys } from "../../../utils/form"; import "./custom.css"; import defaultTemplate from "../../../static/img/default-template-icon.png"; @@ -31,6 +30,7 @@ import { } from "../../errors/FormValidationErrors"; import { mapResponseError } from "../../../utils/api/errors"; import TemplateFormFields from "../../form/TemplateFormFields"; +import { DownOutlined, UpOutlined } from "@ant-design/icons"; const { Title } = Typography; const layout = { @@ -89,8 +89,6 @@ const NewModule = () => { const [namespaces, setNamespaces] = useState([]); - const history = useNavigate(); - const [notificationApi, contextHolder] = notification.useNotification(); const openNotification = (errors: FeedbackError[]) => { notificationApi.error({ @@ -101,6 +99,8 @@ const NewModule = () => { }); }; + const [advancedOptionsExpanded, setAdvancedOptionsExpanded] = useState(false); + const [form] = Form.useForm(); useEffect(() => { @@ -474,6 +474,10 @@ const NewModule = () => { openNotification(errorMessages); }; + const toggleExpand = () => { + setAdvancedOptionsExpanded(!advancedOptionsExpanded); + }; + return (

{error.message.length !== 0 && ( @@ -572,7 +576,7 @@ const NewModule = () => { style={{ border: "solid 1.5px #c3c3c3", borderRadius: "7px", - padding: "12px", + padding: "0px", width: "100%", backgroundColor: "#fafafa", }} @@ -588,6 +592,7 @@ const NewModule = () => {

} + style={{ padding: "12px 12px 0px 12px" }} rules={[ { required: true, @@ -609,35 +614,54 @@ const NewModule = () => { > - + + + Target namespace +

+ Namespace used to deploy resources to +

+
+ } + style={{ padding: "0px 12px 0px 12px" }} + hasFeedback={true} + validateDebounce={1000} + > + + +
+
+ {advancedOptionsExpanded ? (
- Target namespace -

- Namespace used to deploy resources to -

+ Advanced +
- } - hasFeedback={true} - validateDebounce={1000} - > - - + ) : ( +
+ Advanced + +
+ )} +
{ {" "}