Cherry-pick from 4.2.3 (#2628)

* Fix: logs data disapearing causing crashes (#2566)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor helm-chart.api and improve kube validation and error handling (#2265)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix: HPA's not sortable by age (#2565)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Conditionally render status icon for kube meta (#2298)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix custom resource loading spinner appears above extensions' cluster menus (#2344)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Lens should point to the release docs (#2268)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Refactor the Extensions settings page (#2221)

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* try and get jest to not core dump

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2021-04-27 08:11:50 +03:00
1 parent 7fde8125eb
commit 1f854d0a0f
42 files changed
+1777 -1067

No files matched your search

@@ -1,14 +1,13 @@
import "./helm-chart-details.scss";
import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, HelmChart } from "../../api/endpoints/helm-charts.api";
import { observable, autorun } from "mobx";
import { observer } from "mobx-react";
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,35 +25,37 @@ export class HelmChartDetails extends Component<Props> {
@observable readme: string = null;
@observable error: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
private abortController?: AbortController;
componentWillUnmount() {
this.chartPromise?.cancel();
this.abortController?.abort();
}
chartUpdater = autorun(() => {
this.selectedChart = null;
const { chart: { name, repo, version } } = this.props;
helmChartsApi.get(repo, name, version).then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
},
error => {
this.error = error;
});
getChartDetails(repo, name, { version })
.then(result => {
this.readme = result.readme;
this.chartVersions = result.versions;
this.selectedChart = result.versions[0];
})
.catch(error => {
this.error = error;
});
});
@autobind()
async onVersionChange({ value: version }: SelectOption) {
async onVersionChange({ value: version }: SelectOption<string>) {
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.readme = null;
try {
this.chartPromise?.cancel();
this.abortController?.abort();
this.abortController = new AbortController();
const { chart: { name, repo } } = this.props;
const { readme } = await (this.chartPromise = helmChartsApi.get(repo, name, version));
const { readme } = await getChartDetails(repo, name, { version, reqInit: { signal: this.abortController.signal }});
this.readme = readme;
} catch (error) {
@@ -1,7 +1,7 @@
import semver from "semver";
import { observable } from "mobx";
import { autobind } from "../../utils";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api";
import { ItemStore } from "../../item.store";
import flatten from "lodash/flatten";
@@ -16,7 +16,7 @@ export class HelmChartStore extends ItemStore<HelmChart> {
async loadAll() {
try {
const res = await this.loadItems(() => helmChartsApi.list());
const res = await this.loadItems(() => listCharts());
this.failedLoading = false;
@@ -48,13 +48,13 @@ export class HelmChartStore extends ItemStore<HelmChart> {
return versions;
}
const loadVersions = (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
return versions.map(chart => ({
repo,
version: chart.getVersion()
}));
});
const loadVersions = async (repo: string) => {
const { versions } = await getChartDetails(repo, chartName);
return versions.map(chart => ({
repo,
version: chart.getVersion()
}));
};
if (!this.isLoaded) {
@@ -47,7 +47,8 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
[columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
[columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
[columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
[columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(),
[columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(),
}}
searchFilters={[
(item: HorizontalPodAutoscaler) => item.getSearchFields()
@@ -1,16 +1,18 @@
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react";
import { UserStore } from "../../../../common/user-store";
import { ExtensionDiscovery } from "../../../../extensions/extension-discovery";
import { ExtensionLoader } from "../../../../extensions/extension-loader";
import { ThemeStore } from "../../../theme.store";
import { ConfirmDialog } from "../../confirm-dialog";
import { Notifications } from "../../notifications";
import { ExtensionInstallationStateStore } from "../extension-install.store";
import { Extensions } from "../extensions";
import mockFs from "mock-fs";
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils", () => ({
...jest.requireActual("../../../../common/utils"),
@@ -20,37 +22,30 @@ jest.mock("../../../../common/utils", () => ({
extractTar: jest.fn(() => Promise.resolve())
}));
jest.mock("../../notifications", () => ({
ok: jest.fn(),
error: jest.fn(),
info: jest.fn()
jest.mock("electron", () => ({
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: (): void => void 0,
}
}));
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: (): void => void 0,
}
};
});
describe("Extensions", () => {
beforeEach(async () => {
mockFs({
"tmp": {}
});
ExtensionInstallationStateStore.reset();
UserStore.resetInstance();
ThemeStore.resetInstance();
await UserStore.createInstance().load();
await ThemeStore.createInstance().init();
ExtensionLoader.resetInstance();
ExtensionDiscovery.resetInstance();
Extensions.installStates.clear();
ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve());
ExtensionLoader.resetInstance();
ExtensionLoader.createInstance().addExtension({
id: "extensionId",
manifest: {
@@ -64,49 +59,38 @@ describe("Extensions", () => {
});
});
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
fireEvent.click(screen.getByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
afterEach(() => {
mockFs.restore();
});
it("displays error notification on uninstall error", () => {
it("disables uninstall and disable buttons while uninstalling", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
(ExtensionDiscovery.getInstance().uninstallExtension as any).mockImplementationOnce(() =>
Promise.reject()
);
render(<><Extensions /><ConfirmDialog/></>);
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
const res = render(<><Extensions /><ConfirmDialog /></>);
fireEvent.click(screen.getByText("Uninstall"));
expect(res.getByText("Disable").closest("button")).not.toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled();
fireEvent.click(res.getByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
fireEvent.click(res.getByText("Yes"));
waitFor(() => {
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
expect(Notifications.error).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(ExtensionDiscovery.getInstance().uninstallExtension).toHaveBeenCalled();
expect(res.getByText("Disable").closest("button")).toBeDisabled();
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
}, {
timeout: 30000,
});
});
it("disables install button while installing", () => {
render(<Extensions />);
it("disables install button while installing", async () => {
const res = render(<Extensions />);
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", {
exact: false
}), {
target: {
@@ -114,13 +98,8 @@ describe("Extensions", () => {
}
});
fireEvent.click(screen.getByText("Install"));
waitFor(() => {
expect(screen.getByText("Install").closest("button")).toBeDisabled();
expect(fse.move).toHaveBeenCalledWith("");
expect(Notifications.error).not.toHaveBeenCalled();
});
fireEvent.click(res.getByText("Install"));
expect(res.getByText("Install").closest("button")).toBeDisabled();
});
it("displays spinner while extensions are loading", () => {
@@ -128,8 +107,11 @@ describe("Extensions", () => {
const { container } = render(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
});
it("does not display the spinner while extensions are not loading", async () => {
ExtensionDiscovery.getInstance().isLoaded = true;
const { container } = render(<Extensions />);
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
@@ -0,0 +1,218 @@
import { action, computed, observable } from "mobx";
import logger from "../../../main/logger";
import { disposer, ExtendableDisposer } from "../../utils";
import * as uuid from "uuid";
import { broadcastMessage } from "../../../common/ipc";
import { ipcRenderer } from "electron";
export enum ExtensionInstallationState {
INSTALLING = "installing",
UNINSTALLING = "uninstalling",
IDLE = "idle",
}
const Prefix = "[ExtensionInstallationStore]";
export class ExtensionInstallationStateStore {
private static InstallingFromMainChannel = "extension-installation-state-store:install";
private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install";
private static PreInstallIds = observable.set<string>();
private static UninstallingExtensions = observable.set<string>();
private static InstallingExtensions = observable.set<string>();
static bindIpcListeners() {
ipcRenderer
.on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => {
ExtensionInstallationStateStore.setInstalling(extId);
})
.on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => {
ExtensionInstallationStateStore.clearInstalling(extId);
});
}
@action static reset() {
logger.warn(`${Prefix}: resetting, may throw errors`);
ExtensionInstallationStateStore.InstallingExtensions.clear();
ExtensionInstallationStateStore.UninstallingExtensions.clear();
ExtensionInstallationStateStore.PreInstallIds.clear();
}
/**
* Strictly transitions an extension from not installing to installing
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action static setInstalling(extId: string): void {
logger.debug(`${Prefix}: trying to set ${extId} as installing`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`);
}
ExtensionInstallationStateStore.InstallingExtensions.add(extId);
}
/**
* Broadcasts that an extension is being installed by the main process
* @param extId the ID of the extension
*/
static setInstallingFromMain(extId: string): void {
broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId);
}
/**
* Broadcasts that an extension is no longer being installed by the main process
* @param extId the ID of the extension
*/
static clearInstallingFromMain(extId: string): void {
broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId);
}
/**
* Marks the start of a pre-install phase of an extension installation. The
* part of the installation before the tarball has been unpacked and the ID
* determined.
* @returns a disposer which should be called to mark the end of the install phase
*/
@action static startPreInstall(): ExtendableDisposer {
const preInstallStepId = uuid.v4();
logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`);
ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId);
return disposer(() => {
ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId);
logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`);
});
}
/**
* Strictly transitions an extension from not uninstalling to uninstalling
* @param extId the ID of the extension
* @throws if state is not IDLE
*/
@action static setUninstalling(extId: string): void {
logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
if (curState !== ExtensionInstallationState.IDLE) {
throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`);
}
ExtensionInstallationStateStore.UninstallingExtensions.add(extId);
}
/**
* Strictly clears the INSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not INSTALLING
*/
@action static clearInstalling(extId: string): void {
logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.INSTALLING:
return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId);
default:
throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`);
}
}
/**
* Strictly clears the UNINSTALLING state of an extension
* @param extId The ID of the extension
* @throws if state is not UNINSTALLING
*/
@action static clearUninstalling(extId: string): void {
logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
switch (curState) {
case ExtensionInstallationState.UNINSTALLING:
return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId);
default:
throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`);
}
}
/**
* Returns the current state of the extension. IDLE is default value.
* @param extId The ID of the extension
*/
static getInstallationState(extId: string): ExtensionInstallationState {
if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) {
return ExtensionInstallationState.INSTALLING;
}
if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) {
return ExtensionInstallationState.UNINSTALLING;
}
return ExtensionInstallationState.IDLE;
}
/**
* Returns true if the extension is currently INSTALLING
* @param extId The ID of the extension
*/
static isExtensionInstalling(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
}
/**
* Returns true if the extension is currently UNINSTALLING
* @param extId The ID of the extension
*/
static isExtensionUninstalling(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING;
}
/**
* Returns true if the extension is currently IDLE
* @param extId The ID of the extension
*/
static isExtensionIdle(extId: string): boolean {
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE;
}
/**
* The current number of extensions installing
*/
@computed static get installing(): number {
return ExtensionInstallationStateStore.InstallingExtensions.size;
}
/**
* If there is at least one extension currently installing
*/
@computed static get anyInstalling(): boolean {
return ExtensionInstallationStateStore.installing > 0;
}
/**
* The current number of extensions preinstalling
*/
@computed static get preinstalling(): number {
return ExtensionInstallationStateStore.PreInstallIds.size;
}
/**
* If there is at least one extension currently downloading
*/
@computed static get anyPreinstalling(): boolean {
return ExtensionInstallationStateStore.preinstalling > 0;
}
/**
* If there is at least one installing or preinstalling step taking place
*/
@computed static get anyPreInstallingOrInstalling(): boolean {
return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling;
}
}
File diff suppressed because it is too large. Load diff
+8 -14
View File
@@ -1,5 +1,5 @@
import "./button.scss";
import React, { ButtonHTMLAttributes, ReactNode } from "react";
import React, { ButtonHTMLAttributes } from "react";
import { cssNames } from "../../utils";
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
@@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
render() {
const {
className, waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...props
waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, ...btnProps
} = this.props;
const btnProps: Partial<ButtonProps> = props;
if (hidden) return null;
btnProps.className = cssNames("Button", className, {
btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light,
});
const btnContent: ReactNode = (
<>
{label}
{children}
</>
);
// render as link
if (this.props.href) {
return (
<a {...btnProps} ref={e => this.link = e}>
{btnContent}
{label}
{children}
</a>
);
}
@@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
// render as button
return (
<button type="button" {...btnProps} ref={e => this.button = e}>
{btnContent}
{label}
{children}
</button>
);
}
@@ -11,14 +11,18 @@ import { Icon } from "../icon";
export interface ConfirmDialogProps extends Partial<DialogProps> {
}
export interface ConfirmDialogParams {
ok?: () => void;
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
ok?: () => any | Promise<any>;
cancel?: () => any | Promise<any>;
}
export interface ConfirmDialogBooleanParams {
labelOk?: ReactNode;
labelCancel?: ReactNode;
message?: ReactNode;
message: ReactNode;
icon?: ReactNode;
okButtonProps?: Partial<ButtonProps>
cancelButtonProps?: Partial<ButtonProps>
okButtonProps?: Partial<ButtonProps>;
cancelButtonProps?: Partial<ButtonProps>;
}
@observer
@@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
ConfirmDialog.params = params;
}
static close() {
ConfirmDialog.isOpen = false;
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
return new Promise(resolve => {
ConfirmDialog.open({
ok: () => resolve(true),
cancel: () => resolve(false),
...params,
});
});
}
public defaultParams: ConfirmDialogParams = {
static defaultParams: Partial<ConfirmDialogParams> = {
ok: noop,
cancel: noop,
labelOk: "Ok",
labelCancel: "Cancel",
icon: <Icon big material="warning"/>,
};
get params(): ConfirmDialogParams {
return Object.assign({}, this.defaultParams, ConfirmDialog.params);
return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params);
}
ok = async () => {
@@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
await Promise.resolve(this.params.ok()).catch(noop);
} finally {
this.isSaving = false;
ConfirmDialog.isOpen = false;
}
this.close();
};
onClose = () => {
this.isSaving = false;
};
close = () => {
ConfirmDialog.close();
close = async () => {
try {
await Promise.resolve(this.params.cancel()).catch(noop);
} finally {
this.isSaving = false;
ConfirmDialog.isOpen = false;
}
};
render() {
@@ -1,7 +1,7 @@
import { action, autorun } from "mobx";
import { dockStore, IDockTab, TabId, TabKind } from "./dock.store";
import { DockTabStore } from "./dock-tab.store";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { getChartDetails, getChartValues, HelmChart } from "../../api/endpoints/helm-charts.api";
import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api";
import { Notifications } from "../notifications";
@@ -54,7 +54,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
const { repo, name, version } = this.getData(tabId);
this.versions.clearData(tabId); // reset
const charts = await helmChartsApi.get(repo, name, version);
const charts = await getChartDetails(repo, name, { version });
const versions = charts.versions.map(chartVersion => chartVersion.version);
this.versions.setData(tabId, versions);
@@ -64,7 +64,7 @@ export class InstallChartStore extends DockTabStore<IChartInstallData> {
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
const data = this.getData(tabId);
const { repo, name, version } = data;
const values = await helmChartsApi.getValues(repo, name, version);
const values = await getChartValues(repo, name, version);
if (values) {
this.setData(tabId, { ...data, values });
@@ -11,7 +11,7 @@ import { Icon } from "../icon";
import { LogTabData } from "./log-tab.store";
interface Props {
tabData: LogTabData
tabData?: LogTabData
logs: string[]
save: (data: Partial<LogTabData>) => void
reload: () => void
@@ -19,6 +19,11 @@ interface Props {
export const LogControls = observer((props: Props) => {
const { tabData, save, reload, logs } = props;
if (!tabData) {
return null;
}
const { showTimestamps, previous } = tabData;
const since = logs.length ? logStore.getTimestamps(logs[0]) : null;
const pod = new Pod(tabData.selectedPod);
+17 -17
View File
@@ -26,23 +26,14 @@ export class Logs extends React.Component<Props> {
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true })
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }),
);
}
get tabData() {
return logTabStore.getData(this.tabId);
}
get tabId() {
return this.props.tab.id;
}
@autobind()
save(data: Partial<LogTabData>) {
logTabStore.setData(this.tabId, { ...this.tabData, ...data });
}
load = async () => {
this.isLoading = true;
await logStore.load(this.tabId);
@@ -82,15 +73,19 @@ export class Logs extends React.Component<Props> {
}, 100);
}
renderResourceSelector() {
renderResourceSelector(data?: LogTabData) {
if (!data) {
return null;
}
const logs = logStore.logs;
const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps;
const searchLogs = data.showTimestamps ? logs : logStore.logsWithoutTimestamps;
const controls = (
<div className="flex gaps">
<LogResourceSelector
tabId={this.tabId}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
<LogSearch
@@ -115,10 +110,15 @@ export class Logs extends React.Component<Props> {
render() {
const logs = logStore.logs;
const data = logTabStore.getData(this.tabId);
if (!data) {
this.reload();
}
return (
<div className="PodLogs flex column">
{this.renderResourceSelector()}
{this.renderResourceSelector(data)}
<LogList
logs={logs}
id={this.tabId}
@@ -128,8 +128,8 @@ export class Logs extends React.Component<Props> {
/>
<LogControls
logs={logs}
tabData={this.tabData}
save={this.save}
tabData={data}
save={newData => logTabStore.setData(this.tabId, { ...data, ...newData })}
reload={this.reload}
/>
</div>
+1
View File
@@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
rows: multiLine ? (rows || 1) : null,
ref: this.bindRef,
spellCheck: "false",
disabled,
});
const showErrors = errors.length > 0 && !valid && dirty;
const errorsInfo = (
@@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
},
};
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\d\.\d\.\d(-\w+)?))?$/gi;
export const isExtensionNameInstall: InputValidator = {
condition: ({ type }) => type === "text",
message: () => "Not an extension name with optional version",
validate: value => value.match(isExtensionNameInstallRegex) !== null,
};
export const isPath: InputValidator = {
condition: ({ type }) => type === "text",
message: () => `This field must be a valid path`,
@@ -2,105 +2,106 @@ import "./kube-object-status-icon.scss";
import React from "react";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { cssNames, formatDuration } from "../../utils";
import { KubeObjectStatusRegistration, kubeObjectStatusRegistry } from "../../../extensions/registries/kube-object-status-registry";
import { KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { computed } from "mobx";
import { KubeObject, KubeObjectStatus, KubeObjectStatusLevel } from "../../..//extensions/renderer-api/k8s-api";
import { kubeObjectStatusRegistry } from "../../../extensions/registries";
function statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
case KubeObjectStatusLevel.WARNING:
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
}
}
function statusTitle(level: KubeObjectStatusLevel): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
case KubeObjectStatusLevel.WARNING:
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
}
}
function getAge(timestamp: string) {
return timestamp
? formatDuration(Date.now() - new Date(timestamp).getTime(), true)
: "";
}
interface SplitStatusesByLevel {
maxLevel: string,
criticals: KubeObjectStatus[];
warnings: KubeObjectStatus[];
infos: KubeObjectStatus[];
}
/**
* This fuction returns the class level for corresponding to the highest status level
* and the statuses split by their levels.
* @param src a list of status items
*/
function splitByLevel(src: KubeObjectStatus[]): SplitStatusesByLevel {
const parts = new Map(Object.values(KubeObjectStatusLevel).map(v => [v, []]));
src.forEach(status => parts.get(status.level).push(status));
const criticals = parts.get(KubeObjectStatusLevel.CRITICAL);
const warnings = parts.get(KubeObjectStatusLevel.WARNING);
const infos = parts.get(KubeObjectStatusLevel.INFO);
const maxLevel = statusClassName(criticals[0]?.level ?? warnings[0]?.level ?? infos[0].level);
return { maxLevel, criticals, warnings, infos };
}
interface Props {
object: KubeObject;
}
export class KubeObjectStatusIcon extends React.Component<Props> {
@computed get objectStatuses() {
const { object } = this.props;
const registrations = kubeObjectStatusRegistry.getItemsForKind(object.kind, object.apiVersion);
return registrations.map((item: KubeObjectStatusRegistration) => { return item.resolve(object); }).filter((item: KubeObjectStatus) => !!item);
}
statusClassName(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "info";
case KubeObjectStatusLevel.WARNING:
return "warning";
case KubeObjectStatusLevel.CRITICAL:
return "error";
default:
return "";
}
}
statusTitle(level: number): string {
switch (level) {
case KubeObjectStatusLevel.INFO:
return "Info";
case KubeObjectStatusLevel.WARNING:
return "Warning";
case KubeObjectStatusLevel.CRITICAL:
return "Critical";
default:
return "";
}
}
getAge(timestamp: string) {
if (!timestamp) return "";
const diff = Date.now() - new Date(timestamp).getTime();
return formatDuration(diff, true);
}
renderStatuses(statuses: KubeObjectStatus[], level: number) {
const filteredStatuses = statuses.filter((item) => item.level == level);
return filteredStatuses.length > 0 && (
<div className={cssNames("level", this.statusClassName(level))}>
<div className={cssNames("level", statusClassName(level))}>
<span className="title">
{this.statusTitle(level)}
{statusTitle(level)}
</span>
{ filteredStatuses.map((status, index) =>{
return (
{
filteredStatuses.map((status, index) => (
<div key={`kube-resource-status-${level}-${index}`} className={cssNames("status", "msg")}>
- {status.text} <span className="age"> · { this.getAge(status.timestamp) }</span>
- {status.text} <span className="age"> · {getAge(status.timestamp)}</span>
</div>
);
})}
))
}
</div>
);
}
render() {
const { objectStatuses} = this;
const statuses = kubeObjectStatusRegistry.getItemsForObject(this.props.object);
if (!objectStatuses.length) return null;
if (statuses.length === 0) {
return null;
}
const sortedStatuses = objectStatuses.sort((a: KubeObjectStatus, b: KubeObjectStatus) => {
if (a.level < b.level ) {
return 1;
}
if (a.level > b.level ) {
return -1;
}
return 0;
});
const level = this.statusClassName(sortedStatuses[0].level);
const { maxLevel, criticals, warnings, infos } = splitByLevel(statuses);
return (
<Icon
material={level}
className={cssNames("KubeObjectStatusIcon", level)}
material={maxLevel}
className={cssNames("KubeObjectStatusIcon", maxLevel)}
tooltip={{
children: (
<div className="KubeObjectStatusTooltip">
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(sortedStatuses, KubeObjectStatusLevel.INFO)}
{this.renderStatuses(criticals, KubeObjectStatusLevel.CRITICAL)}
{this.renderStatuses(warnings, KubeObjectStatusLevel.WARNING)}
{this.renderStatuses(infos, KubeObjectStatusLevel.INFO)}
</div>
)
}}
@@ -24,13 +24,11 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
}
render() {
const object = this.props.object;
const { object } = this.props;
const {
getName, getNs, getLabels, getResourceVersion, selfLink,
getAnnotations, getFinalizers, getId, getAge,
metadata: { creationTimestamp },
getNs, getLabels, getResourceVersion, selfLink, getAnnotations,
getFinalizers, getId, getAge, getName, metadata: { creationTimestamp },
} = object;
const ownerRefs = object.getOwnerRefs();
return (
@@ -39,7 +37,8 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
{getAge(true, false)} ago ({<LocaleDate date={creationTimestamp} />})
</DrawerItem>
<DrawerItem name="Name" hidden={this.isHidden("name")}>
{getName()} <KubeObjectStatusIcon key="icon" object={object} />
{getName()}
<KubeObjectStatusIcon key="icon" object={object} />
</DrawerItem>
<DrawerItem name="Namespace" hidden={this.isHidden("namespace") || !getNs()}>
{getNs()}
@@ -68,7 +67,7 @@ export class KubeObjectMeta extends React.Component<KubeObjectMetaProps> {
labels={getFinalizers()}
hidden={this.isHidden("finalizers")}
/>
{ownerRefs && ownerRefs.length > 0 &&
{ownerRefs?.length > 0 &&
<DrawerItem name="Controlled By" hidden={this.isHidden("ownerReferences")}>
{
ownerRefs.map(ref => {
+8 -5
View File
@@ -2,7 +2,6 @@ import "./sidebar.scss";
import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { computed } from "mobx";
import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { cssNames } from "../../utils";
@@ -45,9 +44,13 @@ export class Sidebar extends React.Component<Props> {
crdStore.reloadAll();
}
@computed get crdSubMenus(): React.ReactNode {
if (!crdStore.isLoaded && crdStore.isLoading) {
return <Spinner centerHorizontal/>;
renderCustomResources() {
if (crdStore.isLoading) {
return (
<div className="flex justify-center">
<Spinner />
</div>
);
}
return Object.entries(crdStore.groups).map(([group, crds]) => {
@@ -268,7 +271,7 @@ export class Sidebar extends React.Component<Props> {
icon={<Icon material="extension"/>}
>
{this.renderTreeFromTabRoutes(CustomResources.tabRoutes)}
{this.crdSubMenus}
{this.renderCustomResources()}
</SidebarItem>
{this.renderRegisteredMenus()}
</div>
@@ -34,12 +34,6 @@
margin-top: calc(var(--spinner-size) / -2);
}
&.centerHorizontal {
position: absolute;
left: 50%;
margin-left: calc(var(--spinner-size) / -2);
}
@keyframes rotate {
0% {
transform: rotate(0deg);
+2 -3
View File
@@ -6,7 +6,6 @@ import { cssNames } from "../../utils";
export interface SpinnerProps extends React.HTMLProps<any> {
singleColor?: boolean;
center?: boolean;
centerHorizontal?: boolean;
}
export class Spinner extends React.Component<SpinnerProps, {}> {
@@ -16,8 +15,8 @@ export class Spinner extends React.Component<SpinnerProps, {}> {
};
render() {
const { center, singleColor, centerHorizontal, className, ...props } = this.props;
const classNames = cssNames("Spinner", className, { singleColor, center, centerHorizontal });
const { center, singleColor, className, ...props } = this.props;
const classNames = cssNames("Spinner", className, { singleColor, center });
return <div {...props} className={classNames} />;
}