From 8db925171812dd675dd8e19e1cf1916007199f59 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 21 Jan 2021 11:26:01 -0500 Subject: [PATCH] Fix pod loading under some more RBAC restrictions - Correctly handle the case where `undefined` is returned by the fetch because of a 403: Unauthorized being returned from the Kube Api due to only having list permissions in some of the namespaces - Fix rendering of Pod Secrets in the details view to handle correctly the case where the user doesn't have list or get secrets permissions - Remove CancelablePromise as it was only used in one place and the return types where wrong causing a crash while debugging the above. - Move HelmChartDetails to create its own AbortController for cancelling its request itself as that was the only instance of the cancel() method being called - Significantly improve the typing of `isJsonApiData` and `isJsonApiDataList` so that they actually make sure that the form of the data matches the assertion they make. This also removes a crash from incorrectly assuming that `any` could not be `undefined`. - Add tests for the above two functions - Significantly improve the readability of the isKubeJson* functions - add doc comments for `ItemStore.prototype.sortItems` - add many more helper functions for type narrowing - Add some more handling of error cases with RBAC - Add notifications when errors occur (which leave lists in the loading state) - properly set response codes when an error occurs for listing helm releases - support kube API 1.20 with now optional selfLink - fix KubeObjectStore.subscribe not waiting for the corisponding load to occur Signed-off-by: Sebastian Malton --- src/common/utils/index.ts | 1 + src/common/utils/reject-promise.ts | 13 + src/common/utils/type-narrowing.ts | 82 ++++++- src/main/routes/helm-route.ts | 4 +- src/renderer/api/__tests__/kube-api.test.ts | 4 +- .../api/__tests__/kube-object.test.ts | 228 ++++++++++++++++++ src/renderer/api/endpoints/helm-charts.api.ts | 4 +- .../api/endpoints/helm-releases.api.ts | 25 +- src/renderer/api/index.ts | 4 +- src/renderer/api/json-api.ts | 67 ++--- src/renderer/api/kube-api.ts | 213 ++++++++-------- src/renderer/api/kube-json-api.ts | 42 ++-- src/renderer/api/kube-object.ts | 57 ++++- .../+apps-helm-charts/helm-chart-details.tsx | 13 +- .../+apps-releases/release.store.ts | 7 +- .../+namespaces/add-namespace-dialog.tsx | 16 +- .../+workloads-pods/pod-details-secrets.tsx | 49 ++-- src/renderer/components/dock/log.store.ts | 77 +++--- src/renderer/item.store.ts | 14 +- src/renderer/kube-object.store.ts | 105 ++++---- src/renderer/utils/cancelableFetch.ts | 36 --- 21 files changed, 723 insertions(+), 338 deletions(-) create mode 100644 src/common/utils/reject-promise.ts create mode 100644 src/renderer/api/__tests__/kube-object.test.ts delete mode 100644 src/renderer/utils/cancelableFetch.ts diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 6f26bab2da..0043e6a2b1 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,3 +19,4 @@ export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; export * from "./type-narrowing"; +export * from "./reject-promise"; diff --git a/src/common/utils/reject-promise.ts b/src/common/utils/reject-promise.ts new file mode 100644 index 0000000000..3aa7d402b2 --- /dev/null +++ b/src/common/utils/reject-promise.ts @@ -0,0 +1,13 @@ +import { AbortSignal } from "abort-controller"; + +/** + * Creates a new promise that will be rejected when the signal rejects. + * + * Useful for `Promise.race()` applications. + * @param signal The AbortController's signal to reject with + */ +export function rejectPromiseBy(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + signal.addEventListener("abort", reject); + }); +} diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index 6a239c43ee..86d273cefd 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -1,13 +1,89 @@ /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested - * @param key The key to test if it is present on the object + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) */ -export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { +export function hasOwnProperty(val: S, key: K): val is (S & { [key in K]: unknown }) { // this call syntax is for when `val` was created by `Object.create(null)` return Object.prototype.hasOwnProperty.call(val, key); } -export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { +/** + * Narrows `val` to a static type that includes fields of names in `keys` + * @param val the value that we are trying to type narrow + * @param keys the key names (must be literals for tsc to do any type meaningful) + */ +export function hasOwnProperties(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) { return keys.every(key => hasOwnProperty(val, key)); } + +/** + * Narrows `val` to include the property `key` with type `V` + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) + * @param isValid a function to check if the field is valid + */ +export function hasTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) { + return hasOwnProperty(val, key) && isValid(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) + * @param isValid a function to check if the field (when present) is valid + */ +export function hasOptionalProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { + if (hasOwnProperty(val, key)) { + return typeof val[key] === "undefined" || isValid(val[key]); + } + + return true; +} + +/** + * isRecord checks if `val` matches the signature `Record` or `{ [label in T]: V }` + * @param val The value to be checked + * @param isKey a function for checking if the key is of the correct type + * @param isValue a function for checking if a value is of the correct type + */ +export function isRecord(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record { + return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value)); +} + +/** + * isTypedArray checks if `val` is an array and all of its entries are of type `T` + * @param val The value to be checked + * @param isEntry a function for checking if an entry is the correct type + */ +export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] { + return Array.isArray(val) && val.every(isEntry); +} + +/** + * checks if val is of type string + * @param val the value to be checked + */ +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +/** + * checks if val is of type object and isn't null + * @param val the value to be checked + */ +export function isObject(val: unknown): val is object { + return typeof val === "object" && val !== null; +} + +/** + * Creates a new predicate function (with the same predicate) from `fn`. Such + * that it can be called with just the value to be tested. + * + * This is useful for when using `hasOptionalProperty` and `hasTypedProperty` + * @param fn A typescript user predicate function to be bound + * @param boundArgs the set of arguments to be passed to `fn` in the new function + */ +export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { + return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); +} diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index 4d1cae8bc2..c3c0bdb353 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -70,7 +70,7 @@ class HelmApiRoute extends LensApi { this.respondJson(response, result); } catch (error) { logger.debug(error); - this.respondText(response, error); + this.respondText(response, error, 422); } } @@ -83,7 +83,7 @@ class HelmApiRoute extends LensApi { this.respondJson(response, result); } catch(error) { logger.debug(error); - this.respondText(response, error); + this.respondText(response, error, 422); } } diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index 7481bd096a..9d3c41869d 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -28,7 +28,7 @@ describe("KubeApi", () => { }; } }); - + const apiBase = "/apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; const kubeApi = new KubeApi({ @@ -36,7 +36,7 @@ describe("KubeApi", () => { fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, }); - + await kubeApi.get(); expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); diff --git a/src/renderer/api/__tests__/kube-object.test.ts b/src/renderer/api/__tests__/kube-object.test.ts new file mode 100644 index 0000000000..b3f84ea1ae --- /dev/null +++ b/src/renderer/api/__tests__/kube-object.test.ts @@ -0,0 +1,228 @@ +import { KubeObject } from "../kube-object"; + +describe("KubeObject", () => { + describe("isJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["metadata", { kind: "", apiVersion: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: {} }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: {} }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiData (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isJsonApiData(valid)).toBe(true); + }); + }); + + describe("isPartialJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept {}", () => { + expect(KubeObject.isPartialJsonApiData({})).toBe(true); + }); + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "" }], + ]; + + it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(true); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid Partial (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isPartialJsonApiData(valid)).toBe(true); + }); + }); + + describe("isJsonApiDataList", () => { + function isAny(val: unknown): val is any { + return !Boolean(void val); + } + + function isNotAny(val: unknown): val is any { + return Boolean(void val); + } + + function isBoolean(val: unknown): val is Boolean { + return typeof val === "boolean"; + } + + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "" }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }], + ["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => { + const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }; + + expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true); + }); + }); +}); diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 02b5b0dbee..22bcc5557f 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -28,11 +28,11 @@ export const helmChartsApi = { }); }, - get(repo: string, name: string, readmeVersion?: string) { + get(repo: string, name: string, readmeVersion?: string, reqInit?: RequestInit) { const path = endpoint({ repo, name }); return apiBase - .get(`${path}?${stringify({ version: readmeVersion })}`) + .get(`${path}?${stringify({ version: readmeVersion })}`, undefined, reqInit) .then(data => { const versions = data.versions.map(HelmChart.create); const readme = data.readme; diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index 9a5a6dadd0..4a5e29caf7 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -69,24 +69,23 @@ const endpoint = compile(`/v2/releases/:namespace?/:name?`) as ( ) => string; export const helmReleasesApi = { - list(namespace?: string) { - return apiBase - .get(endpoint({ namespace })) - .then(releases => releases.map(HelmRelease.create)); + async list(namespace?: string) { + const releases = await apiBase.get(endpoint({ namespace })); + + return releases.map(HelmRelease.create); }, - get(name: string, namespace: string) { + async get(name: string, namespace: string) { const path = endpoint({ name, namespace }); - return apiBase.get(path).then(details => { - const items: KubeObject[] = JSON.parse(details.resources).items; - const resources = items.map(item => KubeObject.create(item)); + const details = await apiBase.get(path); + const items: KubeObject[] = JSON.parse(details.resources).items; + const resources = items.map(item => KubeObject.create(item)); - return { - ...details, - resources - }; - }); + return { + ...details, + resources + }; }, create(payload: IReleaseCreatePayload): Promise { diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 1a82ce45f8..7c2b55d0e1 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,11 +1,11 @@ import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { Notifications } from "../components/notifications"; -import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; +import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars"; export const apiBase = new JsonApi({ apiBase: apiPrefix, - debug: isDevelopment, + debug: isDevelopment || isDebugging, }); export const apiKube = new KubeJsonApi({ apiBase: apiKubePrefix, diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index df12b08ab7..14923f063f 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -2,8 +2,8 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; -import { cancelableFetch } from "../utils/cancelableFetch"; import { randomBytes } from "crypto"; + export interface JsonApiData { } @@ -72,13 +72,11 @@ export class JsonApi { reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } - const infoLog: JsonApiLog = { + this.writeLog({ method: reqInit.method.toUpperCase(), reqUrl: reqPath, reqInit, - }; - - this.writeLog({ ...infoLog }); + }); return fetch(reqUrl, reqInit); } @@ -99,7 +97,7 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected request(path: string, params?: P, init: RequestInit = {}) { + protected async request(path: string, params?: P, init: RequestInit = {}) { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; @@ -119,48 +117,53 @@ export class JsonApi { reqInit, }; - return cancelableFetch(reqUrl, reqInit).then(res => { - return this.parseResponse(res, infoLog); - }); + const res = await fetch(reqUrl, reqInit); + + return this.parseResponse(res, infoLog); } - protected parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; - return res.text().then(text => { - let data; + const text = await res.text(); + let data; - try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body - } catch (e) { - data = text; - } + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } - if (status >= 200 && status < 300) { - this.onData.emit(data, res); - this.writeLog({ ...log, data }); + if (status >= 200 && status < 300) { + console.log(data, res); + this.onData.emit(data, res); + this.writeLog({ ...log, data }); - return data; - } else if (log.method === "GET" && res.status === 403) { - this.writeLog({ ...log, data }); - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + return data; + } - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; - } - }); + if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, error: data }); + throw data; + } else { + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + throw error; + } } protected parseError(error: JsonApiError | string, res: Response): string[] { if (typeof error === "string") { return [error]; } - else if (Array.isArray(error.errors)) { + + if (Array.isArray(error.errors)) { return error.errors.map(error => error.title); } - else if (error.message) { + + if (error.message) { return [error.message]; } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 448cd9da8f..4ad0dbf480 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -7,11 +7,11 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; import { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; +import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; export interface IKubeApiOptions { /** @@ -34,6 +34,11 @@ export interface IKubeApiOptions { checkPreferredVersion?: boolean; } +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + export interface IKubeApiQueryParams { watch?: boolean | number; resourceVersion?: string; @@ -243,7 +248,7 @@ export class KubeApi { return this.resourceVersions.get(namespace); } - async refreshResourceVersion(params?: { namespace: string }) { + async refreshResourceVersion(params?: KubeApiListOptions) { return this.list(params, { limit: 1 }); } @@ -271,20 +276,12 @@ export class KubeApi { return query; } - protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + protected parseResponse(data: unknown, namespace?: string): T | T[] | null { if (!data) return; const KubeObjectConstructor = this.objectConstructor; - if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); - - ensureObjectSelfLink(this, object); - - return object; - } - - // process items list response - if (KubeObject.isJsonApiDataList(data)) { + // process items list response, check before single item since there is overlap + if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) { const { apiVersion, items, metadata } = data; this.setResourceVersion(namespace, metadata.resourceVersion); @@ -303,55 +300,85 @@ export class KubeApi { }); } + // process a single item + if (KubeObject.isJsonApiData(data)) { + const object = new KubeObjectConstructor(data); + + ensureObjectSelfLink(this, object); + + return object; + } + // custom apis might return array for list response, e.g. users, groups, etc. if (Array.isArray(data)) { return data.map(data => new KubeObjectConstructor(data)); } - return data; + return null; } - async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace }), { query }) - .then(data => this.parseResponse(data, namespace)); + const res = await this.request.get(this.getUrl({ namespace }), { query }, reqInit); + const parsed = this.parseResponse(res, namespace); + + if (!parsed || !Array.isArray(parsed)) { + return null; + } + + return parsed; } - async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { + async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace, name }), { query }) - .then(this.parseResponse); + const res = await this.request.get(this.getUrl({ namespace, name }), { query }); + + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + return null; + } + + return parsed; } - async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace }); - return this.request - .post(apiUrl, { - data: merge({ - kind: this.kind, - apiVersion: this.apiVersionWithGroup, - metadata: { - name, - namespace - } - }, data) - }) - .then(this.parseResponse); + const res = await this.request.post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + return null; + } + + return parsed; } - async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); - return this.request - .put(apiUrl, { data }) - .then(this.parseResponse); + const res = await this.request.put(apiUrl, { data }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + return null; + } + + return parsed; } async delete({ name = "", namespace = "default" }) { @@ -370,78 +397,64 @@ export class KubeApi { } watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { - if (!opts.abortController) { - opts.abortController = new AbortController(); - } let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { abortController, namespace, callback } = opts; + const { abortController: { abort, signal } = new AbortController(), namespace, callback } = opts; - abortController.signal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { clearTimeout(timedRetry); }); const watchUrl = this.getWatchUrl(namespace); - const responsePromise = this.request.getResponse(watchUrl, null, { - signal: abortController.signal - }); + const responsePromise = this.request.getResponse(watchUrl, null, { signal }); - responsePromise.then((response) => { - if (!response.ok && !abortController.signal.aborted) { - callback?.(null, response); - - return; - } - const nodeStream = new ReadableWebToNodeStream(response.body); - - ["end", "close", "error"].forEach((eventName) => { - nodeStream.on(eventName, () => { - if (errorReceived) return; // kubernetes errors should be handled in a callback - - clearTimeout(timedRetry); - timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry - if (abortController.signal.aborted) return; - - this.watch({...opts, namespace, callback}); - }, 1000); - }); - }); - - const stream = byline(nodeStream); - - stream.on("data", (line) => { - try { - const event: IKubeWatchEvent = JSON.parse(line); - - if (event.type === "ERROR" && event.object.kind === "Status") { - errorReceived = true; - callback(null, new KubeStatus(event.object as any)); - - return; - } - - this.modifyWatchEvent(event); - - if (callback) { - callback(event, null); - } - } catch (ignore) { - // ignore parse errors + responsePromise + .then(response => { + if (!response.ok) { + return callback?.(null, response); } + + const nodeStream = new ReadableWebToNodeStream(response.body); + + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({...opts, namespace, callback}); + }, 1000); + }); + }); + + const stream = byline(nodeStream); + + stream.on("data", (line) => { + try { + const event: IKubeWatchEvent = JSON.parse(line); + + if (event.type === "ERROR" && event.object.kind === "Status") { + errorReceived = true; + callback(null, new KubeStatus(event.object as any)); + + return; + } + + this.modifyWatchEvent(event); + + callback?.(event, null); + } catch (ignore) { + // ignore parse errors + } + }); + }) + .catch(error => { + if (error instanceof DOMException) return; // AbortController rejects, we can ignore it + + callback?.(null, error); }); - }, (error) => { - if (error instanceof DOMException) return; // AbortController rejects, we can ignore it - callback?.(null, error); - }).catch((error) => { - callback?.(null, error); - }); - - const disposer = () => { - abortController.abort(); - }; - - return disposer; + return abort; } protected modifyWatchEvent(event: IKubeWatchEvent) { diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 362ee5438e..0dfe53c8d2 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -1,34 +1,38 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; +export interface KubeJsonApiListMetadata { + resourceVersion: string; + selfLink?: string; +} + export interface KubeJsonApiDataList { kind: string; apiVersion: string; items: T[]; - metadata: { - resourceVersion: string; - selfLink: string; + metadata: KubeJsonApiListMetadata; +} + +export interface KubeJsonApiMetadata { + uid: string; + name: string; + namespace?: string; + creationTimestamp?: string; + resourceVersion: string; + continue?: string; + finalizers?: string[]; + selfLink?: string; + labels?: { + [label: string]: string; + }; + annotations?: { + [annotation: string]: string; }; } export interface KubeJsonApiData extends JsonApiData { kind: string; apiVersion: string; - metadata: { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - }; + metadata: KubeJsonApiMetadata; } export interface KubeJsonApiError extends JsonApiError { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 7d0c34de33..8699a3c94f 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -1,12 +1,13 @@ // Base class for all kubernetes objects import moment from "moment"; -import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; import { autobind, formatDuration } from "../utils"; import { ItemObject } from "../item.store"; import { apiKube } from "./index"; import { JsonApiParams } from "./json-api"; import { resourceApplierApi } from "./endpoints/resource-applier.api"; +import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { kind?: string; @@ -78,15 +79,59 @@ export class KubeObject implements ItemObject { return !item.metadata.name.startsWith("system:"); } - static isJsonApiData(object: any): object is KubeJsonApiData { - return !object.items && object.metadata; + static isJsonApiData(object: unknown): object is KubeJsonApiData { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); } - static isJsonApiDataList(object: any): object is KubeJsonApiDataList { - return object.items && object.metadata; + static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + ); } - static stringifyLabels(labels: { [name: string]: string }): string[] { + static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "uid", isString) + && hasTypedProperty(object, "name", isString) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + && hasOptionalProperty(object, "namespace", isString) + && hasOptionalProperty(object, "creationTimestamp", isString) + && hasOptionalProperty(object, "continue", isString) + && hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + ); + } + + static isPartialJsonApiData(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalProperty(object, "kind", isString) + && hasOptionalProperty(object, "apiVersion", isString) + && hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); + } + + static isJsonApiDataList(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) + && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) + ); + } + + static stringifyLabels(labels?: { [name: string]: string }): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`); 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 d31efc5438..a813b87540 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -8,7 +8,6 @@ import { Drawer, DrawerItem } from "../drawer"; import { autobind, stopPropagation } from "../../utils"; import { MarkdownViewer } from "../markdown-viewer"; import { Spinner } from "../spinner"; -import { CancelablePromise } from "../../utils/cancelableFetch"; import { Button } from "../button"; import { Select, SelectOption } from "../select"; import { createInstallChartTab } from "../dock/install-chart.store"; @@ -26,10 +25,11 @@ export class HelmChartDetails extends Component { @observable readme: string = null; @observable error: string = null; - private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; + private chartPromise: Promise<{ readme: string; versions: HelmChart[] }>; + private abortController?: AbortController; componentWillUnmount() { - this.chartPromise?.cancel(); + this.abortController?.abort(); } chartUpdater = autorun(() => { @@ -52,9 +52,12 @@ export class HelmChartDetails extends Component { this.readme = null; try { - this.chartPromise?.cancel(); + this.abortController?.abort(); + // there is no way (by design) to reset an AbortController, so just make a new one + this.abortController = new AbortController(); + const { chart: { name, repo } } = this.props; - const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version)); + const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version, { signal: this.abortController.signal })); this.readme = readme; } catch (error) { diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 559a90eb01..59e8d9a5bc 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -6,6 +6,7 @@ import { ItemStore } from "../../item.store"; import { Secret } from "../../api/endpoints"; import { secretsStore } from "../+config-secrets/secrets.store"; import { namespaceStore } from "../+namespaces/namespace.store"; +import { Notifications } from "../notifications"; @autobind() export class ReleaseStore extends ItemStore { @@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore { this.items.replace(this.sortItems(items)); this.isLoaded = true; } catch (error) { - console.error(`Loading Helm Chart releases has failed: ${error}`); + console.error("Loading Helm Chart releases has failed", error); + + if (error.error) { + Notifications.error(error.error); + } } finally { this.isLoading = false; } diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a3b30235fa..1ad2efae59 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component { this.namespace = ""; }; - close = () => { - AddNamespaceDialog.close(); - }; - addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; try { - await namespaceStore.create({ name: namespace }).then(onSuccess); - this.close(); + const created = await namespaceStore.create({ name: namespace }); + + onSuccess?.(created); + AddNamespaceDialog.close(); } catch (err) { Notifications.error(err); - onError && onError(err); + onError?.(err); } }; @@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component { className="AddNamespaceDialog" isOpen={AddNamespaceDialog.isOpen} onOpen={this.reset} - close={this.close} + close={AddNamespaceDialog.close} > - + { - @observable secrets: Secret[] = []; + // either secrets or just their names + @observable secrets: (Secret | string)[] = []; @disposeOnUnmount - secretsLoader = autorun(async () => { + secretsLoader = autorun(() => { const { pod } = this.props; - this.secrets = await Promise.all( - pod.getSecrets().map(secretName => secretsApi.get({ - name: secretName, - namespace: pod.getNs(), - })) - ); + Promise.all( + pod.getSecrets() + .map(secretName => ( + secretsApi + .get({ + name: secretName, + namespace: pod.getNs(), + }) + .catch(error => (console.error("Failed to load secret details", error), secretName)) + // res is undefined if context doesn't have get/list secrets + )) + ) + .then(secrets => this.secrets = secrets) + .catch(error => console.log("Failed to load secret details", error)); }); + @autobind() + renderSecret(secret: Secret | string) { + if (typeof secret === "string") { + return {secret}; + } + + return ( + + {secret.getName()} + + ); + } + render() { return (
- { - this.secrets.map(secret => { - return ( - - {secret.getName()} - - ); - }) - } + {this.secrets.map(this.renderSecret)}
); } diff --git a/src/renderer/components/dock/log.store.ts b/src/renderer/components/dock/log.store.ts index 14dc9efdd0..6520573158 100644 --- a/src/renderer/components/dock/log.store.ts +++ b/src/renderer/components/dock/log.store.ts @@ -32,6 +32,20 @@ export class LogStore { }, { delay: 500 }); } + handlerError(tabId: TabId, error: any): void { + if (error.error && !(error.message || error.reason || error.code)) { + error = error.error; + } + + const message = [ + `Failed to load logs: ${error.message}`, + `Reason: ${error.reason} (${error.code})` + ]; + + this.refresher.stop(); + this.podLogs.set(tabId, message); + } + /** * Function prepares tailLines param for passing to API request * Each time it increasing it's number, caused to fetch more logs. @@ -47,14 +61,8 @@ export class LogStore { this.refresher.start(); this.podLogs.set(tabId, logs); - } catch ({error}) { - const message = [ - `Failed to load logs: ${error.message}`, - `Reason: ${error.reason} (${error.code})` - ]; - - this.refresher.stop(); - this.podLogs.set(tabId, message); + } catch (error) { + this.handlerError(tabId, error); } }; @@ -65,14 +73,21 @@ export class LogStore { * @param tabId */ loadMore = async (tabId: TabId) => { - if (!this.podLogs.get(tabId).length) return; - const oldLogs = this.podLogs.get(tabId); - const logs = await this.loadLogs(tabId, { - sinceTime: this.getLastSinceTime(tabId) - }); + if (!this.podLogs.get(tabId).length) { + return; + } - // Add newly received logs to bottom - this.podLogs.set(tabId, [...oldLogs, ...logs]); + try { + const oldLogs = this.podLogs.get(tabId); + const logs = await this.loadLogs(tabId, { + sinceTime: this.getLastSinceTime(tabId) + }); + + // Add newly received logs to bottom + this.podLogs.set(tabId, [...oldLogs, ...logs]); + } catch (error) { + this.handlerError(tabId, error); + } }; /** @@ -80,57 +95,51 @@ export class LogStore { * an API request * @param tabId * @param params request parameters described in IPodLogsQuery interface - * @returns {Promise} A fetch request promise + * @returns A fetch request promise */ - loadLogs = async (tabId: TabId, params: Partial) => { + @autobind() + async loadLogs(tabId: TabId, params: Partial): Promise { const data = logTabStore.getData(tabId); const { selectedContainer, previous } = data; const pod = new Pod(data.selectedPod); const namespace = pod.getNs(); const name = pod.getName(); - return podsApi.getLogs({ namespace, name }, { + const result = await podsApi.getLogs({ namespace, name }, { ...params, timestamps: true, // Always setting timestampt to separate old logs from new ones container: selectedContainer.name, previous - }).then(result => { - const logs = [...result.split("\n")]; // Transform them into array - - logs.pop(); // Remove last empty element - - return logs; }); - }; + + return result.trimEnd().split("\n"); + } /** * Converts logs into a string array - * @returns {number} Length of log lines + * @returns Length of log lines */ @computed - get lines() { - const id = dockStore.selectedTabId; - const logs = this.podLogs.get(id); - - return logs ? logs.length : 0; + get lines(): number { + return this.logs.length; } /** * Returns logs with timestamps for selected tab */ + @computed get logs() { const id = dockStore.selectedTabId; - if (!this.podLogs.has(id)) return []; - - return this.podLogs.get(id); + return this.podLogs.get(id) ?? []; } /** * Removes timestamps from each log line and returns changed logs * @returns Logs without timestamps */ + @computed get logsWithoutTimestamps() { return this.logs.map(item => this.removeTimestamps(item)); } diff --git a/src/renderer/item.store.ts b/src/renderer/item.store.ts index a9ac3179c9..4f3d9db0cc 100644 --- a/src/renderer/item.store.ts +++ b/src/renderer/item.store.ts @@ -39,9 +39,19 @@ export abstract class ItemStore { return this.items.findIndex(item => item.getId() === id); } + /** + * Return `items` sorted by the given ordering functions. If two elements of + * `items` are sorted to the same "index" then the next sorting function is used + * to determine where to place them relative to each other. Once the `sorting` + * functions have bee all exausted then the order is the order they were initially + * in (ie a stable sort). + * @param items the items to be sorted (default: the current items in this store) + * @param sorting list of functions to determine sort order (default: sorting by name) + * @param order whether to sort from least to greatest (`"asc"`) or vice-versa (`"desc"`) + */ @action - protected sortItems(items: T[] = this.items, sorting?: ((item: T) => any)[], order?: "asc" | "desc"): T[] { - return orderBy(items, sorting || this.defaultSorting, order); + protected sortItems(items: T[] = this.items, sorting: ((item: T) => any)[] = [this.defaultSorting], order?: "asc" | "desc"): T[] { + return orderBy(items, sorting, order); } protected async createItem(...args: any[]): Promise; diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 987112a25c..836a2bef52 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -1,17 +1,20 @@ import type { ClusterContext } from "./components/context"; import { action, computed, observable, reaction, when } from "mobx"; -import { autobind } from "./utils"; +import { autobind, noop, rejectPromiseBy } from "./utils"; import { KubeObject, KubeStatus } from "./api/kube-object"; import { IKubeWatchEvent } from "./api/kube-watch-api"; import { ItemStore } from "./item.store"; import { apiManager } from "./api/api-manager"; import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; import { KubeJsonApiData } from "./api/kube-json-api"; +import { Notifications } from "./components/notifications"; +import { AbortController } from "abort-controller"; export interface KubeObjectStoreLoadingParams { namespaces: string[]; api?: KubeApi; + reqInit?: RequestInit; } @autobind() @@ -21,9 +24,10 @@ export abstract class KubeObjectStore extends ItemSt abstract api: KubeApi; public readonly limit?: number; public readonly bufferSize: number = 50000; - private loadedNamespaces: string[] = []; + @observable private loadedNamespaces?: string[]; contextReady = when(() => Boolean(this.context)); + namespacesReady = when(() => Boolean(this.loadedNamespaces)); constructor() { super(); @@ -103,10 +107,10 @@ export abstract class KubeObjectStore extends ItemSt } } - protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise { + protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams): Promise { if (this.context?.cluster.isAllowedResource(api.kind)) { if (!api.isNamespaced) { - return api.list({}, this.query); + return api.list({ reqInit }, this.query); } const isLoadingAll = this.context.allNamespaces?.length > 1 @@ -116,13 +120,13 @@ export abstract class KubeObjectStore extends ItemSt if (isLoadingAll) { this.loadedNamespaces = []; - return api.list({}, this.query); + return api.list({ reqInit }, this.query); } else { this.loadedNamespaces = namespaces; return Promise // load resources per namespace - .all(namespaces.map(namespace => api.list({ namespace }))) - .then(items => items.flat()); + .all(namespaces.map(namespace => api.list({ namespace, reqInit }))) + .then(items => items.flat().filter(Boolean)); } } @@ -134,7 +138,7 @@ export abstract class KubeObjectStore extends ItemSt } @action - async loadAll(options: { namespaces?: string[], merge?: boolean } = {}): Promise { + async loadAll(options: { namespaces?: string[], merge?: boolean, reqInit?: RequestInit } = {}): Promise { await this.contextReady; this.isLoading = true; @@ -142,9 +146,10 @@ export abstract class KubeObjectStore extends ItemSt const { namespaces = this.context.allNamespaces, // load all namespaces by default merge = true, // merge loaded items or return as result + reqInit, } = options; - const items = await this.loadItems({ namespaces, api: this.api }); + const items = await this.loadItems({ namespaces, api: this.api, reqInit }); this.isLoaded = true; @@ -156,7 +161,10 @@ export abstract class KubeObjectStore extends ItemSt return items; } catch (error) { - console.error("Loading store items failed", { error, store: this }); + if (error.message) { + Notifications.error(error.message); + } + console.error("Loading store items failed", { error }); this.resetOnError(error); } finally { this.isLoading = false; @@ -272,17 +280,21 @@ export abstract class KubeObjectStore extends ItemSt subscribe(apis = this.getSubscribeApis()) { const abortController = new AbortController(); - const namespaces = [...this.loadedNamespaces]; - if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) { - apis.forEach(api => this.watchNamespace(api, "", abortController)); - } else { - apis.forEach(api => { - this.loadedNamespaces.forEach((namespace) => { - this.watchNamespace(api, namespace, abortController); - }); - }); - } + // This waits for + Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) + .then(() => { + if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { + apis.forEach(api => this.watchNamespace(api, "", abortController)); + } else { + apis.forEach(api => { + this.loadedNamespaces.forEach((namespace) => { + this.watchNamespace(api, namespace, abortController); + }); + }); + } + }) + .catch(noop); // ignore DOMExceptions return () => { abortController.abort(); @@ -291,48 +303,38 @@ export abstract class KubeObjectStore extends ItemSt private watchNamespace(api: KubeApi, namespace: string, abortController: AbortController) { let timedRetry: NodeJS.Timeout; + const watch = () => api.watch({ + namespace, + abortController, + callback + }); - abortController.signal.addEventListener("abort", () => clearTimeout(timedRetry)); + const { signal } = abortController; const callback = (data: IKubeWatchEvent, error: any) => { - if (!this.isLoaded || abortController.signal.aborted) return; + if (!this.isLoaded || error instanceof DOMException) return; if (error instanceof Response) { if (error.status === 404) { // api has gone, let's not retry return; } else { // not sure what to do, best to retry - if (timedRetry) clearTimeout(timedRetry); - timedRetry = setTimeout(() => { - api.watch({ - namespace, - abortController, - callback - }); - }, 5000); + clearTimeout(timedRetry); + timedRetry = setTimeout(watch, 5000); } } else if (error instanceof KubeStatus && error.code === 410) { - if (timedRetry) clearTimeout(timedRetry); + clearTimeout(timedRetry); // resourceVersion has gone, let's try to reload timedRetry = setTimeout(() => { - (namespace === "" ? this.loadAll({ merge: false }) : this.loadAll({namespaces: [namespace]})).then(() => { - api.watch({ - namespace, - abortController, - callback - }); - }); + ( + namespace + ? this.loadAll({ namespaces: [namespace], reqInit: { signal } }) + : this.loadAll({ merge: false, reqInit: { signal } }) + ).then(watch); }, 1000); - } else if(error) { // not sure what to do, best to retry - if (timedRetry) clearTimeout(timedRetry); - - timedRetry = setTimeout(() => { - api.watch({ - namespace, - abortController, - callback - }); - }, 5000); + } else if (error) { // not sure what to do, best to retry + clearTimeout(timedRetry); + timedRetry = setTimeout(watch, 5000); } if (data) { @@ -340,11 +342,8 @@ export abstract class KubeObjectStore extends ItemSt } }; - api.watch({ - namespace, - abortController, - callback: (data, error) => callback(data, error) - }); + signal.addEventListener("abort", () => clearTimeout(timedRetry)); + watch(); } @action diff --git a/src/renderer/utils/cancelableFetch.ts b/src/renderer/utils/cancelableFetch.ts deleted file mode 100644 index a4a197fe0d..0000000000 --- a/src/renderer/utils/cancelableFetch.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Allow to cancel request for window.fetch() - -export interface CancelablePromise extends Promise { - then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): CancelablePromise; - catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): CancelablePromise; - finally(onfinally?: (() => void) | undefined | null): CancelablePromise; - cancel(): void; -} - -interface WrappingFunction { - (result: Promise): CancelablePromise; - (result: T): T; -} - -export function cancelableFetch(reqInfo: RequestInfo, reqInit: RequestInit = {}) { - const abortController = new AbortController(); - const signal = abortController.signal; - const cancel = abortController.abort.bind(abortController); - const wrapResult: WrappingFunction = function (result: any) { - if (result instanceof Promise) { - const promise: CancelablePromise = result as any; - - promise.then = function (onfulfilled, onrejected) { - const data = Object.getPrototypeOf(this).then.call(this, onfulfilled, onrejected); - - return wrapResult(data); - }; - promise.cancel = cancel; - } - - return result; - }; - const req = fetch(reqInfo, { ...reqInit, signal }); - - return wrapResult(req); -}