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

Merge remote-tracking branch 'origin/master' into pages-url-params

# Conflicts:
#	src/renderer/components/+workloads-replicasets/replicasets.tsx
#	src/renderer/components/+workloads/workloads.tsx
This commit is contained in:
Roman 2020-12-10 20:09:46 +02:00
commit 3a4a07ca7f
34 changed files with 1213 additions and 200 deletions

View File

@ -47,7 +47,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center">
{brick}
{name}
<span>{name}</span>
</Component.MenuItem>
);
})

View File

@ -54,7 +54,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center">
<Component.StatusBrick/>
{name}
<span>{name}</span>
</Component.MenuItem>
);
})

View File

@ -273,6 +273,12 @@ describe("Lens integration tests", () => {
expectedSelector: "h5.title",
expectedText: "Stateful Sets"
},
{
name: "ReplicaSets",
href: "replicasets",
expectedSelector: "h5.title",
expectedText: "Replica Sets"
},
{
name: "Jobs",
href: "jobs",

View File

@ -140,6 +140,43 @@ msgstr "Add field"
msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr "Adding helm branch <0>{0}</0> has failed: {1}"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr "Helm repository <0>{0}</0> has added"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr "Skip TLS certificate checks for the repository"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr "Key file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr "Ca file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr "Cerificate file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr "Username"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr "Password"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr "Helm repo name"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr "More"
#: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr "Adding repo <0>{0}</0> has failed: {1}"
@ -186,6 +223,7 @@ msgstr "Affinities"
#: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age"
msgstr "Age"
@ -729,6 +767,10 @@ msgstr "Cron Jobs"
msgid "CronJobs"
msgstr "CronJobs"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr "Current"
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target"
msgstr "Current / Target"
@ -740,6 +782,7 @@ msgstr "Current Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}"
msgstr "Current replica scale: {currentReplicas}"
@ -824,6 +867,10 @@ msgstr "Deployments"
msgid "Description"
msgstr "Description"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr "Desired"
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy"
@ -831,6 +878,7 @@ msgstr "Desired Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas"
msgstr "Desired number of replicas"
@ -1095,6 +1143,7 @@ msgstr "Hide"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues"
msgstr "High number of replicas may cause cluster performance issues"
@ -1546,6 +1595,7 @@ msgstr "Mounts"
#: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name"
msgstr "Name"
@ -1595,6 +1645,7 @@ msgstr "Names"
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace"
msgstr "Namespace"
@ -2004,6 +2055,7 @@ msgstr "Read-only Root Filesystem"
msgid "Readiness"
msgstr "Readiness"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Ready"
@ -2119,6 +2171,10 @@ msgstr "Removing helm branch <0>{0}</0> has failed: {1}"
msgid "Replicas"
msgstr "Replicas"
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr "ReplicaSets"
#: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name"
msgstr "Repo/Name"
@ -2310,6 +2366,7 @@ msgstr "Save"
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale"
msgstr "Scale"
@ -2317,6 +2374,10 @@ msgstr "Scale"
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Scale Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr "Scale Replica Set <0>{replicaSetName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Scale Stateful Set <0>{statefulSetName}</0>"

View File

@ -140,6 +140,42 @@ msgstr ""
msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr ""
#: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr ""
@ -186,6 +222,7 @@ msgstr ""
#: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age"
msgstr ""
@ -725,6 +762,10 @@ msgstr ""
msgid "CronJobs"
msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr ""
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target"
msgstr ""
@ -736,6 +777,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}"
msgstr ""
@ -820,6 +862,10 @@ msgstr ""
msgid "Description"
msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr ""
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy"
@ -827,6 +873,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas"
msgstr ""
@ -1086,6 +1133,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicaset/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues"
msgstr ""
@ -1537,6 +1585,7 @@ msgstr ""
#: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name"
msgstr ""
@ -1586,6 +1635,7 @@ msgstr ""
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace"
msgstr ""
@ -1987,6 +2037,7 @@ msgstr ""
msgid "Readiness"
msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr ""
@ -2102,6 +2153,10 @@ msgstr ""
msgid "Replicas"
msgstr ""
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr ""
#: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name"
msgstr ""
@ -2293,6 +2348,7 @@ msgstr ""
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale"
msgstr ""
@ -2300,6 +2356,10 @@ msgstr ""
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr ""
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr ""

View File

@ -141,6 +141,42 @@ msgstr "Добавить поле"
msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr "Helm репозиторий <0>{0}</0> добавлен"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr ""
#: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr ""
@ -187,6 +223,7 @@ msgstr "Аффинитеты"
#: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age"
msgstr "Возраст"
@ -730,6 +767,10 @@ msgstr ""
msgid "CronJobs"
msgstr "CronJobs"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr "Текущее"
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target"
msgstr "Текущее / Цель"
@ -741,6 +782,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}"
msgstr "Текущий размер реплики: {currentReplicas}"
@ -825,6 +867,10 @@ msgstr "Deployments"
msgid "Description"
msgstr "Описание"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr "Желаемое"
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy"
@ -832,6 +878,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas"
msgstr "Нужный уровень реплик"
@ -1096,6 +1143,7 @@ msgstr "Скрыть"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues"
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
@ -1547,6 +1595,7 @@ msgstr "Установки"
#: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name"
msgstr "Имя"
@ -1596,6 +1645,7 @@ msgstr ""
#: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace"
msgstr "Namespace"
@ -2005,6 +2055,7 @@ msgstr ""
msgid "Readiness"
msgstr "Готовность"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Готовы"
@ -2120,6 +2171,10 @@ msgstr ""
msgid "Replicas"
msgstr "Реплики"
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr "ReplicaSets"
#: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name"
msgstr "Репозиторий/Имя"
@ -2311,6 +2366,7 @@ msgstr "Сохранить"
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale"
msgstr "Масштабировать"
@ -2318,6 +2374,10 @@ msgstr "Масштабировать"
msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Масштабировать Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr "Масштабировать Replica Set <0>{replicaSetName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Масштабировать Stateful Set <0>{statefulSetName}</0>"

View File

@ -31,6 +31,7 @@ export const apiResources: KubeApiResource[] = [
{ resource: "poddisruptionbudgets" },
{ resource: "podsecuritypolicies" },
{ resource: "resourcequotas" },
{ resource: "replicasets", group: "apps" },
{ resource: "secrets" },
{ resource: "services" },
{ resource: "statefulsets", group: "apps" },

View File

@ -0,0 +1,97 @@
import { watch } from "chokidar";
import { join, normalize } from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({
watch: jest.fn()
}));
jest.mock("../extension-installer", () => ({
extensionInstaller: {
extensionPackagesRoot: "",
installPackages: jest.fn()
}
}));
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
name: "my-extension"
}));
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = new ExtensionDiscovery();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
});
it("doesn't emit add for added file under extension", async done => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = new ExtensionDiscovery();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain();
const onAdd = jest.fn();
extensionDiscovery.events.on("add", onAdd);
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled();
done();
}, 10);
});
});

View File

@ -1,4 +1,4 @@
import chokidar from "chokidar";
import { watch } from "chokidar";
import { ipcRenderer } from "electron";
import { EventEmitter } from "events";
import fs from "fs-extra";
@ -138,7 +138,7 @@ export class ExtensionDiscovery {
await this.whenLoaded;
// chokidar works better than fs.watch
chokidar.watch(this.localFolderPath, {
watch(this.localFolderPath, {
// For adding and removing symlinks to work, the depth has to be 1.
depth: 1,
// Try to wait until the file has been completely copied.
@ -155,14 +155,26 @@ export class ExtensionDiscovery {
.on("unlinkDir", this.handleWatchUnlinkDir);
}
handleWatchFileAdd = async (filePath: string) => {
if (path.basename(filePath) === manifestFilename) {
handleWatchFileAdd = async (manifestPath: string) => {
// e.g. "foo/package.json"
const relativePath = path.relative(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2;
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try {
const absPath = path.dirname(filePath);
const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath);
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.removeSymlinkByManifestPath(manifestPath);
// Install dependencies for the new extension
await this.installPackages();
@ -190,6 +202,9 @@ export class ExtensionDiscovery {
.find(([, extensionFolder]) => filePath === extensionFolder)?.[0];
if (extensionName !== undefined) {
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
delete this.packagesJson.dependencies[extensionName];
// Reinstall dependencies to remove the extension from package.json
@ -197,7 +212,7 @@ export class ExtensionDiscovery {
// The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, "package.json");
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, manifestFilename);
logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId);
@ -208,18 +223,34 @@ export class ExtensionDiscovery {
};
/**
* Uninstalls extension by path.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param absolutePath Path to the non-symlinked folder of the extension
* Remove the symlink under node_modules if exists.
* If we don't remove the symlink, the uninstall would leave a non-working symlink,
* which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
* @param name e.g. "@mirantis/lens-extension-cc"
*/
async uninstallExtension(absolutePath: string) {
logger.info(`${logModule} Uninstalling ${absolutePath}`);
removeSymlinkByPackageName(name: string) {
return fs.remove(this.getInstalledPath(name));
}
const exists = await fs.pathExists(absolutePath);
/**
* Remove the symlink under node_modules if it exists.
* @param manifestPath Path to package.json
*/
removeSymlinkByManifestPath(manifestPath: string) {
const manifestJson = __non_webpack_require__(manifestPath);
if (!exists) {
throw new Error(`Extension path ${absolutePath} doesn't exist`);
}
return this.removeSymlinkByPackageName(manifestJson.name);
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extension Extension to unistall.
*/
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore
await fs.remove(absolutePath);
@ -260,6 +291,26 @@ export class ExtensionDiscovery {
return extensions;
}
/**
* Returns the symlinked path to the extension folder,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension"
*/
protected getInstalledPath(name: string) {
return path.join(this.nodeModulesPath, name);
}
/**
* Returns the symlinked path to the package.json,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json"
*/
protected getInstalledManifestPath(name: string) {
return path.join(this.getInstalledPath(name), manifestFilename);
}
/**
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false }: {
isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> {
@ -270,7 +321,7 @@ export class ExtensionDiscovery {
fs.accessSync(manifestPath, fs.constants.F_OK);
manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json");
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
@ -319,7 +370,7 @@ export class ExtensionDiscovery {
}
const absPath = path.resolve(folderPath, fileName);
const extension = await this.loadExtensionFromPath(absPath, { isBundled: true });
const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true });
if (extension) {
extensions.push(extension);
@ -354,7 +405,7 @@ export class ExtensionDiscovery {
continue;
}
const extension = await this.loadExtensionFromPath(absPath);
const extension = await this.loadExtensionFromFolder(absPath);
if (extension) {
extensions.push(extension);
@ -368,8 +419,9 @@ export class ExtensionDiscovery {
/**
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
* @param absPath Folder path to extension
*/
async loadExtensionFromPath(absPath: string, { isBundled = false }: {
async loadExtensionFromFolder(absPath: string, { isBundled = false }: {
isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> {
const manifestPath = path.resolve(absPath, manifestFilename);

View File

@ -1,3 +1,6 @@
import fetchMock from "jest-fetch-mock";
// rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks();
// Mock __non_webpack_require__ for tests
globalThis.__non_webpack_require__ = jest.fn();

View File

@ -22,7 +22,7 @@ export interface HelmRepo {
cacheFilePath?: string
caFile?: string,
certFile?: string,
insecure_skip_tls_verify?: boolean,
insecureSkipTlsVerify?: boolean,
keyFile?: string,
username?: string,
password?: string,
@ -131,6 +131,25 @@ export class HelmRepoManager extends Singleton {
return stdout;
}
public async addСustomRepo(repoAttributes : HelmRepo) {
logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`);
const helm = await helmCli.binaryPath();
const insecureSkipTlsVerify = repoAttributes.insecureSkipTlsVerify ? " --insecure-skip-tls-verify" : "";
const username = repoAttributes.username ? ` --username "${repoAttributes.username}"` : "";
const password = repoAttributes.password ? ` --password "${repoAttributes.password}"` : "";
const caFile = repoAttributes.caFile ? ` --ca-file "${repoAttributes.caFile}"` : "";
const keyFile = repoAttributes.keyFile ? ` --key-file "${repoAttributes.keyFile}"` : "";
const certFile = repoAttributes.certFile ? ` --cert-file "${repoAttributes.certFile}"` : "";
const addRepoCommand = `"${helm}" repo add ${repoAttributes.name} ${repoAttributes.url}${insecureSkipTlsVerify}${username}${password}${caFile}${keyFile}${certFile}`;
const { stdout } = await promiseExec(addRepoCommand).catch((error) => {
throw(error.stderr);
});
return stdout;
}
public async removeRepo({ name, url }: HelmRepo): Promise<string> {
logger.info(`[HELM]: removing repo "${name}" from ${url}`);
const helm = await helmCli.binaryPath();

View File

@ -115,7 +115,7 @@ app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { hasVisibleWindows });
if (!hasVisibleWindows) {
windowManager.initMainWindow();
windowManager?.initMainWindow(false);
}
});

View File

@ -67,7 +67,7 @@ export interface IPodContainer {
image: string;
command?: string[];
args?: string[];
ports: {
ports?: {
name?: string;
containerPort: number;
protocol: string;
@ -137,7 +137,7 @@ interface IContainerProbe {
export interface IPodContainerStatus {
name: string;
state: {
state?: {
[index: string]: object;
running?: {
startedAt: string;
@ -153,21 +153,28 @@ export interface IPodContainerStatus {
reason: string;
};
};
lastState: {
lastState?: {
[index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
containerID: string;
};
};
ready: boolean;
restartCount: number;
image: string;
imageID: string;
containerID: string;
containerID?: string;
started?: boolean;
}
@autobind()
@ -196,28 +203,44 @@ export class Pod extends WorkloadKubeObject {
}[];
initContainers: IPodContainer[];
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
activeDeadlineSeconds?: number;
dnsPolicy?: string;
serviceAccountName: string;
serviceAccount: string;
priority: number;
priorityClassName: string;
nodeName: string;
automountServiceAccountToken?: boolean;
priority?: number;
priorityClassName?: string;
nodeName?: string;
nodeSelector?: {
[selector: string]: string;
};
securityContext: {};
schedulerName: string;
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
securityContext?: {};
imagePullSecrets?: {
name: string;
}[];
affinity: IAffinity;
hostNetwork?: boolean;
hostPID?: boolean;
hostIPC?: boolean;
shareProcessNamespace?: boolean;
hostname?: string;
subdomain?: string;
schedulerName?: string;
tolerations?: {
key?: string;
operator?: string;
effect?: string;
tolerationSeconds?: number;
value?: string;
}[];
hostAliases?: {
ip: string;
hostnames: string[];
};
affinity?: IAffinity;
};
status: {
status?: {
phase: string;
conditions: {
type: string;
@ -230,7 +253,7 @@ export class Pod extends WorkloadKubeObject {
startTime: string;
initContainerStatuses?: IPodContainerStatus[];
containerStatuses?: IPodContainerStatus[];
qosClass: string;
qosClass?: string;
reason?: string;
};

View File

@ -1,51 +1,78 @@
import get from "lodash/get";
import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api";
import { WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer, Pod } from "./pods.api";
import { KubeApi } from "../kube-api";
export class ReplicaSetApi extends KubeApi<ReplicaSet> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return `${this.getUrl(params)}/scale`;
}
getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request
.get(this.getScaleApiUrl(params))
.then(({ status }: any) => status?.replicas);
}
scale(params: { namespace: string; name: string }, replicas: number) {
return this.request.put(this.getScaleApiUrl(params), {
data: {
metadata: params,
spec: {
replicas
}
}
});
}
}
@autobind()
export class ReplicaSet extends WorkloadKubeObject {
static kind = "ReplicaSet";
static namespaced = true;
static apiBase = "/apis/apps/v1/replicasets";
spec: {
replicas?: number;
selector?: {
matchLabels: {
[key: string]: string;
};
};
containers?: IPodContainer[];
selector: { matchLabels: { [app: string]: string } };
template?: {
spec?: {
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
metadata: {
labels: {
app: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
containers: IPodContainer[];
};
spec?: Pod["spec"];
};
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
schedulerName?: string;
minReadySeconds?: number;
};
status: {
replicas: number;
fullyLabeledReplicas: number;
readyReplicas: number;
availableReplicas: number;
observedGeneration: number;
fullyLabeledReplicas?: number;
readyReplicas?: number;
availableReplicas?: number;
observedGeneration?: number;
conditions?: {
type: string;
status: string;
lastUpdateTime: string;
lastTransitionTime: string;
reason: string;
message: string;
}[];
};
getDesired() {
return this.spec.replicas || 0;
}
getCurrent() {
return this.status.availableReplicas || 0;
}
getReady() {
return this.status.readyReplicas || 0;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
@ -53,6 +80,6 @@ export class ReplicaSet extends WorkloadKubeObject {
}
}
export const replicaSetApi = new KubeApi({
export const replicaSetApi = new ReplicaSetApi({
objectConstructor: ReplicaSet,
});

View File

@ -68,7 +68,7 @@ describe("Extensions", () => {
// Approve confirm dialog
fireEvent.click(screen.getByText("Yes"));
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalledWith("/absolute/path");
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
});

View File

@ -413,7 +413,7 @@ export class Extensions extends React.Component {
displayName
});
await extensionDiscovery.uninstallExtension(extension.absolutePath);
await extensionDiscovery.uninstallExtension(extension);
} catch (error) {
Notifications.error(
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>

View File

@ -0,0 +1,22 @@
.AddHelmRepoDialog {
--flex-gap: #{$margin * 1.5};
.accordion {
font-weight: bold;
align-self: flex-start;
padding: 0;
&:focus:not(:active) {
box-shadow: none;
}
}
.Icon {
--color-active: black;
}
.fields-title {
display: grid;
grid-template-columns: min-content auto;
align-items: center;
gap: $margin / 2;
}
}

View File

@ -0,0 +1,178 @@
import "./add-helm-repo-dialog.scss";
import React from "react";
import { remote, FileFilter } from "electron";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { Checkbox } from "../checkbox";
import { Button } from "../button";
import { systemName, isUrl, isPath } from "../input/input_validators";
import { SubTitle } from "../layout/sub-title";
import { Icon } from "../icon";
import { Notifications } from "../notifications";
import { HelmRepo, repoManager } from "../../../main/helm/helm-repo-manager";
interface Props extends Partial<DialogProps> {
onAddRepo: Function
}
enum FileType {
CaFile = "caFile",
KeyFile = "keyFile",
CertFile = "certFile",
}
@observer
export class AddHelmRepoDialog extends React.Component<Props> {
private emptyRepo = {name: "", url: "", username: "", password: "", insecureSkipTlsVerify: false, caFile:"", keyFile: "", certFile: ""};
private static keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
private static certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c" , "p7s", "p12", "pfx", "pem"];
@observable static isOpen = false;
static open() {
AddHelmRepoDialog.isOpen = true;
}
static close() {
AddHelmRepoDialog.isOpen = false;
}
@observable helmRepo : HelmRepo = this.emptyRepo;
@observable showOptions = false;
close = () => {
AddHelmRepoDialog.close();
this.helmRepo = this.emptyRepo;
this.showOptions = false;
};
setFilepath(type: FileType, value: string) {
this.helmRepo[type] = value;
}
getFilePath(type: FileType) : string {
return this.helmRepo[type];
}
async selectFileDialog(type: FileType, fileFilter: FileFilter) {
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: this.getFilePath(type),
properties: ["openFile", "showHiddenFiles"],
message: _i18n._(t`Select file`),
buttonLabel: _i18n._(t`Use file`),
filters: [
fileFilter,
{ name: "Any", extensions: ["*"]}
]
});
if (!canceled && filePaths.length) {
this.setFilepath(type, filePaths[0]);
}
}
async addCustomRepo() {
try {
await repoManager.addСustomRepo(this.helmRepo);
Notifications.ok(<Trans>Helm repository <b>{this.helmRepo.name}</b> has added</Trans>);
this.props.onAddRepo();
this.close();
} catch (err) {
Notifications.error(<Trans>Adding helm branch <b>{this.helmRepo.name}</b> has failed: {String(err)}</Trans>);
}
}
renderFileInput(placeholder:string, fileType:FileType ,fileExtensions:string[]){
return(
<div className="flex gaps align-center">
<Input
placeholder={placeholder}
validators = {isPath}
className="box grow"
value={this.getFilePath(fileType)}
onChange={v => this.setFilepath(fileType, v)}
/>
<Icon
material="folder"
onClick={() => this.selectFileDialog(fileType, {name: placeholder, extensions: fileExtensions})}
tooltip={<Trans>Browse</Trans>}
/>
</div>);
}
renderOptions() {
return (
<>
<SubTitle title={<Trans>Security settings</Trans>} />
<Checkbox
label={_i18n._(t`Skip TLS certificate checks for the repository`)}
value={this.helmRepo.insecureSkipTlsVerify}
onChange={v => this.helmRepo.insecureSkipTlsVerify = v}
/>
{this.renderFileInput(_i18n._(t`Key file`), FileType.KeyFile, AddHelmRepoDialog.keyExtensions)}
{this.renderFileInput(_i18n._(t`Ca file`), FileType.CaFile, AddHelmRepoDialog.certExtensions)}
{this.renderFileInput(_i18n._(t`Cerificate file`), FileType.CertFile, AddHelmRepoDialog.certExtensions)}
<SubTitle title={<Trans>Chart Repository Credentials</Trans>} />
<Input
placeholder={_i18n._(t`Username`)}
value={this.helmRepo.username} onChange= {v => this.helmRepo.username = v}
/>
<Input
type="password"
placeholder={_i18n._(t`Password`)}
value={this.helmRepo.password} onChange={v => this.helmRepo.password = v}
/>
</>);
}
render() {
const { ...dialogProps } = this.props;
const header = <h5><Trans>Add custom Helm Repo</Trans></h5>;
return (
<Dialog
{...dialogProps}
className="AddHelmRepoDialog"
isOpen={AddHelmRepoDialog.isOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep contentClass="flow column" nextLabel={<Trans>Add</Trans>} next={()=>{this.addCustomRepo();}}>
<div className="flex column gaps">
<Input
autoFocus required
placeholder={_i18n._(t`Helm repo name`)}
validators={systemName}
value={this.helmRepo.name} onChange={v => this.helmRepo.name = v}
/>
<Input
required
placeholder={_i18n._(t`URL`)}
validators={isUrl}
value={this.helmRepo.url} onChange={v => this.helmRepo.url = v}
/>
<Button plain className="accordion" onClick={() => this.showOptions = !this.showOptions} >
<Trans>More</Trans>
<Icon
small
tooltip={_i18n._(t`More`)}
material={this.showOptions ? "remove" : "add"}
/>
</Button>
{this.showOptions && this.renderOptions()}
</div>
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -13,11 +13,13 @@ import { Input } from "../input";
import { Checkbox } from "../checkbox";
import { Notifications } from "../notifications";
import { Badge } from "../badge";
import { Button } from "../button";
import { themeStore } from "../../theme.store";
import { Tooltip } from "../tooltip";
import { KubectlBinaries } from "./kubectl-binaries";
import { appPreferenceRegistry } from "../../../extensions/registries/app-preference-registry";
import { PageLayout } from "../layout/page-layout";
import { AddHelmRepoDialog } from "./add-helm-repo-dialog";
@observer
export class Preferences extends React.Component {
@ -134,16 +136,25 @@ export class Preferences extends React.Component {
<KubectlBinaries preferences={preferences}/>
<h2><Trans>Helm</Trans></h2>
<Select id="HelmRepoSelect"
placeholder={<Trans>Repositories</Trans>}
isLoading={this.helmLoading}
isDisabled={this.helmLoading}
options={this.helmOptions}
onChange={this.onRepoSelect}
formatOptionLabel={this.formatHelmOptionLabel}
controlShouldRenderValue={false}
/>
<div className="repos flex column">
<div className="flex gaps">
<Select id="HelmRepoSelect"
placeholder={<Trans>Repositories</Trans>}
isLoading={this.helmLoading}
isDisabled={this.helmLoading}
options={this.helmOptions}
onChange={this.onRepoSelect}
formatOptionLabel={this.formatHelmOptionLabel}
controlShouldRenderValue={false}
className="box grow"
/>
<Button
primary
label={<Trans>Add Custom Helm Repo</Trans>}
onClick={AddHelmRepoDialog.open}
/>
</div>
<AddHelmRepoDialog onAddRepo={()=>this.loadHelmRepos()}/>
<div className="repos flex gaps column">
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
const tooltipId = `message-${name}`;

View File

@ -11,7 +11,6 @@ import { cssNames } from "../../utils";
import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations";
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import { KubeEventDetails } from "../+events/kube-event-details";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { podsStore } from "../+workloads-pods/pods.store";
import { KubeObjectDetailsProps } from "../kube-object";
import { _i18n } from "../../i18n";
@ -20,7 +19,6 @@ import { deploymentStore } from "./deployments.store";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { ReplicaSets } from "../+workloads-replicasets";
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
@ -38,10 +36,6 @@ export class DeploymentDetails extends React.Component<Props> {
if (!podsStore.isLoaded) {
podsStore.loadAll();
}
if (!replicaSetStore.isLoaded) {
replicaSetStore.loadAll();
}
}
componentWillUnmount() {
@ -56,7 +50,6 @@ export class DeploymentDetails extends React.Component<Props> {
const nodeSelector = deployment.getNodeSelectors();
const selectors = deployment.getSelectors();
const childPods = deploymentStore.getChildPods(deployment);
const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment);
const metrics = deploymentStore.metrics;
return (
@ -118,7 +111,6 @@ export class DeploymentDetails extends React.Component<Props> {
<PodDetailsTolerations workload={deployment}/>
<PodDetailsAffinities workload={deployment}/>
<ResourceMetricsText metrics={metrics}/>
<ReplicaSets replicaSets={replicaSets}/>
<PodDetailsList pods={childPods} owner={deployment}/>
</div>
);

View File

@ -15,7 +15,7 @@
.workloads {
display: grid;
grid-template-columns: repeat(auto-fit, 155px);
grid-template-columns: repeat(auto-fit, 180px);
justify-content: space-evenly;
grid-gap: $margin;
padding: $padding * 2;

View File

@ -18,6 +18,7 @@ const resources: KubeResource[] = [
"deployments",
"statefulsets",
"daemonsets",
"replicasets",
"jobs",
"cronjobs",
];

View File

@ -0,0 +1,49 @@
.ReplicaSetScaleDialog {
.Wizard {
.header {
span {
color: #a0a0a0;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.WizardStep {
.step-content {
min-height: 90px;
overflow: hidden;
}
}
.current-scale {
font-weight: bold
}
.desired-scale {
flex: 1.1 0;
}
.slider-container {
flex: 1 0;
}
.plus-minus-container {
margin-left: $margin * 2;
.Icon {
--color-active: black;
}
}
.warning {
color: $colorSoftError;
font-size: small;
display: flex;
align-items: center;
.Icon {
margin: 0;
margin-right: $margin;
}
}
}
}

View File

@ -0,0 +1,167 @@
import "@testing-library/jest-dom/extend-expect";
jest.mock("../../api/endpoints");
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
import { render, waitFor, fireEvent } from "@testing-library/react";
import React from "react";
import { replicaSetApi } from "../../api/endpoints/replica-set.api";
const dummyReplicaSet = {
apiVersion: "v1",
kind: "dummy",
metadata: {
uid: "dummy",
name: "dummy",
creationTimestamp: "dummy",
resourceVersion: "dummy",
selfLink: "link",
},
selfLink: "link",
spec: {
replicas: 1,
selector: {
matchLabels: { "label": "label" }
},
template: {
metadata: {
labels: {
app: "label",
},
},
spec: {
containers: [{
name: "dummy",
image: "dummy",
imagePullPolicy: "dummy",
}],
initContainers: [{
name: "dummy",
image: "dummy",
imagePullPolicy: "dummy",
}],
priority: 1,
serviceAccountName: "dummy",
serviceAccount: "dummy",
securityContext: {},
schedulerName: "dummy",
},
},
minReadySeconds: 1,
},
status: {
replicas: 1,
fullyLabeledReplicas: 1,
readyReplicas: 1,
availableReplicas: 1,
observedGeneration: 1,
conditions: [{
type: "dummy",
status: "dummy",
lastUpdateTime: "dummy",
lastTransitionTime: "dummy",
reason: "dummy",
message: "dummy",
}],
},
getDesired: jest.fn(),
getCurrent: jest.fn(),
getReady: jest.fn(),
getImages: jest.fn(),
getReplicas: jest.fn(),
getSelectors: jest.fn(),
getTemplateLabels: jest.fn(),
getAffinity: jest.fn(),
getTolerations: jest.fn(),
getNodeSelectors: jest.fn(),
getAffinityNumber: jest.fn(),
getId: jest.fn(),
getResourceVersion: jest.fn(),
getName: jest.fn(),
getNs: jest.fn(),
getAge: jest.fn(),
getFinalizers: jest.fn(),
getLabels: jest.fn(),
getAnnotations: jest.fn(),
getOwnerRefs: jest.fn(),
getSearchFields: jest.fn(),
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
describe("<ReplicaSetScaleDialog />", () => {
it("renders w/o errors", () => {
const { container } = render(<ReplicaSetScaleDialog/>);
expect(container).toBeInstanceOf(HTMLElement);
});
it("init with a dummy replica set and mocked current/desired scale", async () => {
// mock replicaSetApi.getReplicas() which will be called
// when <ReplicaSetScaleDialog /> rendered.
const initReplicas = 1;
replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<ReplicaSetScaleDialog/>);
ReplicaSetScaleDialog.open(dummyReplicaSet);
// we need to wait for the replicaSetScaleDialog to show up
// because there is an <Animate /> in <Dialog /> which renders null at start.
await waitFor(async () => {
const [currentScale, desiredScale] = await Promise.all([
getByTestId("current-scale"),
getByTestId("desired-scale"),
]);
expect(currentScale).toHaveTextContent(`${initReplicas}`);
expect(desiredScale).toHaveTextContent(`${initReplicas}`);
});
});
it("changes the desired scale when clicking the icon buttons +/-", async () => {
const initReplicas = 1;
replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const component = render(<ReplicaSetScaleDialog/>);
ReplicaSetScaleDialog.open(dummyReplicaSet);
await waitFor(async () => {
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
});
const up = await component.findByTestId("desired-replicas-up");
const down = await component.findByTestId("desired-replicas-down");
fireEvent.click(up);
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`);
fireEvent.click(down);
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
// edge case, desiredScale must >= 0
let times = 10;
for (let i = 0; i < times; i++) {
fireEvent.click(down);
}
expect(await component.findByTestId("desired-scale")).toHaveTextContent("0");
expect((await component.baseElement.querySelector("input").value)).toBe("0");
// edge case, desiredScale must <= scaleMax (100)
times = 120;
for (let i = 0; i < times; i++) {
fireEvent.click(up);
}
expect(await component.findByTestId("desired-scale")).toHaveTextContent("100");
expect((component.baseElement.querySelector("input").value)).toBe("100");
expect(await component.findByTestId("warning"))
.toHaveTextContent("High number of replicas may cause cluster performance issues");
});
});

View File

@ -0,0 +1,169 @@
import "./replicaset-scale-dialog.scss";
import React, { Component } from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Icon } from "../icon";
import { Slider } from "../slider";
import { Notifications } from "../notifications";
import { cssNames } from "../../utils";
import { ReplicaSet, replicaSetApi } from "../../api/endpoints/replica-set.api";
interface Props extends Partial<DialogProps> {
}
@observer
export class ReplicaSetScaleDialog extends Component<Props> {
@observable static isOpen = false;
@observable static data: ReplicaSet = null;
@observable ready = false;
@observable currentReplicas = 0;
@observable desiredReplicas = 0;
static open(replicaSet: ReplicaSet) {
ReplicaSetScaleDialog.isOpen = true;
ReplicaSetScaleDialog.data = replicaSet;
}
static close() {
ReplicaSetScaleDialog.isOpen = false;
}
get replicaSet() {
return ReplicaSetScaleDialog.data;
}
close = () => {
ReplicaSetScaleDialog.close();
};
onOpen = async () => {
const { replicaSet } = this;
this.currentReplicas = await replicaSetApi.getReplicas({
namespace: replicaSet.getNs(),
name: replicaSet.getName(),
});
this.desiredReplicas = this.currentReplicas;
this.ready = true;
};
onClose = () => {
this.ready = false;
};
onChange = (evt: React.ChangeEvent, value: number) => {
this.desiredReplicas = value;
};
@computed get scaleMax() {
const { currentReplicas } = this;
const defaultMax = 50;
return currentReplicas <= defaultMax
? defaultMax * 2
: currentReplicas * 2;
}
scale = async () => {
const { replicaSet } = this;
const { currentReplicas, desiredReplicas, close } = this;
try {
if (currentReplicas !== desiredReplicas) {
await replicaSetApi.scale({
name: replicaSet.getName(),
namespace: replicaSet.getNs(),
}, desiredReplicas);
}
close();
} catch (err) {
Notifications.error(err);
}
};
desiredReplicasUp = () => {
this.desiredReplicas < this.scaleMax && this.desiredReplicas++;
};
desiredReplicasDown = () => {
this.desiredReplicas > 0 && this.desiredReplicas--;
};
renderContents() {
const { currentReplicas, desiredReplicas, onChange, scaleMax } = this;
const warning = currentReplicas < 10 && desiredReplicas > 90;
return (
<>
<div className="current-scale" data-testid="current-scale">
<Trans>Current replica scale: {currentReplicas}</Trans>
</div>
<div className="flex gaps align-center">
<div className="desired-scale" data-testid="desired-scale">
<Trans>Desired number of replicas</Trans>: {desiredReplicas}
</div>
<div className="slider-container flex align-center" data-testid="slider">
<Slider value={desiredReplicas} max={scaleMax}
onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}
/>
</div>
<div className="plus-minus-container flex gaps">
<Icon
material="add_circle_outline"
onClick={this.desiredReplicasUp}
data-testid="desired-replicas-up"
/>
<Icon
material="remove_circle_outline"
onClick={this.desiredReplicasDown}
data-testid="desired-replicas-down"
/>
</div>
</div>
{warning &&
<div className="warning" data-testid="warning">
<Icon material="warning"/>
<Trans>High number of replicas may cause cluster performance issues</Trans>
</div>
}
</>
);
}
render() {
const { className, ...dialogProps } = this.props;
const replicaSetName = this.replicaSet ? this.replicaSet.getName() : "";
const header = (
<h5>
<Trans>Scale Replica Set <span>{replicaSetName}</span></Trans>
</h5>
);
return (
<Dialog
{...dialogProps}
isOpen={ReplicaSetScaleDialog.isOpen}
className={cssNames("ReplicaSetScaleDialog", className)}
onOpen={this.onOpen}
onClose={this.onClose}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flex gaps column"
next={this.scale}
nextLabel={<Trans>Scale</Trans>}
disabledNext={!this.ready}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -1,20 +1,5 @@
.ReplicaSets {
position: relative;
min-height: 80px;
.Table {
margin: 0 (-$margin * 3);
}
.TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.name {
flex-grow: 2;
}
@ -22,13 +7,5 @@
&.warning {
@include table-cell-warning;
}
&.namespace {
flex-grow: 1.2;
}
&.actions {
@include table-cell-action;
}
}
}
}

View File

@ -4,6 +4,7 @@ import { KubeObjectStore } from "../../kube-object.store";
import { Deployment, IPodMetrics, podsApi, ReplicaSet, replicaSetApi } from "../../api/endpoints";
import { podsStore } from "../+workloads-pods/pods.store";
import { apiManager } from "../../api/api-manager";
import { PodStatus } from "../../api/endpoints/pods.api";
@autobind()
export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> {
@ -20,6 +21,26 @@ export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> {
return podsStore.getPodsByOwner(replicaSet);
}
getStatuses(replicaSets: ReplicaSet[]) {
const status = { failed: 0, pending: 0, running: 0 };
replicaSets.forEach(replicaSet => {
const pods = this.getChildPods(replicaSet);
if (pods.some(pod => pod.getStatus() === PodStatus.FAILED)) {
status.failed++;
}
else if (pods.some(pod => pod.getStatus() === PodStatus.PENDING)) {
status.pending++;
}
else {
status.running++;
}
});
return status;
}
getReplicaSetsByOwner(deployment: Deployment) {
return this.items.filter(replicaSet =>
!!replicaSet.getOwnerRefs().find(owner => owner.uid === deployment.getId())

View File

@ -2,97 +2,93 @@ import "./replicasets.scss";
import React from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { t, Trans } from "@lingui/macro";
import { ReplicaSet } from "../../api/endpoints";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { replicaSetStore } from "./replicasets.store";
import { Spinner } from "../spinner";
import { prevDefault, stopPropagation } from "../../utils";
import { DrawerTitle } from "../drawer";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { showDetails } from "../kube-object";
import { RouteComponentProps } from "react-router";
import { IReplicaSetsRouteParams } from "../+workloads/workloads.route";
import { KubeObjectListLayout } from "../kube-object/kube-object-list-layout";
import { MenuItem } from "../menu/menu";
import { Icon } from "../icon/icon";
import { _i18n } from "../../i18n";
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry";
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
enum sortBy {
name = "name",
namespace = "namespace",
pods = "pods",
desired = "desired",
current = "current",
ready = "ready",
age = "age",
}
interface Props {
replicaSets: ReplicaSet[];
interface Props extends RouteComponentProps<IReplicaSetsRouteParams> {
}
@observer
export class ReplicaSets extends React.Component<Props> {
private sortingCallbacks = {
[sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(),
[sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(),
[sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp,
[sortBy.pods]: (replicaSet: ReplicaSet) => this.getPodsLength(replicaSet),
};
getPodsLength(replicaSet: ReplicaSet) {
return replicaSetStore.getChildPods(replicaSet).length;
}
render() {
const { replicaSets } = this.props;
if (!replicaSets.length && !replicaSetStore.isLoaded) return (
<div className="ReplicaSets"><Spinner center/></div>
);
if (!replicaSets.length) return null;
return (
<div className="ReplicaSets flex column">
<DrawerTitle title={<Trans>Deploy Revisions</Trans>}/>
<Table
selectable
scrollable={false}
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
sortSyncWithUrl={false}
className="box grow"
>
<TableHead>
<TableCell className="name" sortBy={sortBy.name}><Trans>Name</Trans></TableCell>
<TableCell className="warning"/>
<TableCell className="namespace" sortBy={sortBy.namespace}>Namespace</TableCell>
<TableCell className="pods" sortBy={sortBy.pods}><Trans>Pods</Trans></TableCell>
<TableCell className="age" sortBy={sortBy.age}><Trans>Age</Trans></TableCell>
<TableCell className="actions"/>
</TableHead>
{
replicaSets.map(replica => {
return (
<TableRow
key={replica.getId()}
sortItem={replica}
nowrap
onClick={prevDefault(() => showDetails(replica.selfLink, false))}
>
<TableCell className="name">{replica.getName()}</TableCell>
<TableCell className="warning"><KubeObjectStatusIcon key="icon" object={replica}/></TableCell>
<TableCell className="namespace">{replica.getNs()}</TableCell>
<TableCell className="pods">{this.getPodsLength(replica)}</TableCell>
<TableCell className="age">{replica.getAge()}</TableCell>
<TableCell className="actions" onClick={stopPropagation}>
<ReplicaSetMenu object={replica}/>
</TableCell>
</TableRow>
);
})
}
</Table>
</div>
<KubeObjectListLayout
className="ReplicaSets" store={replicaSetStore}
sortingCallbacks={{
[sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(),
[sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(),
[sortBy.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(),
[sortBy.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(),
[sortBy.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(),
[sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp,
}}
searchFilters={[
(replicaSet: ReplicaSet) => replicaSet.getSearchFields(),
]}
renderHeaderTitle={<Trans>Replica Sets</Trans>}
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ className: "warning" },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Desired</Trans>, className: "desired", sortBy: sortBy.desired },
{ title: <Trans>Current</Trans>, className: "current", sortBy: sortBy.current },
{ title: <Trans>Ready</Trans>, className: "ready", sortBy: sortBy.ready },
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
]}
renderTableContents={(replicaSet: ReplicaSet) => [
replicaSet.getName(),
<KubeObjectStatusIcon key="icon" object={replicaSet}/>,
replicaSet.getNs(),
replicaSet.getDesired(),
replicaSet.getCurrent(),
replicaSet.getReady(),
replicaSet.getAge(),
]}
renderItemMenu={(item: ReplicaSet) => {
return <ReplicaSetMenu object={item}/>;
}}
/>
);
}
}
export function ReplicaSetMenu(props: KubeObjectMenuProps<ReplicaSet>) {
const { object, toolbar } = props;
return (
<KubeObjectMenu {...props}/>
<>
<MenuItem onClick={() => ReplicaSetScaleDialog.open(object)}>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span>
</MenuItem>
</>
);
}
kubeObjectMenuRegistry.add({
kind: "ReplicaSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: ReplicaSetMenu
}
});

View File

@ -25,6 +25,9 @@ export const daemonSetsRoute: RouteProps = {
export const statefulSetsRoute: RouteProps = {
path: "/statefulsets"
};
export const replicaSetsRoute: RouteProps = {
path: "/replicasets"
};
export const jobsRoute: RouteProps = {
path: "/jobs"
};
@ -48,6 +51,9 @@ export interface IDaemonSetsRouteParams {
export interface IStatefulSetsRouteParams {
}
export interface IReplicaSetsRouteParams {
}
export interface IJobsRouteParams {
}
@ -61,6 +67,7 @@ export const podsURL = buildURL<IPodsRouteParams>(podsRoute.path);
export const deploymentsURL = buildURL<IDeploymentsRouteParams>(deploymentsRoute.path);
export const daemonSetsURL = buildURL<IDaemonSetsRouteParams>(daemonSetsRoute.path);
export const statefulSetsURL = buildURL<IStatefulSetsRouteParams>(statefulSetsRoute.path);
export const replicaSetsURL = buildURL<IReplicaSetsRouteParams>(replicaSetsRoute.path);
export const jobsURL = buildURL<IJobsRouteParams>(jobsRoute.path);
export const cronJobsURL = buildURL<ICronJobsRouteParams>(cronJobsRoute.path);
@ -69,6 +76,7 @@ export const workloadURL: Partial<Record<KubeResource, ReturnType<typeof buildUR
"deployments": deploymentsURL,
"daemonsets": daemonSetsURL,
"statefulsets": statefulSetsURL,
"replicasets": replicaSetsURL,
"jobs": jobsURL,
"cronjobs": cronJobsURL,
};

View File

@ -6,12 +6,14 @@ import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store";
import { jobStore } from "../+workloads-jobs/job.store";
import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { KubeResource } from "../../../common/rbac";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
export const workloadStores: Partial<Record<KubeResource, KubeObjectStore>> = {
"pods": podsStore,
"deployments": deploymentStore,
"daemonsets": daemonSetStore,
"statefulsets": statefulSetStore,
"replicasets": replicaSetStore,
"jobs": jobStore,
"cronjobs": cronJobStore,
};

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
import { WorkloadsOverview } from "../+workloads-overview/overview";
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route";
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, replicaSetsRoute, replicaSetsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route";
import { namespaceUrlParam } from "../+namespaces/namespace.store";
import { Pods } from "../+workloads-pods";
import { Deployments } from "../+workloads-deployments";
@ -14,6 +14,7 @@ import { StatefulSets } from "../+workloads-statefulsets";
import { Jobs } from "../+workloads-jobs";
import { CronJobs } from "../+workloads-cronjobs";
import { isAllowedResource } from "../../../common/rbac";
import { ReplicaSets } from "../+workloads-replicasets";
@observer
export class Workloads extends React.Component {
@ -64,6 +65,15 @@ export class Workloads extends React.Component {
});
}
if (isAllowedResource("replicasets")) {
routes.push({
title: <Trans>ReplicaSets</Trans>,
component: ReplicaSets,
url: replicaSetsURL({ query }),
routePath: replicaSetsRoute.path.toString(),
});
}
if (isAllowedResource("jobs")) {
routes.push({
title: <Trans>Jobs</Trans>,

View File

@ -48,6 +48,7 @@ import { computed, reaction } from "mobx";
import { nodesStore } from "./+nodes/nodes.store";
import { podsStore } from "./+workloads-pods/pods.store";
import { sum } from "lodash";
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
@observer
export class App extends React.Component {
@ -203,6 +204,7 @@ export class App extends React.Component {
<AddRoleBindingDialog/>
<DeploymentScaleDialog/>
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
</ErrorBoundary>
</Router>

View File

@ -95,15 +95,9 @@ export class PodLogList extends React.Component<Props> {
@action
setLastLineVisibility = (props: ListOnScrollProps) => {
const { scrollHeight, clientHeight } = this.virtualListDiv.current;
const { scrollOffset, scrollDirection } = props;
const { scrollOffset } = props;
if (scrollDirection == "backward") {
this.isLastLineVisible = false;
} else {
if (clientHeight + scrollOffset === scrollHeight) {
this.isLastLineVisible = true;
}
}
this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight;
};
/**

View File

@ -91,9 +91,14 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
let items: T[];
try {
const { allowedNamespaces } = getHostedCluster();
const { allowedNamespaces, accessibleNamespaces, isAdmin } = getHostedCluster();
if (isAdmin && accessibleNamespaces.length == 0) {
items = await this.loadItems();
} else {
items = await this.loadItems(allowedNamespaces);
}
items = await this.loadItems(allowedNamespaces);
items = this.filterItemsOnLoad(items);
} finally {
if (items) {