diff --git a/main.go b/main.go index 2b62aa4..b950965 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,11 @@ type options struct { } func main() { + err := os.Setenv("HD_VERSION", version) // for anyone willing to access it + if err != nil { + fmt.Println("Failed to remember app version because of error: " + err.Error()) + } + opts := parseFlags() if opts.BindHost == "" { host := os.Getenv("HD_BIND") diff --git a/pkg/dashboard/handlers/helmHandlers.go b/pkg/dashboard/handlers/helmHandlers.go index 7deb668..305b434 100644 --- a/pkg/dashboard/handlers/helmHandlers.go +++ b/pkg/dashboard/handlers/helmHandlers.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "errors" "fmt" "github.com/gin-gonic/gin" @@ -207,7 +208,21 @@ func (h *HelmHandler) RepoLatestVer(c *gin.Context) { if len(res) > 0 { c.IndentedJSON(http.StatusOK, res[:1]) } else { - c.Status(http.StatusNoContent) + // caching it to avoid too many requests + found, err := h.Data.Cache.String("chart-artifacthub-query/"+qp.Name, nil, func() (string, error) { + return h.repoFromArtifactHub(qp.Name) + }) + if err != nil { + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + if found == "" { + c.Status(http.StatusNoContent) + } else { + c.Header("Content-Type", "application/json") + c.String(http.StatusOK, found) + } } } @@ -553,6 +568,49 @@ func (h *HelmHandler) handleGetSection(rel *objects.Release, section string, rDi return res, nil } +func (h *HelmHandler) repoFromArtifactHub(name string) (string, error) { + results, err := objects.QueryArtifactHub(name) + if err != nil { + log.Warnf("Failed to query ArtifactHub: %s", err) + return "", nil // swallowing the error to not annoy users + } + + if len(results) == 0 { + return "", nil + } + + sort.SliceStable(results, func(i, j int) bool { + // we prefer official repos + if results[i].Repository.Official && !results[j].Repository.Official { + return true + } + + // or from verified publishers + if results[i].Repository.VerifiedPublisher && !results[j].Repository.VerifiedPublisher { + return true + } + + // or just more popular + return results[i].Stars > results[j].Stars + }) + + r := results[0] + buf, err := json.Marshal([]*RepoChartElement{{ + Name: r.Name, + Version: r.Version, + AppVersion: r.AppVersion, + Description: r.Description, + Repository: r.Repository.Name, + URLs: []string{r.Repository.Url}, + IsSuggestedRepo: true, + }}) + if err != nil { + return "", err + } + + return string(buf), nil +} + type RepoChartElement struct { // TODO: do we need it at all? there is existing repo.ChartVersion in Helm Name string `json:"name"` Version string `json:"version"` @@ -563,6 +621,7 @@ type RepoChartElement struct { // TODO: do we need it at all? there is existing InstalledName string `json:"installed_name"` Repository string `json:"repository"` URLs []string `json:"urls"` + IsSuggestedRepo bool `json:"isSuggestedRepo"` } func HReleaseToJSON(o *release.Release) *ReleaseElement { diff --git a/pkg/dashboard/objects/artifacthub.go b/pkg/dashboard/objects/artifacthub.go new file mode 100644 index 0000000..88415df --- /dev/null +++ b/pkg/dashboard/objects/artifacthub.go @@ -0,0 +1,90 @@ +package objects + +import ( + "encoding/json" + "fmt" + log "github.com/sirupsen/logrus" + "net/http" + neturl "net/url" + "os" + "sync" +) + +var mxArtifactHub sync.Mutex + +func QueryArtifactHub(chartName string) ([]*ArtifactHubResult, error) { + mxArtifactHub.Lock() // to avoid parallel request spike + defer mxArtifactHub.Unlock() + + url := os.Getenv("HD_ARTIFACT_HUB_URL") + if url == "" { + url = "https://artifacthub.io/api/v1/packages/search" + } + + p, err := neturl.Parse(url) + if err != nil { + return nil, err + } + + p.RawQuery = "offset=0&limit=5&facets=false&kind=0&deprecated=false&sort=relevance&ts_query_web=" + neturl.QueryEscape(chartName) + + req, err := http.NewRequest("GET", p.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", "Komodor Helm Dashboard/"+os.Getenv("HD_VERSION")) // TODO + + log.Debugf("Making HTTP request: %v", req) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("failed to fetch %s : %s", p.String(), res.Status) + } + + result := ArtifactHubResults{} + + err = json.NewDecoder(res.Body).Decode(&result) + if err != nil { + return nil, err + } + + return result.Packages, nil +} + +type ArtifactHubResults struct { + Packages []*ArtifactHubResult `json:"packages"` +} + +type ArtifactHubResult struct { + PackageId string `json:"package_id"` + Name string `json:"name"` + NormalizedName string `json:"normalized_name"` + LogoImageId string `json:"logo_image_id"` + Stars int `json:"stars"` + Description string `json:"description"` + Version string `json:"version"` + AppVersion string `json:"app_version"` + Deprecated bool `json:"deprecated"` + Signed bool `json:"signed"` + ProductionOrganizationsCount int `json:"production_organizations_count"` + Ts int `json:"ts"` + Repository ArtifactHubRepo `json:"repository"` +} + +type ArtifactHubRepo struct { + Url string `json:"url"` + Kind int `json:"kind"` + Name string `json:"name"` + Official bool `json:"official"` + DisplayName string `json:"display_name"` + RepositoryId string `json:"repository_id"` + ScannerDisabled bool `json:"scanner_disabled"` + OrganizationName string `json:"organization_name"` + VerifiedPublisher bool `json:"verified_publisher"` + OrganizationDisplayName string `json:"organization_display_name"` +} diff --git a/pkg/dashboard/static/actions.js b/pkg/dashboard/static/actions.js index 111d179..0db66c0 100644 --- a/pkg/dashboard/static/actions.js +++ b/pkg/dashboard/static/actions.js @@ -25,7 +25,11 @@ function checkUpgradeable(name) { if (!data || !data.length) { btnUpgradeCheck.prop("disabled", true) btnUpgradeCheck.text("") - $("#btnAddRepository").text("Add repository for it") + $("#btnAddRepository").text("Add repository for it").data("suggestRepo", "") + } else if (data[0].isSuggestedRepo) { + btnUpgradeCheck.prop("disabled", true) + btnUpgradeCheck.text("") + $("#btnAddRepository").text("Add repository for it: "+data[0].repository).data("suggestRepo", data[0].repository).data("suggestRepoUrl", data[0].urls[0]) } else { $("#btnAddRepository").text("") btnUpgradeCheck.text("Check for new version") @@ -399,7 +403,12 @@ $("#btnRollback").click(function () { }) $("#btnAddRepository").click(function () { + const self=$(this) setHashParam("section", "repository") + if (self.data("suggestRepo")) { + setHashParam("suggestRepo", self.data("suggestRepo")) + setHashParam("suggestRepoUrl", self.data("suggestRepoUrl")) + } window.location.reload() }) diff --git a/pkg/dashboard/static/index.html b/pkg/dashboard/static/index.html index ea59ee4..aff332d 100644 --- a/pkg/dashboard/static/index.html +++ b/pkg/dashboard/static/index.html @@ -89,6 +89,7 @@ +
Charts developers: you can also add local directories as chart source. Use --local-chart CLI switch to specify it.
diff --git a/pkg/dashboard/static/list-view.js b/pkg/dashboard/static/list-view.js index eb171aa..77ab274 100644 --- a/pkg/dashboard/static/list-view.js +++ b/pkg/dashboard/static/list-view.js @@ -89,7 +89,13 @@ function buildChartCard(elm) { } if (isNewerVersion(elm.chartVersion, data[0].version)) { - card.find(".rel-name span").append("") + const icon = $("") + if (data[0].isSuggestedRepo) { + icon.addClass("bi-arrow-up-circle") + } else { + icon.addClass("bi-arrow-up-circle-fill") + } + card.find(".rel-name span").append(icon) } }) diff --git a/pkg/dashboard/static/repo.js b/pkg/dashboard/static/repo.js index b7d0fcb..c9add22 100644 --- a/pkg/dashboard/static/repo.js +++ b/pkg/dashboard/static/repo.js @@ -2,6 +2,13 @@ function loadRepoView() { $("#sectionRepo .repo-details").hide() $("#sectionRepo").show() + $("#repoAddModal input[name=name]").val(getHashParam("suggestRepo")) + $("#repoAddModal input[name=url]").val(getHashParam("suggestRepoUrl")) + + if (getHashParam("suggestRepo")) { + $("#sectionRepo .repo-list .btn").click() + } + $.getJSON("/api/helm/repositories").fail(function (xhr) { reportError("Failed to get list of repositories", xhr) sendStats('Get repo', {'status': 'fail'}); @@ -85,6 +92,8 @@ $("#inputSearch").keyup(function () { }) $("#sectionRepo .repo-list .btn").click(function () { + setHashParam("suggestRepo", null) + setHashParam("suggestRepoUrl", null) const myModal = new bootstrap.Modal(document.getElementById('repoAddModal'), {}); myModal.show() })