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:
parent
b3176a6fc4
commit
8db9251718
@ -19,3 +19,4 @@ export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
export * from "./type-narrowing";
|
||||
export * from "./reject-promise";
|
||||
|
||||
13
src/common/utils/reject-promise.ts
Normal file
13
src/common/utils/reject-promise.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
228
src/renderer/api/__tests__/kube-object.test.ts
Normal file
228
src/renderer/api/__tests__/kube-object.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user