diff --git a/pkg/dashboard/handlers/helmHandlers.go b/pkg/dashboard/handlers/helmHandlers.go index 79ff5b5..1607ce9 100644 --- a/pkg/dashboard/handlers/helmHandlers.go +++ b/pkg/dashboard/handlers/helmHandlers.go @@ -136,6 +136,26 @@ func (h *HelmHandler) Resources(c *gin.Context) { return } + if c.Query("health") != "" { // we need to query k8s for health status + app := h.GetApp(c) + if app == nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + for _, obj := range res { + ns := obj.Namespace + if ns == "" { + ns = c.Param("ns") + } + info, err := app.K8s.GetResourceInfo(obj.Kind, ns, obj.Name) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + obj.Status = *EnhanceStatus(info) + } + } + c.IndentedJSON(http.StatusOK, res) } @@ -587,23 +607,24 @@ func (h *HelmHandler) repoFromArtifactHub(name string) (string, error) { return true } + // more popular + if ri.Stars != rj.Stars { + return ri.Stars > rj.Stars + } + // or from verified publishers if ri.Repository.VerifiedPublisher && !rj.Repository.VerifiedPublisher { return true } - // or just more popular - if ri.Stars > rj.Stars { - return true - } - // or with more recent app version - - if semver.Compare("v"+ri.AppVersion, "v"+rj.AppVersion) > 0 { - return true + c := semver.Compare("v"+ri.AppVersion, "v"+rj.AppVersion) + if c != 0 { + return c > 0 } - return false + // shorter repo name is usually closer to officials + return len(ri.Repository.Name) < len(rj.Repository.Name) }) r := results[0] diff --git a/pkg/dashboard/handlers/kubeHandlers.go b/pkg/dashboard/handlers/kubeHandlers.go index 60bc28e..7bea038 100644 --- a/pkg/dashboard/handlers/kubeHandlers.go +++ b/pkg/dashboard/handlers/kubeHandlers.go @@ -1,15 +1,21 @@ package handlers import ( - "github.com/joomcode/errorx" - "k8s.io/apimachinery/pkg/api/errors" - "net/http" - "github.com/gin-gonic/gin" + "github.com/joomcode/errorx" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils" + log "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/api/errors" v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" + "k8s.io/utils/strings/slices" + "net/http" ) +const Unknown = "Unknown" +const Healthy = "Healthy" +const Unhealthy = "Unhealthy" +const Progressing = "Progressing" + type KubeHandler struct { *Contexted } @@ -55,20 +61,59 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) { c.IndentedJSON(http.StatusOK, res) } -func EnhanceStatus(res *v12.Carp) { - // custom logic to provide most meaningful status for the resource - if res.Status.Phase == "Active" || res.Status.Phase == "Error" { - _ = res.Name + "" - } else if res.Status.Phase == "" && len(res.Status.Conditions) > 0 { - res.Status.Phase = v12.CarpPhase(res.Status.Conditions[len(res.Status.Conditions)-1].Type) - res.Status.Message = res.Status.Conditions[len(res.Status.Conditions)-1].Message - res.Status.Reason = res.Status.Conditions[len(res.Status.Conditions)-1].Reason - if res.Status.Conditions[len(res.Status.Conditions)-1].Status == "False" { - res.Status.Phase = "Not" + res.Status.Phase - } - } else if res.Status.Phase == "" { - res.Status.Phase = "Exists" +func EnhanceStatus(res *v12.Carp) *v12.CarpStatus { + s := res.Status + if s.Conditions == nil { + s.Conditions = []v12.CarpCondition{} } + + c := v12.CarpCondition{ + Type: "hdHealth", + Status: Unknown, + Reason: s.Reason, + Message: s.Message, + } + + // custom logic to provide most meaningful status for the resource + if s.Phase == "Error" { + c.Status = Unhealthy + } else if slices.Contains([]string{"Available", "Active", "Established", "Bound", "Ready"}, string(s.Phase)) { + c.Status = Healthy + } else if s.Phase == "" && len(s.Conditions) > 0 { + for _, cond := range s.Conditions { + if cond.Type == "Progressing" { // https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ + if cond.Status == "False" { + c.Status = Unhealthy + c.Reason = cond.Reason + c.Message = cond.Message + } else if cond.Reason != "NewReplicaSetAvailable" { + c.Status = Progressing + c.Reason = cond.Reason + c.Message = cond.Message + } + } else if cond.Type == "Available" && c.Status == Unknown { + if cond.Status == "False" { + c.Status = Unhealthy + } else { + c.Status = Healthy + } + c.Reason = cond.Reason + c.Message = cond.Message + } + } + } else if s.Phase == "Pending" { + c.Status = Progressing + c.Reason = string(s.Phase) + } else if s.Phase == "" { + c.Status = Healthy + c.Reason = "Exists" + } else { + log.Warnf("Unhandled status: %v", s) + c.Reason = string(s.Phase) + } + + s.Conditions = append(s.Conditions, c) + return &s } func (h *KubeHandler) Describe(c *gin.Context) { diff --git a/pkg/dashboard/objects/kubectl.go b/pkg/dashboard/objects/kubectl.go index 937e385..4eadf31 100644 --- a/pkg/dashboard/objects/kubectl.go +++ b/pkg/dashboard/objects/kubectl.go @@ -126,7 +126,14 @@ func (k *K8s) DescribeResource(kind string, ns string, name string) (string, err func (k *K8s) GetResource(kind string, namespace string, name string) (*runtime.Object, error) { builder := k.Factory.NewBuilder() - resp := builder.Unstructured().NamespaceParam(namespace).Flatten().ResourceNames(kind, name).Do() + builder = builder.Unstructured().SingleResourceType() + if namespace != "" { + builder = builder.NamespaceParam(namespace) + } else { + builder = builder.DefaultNamespace() + } + + resp := builder.Flatten().ResourceNames(kind, name).Do() if resp.Err() != nil { return nil, errorx.Decorate(resp.Err(), "failed to get k8s resource") } @@ -139,6 +146,7 @@ func (k *K8s) GetResource(kind string, namespace string, name string) (*runtime. } func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*testapiv1.Carp, error) { + // TODO: mutex to avoid a lot of requests? obj, err := k.GetResource(kind, namespace, name) if err != nil { return nil, errorx.Decorate(err, "failed to get k8s object") diff --git a/pkg/dashboard/static/details-view.js b/pkg/dashboard/static/details-view.js index c8e09c2..944a37a 100644 --- a/pkg/dashboard/static/details-view.js +++ b/pkg/dashboard/static/details-view.js @@ -149,7 +149,7 @@ function showResources(namespace, chart, revision) { const resBody = $("#nav-resources .body"); const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"]; resBody.empty().append(''); - let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources" + let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources?health=true" $.getJSON(url).fail(function (xhr) { reportError("Failed to get list of resources", xhr) }).done(function (data) { @@ -180,22 +180,29 @@ function showResources(namespace, chart, revision) { resBody.append(resBlock) let ns = res.metadata.namespace ? res.metadata.namespace : namespace - $.getJSON("/api/k8s/" + res.kind.toLowerCase() + "/get?name=" + res.metadata.name + "&namespace=" + ns).fail(function () { - //reportError("Failed to get list of resources") - }).done(function (data) { - const badge = $("").text(data.status.phase); - if (["Available", "Active", "Established", "Bound", "Ready"].includes(data.status.phase)) { + for (let k = 0; k < res.status.conditions.length; k++) { + if (res.status.conditions[k].type !== "hdHealth") { // it's our custom condition type + continue + } + + const cond = res.status.conditions[k] + + const badge = $("").text(cond.reason); + if (cond.status === "Healthy") { badge.addClass("bg-success text-dark") - } else if (["Exists"].includes(data.status.phase)) { - badge.addClass("bg-success text-dark bg-opacity-50") - } else if (["Progressing"].includes(data.status.phase)) { + } else if (cond.status === "Progressing") { badge.addClass("bg-warning") } else { badge.addClass("bg-danger") } + + if (["Exists"].includes(cond.reason)) { + badge.addClass("bg-opacity-50") + } + const statusBlock = resBlock.find(".res-status"); - statusBlock.empty().append(badge).attr("title", data.status.phase) - const statusMessage = getStatusMessage(data.status) + statusBlock.empty().append(badge).attr("title", cond.reason) + const statusMessage = cond.message resBlock.find(".res-statusmsg").html("" + (statusMessage ? statusMessage : '') + "") if (badge.text() !== "NotFound" && revision == $("#specRev").data("last-rev")) { @@ -219,21 +226,11 @@ function showResources(namespace, chart, revision) { if (badge.hasClass("bg-danger")) { resBlock.find(".res-statusmsg").append("Troubleshoot in Komodor ") } - }) + } } }) } -function getStatusMessage(status) { - if (!status) { - return - } - if (status.conditions) { - return status.conditions[0].message || status.conditions[0].reason - } - return status.message || status.reason -} - function showDescribe(ns, kind, name, badge) { $("#describeModal .offcanvas-header p").text(kind) $("#describeModalLabel").text(name).append(badge.addClass("ms-3 small fw-normal")) diff --git a/pkg/dashboard/static/list-view.js b/pkg/dashboard/static/list-view.js index b1ebd61..aaae1eb 100644 --- a/pkg/dashboard/static/list-view.js +++ b/pkg/dashboard/static/list-view.js @@ -93,7 +93,7 @@ function buildChartCard(elm) { if (data[0].isSuggestedRepo) { icon.addClass("bi-plus-circle-fill text-primary") icon.text(" ADD REPO") - icon.attr("data-bs-title", "Add '" + data[0].repository+"' to list of known repositories") + icon.attr("data-bs-title", "Add '" + data[0].repository + "' to list of known repositories") } else { icon.addClass("bi-arrow-up-circle-fill text-primary") icon.text(" UPGRADE") @@ -101,12 +101,41 @@ function buildChartCard(elm) { } card.find(".rel-chart div").append(icon) - const tooltipTriggerList = card.find('[data-bs-toggle="tooltip"]') + const tooltipTriggerList = card.find('.rel-chart [data-bs-toggle="tooltip"]') const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) sendStats('upgradeIconShown', {'isProbable': data[0].isSuggestedRepo}) } }) + // check resource health status + $.getJSON("/api/helm/releases/" + elm.namespace + "/" + elm.name + "/resources?health=true").fail(function (xhr) { + reportError("Failed to find chart in repo", xhr) + }).done(function (data) { + for (let i = 0; i < data.length; i++) { + const res = data[i] + for (let k = 0; k < res.status.conditions.length; k++) { + if (res.status.conditions[k].type !== "hdHealth") { // it's our custom condition type + continue + } + + const cond = res.status.conditions[k] + const square=$(" ") + if (cond.status === "Healthy") { + square.addClass("bg-success") + } else if (cond.status === "Progressing") { + square.addClass("bg-warning") + } else { + square.addClass("bg-danger") + } + square.attr("data-bs-title", cond.status+" "+res.kind+" '"+res.metadata.name+"'") + card.find(".rel-status div").append(square) + } + } + + const tooltipTriggerList = card.find('.rel-status [data-bs-toggle="tooltip"]') + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) + }) + return card; } diff --git a/pkg/dashboard/static/openapi.json b/pkg/dashboard/static/openapi.json index 09180f0..67e3be9 100644 --- a/pkg/dashboard/static/openapi.json +++ b/pkg/dashboard/static/openapi.json @@ -302,6 +302,11 @@ "name": "name", "in": "path", "description": "Name of Helm release" + }, + { + "name": "health", + "in": "query", + "description": "Flag to query k8s health status of resources" } ], "get": { diff --git a/pkg/dashboard/static/styles.css b/pkg/dashboard/static/styles.css index 58d9eb9..cfed352 100644 --- a/pkg/dashboard/static/styles.css +++ b/pkg/dashboard/static/styles.css @@ -300,4 +300,23 @@ nav .nav-tabs .nav-link.active { .test-result { font-size: 1rem; +} + +.square { + width: 0.55rem; + height: 0.55rem; + display: inline-block; + border-radius: 0.1rem!important; +} + +.square.bg-danger { + background-color: #ff0072!important; +} + +.square.bg-warning { + background-color: #ffa800!important; +} + +.square.bg-success { + background-color: #00c2ab!important; } \ No newline at end of file