1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix loading helm release details (#6318)

* Fix loading helm release details

- The helm manifest can sometimes contain KubeJsonApiDataLists
  instead of just KubeJsonApiData entries

- Add additional logging to main for when a route handler throws so that
  we can gain more context in the future

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Update tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix usage of getHelmReleaseResources

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Add test to verify handling of Lists being returned

Signed-off-by: Sebastian Malton <sebastian@malton.name>

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-10-07 09:16:36 -04:00 committed by GitHub
parent 5e6cf163a2
commit cdeededa82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 180 additions and 319 deletions

View File

@ -17,6 +17,7 @@ export function keys<K extends keyof any>(obj: Record<K, any>): K[] {
return Object.keys(obj) as K[]; return Object.keys(obj) as K[];
} }
export function entries<K extends string, V>(obj: Partial<Record<K, V>> | null | undefined): [K, V][];
export function entries<K extends string | number | symbol, V>(obj: Partial<Record<K, V>> | null | undefined): [K, V][]; export function entries<K extends string | number | symbol, V>(obj: Partial<Record<K, V>> | null | undefined): [K, V][];
export function entries<K extends string | number | symbol, V>(obj: Record<K, V> | null | undefined): [K, V][]; export function entries<K extends string | number | symbol, V>(obj: Record<K, V> | null | undefined): [K, V][];

View File

@ -5552,11 +5552,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -5571,11 +5566,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>
@ -6784,11 +6774,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -6803,11 +6788,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>
@ -8016,11 +7996,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -8035,11 +8010,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>
@ -9073,11 +9043,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -9092,11 +9057,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>
@ -10132,11 +10092,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -10151,11 +10106,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>
@ -11364,11 +11314,6 @@ exports[`showing details for helm release given application is started when navi
> >
Namespace Namespace
</div> </div>
<div
class="TableCell age"
>
Age
</div>
</div> </div>
<div <div
class="TableRow" class="TableRow"
@ -11383,11 +11328,6 @@ exports[`showing details for helm release given application is started when navi
> >
some-namespace some-namespace
</div> </div>
<div
class="TableCell age"
>
0s
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,7 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable";
import type { AsyncResult } from "../../../../../common/utils/async-result"; import type { AsyncResult } from "../../../../../common/utils/async-result";
import execHelmInjectable from "../../../exec-helm/exec-helm.injectable"; import execHelmInjectable from "../../../exec-helm/exec-helm.injectable";
import yaml from "js-yaml"; import yaml from "js-yaml";
import type { KubeJsonApiData } from "../../../../../common/k8s-api/kube-json-api"; import type { KubeJsonApiData, KubeJsonApiDataList } from "../../../../../common/k8s-api/kube-json-api";
const callForHelmManifestInjectable = getInjectable({ const callForHelmManifestInjectable = getInjectable({
id: "call-for-helm-manifest", id: "call-for-helm-manifest",
@ -18,7 +18,7 @@ const callForHelmManifestInjectable = getInjectable({
name: string, name: string,
namespace: string, namespace: string,
kubeconfigPath: string, kubeconfigPath: string,
): Promise<AsyncResult<KubeJsonApiData[]>> => { ): Promise<AsyncResult<(KubeJsonApiData | KubeJsonApiDataList)[]>> => {
const result = await execHelm([ const result = await execHelm([
"get", "get",
"manifest", "manifest",

View File

@ -3,48 +3,37 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import callForKubeResourcesByManifestInjectable from "./call-for-kube-resources-by-manifest/call-for-kube-resources-by-manifest.injectable";
import { groupBy, map } from "lodash/fp";
import type { JsonObject } from "type-fest";
import { pipeline } from "@ogre-tools/fp";
import callForHelmManifestInjectable from "./call-for-helm-manifest/call-for-helm-manifest.injectable"; import callForHelmManifestInjectable from "./call-for-helm-manifest/call-for-helm-manifest.injectable";
import type { KubeJsonApiData, KubeJsonApiDataList } from "../../../../common/k8s-api/kube-json-api";
import type { AsyncResult } from "../../../../common/utils/async-result";
export type GetHelmReleaseResources = ( export type GetHelmReleaseResources = (
name: string, name: string,
namespace: string, namespace: string,
kubeconfigPath: string, kubeconfigPath: string,
kubectlPath: string ) => Promise<AsyncResult<KubeJsonApiData[], string>>;
) => Promise<JsonObject[]>;
const getHelmReleaseResourcesInjectable = getInjectable({ const getHelmReleaseResourcesInjectable = getInjectable({
id: "get-helm-release-resources", id: "get-helm-release-resources",
instantiate: (di): GetHelmReleaseResources => { instantiate: (di): GetHelmReleaseResources => {
const callForHelmManifest = di.inject(callForHelmManifestInjectable); const callForHelmManifest = di.inject(callForHelmManifestInjectable);
const callForKubeResourcesByManifest = di.inject(callForKubeResourcesByManifestInjectable);
return async (name, namespace, kubeconfigPath, kubectlPath) => { return async (name, namespace, kubeconfigPath) => {
const result = await callForHelmManifest(name, namespace, kubeconfigPath); const result = await callForHelmManifest(name, namespace, kubeconfigPath);
if (!result.callWasSuccessful) { if (!result.callWasSuccessful) {
throw new Error(result.error); return result;
} }
const results = await pipeline( return {
result.response, callWasSuccessful: true,
response: result.response.flatMap(item => (
groupBy((item) => item.metadata.namespace || namespace), Array.isArray(item.items)
? (item as KubeJsonApiDataList).items
(x) => Object.entries(x), : item as KubeJsonApiData
)),
map(([namespace, manifest]) => };
callForKubeResourcesByManifest(namespace, kubeconfigPath, kubectlPath, manifest),
),
promises => Promise.all(promises),
);
return results.flat(1);
}; };
}, },
}); });

View File

@ -10,9 +10,10 @@ import type { ExecHelm } from "../../exec-helm/exec-helm.injectable";
import execHelmInjectable from "../../exec-helm/exec-helm.injectable"; import execHelmInjectable from "../../exec-helm/exec-helm.injectable";
import type { AsyncFnMock } from "@async-fn/jest"; import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest"; import asyncFn from "@async-fn/jest";
import type { JsonObject } from "type-fest";
import type { ExecFileWithInput } from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable"; import type { ExecFileWithInput } from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable";
import execFileWithInputInjectable from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable"; import execFileWithInputInjectable from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable";
import type { AsyncResult } from "../../../../common/utils/async-result";
import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api";
describe("get helm release resources", () => { describe("get helm release resources", () => {
let getHelmReleaseResources: GetHelmReleaseResources; let getHelmReleaseResources: GetHelmReleaseResources;
@ -36,14 +37,13 @@ describe("get helm release resources", () => {
}); });
describe("when called", () => { describe("when called", () => {
let actualPromise: Promise<JsonObject[]>; let actualPromise: Promise<AsyncResult<KubeJsonApiData[], string>>;
beforeEach(() => { beforeEach(() => {
actualPromise = getHelmReleaseResources( actualPromise = getHelmReleaseResources(
"some-release", "some-release",
"some-namespace", "some-namespace",
"/some-kubeconfig-path", "/some-kubeconfig-path",
"/some-kubectl-path",
); );
}); });
@ -65,14 +65,16 @@ describe("get helm release resources", () => {
const actual = await actualPromise; const actual = await actualPromise;
expect(actual).toEqual([]); expect(actual).toEqual({
callWasSuccessful: true,
response: [],
});
}); });
describe("when call for manifest resolves", () => { it("when call to manifest resolves with resources, resolves with resources", async () => {
beforeEach(async () => { await execHelmMock.resolve({
await execHelmMock.resolve({ callWasSuccessful: true,
callWasSuccessful: true, response: `---
response: `---
apiVersion: v1 apiVersion: v1
kind: SomeKind kind: SomeKind
metadata: metadata:
@ -80,135 +82,63 @@ metadata:
namespace: some-namespace namespace: some-namespace
--- ---
apiVersion: v1 apiVersion: v1
kind: SomeKind kind: SomeOtherKind
metadata: metadata:
name: some-resource-without-namespace name: some-resource-without-namespace
--- ---
apiVersion: v1 apiVersion: v1
kind: List
items:
- apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: collection-sumologic-fluentd-logs
namespace: some-namespace
---
apiVersion: v1
kind: SomeKind kind: SomeKind
metadata: metadata:
name: some-resource-with-different-namespace name: some-resource-with-different-namespace
namespace: some-other-namespace namespace: some-other-namespace
--- ---
`, `,
});
}); });
it("calls for resources from each namespace separately using the manifest as input", () => { expect(await actualPromise).toEqual({
expect(execFileWithStreamInputMock.mock.calls).toEqual([ callWasSuccessful: true,
[ response: [
{ {
filePath: "/some-kubectl-path", apiVersion: "v1",
commandArguments: ["get", "--kubeconfig", "/some-kubeconfig-path", "-f", "-", "--namespace", "some-namespace", "--output", "json"], kind: "SomeKind",
input: `--- metadata: {
apiVersion: v1 name: "some-resource-with-same-namespace",
kind: SomeKind namespace: "some-namespace",
metadata:
name: some-resource-with-same-namespace
namespace: some-namespace
---
apiVersion: v1
kind: SomeKind
metadata:
name: some-resource-without-namespace
---
`,
}, },
], },
{
[ apiVersion: "v1",
{ kind: "SomeOtherKind",
filePath: "/some-kubectl-path", metadata: {
commandArguments: ["get", "--kubeconfig", "/some-kubeconfig-path", "-f", "-", "--namespace", "some-other-namespace", "--output", "json"], name: "some-resource-without-namespace",
input: `---
apiVersion: v1
kind: SomeKind
metadata:
name: some-resource-with-different-namespace
namespace: some-other-namespace
---
`,
}, },
],
]);
});
it("when all calls for resources resolve, resolves with combined result", async () => {
await execFileWithStreamInputMock.resolveSpecific(
([{ commandArguments }]) =>
commandArguments.includes("some-namespace"),
{
callWasSuccessful: true,
response: JSON.stringify({
items: [{ some: "item" }],
kind: "List",
metadata: {
resourceVersion: "",
selfLink: "",
},
}),
}, },
);
await execFileWithStreamInputMock.resolveSpecific(
([{ commandArguments }]) =>
commandArguments.includes("some-other-namespace"),
{ {
callWasSuccessful: true, apiVersion: "monitoring.coreos.com/v1",
kind: "ServiceMonitor",
response: JSON.stringify({ metadata: {
items: [{ some: "other-item" }], name: "collection-sumologic-fluentd-logs",
namespace: "some-namespace",
kind: "List", },
metadata: {
resourceVersion: "",
selfLink: "",
},
}),
}, },
);
const actual = await actualPromise;
expect(actual).toEqual([{ some: "item" }, { some: "other-item" }]);
});
it("given some call fails, when all calls have finished, rejects with failure", async () => {
await execFileWithStreamInputMock.resolveSpecific(
([{ commandArguments }]) =>
commandArguments.includes("some-namespace"),
{ {
callWasSuccessful: true, apiVersion: "v1",
kind: "SomeKind",
response: JSON.stringify({ metadata: {
items: [{ some: "item" }], name: "some-resource-with-different-namespace",
namespace: "some-other-namespace",
kind: "List", },
metadata: {
resourceVersion: "",
selfLink: "",
},
}),
}, },
); ],
execFileWithStreamInputMock.resolveSpecific(
([{ commandArguments }]) =>
commandArguments.includes("some-other-namespace"),
{
callWasSuccessful: false,
error: "some-error",
},
);
return expect(actualPromise).rejects.toEqual(expect.any(Error));
}); });
}); });
}); });

View File

@ -19,12 +19,10 @@ const getHelmReleaseInjectable = getInjectable({
return async (cluster: Cluster, releaseName: string, namespace: string) => { return async (cluster: Cluster, releaseName: string, namespace: string) => {
const kubeconfigPath = await cluster.getProxyKubeconfigPath(); const kubeconfigPath = await cluster.getProxyKubeconfigPath();
const kubectl = await cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
logger.debug("Fetch release"); logger.debug("Fetch release");
const args = [ const result = await execHelm([
"status", "status",
releaseName, releaseName,
"--namespace", "--namespace",
@ -33,11 +31,11 @@ const getHelmReleaseInjectable = getInjectable({
kubeconfigPath, kubeconfigPath,
"--output", "--output",
"json", "json",
]; ]);
const result = await execHelm(args);
if (!result.callWasSuccessful) { if (!result.callWasSuccessful) {
logger.warn(`Failed to exectute helm: ${result.error}`);
return undefined; return undefined;
} }
@ -47,14 +45,22 @@ const getHelmReleaseInjectable = getInjectable({
return undefined; return undefined;
} }
release.resources = await getHelmReleaseResources( const resourcesResult = await getHelmReleaseResources(
releaseName, releaseName,
namespace, namespace,
kubeconfigPath, kubeconfigPath,
kubectlPath,
); );
return release; if (!resourcesResult.callWasSuccessful) {
logger.warn(`Failed to get helm release resources: ${resourcesResult.error}`);
return undefined;
}
return {
...release,
resources: resourcesResult.response,
};
}; };
}, },

View File

@ -0,0 +1,80 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { ServerResponse } from "http";
import loggerInjectable from "../../common/logger.injectable";
import { object } from "../../common/utils";
import type { LensApiRequest, Route } from "./route";
import { contentTypes } from "./router-content-types";
export type RouteHandler = (request: LensApiRequest<string>, response: ServerResponse) => Promise<void>;
export type CreateHandlerForRoute = (route: Route<unknown, string>) => RouteHandler;
interface LensServerResponse {
statusCode: number;
content: any;
headers: {
[name: string]: string;
};
}
const writeServerResponseFor = (serverResponse: ServerResponse) => ({
statusCode,
content,
headers,
}: LensServerResponse) => {
serverResponse.statusCode = statusCode;
for (const [name, value] of object.entries(headers)) {
serverResponse.setHeader(name, value);
}
if (content instanceof Buffer) {
serverResponse.write(content);
serverResponse.end();
} else if (content) {
serverResponse.end(content);
} else {
serverResponse.end();
}
};
const createHandlerForRouteInjectable = getInjectable({
id: "create-handler-for-route",
instantiate: (di): CreateHandlerForRoute => {
const logger = di.inject(loggerInjectable);
return (route) => async (request, response) => {
const writeServerResponse = writeServerResponseFor(response);
try {
const result = await route.handler(request);
if (!result) {
const mappedResult = contentTypes.txt.resultMapper({
statusCode: 204,
response: undefined,
});
writeServerResponse(mappedResult);
} else if (!result.proxy) {
const contentType = result.contentType || contentTypes.json;
writeServerResponse(contentType.resultMapper(result));
}
} catch(error) {
const mappedResult = contentTypes.txt.resultMapper({
statusCode: 500,
error: error ? String(error) : "unknown error",
});
logger.error(`[ROUTER]: route ${route.path}, called with ${request.path}, threw an error`, error);
writeServerResponse(mappedResult);
}
};
},
});
export default createHandlerForRouteInjectable;

View File

@ -7,6 +7,7 @@ import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import { Router } from "./router"; import { Router } from "./router";
import parseRequestInjectable from "./parse-request.injectable"; import parseRequestInjectable from "./parse-request.injectable";
import type { Route } from "./route"; import type { Route } from "./route";
import createHandlerForRouteInjectable from "./create-handler-for-route.injectable";
export const routeInjectionToken = getInjectionToken<Route<unknown, string>>({ export const routeInjectionToken = getInjectionToken<Route<unknown, string>>({
id: "route-injection-token", id: "route-injection-token",
@ -24,13 +25,11 @@ export function getRouteInjectable<T, Path extends string>(
const routerInjectable = getInjectable({ const routerInjectable = getInjectable({
id: "router", id: "router",
instantiate: (di) => { instantiate: (di) => new Router({
const routes = di.injectMany(routeInjectionToken); parseRequest: di.inject(parseRequestInjectable),
routes: di.injectMany(routeInjectionToken),
return new Router(routes, { createHandlerForRoute: di.inject(createHandlerForRouteInjectable),
parseRequest: di.inject(parseRequestInjectable), }),
});
},
}); });
export default routerInjectable; export default routerInjectable;

View File

@ -5,12 +5,11 @@
import Call from "@hapi/call"; import Call from "@hapi/call";
import type http from "http"; import type http from "http";
import { toPairs } from "lodash/fp";
import type { Cluster } from "../../common/cluster/cluster"; import type { Cluster } from "../../common/cluster/cluster";
import { contentTypes } from "./router-content-types"; import type { LensApiRequest, Route } from "./route";
import type { LensApiRequest, LensApiResult, Route } from "./route";
import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy"; import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy";
import type { ParseRequest } from "./parse-request.injectable"; import type { ParseRequest } from "./parse-request.injectable";
import type { CreateHandlerForRoute, RouteHandler } from "./create-handler-for-route.injectable";
export interface RouterRequestOpts { export interface RouterRequestOpts {
req: http.IncomingMessage; req: http.IncomingMessage;
@ -22,15 +21,17 @@ export interface RouterRequestOpts {
interface Dependencies { interface Dependencies {
parseRequest: ParseRequest; parseRequest: ParseRequest;
createHandlerForRoute: CreateHandlerForRoute;
readonly routes: Route<unknown, string>[];
} }
export class Router { export class Router {
protected router = new Call.Router<ReturnType<typeof handleRoute>>(); private readonly router = new Call.Router<RouteHandler>();
constructor(routes: Route<unknown, string>[], private dependencies: Dependencies) { constructor(private readonly dependencies: Dependencies) {
routes.forEach(route => { for (const route of this.dependencies.routes) {
this.router.add({ method: route.method, path: route.path }, handleRoute(route)); this.router.add({ method: route.method, path: route.path }, this.dependencies.createHandlerForRoute(route));
}); }
} }
public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise<boolean> { public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise<boolean> {
@ -52,7 +53,6 @@ export class Router {
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest<string>> { protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest<string>> {
const { req, res, url, cluster, params } = opts; const { req, res, url, cluster, params } = opts;
const { payload } = await this.dependencies.parseRequest(req, null, { const { payload } = await this.dependencies.parseRequest(req, null, {
parse: true, parse: true,
output: "data", output: "data",
@ -70,77 +70,3 @@ export class Router {
}; };
} }
} }
const handleRoute = (route: Route<unknown, string>) => async (request: LensApiRequest<string>, response: http.ServerResponse) => {
let result: LensApiResult<any> | void;
const writeServerResponse = writeServerResponseFor(response);
try {
result = await route.handler(request);
} catch(error) {
const mappedResult = contentTypes.txt.resultMapper({
statusCode: 500,
error: error ? String(error) : "unknown error",
});
writeServerResponse(mappedResult);
return;
}
if (!result) {
const mappedResult = contentTypes.txt.resultMapper({
statusCode: 204,
response: undefined,
});
writeServerResponse(mappedResult);
return;
}
if (result.proxy) {
return;
}
const contentType = result.contentType || contentTypes.json;
const mappedResult = contentType.resultMapper(result);
writeServerResponse(mappedResult);
};
const writeServerResponseFor =
(serverResponse: http.ServerResponse) =>
({
statusCode,
content,
headers,
}: {
statusCode: number;
content: any;
headers: { [name: string]: string };
}) => {
serverResponse.statusCode = statusCode;
const headerPairs = toPairs<string>(headers);
headerPairs.forEach(([name, value]) => {
serverResponse.setHeader(name, value);
});
if (content instanceof Buffer) {
serverResponse.write(content);
serverResponse.end();
return;
}
if (content) {
serverResponse.end(content);
} else {
serverResponse.end();
}
};

View File

@ -19,7 +19,6 @@ import { kebabCase } from "lodash/fp";
import { Badge } from "../../badge"; import { Badge } from "../../badge";
import { SubTitle } from "../../layout/sub-title"; import { SubTitle } from "../../layout/sub-title";
import { Table, TableCell, TableHead, TableRow } from "../../table"; import { Table, TableCell, TableHead, TableRow } from "../../table";
import { ReactiveDuration } from "../../duration/reactive-duration";
import { Checkbox } from "../../checkbox"; import { Checkbox } from "../../checkbox";
import { MonacoEditor } from "../../monaco-editor"; import { MonacoEditor } from "../../monaco-editor";
import { Spinner } from "../../spinner"; import { Spinner } from "../../spinner";
@ -131,12 +130,10 @@ const ResourceGroup = ({
<TableCell className="name">Name</TableCell> <TableCell className="name">Name</TableCell>
{isNamespaced && <TableCell className="namespace">Namespace</TableCell>} {isNamespaced && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead> </TableHead>
{resources.map( {resources.map(
({ creationTimestamp, detailsUrl, name, namespace, uid }) => ( ({ detailsUrl, name, namespace, uid }) => (
<TableRow key={uid}> <TableRow key={uid}>
<TableCell className="name"> <TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name} {detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
@ -145,10 +142,6 @@ const ResourceGroup = ({
{isNamespaced && ( {isNamespaced && (
<TableCell className="namespace">{namespace}</TableCell> <TableCell className="namespace">{namespace}</TableCell>
)} )}
<TableCell className="age">
<ReactiveDuration timestamp={creationTimestamp} />
</TableCell>
</TableRow> </TableRow>
), ),
)} )}

View File

@ -222,12 +222,12 @@ export class ReleaseDetailsModel {
} }
@computed get notes() { @computed get notes() {
return this.details.info.notes; return this.details?.info.notes ?? "";
} }
@computed get groupedResources(): MinimalResourceGroup[] { @computed get groupedResources(): MinimalResourceGroup[] {
return pipeline( return pipeline(
this.details.resources, this.details?.resources ?? [],
groupBy((resource) => resource.kind), groupBy((resource) => resource.kind),
(grouped) => Object.entries(grouped), (grouped) => Object.entries(grouped),
@ -271,20 +271,17 @@ export interface MinimalResource {
name: string; name: string;
namespace: string | undefined; namespace: string | undefined;
detailsUrl: string | undefined; detailsUrl: string | undefined;
creationTimestamp: string | undefined;
} }
const toMinimalResourceFor = const toMinimalResourceFor =
(getResourceDetailsUrl: GetResourceDetailsUrl, kind: string) => (getResourceDetailsUrl: GetResourceDetailsUrl, kind: string) =>
(resource: KubeJsonApiData): MinimalResource => { (resource: KubeJsonApiData): MinimalResource => {
const { creationTimestamp, name, namespace, uid } = resource.metadata; const { name, namespace, uid } = resource.metadata;
return { return {
uid, uid,
name, name,
namespace, namespace,
creationTimestamp,
detailsUrl: getResourceDetailsUrl( detailsUrl: getResourceDetailsUrl(
kind, kind,
resource.apiVersion, resource.apiVersion,

View File

@ -11,7 +11,7 @@ import type { AsyncResult } from "../../../../../common/utils/async-result";
export interface DetailedHelmRelease { export interface DetailedHelmRelease {
release: HelmReleaseDto; release: HelmReleaseDto;
details: HelmReleaseDetails; details?: HelmReleaseDetails;
} }
export type RequestDetailedHelmRelease = ( export type RequestDetailedHelmRelease = (