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

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<T> 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 <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-01-21 11:26:01 -05:00
parent b3176a6fc4
commit 8db9251718
21 changed files with 723 additions and 338 deletions

View File

@ -19,3 +19,4 @@ export * from "./downloadFile";
export * from "./escapeRegExp";
export * from "./tar";
export * from "./type-narrowing";
export * from "./reject-promise";

View File

@ -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<void> {
return new Promise((_, reject) => {
signal.addEventListener("abort", reject);
});
}

View File

@ -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<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
export function hasOwnProperty<S extends object, K extends PropertyKey>(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<V extends object, K extends PropertyKey>(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<S extends object, K extends PropertyKey>(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<S extends object, K extends PropertyKey, V>(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<S extends object, K extends PropertyKey, V>(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<T, V>` 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<T extends PropertyKey, V>(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record<T, V> {
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<T>(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<FnArgs extends any[], T>(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T {
return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs);
}

View File

@ -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);
}
}

View File

@ -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");

View File

@ -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<KubeJsonApiData> (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);
});
});
});

View File

@ -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<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`)
.get<IHelmChartDetails>(`${path}?${stringify({ version: readmeVersion })}`, undefined, reqInit)
.then(data => {
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;

View File

@ -69,24 +69,23 @@ const endpoint = compile(`/v2/releases/:namespace?/:name?`) as (
) => string;
export const helmReleasesApi = {
list(namespace?: string) {
return apiBase
.get<HelmRelease[]>(endpoint({ namespace }))
.then(releases => releases.map(HelmRelease.create));
async list(namespace?: string) {
const releases = await apiBase.get<HelmRelease[]>(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<IReleaseRawDetails>(path).then(details => {
const items: KubeObject[] = JSON.parse(details.resources).items;
const resources = items.map(item => KubeObject.create(item));
const details = await apiBase.get<IReleaseRawDetails>(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<IReleaseUpdateDetails> {

View File

@ -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,

View File

@ -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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
protected async request<D>(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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
return this.parseResponse<D>(res, infoLog);
});
const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog);
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
protected async parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
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];
}

View File

@ -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<T extends KubeObject> {
/**
@ -34,6 +34,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean;
}
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
@ -243,7 +248,7 @@ export class KubeApi<T extends KubeObject = any> {
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<T extends KubeObject = any> {
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<T extends KubeObject = any> {
});
}
// 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<T[]> {
async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
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<T> {
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T | null> {
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<T>): Promise<T> {
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
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<T>): Promise<T> {
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
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<T extends KubeObject = any> {
}
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) {

View File

@ -1,34 +1,38 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiListMetadata {
resourceVersion: string;
selfLink?: string;
}
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
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 {

View File

@ -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<T extends KubeObject = any> = (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<KubeJsonApiData> {
return (
isObject(object)
&& hasOptionalProperty(object, "kind", isString)
&& hasOptionalProperty(object, "apiVersion", isString)
&& hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
}
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> {
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}`);

View File

@ -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<Props> {
@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<Props> {
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) {

View File

@ -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<HelmRelease> {
@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
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;
}

View File

@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component<Props> {
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<Props> {
className="AddNamespaceDialog"
isOpen={AddNamespaceDialog.isOpen}
onOpen={this.reset}
close={this.close}
close={AddNamespaceDialog.close}
>
<Wizard header={header} done={this.close}>
<Wizard header={header} done={AddNamespaceDialog.close}>
<WizardStep
contentClass="flex gaps column"
nextLabel="Create"

View File

@ -6,6 +6,7 @@ import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Pod, Secret, secretsApi } from "../../api/endpoints";
import { getDetailsUrl } from "../kube-object";
import { autobind } from "../../utils";
interface Props {
pod: Pod;
@ -13,32 +14,46 @@ interface Props {
@observer
export class PodDetailsSecrets extends Component<Props> {
@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 <React.Fragment key={secret}>{secret}</React.Fragment>;
}
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
);
}
render() {
return (
<div className="PodDetailsSecrets">
{
this.secrets.map(secret => {
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
);
})
}
{this.secrets.map(this.renderSecret)}
</div>
);
}

View File

@ -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<IPodLogsQuery>) => {
@autobind()
async loadLogs(tabId: TabId, params: Partial<IPodLogsQuery>): Promise<string[]> {
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));
}

View File

@ -39,9 +39,19 @@ export abstract class ItemStore<T extends ItemObject = ItemObject> {
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<any>;

View File

@ -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<T extends KubeObject = any> extends ItemSt
abstract api: KubeApi<T>;
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<T extends KubeObject = any> extends ItemSt
}
}
protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise<T[]> {
protected async loadItems({ namespaces, api, reqInit }: KubeObjectStoreLoadingParams): Promise<T[]> {
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<T extends KubeObject = any> 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<T extends KubeObject = any> extends ItemSt
}
@action
async loadAll(options: { namespaces?: string[], merge?: boolean } = {}): Promise<void | T[]> {
async loadAll(options: { namespaces?: string[], merge?: boolean, reqInit?: RequestInit } = {}): Promise<void | T[]> {
await this.contextReady;
this.isLoading = true;
@ -142,9 +146,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> 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<T extends KubeObject = any> 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<T extends KubeObject = any> 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<T extends KubeObject = any> extends ItemSt
private watchNamespace(api: KubeApi<T>, 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<T extends KubeObject = any> extends ItemSt
}
};
api.watch({
namespace,
abortController,
callback: (data, error) => callback(data, error)
});
signal.addEventListener("abort", () => clearTimeout(timedRetry));
watch();
}
@action

View File

@ -1,36 +0,0 @@
// Allow to cancel request for window.fetch()
export interface CancelablePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): CancelablePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): CancelablePromise<T | TResult>;
finally(onfinally?: (() => void) | undefined | null): CancelablePromise<T>;
cancel(): void;
}
interface WrappingFunction {
<T>(result: Promise<T>): CancelablePromise<T>;
<T>(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<any> = 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);
}