From b5bf068b820089fd20f8e8a2eeffb461b974f83c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 18 Aug 2021 13:29:00 -0400 Subject: [PATCH 1/4] Fix filtering deprecated charts - Always show charts, just disable the option to select them in the details view - Optimize the loading of repo charts to sort and normalize once - Using the structured clone algorithm instead of JSON and fix the cache placement Signed-off-by: Sebastian Malton --- .../k8s-api/endpoints/helm-charts.api.ts | 15 +- src/common/utils/sort-compare.ts | 33 ++- src/main/helm/__mocks__/helm-chart-manager.ts | 270 +++++++++--------- src/main/helm/__tests__/helm-service.test.ts | 56 +++- src/main/helm/helm-chart-manager.ts | 98 ++++--- src/main/helm/helm-repo-manager.ts | 8 +- src/main/helm/helm-service.ts | 95 +----- .../+apps-helm-charts/helm-chart-details.scss | 6 + .../+apps-helm-charts/helm-chart-details.tsx | 27 +- 9 files changed, 325 insertions(+), 283 deletions(-) diff --git a/src/common/k8s-api/endpoints/helm-charts.api.ts b/src/common/k8s-api/endpoints/helm-charts.api.ts index 82d486ac81f0..706fcdf7f9a9 100644 --- a/src/common/k8s-api/endpoints/helm-charts.api.ts +++ b/src/common/k8s-api/endpoints/helm-charts.api.ts @@ -26,8 +26,7 @@ import type { RequestInit } from "node-fetch"; import { autoBind, bifurcateArray } from "../../utils"; import Joi from "joi"; -export type RepoHelmChartList = Record; -export type HelmChartList = Record; +export type RepoHelmChartList = Record; export interface IHelmChartDetails { readme: string; @@ -43,7 +42,7 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { * Get a list of all helm charts from all saved helm repos */ export async function listCharts(): Promise { - const data = await apiBase.get(endpoint()); + const data = await apiBase.get>(endpoint()); return Object .values(data) @@ -311,11 +310,9 @@ export class HelmChart { } static create(data: RawHelmChart, { onError = "throw" }: HelmChartCreateOpts = {}): HelmChart | undefined { - const result = helmChartValidator.validate(data, { + const { value, error } = helmChartValidator.validate(data, { abortEarly: false, }); - let { error } = result; - const { value } = result; if (!error) { return new HelmChart(value); @@ -331,13 +328,13 @@ export class HelmChart { return new HelmChart(value); } - error = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, error._original); + const validationError = new Joi.ValidationError(actualErrors.map(er => er.message).join(". "), actualErrors, error._original); if (onError === "throw") { - throw error; + throw validationError; } - console.warn("[HELM-CHART]: failed to validate data", data, error); + console.warn("[HELM-CHART]: failed to validate data", data, validationError); return undefined; } diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts index cd4e1b128a8e..b5414d284158 100644 --- a/src/common/utils/sort-compare.ts +++ b/src/common/utils/sort-compare.ts @@ -19,7 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import semver, { SemVer } from "semver"; +import semver, { coerce, SemVer } from "semver"; +import * as iter from "./iter"; +import type { RawHelmChart } from "../k8s-api/endpoints/helm-charts.api"; export function sortCompare(left: T, right: T): -1 | 0 | 1 { if (left < right) { @@ -53,3 +55,32 @@ export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion return sortCompare(left.version, right.version); } + + + +export function sortCharts(charts: RawHelmChart[], log?: (...args: any[]) => void) { + interface ExtendedHelmChart extends RawHelmChart { + __version: SemVer; + } + + const chartsWithVersion = Array.from( + iter.map( + charts, + (chart => { + const __version = coerce(chart.version, { includePrerelease: true, loose: true }); + + if (!__version) { + log?.(`[HELM-SERVICE]: Version from helm chart is not loosely coercable to semver.`, { name: chart.name, version: chart.version, repo: chart.repo }); + } + + (chart as ExtendedHelmChart).__version = __version; + + return chart as ExtendedHelmChart; + }) + ), + ); + + return chartsWithVersion + .sort(sortCompareChartVersions) + .map(chart => (delete chart.__version, chart)); +} diff --git a/src/main/helm/__mocks__/helm-chart-manager.ts b/src/main/helm/__mocks__/helm-chart-manager.ts index 6a511273da6b..bb8aaf37ffd9 100644 --- a/src/main/helm/__mocks__/helm-chart-manager.ts +++ b/src/main/helm/__mocks__/helm-chart-manager.ts @@ -19,141 +19,151 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { HelmRepo, HelmRepoManager } from "../helm-repo-manager"; +import { sortCharts } from "../../../common/utils"; +import type { HelmRepo } from "../helm-repo-manager"; + +const charts = new Map([ + ["stable", { + "invalid-semver": sortCharts([ + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver", + repo: "stable", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.3.0", + repo: "stable", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver but more", + repo: "stable", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.4.0", + repo: "stable", + digest: "test", + created: "now", + }, + ]), + "apm-server": sortCharts([ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.7", + repo: "stable", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.6", + repo: "stable", + digest: "test", + created: "now", + } + ]), + "redis": sortCharts([ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "1.0.0", + repo: "stable", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "0.0.9", + repo: "stable", + digest: "test", + created: "now", + } + ]), + }], + ["experiment", { + "fairwind": sortCharts([ + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.1", + repo: "experiment", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.2", + repo: "experiment", + digest: "test", + deprecated: true, + created: "now", + } + ]), + }], + ["bitnami", { + "hotdog": sortCharts([ + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.1", + repo: "bitnami", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.2", + repo: "bitnami", + digest: "test", + created: "now", + } + ]), + "pretzel": sortCharts([ + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0", + repo: "bitnami", + digest: "test", + created: "now", + }, + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0.1", + repo: "bitnami", + digest: "test", + created: "now", + } + ]), + }], +]) export class HelmChartManager { - cache: any = {}; - private repo: HelmRepo; + constructor(private repo: HelmRepo){ } - constructor(repo: HelmRepo){ - this.cache = HelmRepoManager.cache; - this.repo = repo; + static forRepo(repo: HelmRepo) { + return new this(repo); } public async charts(): Promise { - switch (this.repo.name) { - case "stable": - return Promise.resolve({ - "invalid-semver": [ - { - apiVersion: "3.0.0", - name: "weird-versioning", - version: "I am not semver", - repo: "stable", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "weird-versioning", - version: "v4.3.0", - repo: "stable", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "weird-versioning", - version: "I am not semver but more", - repo: "stable", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "weird-versioning", - version: "v4.4.0", - repo: "stable", - digest: "test" - }, - ], - "apm-server": [ - { - apiVersion: "3.0.0", - name: "apm-server", - version: "2.1.7", - repo: "stable", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "apm-server", - version: "2.1.6", - repo: "stable", - digest: "test" - } - ], - "redis": [ - { - apiVersion: "3.0.0", - name: "apm-server", - version: "1.0.0", - repo: "stable", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "apm-server", - version: "0.0.9", - repo: "stable", - digest: "test" - } - ] - }); - case "experiment": - return Promise.resolve({ - "fairwind": [ - { - apiVersion: "3.0.0", - name: "fairwind", - version: "0.0.1", - repo: "experiment", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "fairwind", - version: "0.0.2", - repo: "experiment", - digest: "test", - deprecated: true - } - ] - }); - case "bitnami": - return Promise.resolve({ - "hotdog": [ - { - apiVersion: "3.0.0", - name: "hotdog", - version: "1.0.1", - repo: "bitnami", - digest: "test" - }, - { - apiVersion: "3.0.0", - name: "hotdog", - version: "1.0.2", - repo: "bitnami", - digest: "test", - } - ], - "pretzel": [ - { - apiVersion: "3.0.0", - name: "pretzel", - version: "1.0", - repo: "bitnami", - digest: "test", - }, - { - apiVersion: "3.0.0", - name: "pretzel", - version: "1.0.1", - repo: "bitnami", - digest: "test" - } - ] - }); - default: - return Promise.resolve({}); - } + return charts.get(this.repo.name) ?? {}; } } diff --git a/src/main/helm/__tests__/helm-service.test.ts b/src/main/helm/__tests__/helm-service.test.ts index 56127c168414..7eaf86546446 100644 --- a/src/main/helm/__tests__/helm-service.test.ts +++ b/src/main/helm/__tests__/helm-service.test.ts @@ -31,7 +31,7 @@ describe("Helm Service tests", () => { jest.resetAllMocks(); }); - it("list charts without deprecated ones", async () => { + it("list charts with deprecated entries", async () => { mockHelmRepoManager.mockReturnValue({ init: jest.fn(), repositories: jest.fn().mockImplementation(async () => { @@ -52,14 +52,16 @@ describe("Helm Service tests", () => { name: "apm-server", version: "2.1.7", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "apm-server", version: "2.1.6", repo: "stable", - digest: "test" + digest: "test", + created: "now", } ], "invalid-semver": [ @@ -68,28 +70,32 @@ describe("Helm Service tests", () => { name: "weird-versioning", version: "v4.4.0", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "weird-versioning", version: "v4.3.0", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "weird-versioning", version: "I am not semver", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "weird-versioning", version: "I am not semver but more", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, ], "redis": [ @@ -98,18 +104,40 @@ describe("Helm Service tests", () => { name: "apm-server", version: "1.0.0", repo: "stable", - digest: "test" + digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "apm-server", version: "0.0.9", repo: "stable", - digest: "test" + digest: "test", + created: "now", } ] }, - experiment: {} + experiment: { + "fairwind": [ + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.2", + repo: "experiment", + digest: "test", + deprecated: true, + created: "now", + }, + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.1", + repo: "experiment", + digest: "test", + created: "now", + }, + ] + } }); }); @@ -134,13 +162,15 @@ describe("Helm Service tests", () => { version: "1.0.2", repo: "bitnami", digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "hotdog", version: "1.0.1", repo: "bitnami", - digest: "test" + digest: "test", + created: "now", }, ], "pretzel": [ @@ -150,13 +180,15 @@ describe("Helm Service tests", () => { version: "1.0.1", repo: "bitnami", digest: "test", + created: "now", }, { apiVersion: "3.0.0", name: "pretzel", version: "1.0", repo: "bitnami", - digest: "test" + digest: "test", + created: "now", } ] } diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index 5523e0f05081..2dfbac14adf7 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -20,27 +20,25 @@ */ import fs from "fs"; +import v8 from "v8"; import * as yaml from "js-yaml"; -import { HelmRepo, HelmRepoManager } from "./helm-repo-manager"; +import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; import { promiseExec } from "../promise-exec"; import { helmCli } from "./helm-cli"; import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; - -type CachedYaml = { - entries: RepoHelmChartList -}; +import { sortCharts } from "../../common/utils"; export class HelmChartManager { - protected cache: any = {}; - protected repo: HelmRepo; + static #cache = new Map(); + + private constructor(protected repo: HelmRepo) {} - constructor(repo: HelmRepo){ - this.cache = HelmRepoManager.cache; - this.repo = repo; + static forRepo(repo: HelmRepo) { + return new this(repo); } - public async chart(name: string) { + public async chartVersions(name: string) { const charts = await this.charts(); return charts[name]; @@ -48,9 +46,7 @@ export class HelmChartManager { public async charts(): Promise { try { - const cachedYaml = await this.cachedYaml(); - - return cachedYaml["entries"]; + return await this.cachedYaml(); } catch(error) { logger.error("HELM-CHART-MANAGER]: failed to list charts", { error }); @@ -58,48 +54,66 @@ export class HelmChartManager { } } - public async getReadme(name: string, version = "") { + private async command(action: string, name: string, version?: string) { const helm = await helmCli.binaryPath(); + const cmd = [`"${helm}" ${action} ${this.repo.name}/${name}`]; - if(version && version != "") { - const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); + if (version) { + cmd.push("--version", version); + } - return stdout; - } else { - const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);}); + try { + const { stdout } = await promiseExec(cmd.join(" ")); return stdout; + } catch (error) { + throw error.stderr || error; } } - public async getValues(name: string, version = "") { - const helm = await helmCli.binaryPath(); - - if(version && version != "") { - const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); - - return stdout; - } else { - const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);}); + public async getReadme(name: string, version?: string) { + return this.command("show readme", name, version); + } - return stdout; - } + public async getValues(name: string, version?: string) { + return this.command("show values", name, version); } - protected async cachedYaml(): Promise { - if (!(this.repo.name in this.cache)) { + protected async cachedYaml(): Promise { + if (!HelmChartManager.#cache.has(this.repo.name)) { const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8"); - const data = yaml.safeLoad(cacheFile); - - for(const key in data["entries"]) { - data["entries"][key].forEach((version: any) => { - version["repo"] = this.repo.name; - version["created"] = Date.parse(version.created).toString(); - }); + const { entries } = yaml.safeLoad(cacheFile) as { entries: RepoHelmChartList }; + + /** + * Do some initial preprocessing on the data, so as to avoid needing to do it later + * 1. Set the repo name + * 2. Normalize the created date + * 3. Filter out deprecated items + */ + + const normalized = Object.fromEntries( + Object.entries(entries) + .map(([name, charts]) => [ + name, + sortCharts( + charts.map(chart => ({ + ...chart, + created: Date.parse(chart.created).toString(), + repo: this.repo.name, + })), + logger.warn, + ), + ] as const) + .filter(([, charts]) => !charts.every(chart => chart.deprecated)) + ); + + if (this.repo.name === "grafana") { + console.log(JSON.stringify(normalized)); } - this.cache[this.repo.name] = Buffer.from(JSON.stringify(data)); + + HelmChartManager.#cache.set(this.repo.name, v8.serialize(normalized)); } - return JSON.parse(this.cache[this.repo.name].toString()); + return v8.deserialize(HelmChartManager.#cache.get(this.repo.name)); } } diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index d1c904539d2b..9e4038a10f7f 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -50,8 +50,6 @@ export interface HelmRepo { } export class HelmRepoManager extends Singleton { - static cache = {}; // todo: remove implicit updates in helm-chart-manager.ts - protected repos: HelmRepo[]; protected helmEnv: HelmEnv; protected initialized: boolean; @@ -97,6 +95,12 @@ export class HelmRepoManager extends Singleton { return env; } + public async repo(name: string): Promise { + const repos = await this.repositories(); + + return repos.find(repo => repo.name === name); + } + public async repositories(): Promise { try { if (!this.initialized) { diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 2fe531c21505..9387533b574c 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -19,14 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import semver, { SemVer } from "semver"; import type { Cluster } from "../cluster"; import logger from "../logger"; import { HelmRepoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; -import type { HelmChart, HelmChartList, RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; -import { iter, sortCompareChartVersions } from "../../common/utils"; interface GetReleaseValuesArgs { cluster: Cluster; @@ -42,43 +39,27 @@ class HelmService { } public async listCharts() { - const charts: HelmChartList = {}; const repositories = await HelmRepoManager.getInstance().repositories(); - for (const repo of repositories) { - charts[repo.name] = {}; - const manager = new HelmChartManager(repo); - const sortedCharts = this.sortChartsByVersion(await manager.charts()); - const enabledCharts = this.excludeDeprecatedChartGroups(sortedCharts); - - charts[repo.name] = enabledCharts; - } - - return charts; + return Object.fromEntries( + await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()])) + ); } public async getChart(repoName: string, chartName: string, version = "") { - const result = { - readme: "", - versions: {} - }; - const repos = await HelmRepoManager.getInstance().repositories(); - const repo = repos.find(repo => repo.name === repoName); - const chartManager = new HelmChartManager(repo); - const chart = await chartManager.chart(chartName); - - result.readme = await chartManager.getReadme(chartName, version); - result.versions = chart; - - return result; + const repo = await HelmRepoManager.getInstance().repo(repoName); + const chartManager = HelmChartManager.forRepo(repo); + + return { + readme: await chartManager.getReadme(chartName, version), + versions: await chartManager.chartVersions(chartName), + } } public async getChartValues(repoName: string, chartName: string, version = "") { - const repos = await HelmRepoManager.getInstance().repositories(); - const repo = repos.find(repo => repo.name === repoName); - const chartManager = new HelmChartManager(repo); + const repo = await HelmRepoManager.getInstance().repo(repoName); - return chartManager.getValues(chartName, version); + return HelmChartManager.forRepo(repo).getValues(chartName, version); } public async listReleases(cluster: Cluster, namespace: string = null) { @@ -131,58 +112,6 @@ class HelmService { return { message: output }; } - - private excludeDeprecatedChartGroups(chartGroups: RepoHelmChartList) { - return Object.fromEntries( - iter.filterMap( - Object.entries(chartGroups), - ([name, charts]) => { - for (const chart of charts) { - if (chart.deprecated) { - // ignore chart group if any chart is deprecated - return undefined; - } - } - - return [name, charts]; - } - ) - ); - } - - private sortCharts(charts: HelmChart[]) { - interface ExtendedHelmChart extends HelmChart { - __version: SemVer; - } - - const chartsWithVersion = Array.from( - iter.map( - charts, - (chart => { - const __version = semver.coerce(chart.version, { includePrerelease: true, loose: true }); - - if (!__version) { - logger.error(`[HELM-SERVICE]: Version from helm chart is not loosely coercable to semver.`, { name: chart.name, version: chart.version, repo: chart.repo }); - } - - (chart as ExtendedHelmChart).__version = __version; - - return chart as ExtendedHelmChart; - }) - ), - ); - - return chartsWithVersion - .sort(sortCompareChartVersions) - .map(chart => (delete chart.__version, chart as HelmChart)); - } - - private sortChartsByVersion(chartGroups: RepoHelmChartList) { - return Object.fromEntries( - Object.entries(chartGroups) - .map(([name, charts]) => [name, this.sortCharts(charts)]) - ); - } } export const helmService = new HelmService(); diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.scss b/src/renderer/components/+apps-helm-charts/helm-chart-details.scss index 13521ce5834e..7bee3b7f103b 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.scss +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.scss @@ -30,6 +30,12 @@ box-sizing: content-box; } + .Select__option { + span.deprecated { + text-decoration: line-through; + } + } + .intro-contents { .description { font-weight: bold; diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index d1a152808a45..e64b5ae58e44 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -33,12 +33,19 @@ import { Button } from "../button"; import { Select, SelectOption } from "../select"; import { createInstallChartTab } from "../dock/install-chart.store"; import { Badge } from "../badge"; +import { Tooltip, withStyles } from "@material-ui/core"; interface Props { chart: HelmChart; hideDetails(): void; } +const LargeTooltip = withStyles({ + tooltip: { + fontSize: "var(--font-size-small)", + } +})(Tooltip); + @observer export class HelmChartDetails extends Component { @observable chartVersions: HelmChart[]; @@ -73,15 +80,15 @@ export class HelmChartDetails extends Component { }); @boundMethod - async onVersionChange({ value: version }: SelectOption) { - this.selectedChart = this.chartVersions.find(chart => chart.version === version); + async onVersionChange({ value: chart }: SelectOption) { + this.selectedChart = chart; this.readme = null; try { this.abortController?.abort(); this.abortController = new AbortController(); const { chart: { name, repo } } = this.props; - const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }}); + const { readme } = await getChartDetails(repo, name, { version: chart.version, reqInit: { signal: this.abortController.signal }}); this.readme = readme; } catch (error) { @@ -115,7 +122,19 @@ export class HelmChartDetails extends Component {