diff --git a/dashboard/client/api/__test__/parseAPI.test.ts b/dashboard/client/api/__test__/parseAPI.test.ts new file mode 100644 index 0000000000..82c0c59f75 --- /dev/null +++ b/dashboard/client/api/__test__/parseAPI.test.ts @@ -0,0 +1,58 @@ +import { KubeApi, IKubeApiLinkBase } from "../kube-api"; + +interface ParseAPITest { + url: string; + expected: IKubeApiLinkBase; +} + +const tests: ParseAPITest[] = [ + { + url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", + expected: { + apiBase: "/api/v1/pods", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + namespace: "kube-system", + resource: "pods", + name: "coredns-6955765f44-v8p27" + }, + }, + { + url: "/apis/stable.example.com/foo1/crontabs", + expected: { + apiBase: "/apis/stable.example.com/foo1/crontabs", + apiPrefix: "/apis", + apiGroup: "stable.example.com", + apiVersion: "foo1", + apiVersionWithGroup: "stable.example.com/foo1", + resource: "crontabs", + name: undefined, + namespace: undefined, + }, + }, + { + url: "/apis/cluster.k8s.io/v1alpha1/clusters", + expected: { + apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters", + apiPrefix: "/apis", + apiGroup: "cluster.k8s.io", + apiVersion: "v1alpha1", + apiVersionWithGroup: "cluster.k8s.io/v1alpha1", + resource: "clusters", + name: undefined, + namespace: undefined, + }, + }, +]; + +jest.mock('../kube-watch-api.ts', () => 'KubeWatchApi'); +describe("parseAPI unit tests", () => { + for (const i in tests) { + const { url: tUrl, expected:tExpect} = tests[i]; + test(`test #${parseInt(i)+1}`, () => { + expect(KubeApi.parseApi(tUrl)).toStrictEqual(tExpect); + }); + } +}); \ No newline at end of file diff --git a/dashboard/client/api/kube-api.ts b/dashboard/client/api/kube-api.ts index 9ce8d2be94..be8016c68c 100644 --- a/dashboard/client/api/kube-api.ts +++ b/dashboard/client/api/kube-api.ts @@ -7,6 +7,7 @@ import { IKubeObjectRef, KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } fro import { apiKube } from "./index"; import { kubeWatchApi } from "./kube-watch-api"; import { apiManager } from "./api-manager"; +import { split } from "../utils/arrays"; export interface IKubeApiOptions { kind: string; // resource type within api-group, e.g. "Namespace" @@ -32,20 +33,45 @@ export interface IKubeApiLinkRef { namespace?: string; } -export class KubeApi { - static matcher = /(\/apis?.*?)\/(?:(.*?)\/)?(v.*?)(?:\/namespaces\/(.+?))?\/([^\/]+)(?:\/([^\/?]+))?.*$/ +export interface IKubeApiLinkBase extends IKubeApiLinkRef { + apiBase: string; + apiGroup: string; + apiVersionWithGroup: string; +} - static parseApi(apiPath = "") { +export class KubeApi { + static parseApi(apiPath = ""): IKubeApiLinkBase { apiPath = new URL(apiPath, location.origin).pathname; - const [, apiPrefix, apiGroup = "", apiVersion, namespace, resource, name] = apiPath.match(KubeApi.matcher) || []; + const [, prefix, ...parts] = apiPath.split("/"); + const apiPrefix = `/${prefix}`; + + const [left, right, found] = split(parts, "namespaces"); + let apiGroup, apiVersion, namespace, resource, name; + + if (found) { + if (left.length == 0) { + throw new Error(`invalid apiPath: ${apiPath}`) + } + + apiVersion = left.pop(); + apiGroup = left.join("/"); + [namespace, resource, name] = right; + } else { + [apiGroup, apiVersion, resource] = left; + } const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/"); const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/"); + + if (!apiBase) { + throw new Error(`invalid apiPath: ${apiPath}`) + } + return { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, namespace, resource, name, - } + }; } static createLink(ref: IKubeApiLinkRef): string { @@ -55,7 +81,7 @@ export class KubeApi { namespace = `namespaces/${namespace}` } return [apiPrefix, apiVersion, namespace, resource, name] - .filter(v => !!v) + .filter(v => v) .join("/") } @@ -130,8 +156,9 @@ export class KubeApi { if (KubeObject.isJsonApiData(data)) { return new KubeObjectConstructor(data); } + // process items list response - else if (KubeObject.isJsonApiDataList(data)) { + if (KubeObject.isJsonApiDataList(data)) { const { apiVersion, items, metadata } = data; this.setResourceVersion(namespace, metadata.resourceVersion); this.setResourceVersion("", metadata.resourceVersion); @@ -141,10 +168,12 @@ export class KubeApi { ...item, })) } + // custom apis might return array for list response, e.g. users, groups, etc. - else if (Array.isArray(data)) { + if (Array.isArray(data)) { return data.map(data => new KubeObjectConstructor(data)); } + return data; } @@ -162,16 +191,19 @@ export class KubeApi { async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { 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); + + return this.request + .post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }) + .then(this.parseResponse); } async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { diff --git a/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx index 8fbc47cd78..d421f4692e 100644 --- a/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx +++ b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx @@ -96,7 +96,7 @@ export class DeploymentScaleDialog extends Component { Desired number of replicas: {desiredReplicas}
- +
{warning && diff --git a/dashboard/client/components/chart/bar-chart.tsx b/dashboard/client/components/chart/bar-chart.tsx index 1bc5bd02ff..3e0792b585 100644 --- a/dashboard/client/components/chart/bar-chart.tsx +++ b/dashboard/client/components/chart/bar-chart.tsx @@ -150,7 +150,7 @@ export function BarChart(props: Props) { } }; const options = merge(barOptions, customOptions); - if (!chartData.datasets.length) { + if (chartData.datasets.length == 0) { return } return ( @@ -170,9 +170,17 @@ export const memoryOptions: ChartOptions = { scales: { yAxes: [{ ticks: { - callback: value => { - if (!value) return 0; - return parseFloat(value) < 1 ? value.toFixed(3) : bytesToUnits(parseInt(value)); + callback: (value: number | string): string => { + if (typeof value == "string") { + const float = parseFloat(value); + if (float < 1) { + return float.toFixed(3); + } + + return bytesToUnits(parseInt(value)); + } + + return `${value}`; }, stepSize: 1 } @@ -194,11 +202,12 @@ export const cpuOptions: ChartOptions = { scales: { yAxes: [{ ticks: { - callback: value => { - if (value == 0) return 0; - if (value < 10) return value.toFixed(3); - if (value < 100) return value.toFixed(2); - return value.toFixed(1); + callback: (value: number | string): string => { + const float = parseFloat(`${value}`); + if (float == 0) return "0"; + if (float < 10) return float.toFixed(3); + if (float < 100) return float.toFixed(2); + return float.toFixed(1); } } }] diff --git a/dashboard/client/components/chart/chart.tsx b/dashboard/client/components/chart/chart.tsx index 6678e94f38..ec8037bb3d 100644 --- a/dashboard/client/components/chart/chart.tsx +++ b/dashboard/client/components/chart/chart.tsx @@ -19,10 +19,7 @@ export interface ChartDataSet extends ChartDataSets { } export interface ChartProps { - data: { - labels?: Array; - datasets?: ChartDataSet[]; - }; + data: ChartData; width?: number | string; height?: number | string; options?: ChartOptions; // Passed to ChartJS instance diff --git a/dashboard/client/components/chart/pie-chart.tsx b/dashboard/client/components/chart/pie-chart.tsx index 7187b1a34d..8960bf67f5 100644 --- a/dashboard/client/components/chart/pie-chart.tsx +++ b/dashboard/client/components/chart/pie-chart.tsx @@ -7,7 +7,6 @@ import { cssNames } from "../../utils"; import { themeStore } from "../../theme.store"; interface Props extends ChartProps { - data: ChartData; title?: string; } diff --git a/dashboard/client/components/select/select.tsx b/dashboard/client/components/select/select.tsx index 1603864bc5..e2cd986e69 100644 --- a/dashboard/client/components/select/select.tsx +++ b/dashboard/client/components/select/select.tsx @@ -10,7 +10,7 @@ import ReactSelect, { components as ReactSelectComponents } from "react-select" import { Props as ReactSelectProps } from "react-select/base" import Creatable, { CreatableProps } from "react-select/creatable" import { StylesConfig } from "react-select/src/styles" -import { ActionMeta } from "react-select/src/types" +import { ActionMeta, OptionTypeBase } from "react-select/src/types" import { themeStore } from "../../theme.store"; export { ReactSelectComponents } @@ -31,7 +31,7 @@ export interface SelectProps extends ReactSelectProps, CreatableProp menuClass?: string; isCreatable?: boolean; autoConvertOptions?: boolean; // to internal format (i.e. {value: T, label: string}[]), not working with groups - onChange?(option: T, meta?: ActionMeta): void; + onChange?(option: T, meta?: ActionMeta): void; } @observer @@ -76,7 +76,7 @@ export class Select extends React.Component { } @autobind() - onChange(value: SelectOption, meta: ActionMeta) { + onChange(value: SelectOption, meta: ActionMeta) { if (this.props.onChange) { this.props.onChange(value, meta); } diff --git a/dashboard/client/utils/__tests__/arrays.test.ts b/dashboard/client/utils/__tests__/arrays.test.ts new file mode 100644 index 0000000000..ab3b91629a --- /dev/null +++ b/dashboard/client/utils/__tests__/arrays.test.ts @@ -0,0 +1,31 @@ +import { split } from "../arrays"; + +describe("split array on element tests", () => { + test("empty array", () => { + expect(split([], 10)).toStrictEqual([[], [], false]); + }); + + test("one element, not in array", () => { + expect(split([1], 10)).toStrictEqual([[1], [], false]); + }); + + test("ten elements, not in array", () => { + expect(split([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 10)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [], false]); + }); + + test("one elements, in array", () => { + expect(split([1], 1)).toStrictEqual([[], [], true]); + }); + + test("ten elements, in front array", () => { + expect(split([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0)).toStrictEqual([[], [1, 2, 3, 4, 5, 6, 7, 8, 9], true]); + }); + + test("ten elements, in middle array", () => { + expect(split([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 4)).toStrictEqual([[0, 1, 2, 3], [5, 6, 7, 8, 9], true]); + }); + + test("ten elements, in end array", () => { + expect(split([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]); + }); +}); \ No newline at end of file diff --git a/dashboard/client/utils/arrays.ts b/dashboard/client/utils/arrays.ts new file mode 100644 index 0000000000..bfc3900f0d --- /dev/null +++ b/dashboard/client/utils/arrays.ts @@ -0,0 +1,19 @@ +/** + * This function splits an array into two sub arrays on the first instance of + * element (from the left). If the array does not contain the element. The + * return value is defined to be `[array, [], false]`. If the element is in + * the array then the return value is `[left, right, true]` where `left` is + * the elements of `array` from `[0, index)` and `right` is `(index, length)` + * @param array the full array to split into two sub-arrays + * @param element the element in the middle of the array + * @returns the left and right sub-arrays which when conjoined with `element` + * is the same as `array`, and `true` + */ +export function split(array: Array, element: T): [Array, Array, boolean] { + const index = array.indexOf(element); + if (index < 0) { + return [array, [], false]; + } + + return [array.slice(0, index), array.slice(index+1, array.length), true] +} \ No newline at end of file diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index e4c7d67d25..4db70bf45d 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -18,7 +18,7 @@ export class PrometheusLens implements PrometheusProvider { port: service.spec.ports[0].port } } catch(error) { - logger.warn(`PrometheusLens: failed to list services: ${error.toString()}`) + logger.warn(`PrometheusLens: failed to list services: ${error.response.body.message}`) } }