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 "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./type-narrowing"; 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) * Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested * @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)` // this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key); 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)); 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); this.respondJson(response, result);
} catch (error) { } catch (error) {
logger.debug(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); this.respondJson(response, result);
} catch(error) { } catch(error) {
logger.debug(error); logger.debug(error);
this.respondText(response, error); this.respondText(response, error, 422);
} }
} }

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

View File

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

View File

@ -1,11 +1,11 @@
import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { JsonApi, JsonApiErrorParsed } from "./json-api";
import { KubeJsonApi } from "./kube-json-api"; import { KubeJsonApi } from "./kube-json-api";
import { Notifications } from "../components/notifications"; 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({ export const apiBase = new JsonApi({
apiBase: apiPrefix, apiBase: apiPrefix,
debug: isDevelopment, debug: isDevelopment || isDebugging,
}); });
export const apiKube = new KubeJsonApi({ export const apiKube = new KubeJsonApi({
apiBase: apiKubePrefix, apiBase: apiKubePrefix,

View File

@ -2,8 +2,8 @@
import { stringify } from "querystring"; import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter"; import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
export interface JsonApiData { export interface JsonApiData {
} }
@ -72,13 +72,11 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
} }
const infoLog: JsonApiLog = { this.writeLog({
method: reqInit.method.toUpperCase(), method: reqInit.method.toUpperCase(),
reqUrl: reqPath, reqUrl: reqPath,
reqInit, reqInit,
}; });
this.writeLog({ ...infoLog });
return fetch(reqUrl, reqInit); 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" }); 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; let reqUrl = this.config.apiBase + path;
const reqInit: RequestInit = { ...this.reqInit, ...init }; const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P; const { data, query } = params || {} as P;
@ -119,48 +117,53 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
reqInit, reqInit,
}; };
return cancelableFetch(reqUrl, reqInit).then(res => { const res = await fetch(reqUrl, reqInit);
return this.parseResponse<D>(res, infoLog);
}); 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; const { status } = res;
return res.text().then(text => { const text = await res.text();
let data; let data;
try { try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) { } catch (e) {
data = text; data = text;
} }
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
this.onData.emit(data, res); console.log(data, res);
this.writeLog({ ...log, data }); this.onData.emit(data, res);
this.writeLog({ ...log, data });
return 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));
this.onError.emit(error, res); if (log.method === "GET" && res.status === 403) {
this.writeLog({ ...log, error }); this.writeLog({ ...log, error: data });
throw error; 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[] { protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") { if (typeof error === "string") {
return [error]; return [error];
} }
else if (Array.isArray(error.errors)) {
if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title); return error.errors.map(error => error.title);
} }
else if (error.message) {
if (error.message) {
return [error.message]; return [error.message];
} }

View File

@ -7,11 +7,11 @@ import logger from "../../main/logger";
import { apiManager } from "./api-manager"; import { apiManager } from "./api-manager";
import { apiKube } from "./index"; import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object";
import byline from "byline"; import byline from "byline";
import { IKubeWatchEvent } from "./kube-watch-api"; import { IKubeWatchEvent } from "./kube-watch-api";
import { ReadableWebToNodeStream } from "../utils/readableStream"; import { ReadableWebToNodeStream } from "../utils/readableStream";
import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api";
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
/** /**
@ -34,6 +34,11 @@ export interface IKubeApiOptions<T extends KubeObject> {
checkPreferredVersion?: boolean; checkPreferredVersion?: boolean;
} }
export interface KubeApiListOptions {
namespace?: string;
reqInit?: RequestInit;
}
export interface IKubeApiQueryParams { export interface IKubeApiQueryParams {
watch?: boolean | number; watch?: boolean | number;
resourceVersion?: string; resourceVersion?: string;
@ -243,7 +248,7 @@ export class KubeApi<T extends KubeObject = any> {
return this.resourceVersions.get(namespace); return this.resourceVersions.get(namespace);
} }
async refreshResourceVersion(params?: { namespace: string }) { async refreshResourceVersion(params?: KubeApiListOptions) {
return this.list(params, { limit: 1 }); return this.list(params, { limit: 1 });
} }
@ -271,20 +276,12 @@ export class KubeApi<T extends KubeObject = any> {
return query; return query;
} }
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { protected parseResponse(data: unknown, namespace?: string): T | T[] | null {
if (!data) return; if (!data) return;
const KubeObjectConstructor = this.objectConstructor; const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) { // process items list response, check before single item since there is overlap
const object = new KubeObjectConstructor(data); if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) {
ensureObjectSelfLink(this, object);
return object;
}
// process items list response
if (KubeObject.isJsonApiDataList(data)) {
const { apiVersion, items, metadata } = data; const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion); 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. // custom apis might return array for list response, e.g. users, groups, etc.
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(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(); await this.checkPreferredVersion();
return this.request const res = await this.request.get(this.getUrl({ namespace }), { query }, reqInit);
.get(this.getUrl({ namespace }), { query }) const parsed = this.parseResponse(res, namespace);
.then(data => this.parseResponse(data, 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(); await this.checkPreferredVersion();
return this.request const res = await this.request.get(this.getUrl({ namespace, name }), { query });
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse); 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(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace }); const apiUrl = this.getUrl({ namespace });
return this.request const res = await this.request.post(apiUrl, {
.post(apiUrl, { data: merge({
data: merge({ kind: this.kind,
kind: this.kind, apiVersion: this.apiVersionWithGroup,
apiVersion: this.apiVersionWithGroup, metadata: {
metadata: { name,
name, namespace
namespace }
} }, data)
}, data) });
}) const parsed = this.parseResponse(res);
.then(this.parseResponse);
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(); await this.checkPreferredVersion();
const apiUrl = this.getUrl({ namespace, name }); const apiUrl = this.getUrl({ namespace, name });
return this.request const res = await this.request.put(apiUrl, { data });
.put(apiUrl, { data }) const parsed = this.parseResponse(res);
.then(this.parseResponse);
if (Array.isArray(parsed)) {
return null;
}
return parsed;
} }
async delete({ name = "", namespace = "default" }) { async delete({ name = "", namespace = "default" }) {
@ -370,78 +397,64 @@ export class KubeApi<T extends KubeObject = any> {
} }
watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
if (!opts.abortController) {
opts.abortController = new AbortController();
}
let errorReceived = false; let errorReceived = false;
let timedRetry: NodeJS.Timeout; 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); clearTimeout(timedRetry);
}); });
const watchUrl = this.getWatchUrl(namespace); const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, { const responsePromise = this.request.getResponse(watchUrl, null, { signal });
signal: abortController.signal
});
responsePromise.then((response) => { responsePromise
if (!response.ok && !abortController.signal.aborted) { .then(response => {
callback?.(null, response); if (!response.ok) {
return 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
} }
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); return abort;
}).catch((error) => {
callback?.(null, error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
} }
protected modifyWatchEvent(event: IKubeWatchEvent) { protected modifyWatchEvent(event: IKubeWatchEvent) {

View File

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

View File

@ -1,12 +1,13 @@
// Base class for all kubernetes objects // Base class for all kubernetes objects
import moment from "moment"; 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 { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store"; import { ItemObject } from "../item.store";
import { apiKube } from "./index"; import { apiKube } from "./index";
import { JsonApiParams } from "./json-api"; import { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.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) & { export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string; kind?: string;
@ -78,15 +79,59 @@ export class KubeObject implements ItemObject {
return !item.metadata.name.startsWith("system:"); return !item.metadata.name.startsWith("system:");
} }
static isJsonApiData(object: any): object is KubeJsonApiData { static isJsonApiData(object: unknown): object is KubeJsonApiData {
return !object.items && object.metadata; return (
isObject(object)
&& hasTypedProperty(object, "kind", isString)
&& hasTypedProperty(object, "apiVersion", isString)
&& hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata)
);
} }
static isJsonApiDataList(object: any): object is KubeJsonApiDataList { static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata {
return object.items && object.metadata; 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 []; if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`); 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 { autobind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer"; import { MarkdownViewer } from "../markdown-viewer";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { CancelablePromise } from "../../utils/cancelableFetch";
import { Button } from "../button"; import { Button } from "../button";
import { Select, SelectOption } from "../select"; import { Select, SelectOption } from "../select";
import { createInstallChartTab } from "../dock/install-chart.store"; import { createInstallChartTab } from "../dock/install-chart.store";
@ -26,10 +25,11 @@ export class HelmChartDetails extends Component<Props> {
@observable readme: string = null; @observable readme: string = null;
@observable error: string = null; @observable error: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; private chartPromise: Promise<{ readme: string; versions: HelmChart[] }>;
private abortController?: AbortController;
componentWillUnmount() { componentWillUnmount() {
this.chartPromise?.cancel(); this.abortController?.abort();
} }
chartUpdater = autorun(() => { chartUpdater = autorun(() => {
@ -52,9 +52,12 @@ export class HelmChartDetails extends Component<Props> {
this.readme = null; this.readme = null;
try { 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 { 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; this.readme = readme;
} catch (error) { } catch (error) {

View File

@ -6,6 +6,7 @@ import { ItemStore } from "../../item.store";
import { Secret } from "../../api/endpoints"; import { Secret } from "../../api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
import { Notifications } from "../notifications";
@autobind() @autobind()
export class ReleaseStore extends ItemStore<HelmRelease> { export class ReleaseStore extends ItemStore<HelmRelease> {
@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
this.items.replace(this.sortItems(items)); this.items.replace(this.sortItems(items));
this.isLoaded = true; this.isLoaded = true;
} catch (error) { } 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 { } finally {
this.isLoading = false; this.isLoading = false;
} }

View File

@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component<Props> {
this.namespace = ""; this.namespace = "";
}; };
close = () => {
AddNamespaceDialog.close();
};
addNamespace = async () => { addNamespace = async () => {
const { namespace } = this; const { namespace } = this;
const { onSuccess, onError } = this.props; const { onSuccess, onError } = this.props;
try { try {
await namespaceStore.create({ name: namespace }).then(onSuccess); const created = await namespaceStore.create({ name: namespace });
this.close();
onSuccess?.(created);
AddNamespaceDialog.close();
} catch (err) { } catch (err) {
Notifications.error(err); Notifications.error(err);
onError && onError(err); onError?.(err);
} }
}; };
@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component<Props> {
className="AddNamespaceDialog" className="AddNamespaceDialog"
isOpen={AddNamespaceDialog.isOpen} isOpen={AddNamespaceDialog.isOpen}
onOpen={this.reset} onOpen={this.reset}
close={this.close} close={AddNamespaceDialog.close}
> >
<Wizard header={header} done={this.close}> <Wizard header={header} done={AddNamespaceDialog.close}>
<WizardStep <WizardStep
contentClass="flex gaps column" contentClass="flex gaps column"
nextLabel="Create" nextLabel="Create"

View File

@ -6,6 +6,7 @@ import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Pod, Secret, secretsApi } from "../../api/endpoints"; import { Pod, Secret, secretsApi } from "../../api/endpoints";
import { getDetailsUrl } from "../kube-object"; import { getDetailsUrl } from "../kube-object";
import { autobind } from "../../utils";
interface Props { interface Props {
pod: Pod; pod: Pod;
@ -13,32 +14,46 @@ interface Props {
@observer @observer
export class PodDetailsSecrets extends Component<Props> { export class PodDetailsSecrets extends Component<Props> {
@observable secrets: Secret[] = []; // either secrets or just their names
@observable secrets: (Secret | string)[] = [];
@disposeOnUnmount @disposeOnUnmount
secretsLoader = autorun(async () => { secretsLoader = autorun(() => {
const { pod } = this.props; const { pod } = this.props;
this.secrets = await Promise.all( Promise.all(
pod.getSecrets().map(secretName => secretsApi.get({ pod.getSecrets()
name: secretName, .map(secretName => (
namespace: pod.getNs(), 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() { render() {
return ( return (
<div className="PodDetailsSecrets"> <div className="PodDetailsSecrets">
{ {this.secrets.map(this.renderSecret)}
this.secrets.map(secret => {
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
);
})
}
</div> </div>
); );
} }

View File

@ -32,6 +32,20 @@ export class LogStore {
}, { delay: 500 }); }, { 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 * Function prepares tailLines param for passing to API request
* Each time it increasing it's number, caused to fetch more logs. * Each time it increasing it's number, caused to fetch more logs.
@ -47,14 +61,8 @@ export class LogStore {
this.refresher.start(); this.refresher.start();
this.podLogs.set(tabId, logs); this.podLogs.set(tabId, logs);
} catch ({error}) { } catch (error) {
const message = [ this.handlerError(tabId, error);
`Failed to load logs: ${error.message}`,
`Reason: ${error.reason} (${error.code})`
];
this.refresher.stop();
this.podLogs.set(tabId, message);
} }
}; };
@ -65,14 +73,21 @@ export class LogStore {
* @param tabId * @param tabId
*/ */
loadMore = async (tabId: TabId) => { loadMore = async (tabId: TabId) => {
if (!this.podLogs.get(tabId).length) return; if (!this.podLogs.get(tabId).length) {
const oldLogs = this.podLogs.get(tabId); return;
const logs = await this.loadLogs(tabId, { }
sinceTime: this.getLastSinceTime(tabId)
});
// Add newly received logs to bottom try {
this.podLogs.set(tabId, [...oldLogs, ...logs]); 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 * an API request
* @param tabId * @param tabId
* @param params request parameters described in IPodLogsQuery interface * @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 data = logTabStore.getData(tabId);
const { selectedContainer, previous } = data; const { selectedContainer, previous } = data;
const pod = new Pod(data.selectedPod); const pod = new Pod(data.selectedPod);
const namespace = pod.getNs(); const namespace = pod.getNs();
const name = pod.getName(); const name = pod.getName();
return podsApi.getLogs({ namespace, name }, { const result = await podsApi.getLogs({ namespace, name }, {
...params, ...params,
timestamps: true, // Always setting timestampt to separate old logs from new ones timestamps: true, // Always setting timestampt to separate old logs from new ones
container: selectedContainer.name, container: selectedContainer.name,
previous 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 * Converts logs into a string array
* @returns {number} Length of log lines * @returns Length of log lines
*/ */
@computed @computed
get lines() { get lines(): number {
const id = dockStore.selectedTabId; return this.logs.length;
const logs = this.podLogs.get(id);
return logs ? logs.length : 0;
} }
/** /**
* Returns logs with timestamps for selected tab * Returns logs with timestamps for selected tab
*/ */
@computed
get logs() { get logs() {
const id = dockStore.selectedTabId; 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 * Removes timestamps from each log line and returns changed logs
* @returns Logs without timestamps * @returns Logs without timestamps
*/ */
@computed
get logsWithoutTimestamps() { get logsWithoutTimestamps() {
return this.logs.map(item => this.removeTimestamps(item)); 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 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 @action
protected sortItems(items: T[] = this.items, sorting?: ((item: T) => any)[], order?: "asc" | "desc"): T[] { protected sortItems(items: T[] = this.items, sorting: ((item: T) => any)[] = [this.defaultSorting], order?: "asc" | "desc"): T[] {
return orderBy(items, sorting || this.defaultSorting, order); return orderBy(items, sorting, order);
} }
protected async createItem(...args: any[]): Promise<any>; protected async createItem(...args: any[]): Promise<any>;

View File

@ -1,17 +1,20 @@
import type { ClusterContext } from "./components/context"; import type { ClusterContext } from "./components/context";
import { action, computed, observable, reaction, when } from "mobx"; 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 { KubeObject, KubeStatus } from "./api/kube-object";
import { IKubeWatchEvent } from "./api/kube-watch-api"; import { IKubeWatchEvent } from "./api/kube-watch-api";
import { ItemStore } from "./item.store"; import { ItemStore } from "./item.store";
import { apiManager } from "./api/api-manager"; import { apiManager } from "./api/api-manager";
import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api";
import { KubeJsonApiData } from "./api/kube-json-api"; import { KubeJsonApiData } from "./api/kube-json-api";
import { Notifications } from "./components/notifications";
import { AbortController } from "abort-controller";
export interface KubeObjectStoreLoadingParams { export interface KubeObjectStoreLoadingParams {
namespaces: string[]; namespaces: string[];
api?: KubeApi; api?: KubeApi;
reqInit?: RequestInit;
} }
@autobind() @autobind()
@ -21,9 +24,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
abstract api: KubeApi<T>; abstract api: KubeApi<T>;
public readonly limit?: number; public readonly limit?: number;
public readonly bufferSize: number = 50000; public readonly bufferSize: number = 50000;
private loadedNamespaces: string[] = []; @observable private loadedNamespaces?: string[];
contextReady = when(() => Boolean(this.context)); contextReady = when(() => Boolean(this.context));
namespacesReady = when(() => Boolean(this.loadedNamespaces));
constructor() { constructor() {
super(); 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 (this.context?.cluster.isAllowedResource(api.kind)) {
if (!api.isNamespaced) { if (!api.isNamespaced) {
return api.list({}, this.query); return api.list({ reqInit }, this.query);
} }
const isLoadingAll = this.context.allNamespaces?.length > 1 const isLoadingAll = this.context.allNamespaces?.length > 1
@ -116,13 +120,13 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
if (isLoadingAll) { if (isLoadingAll) {
this.loadedNamespaces = []; this.loadedNamespaces = [];
return api.list({}, this.query); return api.list({ reqInit }, this.query);
} else { } else {
this.loadedNamespaces = namespaces; this.loadedNamespaces = namespaces;
return Promise // load resources per namespace return Promise // load resources per namespace
.all(namespaces.map(namespace => api.list({ namespace }))) .all(namespaces.map(namespace => api.list({ namespace, reqInit })))
.then(items => items.flat()); .then(items => items.flat().filter(Boolean));
} }
} }
@ -134,7 +138,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
@action @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; await this.contextReady;
this.isLoading = true; this.isLoading = true;
@ -142,9 +146,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
const { const {
namespaces = this.context.allNamespaces, // load all namespaces by default namespaces = this.context.allNamespaces, // load all namespaces by default
merge = true, // merge loaded items or return as result merge = true, // merge loaded items or return as result
reqInit,
} = options; } = options;
const items = await this.loadItems({ namespaces, api: this.api }); const items = await this.loadItems({ namespaces, api: this.api, reqInit });
this.isLoaded = true; this.isLoaded = true;
@ -156,7 +161,10 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
return items; return items;
} catch (error) { } 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); this.resetOnError(error);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
@ -272,17 +280,21 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
subscribe(apis = this.getSubscribeApis()) { subscribe(apis = this.getSubscribeApis()) {
const abortController = new AbortController(); const abortController = new AbortController();
const namespaces = [...this.loadedNamespaces];
if (this.context.cluster?.isGlobalWatchEnabled && namespaces.length === 0) { // This waits for
apis.forEach(api => this.watchNamespace(api, "", abortController)); Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])])
} else { .then(() => {
apis.forEach(api => { if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
this.loadedNamespaces.forEach((namespace) => { apis.forEach(api => this.watchNamespace(api, "", abortController));
this.watchNamespace(api, namespace, abortController); } else {
}); apis.forEach(api => {
}); this.loadedNamespaces.forEach((namespace) => {
} this.watchNamespace(api, namespace, abortController);
});
});
}
})
.catch(noop); // ignore DOMExceptions
return () => { return () => {
abortController.abort(); 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) { private watchNamespace(api: KubeApi<T>, namespace: string, abortController: AbortController) {
let timedRetry: NodeJS.Timeout; 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) => { 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 instanceof Response) {
if (error.status === 404) { if (error.status === 404) {
// api has gone, let's not retry // api has gone, let's not retry
return; return;
} else { // not sure what to do, best to retry } else { // not sure what to do, best to retry
if (timedRetry) clearTimeout(timedRetry); clearTimeout(timedRetry);
timedRetry = setTimeout(() => { timedRetry = setTimeout(watch, 5000);
api.watch({
namespace,
abortController,
callback
});
}, 5000);
} }
} else if (error instanceof KubeStatus && error.code === 410) { } else if (error instanceof KubeStatus && error.code === 410) {
if (timedRetry) clearTimeout(timedRetry); clearTimeout(timedRetry);
// resourceVersion has gone, let's try to reload // resourceVersion has gone, let's try to reload
timedRetry = setTimeout(() => { timedRetry = setTimeout(() => {
(namespace === "" ? this.loadAll({ merge: false }) : this.loadAll({namespaces: [namespace]})).then(() => { (
api.watch({ namespace
namespace, ? this.loadAll({ namespaces: [namespace], reqInit: { signal } })
abortController, : this.loadAll({ merge: false, reqInit: { signal } })
callback ).then(watch);
});
});
}, 1000); }, 1000);
} else if(error) { // not sure what to do, best to retry } else if (error) { // not sure what to do, best to retry
if (timedRetry) clearTimeout(timedRetry); clearTimeout(timedRetry);
timedRetry = setTimeout(watch, 5000);
timedRetry = setTimeout(() => {
api.watch({
namespace,
abortController,
callback
});
}, 5000);
} }
if (data) { if (data) {
@ -340,11 +342,8 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
}; };
api.watch({ signal.addEventListener("abort", () => clearTimeout(timedRetry));
namespace, watch();
abortController,
callback: (data, error) => callback(data, error)
});
} }
@action @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);
}