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

Merge branch 'master' into extensions-docs

# Conflicts:
#	src/main/menu.ts
This commit is contained in:
Mario Sarcher 2020-11-05 12:57:52 +01:00
commit 69021b121d
105 changed files with 5553 additions and 426 deletions

View File

@ -39,6 +39,8 @@ jobs:
displayName: Install dependencies displayName: Install dependencies
- script: make integration-win - script: make integration-win
displayName: Run integration tests displayName: Run integration tests
- script: make test-extensions
displayName: Run In-tree Extension tests
- script: make build - script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
displayName: Build displayName: Build
@ -78,6 +80,8 @@ jobs:
displayName: Run tests displayName: Run tests
- script: make integration-mac - script: make integration-mac
displayName: Run integration tests displayName: Run integration tests
- script: make test-extensions
displayName: Run In-tree Extension tests
- script: make build - script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
displayName: Build displayName: Build
@ -119,6 +123,8 @@ jobs:
condition: eq(variables.CACHE_RESTORED, 'true') condition: eq(variables.CACHE_RESTORED, 'true')
- script: make install-deps - script: make install-deps
displayName: Install dependencies displayName: Install dependencies
- script: make test-extensions
displayName: Run In-tree Extension tests
- script: make lint - script: make lint
displayName: Lint displayName: Lint
- script: make test - script: make test

View File

@ -28,7 +28,8 @@ module.exports = {
"src/**/*.ts", "src/**/*.ts",
"integration/**/*.ts", "integration/**/*.ts",
"src/extensions/**/*.ts*", "src/extensions/**/*.ts*",
"extensions/**/*.ts*" "extensions/**/*.ts*",
"__mocks__/*.ts",
], ],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
extends: [ extends: [

17
.github/labeler-config.yml vendored Normal file
View File

@ -0,0 +1,17 @@
---
area/ui:
- src/renderer/**/*
area/test:
- integration/**/*
- __mocks__/**/*
area/extension:
- extensions/**/*
- src/extensions/**/*
area/documentation:
- README.md
- docs/**/*
area/ci:
- .github/workflows/**/*
- .azure-pipelines.yml
dependencies:
- yarn.lock

15
.github/workflows/labeler.yml vendored Normal file
View File

@ -0,0 +1,15 @@
---
name: "Pull Request Labeler"
on:
- pull_request
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v2
if: github.repository == 'lensapp/lens'
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
configuration-path: .github/labeler-config.yml

View File

@ -33,15 +33,15 @@ lint:
test: download-bins test: download-bins
yarn test yarn test
integration-linux: integration-linux: build-extension-types build-extensions
yarn build:linux yarn build:linux
yarn integration yarn integration
integration-mac: integration-mac: build-extension-types build-extensions
yarn build:mac yarn build:mac
yarn integration yarn integration
integration-win: integration-win: build-extension-types build-extensions
yarn build:win yarn build:win
yarn integration yarn integration
@ -58,10 +58,15 @@ endif
build-extensions: build-extensions:
$(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), $(MAKE) -C $(dir) build;) $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), $(MAKE) -C $(dir) build;)
build-npm: test-extensions:
yarn compile:extension-types $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), $(MAKE) -C $(dir) test;)
build-npm: build-extension-types
yarn npm:fix-package-version yarn npm:fix-package-version
build-extension-types:
yarn compile:extension-types
publish-npm: build-npm publish-npm: build-npm
npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}"
cd src/extensions/npm/extensions && npm publish --access=public cd src/extensions/npm/extensions && npm publish --access=public

View File

@ -0,0 +1,3 @@
module.exports = {
Trans: ({ children }: { children: React.ReactNode }) => children,
};

View File

@ -13,5 +13,8 @@ module.exports = {
getPath: jest.fn() getPath: jest.fn()
} }
}, },
dialog: jest.fn() dialog: jest.fn(),
ipcRenderer: {
on: jest.fn()
}
}; };

View File

@ -6,4 +6,4 @@ import appInfo from "../package.json"
const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json") const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json")
packageInfo.version = appInfo.version packageInfo.version = appInfo.version
fs.writeFileSync(packagePath, JSON.stringify(packageInfo, null, 2)) fs.writeFileSync(packagePath, JSON.stringify(packageInfo, null, 2) + "\n")

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -10,7 +10,8 @@
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "webpack --config webpack.config.js",
"dev": "npm run build --watch" "dev": "npm run build --watch",
"test": "echo NO TESTS"
}, },
"dependencies": { "dependencies": {
"react-open-doodles": "^1.0.5" "react-open-doodles": "^1.0.5"

View File

@ -0,0 +1,8 @@
install-deps:
yarn install
build: install-deps
yarn run build
test:
yarn run test

View File

@ -0,0 +1,13 @@
import { LensMainExtension, Util } from "@k8slens/extensions";
export default class LicenseLensMainExtension extends LensMainExtension {
appMenus = [
{
parentId: "help",
label: "License",
async click() {
Util.openExternal("https://k8slens.dev/licenses/eula.md")
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "lens-license",
"version": "0.1.0",
"description": "License menu item",
"main": "dist/main.js",
"scripts": {
"build": "webpack -p",
"dev": "webpack --watch",
"test": "echo NO TESTS"
},
"dependencies": {},
"devDependencies": {
"@types/webpack": "^4.41.17",
"@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"mobx": "^5.15.5",
"react": "^16.13.1",
"ts-loader": "^8.0.4",
"ts-node": "^9.0.0",
"typescript": "^4.0.3",
"webpack": "^4.44.2"
}
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"module": "CommonJS",
"target": "ES2017",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "Node",
"sourceMap": false,
"declaration": false,
"strict": false,
"noImplicitAny": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"jsx": "react"
}
}

View File

@ -0,0 +1,34 @@
import path from "path"
const outputPath = path.resolve(__dirname, 'dist');
export default [
{
entry: './main.ts',
context: __dirname,
target: "electron-main",
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
externals: {
"@k8slens/extensions": "var global.LensExtensions",
"mobx": "var global.Mobx",
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
libraryTarget: "commonjs2",
globalObject: "this",
filename: 'main.js',
path: outputPath,
},
},
];

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -9,7 +9,8 @@
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "webpack --config webpack.config.js",
"dev": "npm run build --watch" "dev": "npm run build --watch",
"test": "echo NO TESTS"
}, },
"dependencies": { "dependencies": {
"semver": "^7.3.2" "semver": "^7.3.2"

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -9,7 +9,8 @@
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "webpack --config webpack.config.js",
"dev": "npm run build --watch" "dev": "npm run build --watch",
"test": "echo NO TESTS"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -9,7 +9,8 @@
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "webpack --config webpack.config.js",
"dev": "npm run build --watch" "dev": "npm run build --watch",
"test": "echo NO TESTS"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {

View File

@ -22,7 +22,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
const { object: pod, toolbar } = this.props const { object: pod, toolbar } = this.props
const containers = pod.getAllContainers(); const containers = pod.getAllContainers();
const statuses = pod.getContainerStatuses(); const statuses = pod.getContainerStatuses();
if (!containers.length) return; if (!containers.length) return null;
return ( return (
<Component.MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}> <Component.MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}>
<Component.Icon material="subject" title="Logs" interactive={toolbar}/> <Component.Icon material="subject" title="Logs" interactive={toolbar}/>

View File

@ -34,7 +34,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
render() { render() {
const { object, toolbar } = this.props const { object, toolbar } = this.props
const containers = object.getRunningContainers(); const containers = object.getRunningContainers();
if (!containers.length) return; if (!containers.length) return null;
return ( return (
<Component.MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}> <Component.MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}>
<Component.Icon svg="ssh" interactive={toolbar} title="Pod shell"/> <Component.Icon svg="ssh" interactive={toolbar} title="Pod shell"/>

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -20,10 +20,10 @@
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==",
"dev": true "dev": true
}, },
"@types/node": { "@types/json-schema": {
"version": "14.11.11", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
"integrity": "sha512-UcaAZrL8uO5GNS+NLxkYg1RiOMgdLxCXGqs+TTupltXN8rTvUEKTOpqCV3tlcAIZJXzcBQajzmjdrvuPvnuMUw==", "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==",
"dev": true "dev": true
}, },
"@types/prop-types": { "@types/prop-types": {
@ -741,6 +741,12 @@
"unset-value": "^1.0.0" "unset-value": "^1.0.0"
} }
}, },
"camelcase": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
"integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
"dev": true
},
"chalk": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -842,6 +848,12 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true "dev": true
}, },
"colorette": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
"integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==",
"dev": true
},
"commander": { "commander": {
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -980,6 +992,71 @@
"randomfill": "^1.0.3" "randomfill": "^1.0.3"
} }
}, },
"css-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.0.0.tgz",
"integrity": "sha512-9g35eXRBgjvswyJWoqq/seWp+BOxvUl8IinVNTsUBFFxtwfEYvlmEn6ciyn0liXGbGh5HyJjPGCuobDSfqMIVg==",
"dev": true,
"requires": {
"camelcase": "^6.1.0",
"cssesc": "^3.0.0",
"icss-utils": "^5.0.0",
"loader-utils": "^2.0.0",
"postcss": "^8.1.1",
"postcss-modules-extract-imports": "^3.0.0",
"postcss-modules-local-by-default": "^4.0.0",
"postcss-modules-scope": "^3.0.0",
"postcss-modules-values": "^4.0.0",
"postcss-value-parser": "^4.1.0",
"schema-utils": "^3.0.0",
"semver": "^7.3.2"
},
"dependencies": {
"json5": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
"integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
"integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.6",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
},
"semver": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
"dev": true
}
}
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true
},
"csstype": { "csstype": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.3.tgz",
@ -1600,6 +1677,12 @@
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
"dev": true "dev": true
}, },
"icss-utils": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.0.0.tgz",
"integrity": "sha512-aF2Cf/CkEZrI/vsu5WI/I+akFgdbwQHVE9YRZxATrhH4PVIe6a3BIjwjEcW+z+jP/hNh+YvM3lAAn1wJQ6opSg==",
"dev": true
},
"ieee754": { "ieee754": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
@ -1618,6 +1701,12 @@
"integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
"dev": true "dev": true
}, },
"indexes-of": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=",
"dev": true
},
"infer-owner": { "infer-owner": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
@ -1810,6 +1899,33 @@
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true "dev": true
}, },
"klona": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.4.tgz",
"integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==",
"dev": true
},
"line-column": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/line-column/-/line-column-1.0.2.tgz",
"integrity": "sha1-0lryk2tvSEkXKzEuR5LR2Ye8NKI=",
"dev": true,
"requires": {
"isarray": "^1.0.0",
"isobject": "^2.0.0"
},
"dependencies": {
"isobject": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
"integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=",
"dev": true,
"requires": {
"isarray": "1.0.0"
}
}
}
},
"loader-runner": { "loader-runner": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
@ -2051,6 +2167,12 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"nanoid": {
"version": "3.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.16.tgz",
"integrity": "sha512-+AK8MN0WHji40lj8AEuwLOvLSbWYApQpre/aFJZD71r43wVRLrOYS4FmJOPQYon1TqB462RzrrxlfA74XRES8w==",
"dev": true
},
"nanomatch": { "nanomatch": {
"version": "1.2.13", "version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@ -2317,6 +2439,71 @@
"integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=",
"dev": true "dev": true
}, },
"postcss": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.1.4.tgz",
"integrity": "sha512-LfqcwgMq9LOd8pX7K2+r2HPitlIGC5p6PoZhVELlqhh2YGDVcXKpkCseqan73Hrdik6nBd2OvoDPUaP/oMj9hQ==",
"dev": true,
"requires": {
"colorette": "^1.2.1",
"line-column": "^1.0.2",
"nanoid": "^3.1.15",
"source-map": "^0.6.1"
}
},
"postcss-modules-extract-imports": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
"integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
"dev": true
},
"postcss-modules-local-by-default": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
"integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
"dev": true,
"requires": {
"icss-utils": "^5.0.0",
"postcss-selector-parser": "^6.0.2",
"postcss-value-parser": "^4.1.0"
}
},
"postcss-modules-scope": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
"integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
"dev": true,
"requires": {
"postcss-selector-parser": "^6.0.4"
}
},
"postcss-modules-values": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
"integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
"dev": true,
"requires": {
"icss-utils": "^5.0.0"
}
},
"postcss-selector-parser": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz",
"integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==",
"dev": true,
"requires": {
"cssesc": "^3.0.0",
"indexes-of": "^1.0.1",
"uniq": "^1.0.1",
"util-deprecate": "^1.0.2"
}
},
"postcss-value-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
"dev": true
},
"process": { "process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@ -2576,6 +2763,58 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true "dev": true
}, },
"sass-loader": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.0.4.tgz",
"integrity": "sha512-zhdZ8qvZM4iL5XjLVEjJLvKWvC+MB+hHgzL2x/Nf7UHpUNmPYsJvypW79bW39g4LZ603dH/dRSsRYzJJIljtdA==",
"dev": true,
"requires": {
"klona": "^2.0.4",
"loader-utils": "^2.0.0",
"neo-async": "^2.6.2",
"schema-utils": "^3.0.0",
"semver": "^7.3.2"
},
"dependencies": {
"json5": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
"integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
"integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.6",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
},
"semver": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
"dev": true
}
}
},
"schema-utils": { "schema-utils": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
@ -2882,6 +3121,49 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"style-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
"integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"json5": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
"integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
"integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.6",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"supports-color": { "supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -3053,6 +3335,12 @@
"set-value": "^2.0.1" "set-value": "^2.0.1"
} }
}, },
"uniq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
"integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=",
"dev": true
},
"unique-filename": { "unique-filename": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",

View File

@ -6,17 +6,20 @@
"renderer": "dist/renderer.js", "renderer": "dist/renderer.js",
"scripts": { "scripts": {
"build": "webpack -p", "build": "webpack -p",
"dev": "webpack --watch" "dev": "webpack --watch",
"test": "echo NO TESTS"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@types/node": "^14.11.11", "@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"@types/react": "^16.9.53", "@types/react": "^16.9.53",
"@types/react-router": "^5.1.8", "@types/react-router": "^5.1.8",
"@types/webpack": "^4.41.17", "@types/webpack": "^4.41.17",
"@k8slens/extensions": "file:../../src/extensions/npm/extensions", "css-loader": "^5.0.0",
"mobx": "^5.15.5", "mobx": "^5.15.5",
"react": "^16.13.1", "react": "^16.13.1",
"sass-loader": "^10.0.4",
"style-loader": "^2.0.0",
"ts-loader": "^8.0.4", "ts-loader": "^8.0.4",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typescript": "^4.0.3", "typescript": "^4.0.3",

View File

@ -22,8 +22,7 @@ export default class SupportPageRendererExtension extends LensRendererExtension
className="flex align-center gaps hover-highlight" className="flex align-center gaps hover-highlight"
onClick={() => Navigation.navigate(supportPageURL())} onClick={() => Navigation.navigate(supportPageURL())}
> >
<Component.Icon material="help_outline" small/> <Component.Icon material="help" smallest />
<span>Support</span>
</div> </div>
) )
} }

View File

@ -0,0 +1,13 @@
.PageLayout.Support {
a[target=_blank] {
text-decoration: none;
border-bottom: 1px solid;
&:after {
content: "launch";
font: small "Material Icons";
vertical-align: middle;
margin-left: 2px;
}
}
}

View File

@ -1,6 +1,6 @@
// TODO: figure out how to consume styles / handle import "./support.scss"
// TODO: support localization / figure out how to extract / consume i18n strings // TODO: support localization / figure out how to extract / consume i18n strings
import "./support.scss"
import React from "react" import React from "react"
import { observer } from "mobx-react" import { observer } from "mobx-react"
import { App, Component } from "@k8slens/extensions"; import { App, Component } from "@k8slens/extensions";

View File

@ -2,7 +2,6 @@ import path from "path"
const outputPath = path.resolve(__dirname, 'dist'); const outputPath = path.resolve(__dirname, 'dist');
// TODO: figure out how to share base TS and Webpack configs from Lens (npm, filesystem, etc?)
const lensExternals = { const lensExternals = {
"@k8slens/extensions": "var global.LensExtensions", "@k8slens/extensions": "var global.LensExtensions",
"react": "var global.React", "react": "var global.React",
@ -50,6 +49,14 @@ export default [
use: 'ts-loader', use: 'ts-loader',
exclude: /node_modules/, exclude: /node_modules/,
}, },
{
test: /\.s?css$/,
use: [
"style-loader",
"css-loader",
"sass-loader",
]
}
], ],
}, },
externals: [ externals: [

View File

@ -1,5 +1,8 @@
install-deps: install-deps:
npm install yarn install
build: install-deps build: install-deps
npm run build yarn run build
test:
yarn run test

View File

@ -7,6 +7,7 @@ export default class TelemetryMainExtension extends LensMainExtension {
async onActivate() { async onActivate() {
console.log("telemetry main extension activated") console.log("telemetry main extension activated")
tracker.start() tracker.start()
tracker.reportPeriodically()
await telemetryPreferencesStore.loadExtension(this) await telemetryPreferencesStore.loadExtension(this)
} }

View File

@ -8,6 +8,22 @@
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true "dev": true
}, },
"@segment/loosely-validate-event": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz",
"integrity": "sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==",
"dev": true,
"requires": {
"component-type": "^1.2.1",
"join-component": "^1.1.0"
}
},
"@types/analytics-node": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/analytics-node/-/analytics-node-3.1.3.tgz",
"integrity": "sha512-Yk299LUqnyJ6fNYQkLFd0yTfUwIvgfxH3f5WEX3ib0PC5T+mZgqcOPMDhNZ4AOD/A9tXKJQeBIb6KvgzuXflaQ==",
"dev": true
},
"@webassemblyjs/ast": { "@webassemblyjs/ast": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -225,6 +241,22 @@
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true "dev": true
}, },
"analytics-node": {
"version": "3.4.0-beta.3",
"resolved": "https://registry.npmjs.org/analytics-node/-/analytics-node-3.4.0-beta.3.tgz",
"integrity": "sha512-NIdpxiwlZ4cKgs9MDlDe89b5bg/pMq2W7XTA+cjzCM66IwW3ujZhVE49vk+zG6Yrxk0s/DXmennJ+cCQIsTKMA==",
"dev": true,
"requires": {
"@segment/loosely-validate-event": "^2.0.0",
"axios": "^0.19.2",
"axios-retry": "^3.0.2",
"lodash.isstring": "^4.0.1",
"md5": "^2.2.1",
"ms": "^2.0.0",
"remove-trailing-slash": "^0.1.0",
"uuid": "^3.2.1"
}
},
"ansi-styles": { "ansi-styles": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
@ -374,6 +406,24 @@
"integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==", "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==",
"dev": true "dev": true
}, },
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"dev": true,
"requires": {
"follow-redirects": "1.5.10"
}
},
"axios-retry": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-3.1.9.tgz",
"integrity": "sha512-NFCoNIHq8lYkJa6ku4m+V1837TP6lCa7n79Iuf8/AqATAHYB0ISaAS1eyIenDOfHOLtym34W65Sjke2xjg2fsA==",
"dev": true,
"requires": {
"is-retry-allowed": "^1.1.0"
}
},
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -696,6 +746,12 @@
"supports-color": "^5.3.0" "supports-color": "^5.3.0"
} }
}, },
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
"dev": true
},
"chokidar": { "chokidar": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz",
@ -813,6 +869,12 @@
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
"dev": true "dev": true
}, },
"component-type": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/component-type/-/component-type-1.2.1.tgz",
"integrity": "sha1-ikeQFwAjjk/DIml3EjAibyS0Fak=",
"dev": true
},
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -914,6 +976,12 @@
"sha.js": "^2.4.8" "sha.js": "^2.4.8"
} }
}, },
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"dev": true
},
"crypto-browserify": { "crypto-browserify": {
"version": "3.12.0", "version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@ -1377,6 +1445,26 @@
"readable-stream": "^2.3.6" "readable-stream": "^2.3.6"
} }
}, },
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"dev": true,
"requires": {
"debug": "=3.1.0"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": {
"ms": "2.0.0"
}
}
}
},
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -1784,6 +1872,12 @@
"isobject": "^3.0.1" "isobject": "^3.0.1"
} }
}, },
"is-retry-allowed": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
"integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==",
"dev": true
},
"is-typedarray": { "is-typedarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@ -1820,6 +1914,12 @@
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
"dev": true "dev": true
}, },
"join-component": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/join-component/-/join-component-1.1.0.tgz",
"integrity": "sha1-uEF7dQZho5K+4sJTfGiyqdSXfNU=",
"dev": true
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1910,6 +2010,12 @@
"path-exists": "^3.0.0" "path-exists": "^3.0.0"
} }
}, },
"lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=",
"dev": true
},
"loose-envify": { "loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -1961,6 +2067,17 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dev": true,
"requires": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"md5.js": { "md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -2615,6 +2732,12 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"remove-trailing-slash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz",
"integrity": "sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==",
"dev": true
},
"repeat-element": { "repeat-element": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz",

View File

@ -10,17 +10,20 @@
}, },
"scripts": { "scripts": {
"build": "webpack -p", "build": "webpack -p",
"dev": "webpack --watch" "dev": "webpack --watch",
"test": "echo NO TESTS"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@k8slens/extensions": "file:../../src/extensions/npm/extensions", "@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"@types/analytics-node": "^3.1.3",
"ts-loader": "^8.0.4", "ts-loader": "^8.0.4",
"typescript": "^4.0.3", "typescript": "^4.0.3",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"mobx": "^5.15.5", "mobx": "^5.15.5",
"react": "^16.13.1", "react": "^16.13.1",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"universal-analytics": "^0.4.23" "universal-analytics": "^0.4.23",
"analytics-node": "^3.4.0-beta.3"
} }
} }

View File

@ -1,31 +1,40 @@
import { EventBus, Util, Store, App } from "@k8slens/extensions" import { EventBus, Util, Store, App } from "@k8slens/extensions"
import ua from "universal-analytics" import ua from "universal-analytics"
import Analytics from "analytics-node"
import { machineIdSync } from "node-machine-id" import { machineIdSync } from "node-machine-id"
import { telemetryPreferencesStore } from "./telemetry-preferences-store" import { telemetryPreferencesStore } from "./telemetry-preferences-store"
export class Tracker extends Util.Singleton { export class Tracker extends Util.Singleton {
static readonly GA_ID = "UA-159377374-1" static readonly GA_ID = "UA-159377374-1"
static readonly SEGMENT_KEY = "YENwswyhlOgz8P7EFKUtIZ2MfON7Yxqb"
protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = [] protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = []
protected started = false protected started = false
protected visitor: ua.Visitor protected visitor: ua.Visitor
protected analytics: Analytics
protected machineId: string = null; protected machineId: string = null;
protected ip: string = null; protected ip: string = null;
protected appVersion: string; protected appVersion: string;
protected locale: string; protected locale: string;
protected electronUA: string; protected userAgent: string;
protected anonymousId: string;
protected os: string
protected reportInterval: NodeJS.Timeout protected reportInterval: NodeJS.Timeout
private constructor() { private constructor() {
super(); super();
this.anonymousId = machineIdSync()
this.os = this.resolveOS()
this.userAgent = `Lens ${App.version} (${this.os})`
try { try {
this.visitor = ua(Tracker.GA_ID, machineIdSync(), { strictCidFormat: false }) this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false })
} catch (error) { } catch (error) {
this.visitor = ua(Tracker.GA_ID) this.visitor = ua(Tracker.GA_ID)
} }
this.analytics = new Analytics(Tracker.SEGMENT_KEY, { flushAt: 1 })
this.visitor.set("dl", "https://telemetry.k8slens.dev") this.visitor.set("dl", "https://telemetry.k8slens.dev")
this.visitor.set("ua", `Lens ${App.version} (${this.getOS()})`) this.visitor.set("ua", this.userAgent)
} }
start() { start() {
@ -38,6 +47,9 @@ export class Tracker extends Util.Singleton {
} }
this.eventHandlers.push(handler) this.eventHandlers.push(handler)
EventBus.appEventBus.addListener(handler) EventBus.appEventBus.addListener(handler)
}
reportPeriodically() {
this.reportInterval = setInterval(() => { this.reportInterval = setInterval(() => {
this.reportData() this.reportData()
}, 60 * 60 * 1000) // report every 1h }, 60 * 60 * 1000) // report every 1h
@ -61,12 +73,13 @@ export class Tracker extends Util.Singleton {
} }
protected reportData() { protected reportData() {
const clustersList = Store.clusterStore.clustersList const clustersList = Store.clusterStore.enabledClustersList
this.event("generic-data", "report", { this.event("generic-data", "report", {
appVersion: App.version, appVersion: App.version,
os: this.os,
clustersCount: clustersList.length, clustersCount: clustersList.length,
workspacesCount: Store.workspaceStore.workspacesList.length workspacesCount: Store.workspaceStore.enabledWorkspacesList.length
}) })
clustersList.forEach((cluster) => { clustersList.forEach((cluster) => {
@ -78,6 +91,7 @@ export class Tracker extends Util.Singleton {
protected reportClusterData(cluster: Store.ClusterModel) { protected reportClusterData(cluster: Store.ClusterModel) {
this.event("cluster-data", "report", { this.event("cluster-data", "report", {
id: cluster.metadata.id, id: cluster.metadata.id,
managed: !!cluster.ownerRef,
kubernetesVersion: cluster.metadata.version, kubernetesVersion: cluster.metadata.version,
distribution: cluster.metadata.distribution, distribution: cluster.metadata.distribution,
nodesCount: cluster.metadata.nodes, nodesCount: cluster.metadata.nodes,
@ -85,7 +99,7 @@ export class Tracker extends Util.Singleton {
}) })
} }
protected getOS() { protected resolveOS() {
let os = "" let os = ""
if (App.isMac) { if (App.isMac) {
os = "MacOS" os = "MacOS"
@ -115,6 +129,19 @@ export class Tracker extends Util.Singleton {
ea: eventAction, ea: eventAction,
...otherParams, ...otherParams,
}).send() }).send()
this.analytics.track({
anonymousId: this.anonymousId,
event: `${eventCategory} ${eventAction}`,
context: {
userAgent: this.userAgent,
},
properties: {
category: eventCategory,
...otherParams,
},
})
} catch (err) { } catch (err) {
console.error(`Failed to track "${eventCategory}:${eventAction}"`, err) console.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
} }

View File

@ -549,6 +549,14 @@ msgstr "Condition"
msgid "Conditions" msgid "Conditions"
msgstr "Conditions" msgstr "Conditions"
#: src/renderer/components/+workloads-deployments/deployments.tsx: 118
msgid "Restart"
msgstr "Restart"
#: src/renderer/components/+workloads-deployments/deployments.tsx: 121
msgid "Are you sure you want to restart deployment <0>{0}</0>?"
msgstr "Are you sure you want to restart deployment <0>{0}</0>?"
#: src/renderer/components/+config-maps/config-maps.tsx:33 #: src/renderer/components/+config-maps/config-maps.tsx:33
msgid "Config Maps" msgid "Config Maps"
msgstr "Config Maps" msgstr "Config Maps"

View File

@ -545,6 +545,14 @@ msgstr ""
msgid "Conditions" msgid "Conditions"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-deployments/deployments.tsx: 118
msgid "Restart"
msgstr ""
#: src/renderer/components/+workloads-deployments/deployments.tsx: 121
msgid "Are you sure you want to restart deployment <0>{0}</0>?"
msgstr ""
#: src/renderer/components/+config-maps/config-maps.tsx:33 #: src/renderer/components/+config-maps/config-maps.tsx:33
msgid "Config Maps" msgid "Config Maps"
msgstr "" msgstr ""

View File

@ -550,6 +550,14 @@ msgstr "Состояние"
msgid "Conditions" msgid "Conditions"
msgstr "Состояния" msgstr "Состояния"
#: src/renderer/components/+workloads-deployments/deployments.tsx: 118
msgid "Restart"
msgstr "Перезагрузка"
#: src/renderer/components/+workloads-deployments/deployments.tsx: 121
msgid "Are you sure you want to restart deployment <0>{0}</0>?"
msgstr "Выполнить перезагрузку деплоймента <0>{0}</0>?"
#: src/renderer/components/+config-maps/config-maps.tsx:33 #: src/renderer/components/+config-maps/config-maps.tsx:33
msgid "Config Maps" msgid "Config Maps"
msgstr "" msgstr ""

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.0.0-alpha.2", "version": "4.0.0-alpha.4",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -72,10 +72,14 @@
"^.+\\.tsx?$": "ts-jest" "^.+\\.tsx?$": "ts-jest"
}, },
"moduleNameMapper": { "moduleNameMapper": {
"\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts" "\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts",
"^@lingui/macro$": "<rootDir>/__mocks__/@linguiMacro.ts"
}, },
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
"<rootDir>/dist" "<rootDir>/dist"
],
"setupFiles": [
"<rootDir>/src/jest.setup.ts"
] ]
}, },
"build": { "build": {
@ -186,6 +190,7 @@
"pod-menu", "pod-menu",
"node-menu", "node-menu",
"metrics-cluster-feature", "metrics-cluster-feature",
"license-menu-item",
"support-page" "support-page"
] ]
}, },
@ -264,6 +269,8 @@
"@lingui/react": "^3.0.0-13", "@lingui/react": "^3.0.0-13",
"@material-ui/core": "^4.10.1", "@material-ui/core": "^4.10.1",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
"@testing-library/jest-dom": "^5.11.5",
"@testing-library/react": "^11.1.0",
"@types/chart.js": "^2.9.21", "@types/chart.js": "^2.9.21",
"@types/circular-dependency-plugin": "^5.0.1", "@types/circular-dependency-plugin": "^5.0.1",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
@ -337,6 +344,7 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"include-media": "^1.4.9", "include-media": "^1.4.9",
"jest": "^26.0.1", "jest": "^26.0.1",
"jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.1", "make-plural": "^6.2.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",

View File

@ -64,13 +64,13 @@ describe("empty config", () => {
it("sets active cluster", () => { it("sets active cluster", () => {
clusterStore.setActive("foo"); clusterStore.setActive("foo");
expect(clusterStore.activeCluster.id).toBe("foo"); expect(clusterStore.active.id).toBe("foo");
}) })
}) })
describe("with prod and dev clusters added", () => { describe("with prod and dev clusters added", () => {
beforeEach(() => { beforeEach(() => {
clusterStore.addCluster( clusterStore.addClusters(
new Cluster({ new Cluster({
id: "prod", id: "prod",
contextName: "prod", contextName: "prod",

View File

@ -10,7 +10,7 @@ jest.mock("electron", () => {
} }
}) })
import { WorkspaceStore } from "../workspace-store" import { Workspace, WorkspaceStore } from "../workspace-store"
describe("workspace store tests", () => { describe("workspace store tests", () => {
describe("for an empty config", () => { describe("for an empty config", () => {
@ -35,16 +35,16 @@ describe("workspace store tests", () => {
it("cannot remove the default workspace", () => { it("cannot remove the default workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.removeWorkspace(WorkspaceStore.defaultId)).toThrowError("Cannot remove"); expect(() => ws.removeWorkspaceById(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
}) })
it("can update default workspace name", () => { it("can update default workspace name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: WorkspaceStore.defaultId, id: WorkspaceStore.defaultId,
name: "foobar", name: "foobar",
}); }));
expect(ws.currentWorkspace.name).toBe("foobar"); expect(ws.currentWorkspace.name).toBe("foobar");
}) })
@ -52,10 +52,10 @@ describe("workspace store tests", () => {
it("can add workspaces", () => { it("can add workspaces", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "123", id: "123",
name: "foobar", name: "foobar",
}); }));
expect(ws.getById("123").name).toBe("foobar"); expect(ws.getById("123").name).toBe("foobar");
}) })
@ -69,10 +69,10 @@ describe("workspace store tests", () => {
it("can set a existent workspace to be active", () => { it("can set a existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "abc", id: "abc",
name: "foobar", name: "foobar",
}); }));
expect(() => ws.setActive("abc")).not.toThrowError(); expect(() => ws.setActive("abc")).not.toThrowError();
}) })
@ -80,15 +80,15 @@ describe("workspace store tests", () => {
it("can remove a workspace", () => { it("can remove a workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "123", id: "123",
name: "foobar", name: "foobar",
}); }));
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "1234", id: "1234",
name: "foobar 1", name: "foobar 1",
}); }));
ws.removeWorkspace("123"); ws.removeWorkspaceById("123");
expect(ws.workspaces.size).toBe(2); expect(ws.workspaces.size).toBe(2);
}) })
@ -96,10 +96,10 @@ describe("workspace store tests", () => {
it("cannot create workspace with existent name", () => { it("cannot create workspace with existent name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "someid", id: "someid",
name: "default", name: "default",
}); }));
expect(ws.workspacesList.length).toBe(1); // default workspace only expect(ws.workspacesList.length).toBe(1); // default workspace only
}) })
@ -107,10 +107,10 @@ describe("workspace store tests", () => {
it("cannot create workspace with empty name", () => { it("cannot create workspace with empty name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "random", id: "random",
name: "", name: "",
}); }));
expect(ws.workspacesList.length).toBe(1); // default workspace only expect(ws.workspacesList.length).toBe(1); // default workspace only
}) })
@ -118,10 +118,10 @@ describe("workspace store tests", () => {
it("cannot create workspace with ' ' name", () => { it("cannot create workspace with ' ' name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "random", id: "random",
name: " ", name: " ",
}); }));
expect(ws.workspacesList.length).toBe(1); // default workspace only expect(ws.workspacesList.length).toBe(1); // default workspace only
}) })
@ -129,10 +129,10 @@ describe("workspace store tests", () => {
it("trim workspace name", () => { it("trim workspace name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.saveWorkspace({ ws.addWorkspace(new Workspace({
id: "random", id: "random",
name: "default ", name: "default ",
}); }));
expect(ws.workspacesList.length).toBe(1); // default workspace only expect(ws.workspacesList.length).toBe(1); // default workspace only
}) })
@ -169,4 +169,4 @@ describe("workspace store tests", () => {
expect(ws.currentWorkspaceId).toBe("abc"); expect(ws.currentWorkspaceId).toBe("abc");
}) })
}) })
}) })

View File

@ -2,7 +2,7 @@ import path from "path"
import Config from "conf" import Config from "conf"
import { Options as ConfOptions } from "conf/dist/source/types" import { Options as ConfOptions } from "conf/dist/source/types"
import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron" import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron"
import { action, observable, reaction, runInAction, toJS, when } from "mobx"; import { action, IReactionOptions, observable, reaction, runInAction, toJS, when } from "mobx";
import Singleton from "./utils/singleton"; import Singleton from "./utils/singleton";
import { getAppVersion } from "./utils/app-version"; import { getAppVersion } from "./utils/app-version";
import logger from "../main/logger"; import logger from "../main/logger";
@ -12,6 +12,7 @@ import isEqual from "lodash/isEqual";
export interface BaseStoreParams<T = any> extends ConfOptions<T> { export interface BaseStoreParams<T = any> extends ConfOptions<T> {
autoLoad?: boolean; autoLoad?: boolean;
syncEnabled?: boolean; syncEnabled?: boolean;
syncOptions?: IReactionOptions;
} }
export class BaseStore<T = any> extends Singleton { export class BaseStore<T = any> extends Singleton {
@ -20,7 +21,7 @@ export class BaseStore<T = any> extends Singleton {
whenLoaded = when(() => this.isLoaded); whenLoaded = when(() => this.isLoaded);
@observable isLoaded = false; @observable isLoaded = false;
@observable protected data: T; @observable data = {} as T;
protected constructor(protected params: BaseStoreParams) { protected constructor(protected params: BaseStoreParams) {
super(); super();
@ -36,8 +37,12 @@ export class BaseStore<T = any> extends Singleton {
return path.basename(this.storeConfig.path); return path.basename(this.storeConfig.path);
} }
get path() {
return this.storeConfig.path;
}
get syncChannel() { get syncChannel() {
return `store-sync:${this.name}` return `STORE-SYNC:${this.path}`
} }
protected async init() { protected async init() {
@ -56,19 +61,19 @@ export class BaseStore<T = any> extends Singleton {
...confOptions, ...confOptions,
projectName: "lens", projectName: "lens",
projectVersion: getAppVersion(), projectVersion: getAppVersion(),
cwd: this.storePath(), cwd: this.cwd(),
}); });
logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`); logger.info(`[STORE]: LOADED from ${this.path}`);
this.fromStore(this.storeConfig.store); this.fromStore(this.storeConfig.store);
this.isLoaded = true; this.isLoaded = true;
} }
protected storePath() { protected cwd() {
return (app || remote.app).getPath("userData") return (app || remote.app).getPath("userData")
} }
protected async saveToFile(model: T) { protected async saveToFile(model: T) {
logger.info(`[STORE]: SAVING ${this.name}`); logger.info(`[STORE]: SAVING ${this.path}`);
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114 // todo: update when fixed https://github.com/sindresorhus/conf/issues/114
Object.entries(model).forEach(([key, value]) => { Object.entries(model).forEach(([key, value]) => {
this.storeConfig.set(key, value); this.storeConfig.set(key, value);
@ -77,7 +82,7 @@ export class BaseStore<T = any> extends Singleton {
enableSync() { enableSync() {
this.syncDisposers.push( this.syncDisposers.push(
reaction(() => this.toJSON(), model => this.onModelChange(model)), reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions),
); );
if (ipcMain) { if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => { const callback = (event: IpcMainEvent, model: T) => {
@ -169,6 +174,7 @@ export class BaseStore<T = any> extends Singleton {
@action @action
protected fromStore(data: T) { protected fromStore(data: T) {
if (!data) return;
this.data = data; this.data = data;
} }

View File

@ -33,11 +33,12 @@ export type ClusterId = string;
export interface ClusterModel { export interface ClusterModel {
id: ClusterId; id: ClusterId;
kubeConfigPath: string;
workspace?: WorkspaceId; workspace?: WorkspaceId;
contextName?: string; contextName?: string;
preferences?: ClusterPreferences; preferences?: ClusterPreferences;
metadata?: ClusterMetadata; metadata?: ClusterMetadata;
kubeConfigPath: string; ownerRef?: string;
/** @deprecated */ /** @deprecated */
kubeConfig?: string; // yaml kubeConfig?: string; // yaml
@ -72,25 +73,34 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return filePath; return filePath;
} }
@observable activeCluster: ClusterId;
@observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>();
private constructor() { private constructor() {
super({ super({
configName: "lens-cluster-store", configName: "lens-cluster-store",
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
migrations: migrations, migrations: migrations,
}); });
this.pushStateToViewsPeriodically()
} }
@observable activeClusterId: ClusterId; protected pushStateToViewsPeriodically() {
@observable removedClusters = observable.map<ClusterId, Cluster>(); if (!ipcRenderer) {
@observable clusters = observable.map<ClusterId, Cluster>(); // This is a bit of a hack, we need to do this because we might loose messages that are sent before a view is ready
setInterval(() => {
this.pushState()
}, 5000)
}
}
registerIpcListener() { registerIpcListener() {
logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`) logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`)
ipcRenderer.on("cluster:state", (event, model: ClusterState) => { ipcRenderer.on("cluster:state", (event, clusterId: string, state: ClusterState) => {
this.applyWithoutSync(() => { logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state);
logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, model); this.getById(clusterId)?.setState(state)
this.getById(model.id)?.updateModel(model);
})
}) })
} }
@ -99,21 +109,35 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
ipcRenderer.removeAllListeners("cluster:state") ipcRenderer.removeAllListeners("cluster:state")
} }
@computed get activeCluster(): Cluster | null { pushState() {
return this.getById(this.activeClusterId); this.clusters.forEach((c) => {
c.pushState()
})
}
get activeClusterId() {
return this.activeCluster
} }
@computed get clustersList(): Cluster[] { @computed get clustersList(): Cluster[] {
return Array.from(this.clusters.values()); return Array.from(this.clusters.values());
} }
@computed get enabledClustersList(): Cluster[] {
return this.clustersList.filter((c) => c.enabled)
}
@computed get active(): Cluster | null {
return this.getById(this.activeCluster);
}
isActive(id: ClusterId) { isActive(id: ClusterId) {
return this.activeClusterId === id; return this.activeCluster === id;
} }
@action @action
setActive(id: ClusterId) { setActive(id: ClusterId) {
this.activeClusterId = this.clusters.has(id) ? id : null; this.activeCluster = this.clusters.has(id) ? id : null;
} }
@action @action
@ -145,12 +169,28 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
@action @action
addCluster(...models: ClusterModel[]) { addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = []
models.forEach(model => { models.forEach(model => {
appEventBus.emit({name: "cluster", action: "add"}) clusters.push(this.addCluster(model))
const cluster = new Cluster(model);
this.clusters.set(model.id, cluster);
}) })
return clusters
}
@action
addCluster(model: ClusterModel | Cluster ): Cluster {
appEventBus.emit({name: "cluster", action: "add"})
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model)
}
this.clusters.set(model.id, cluster);
return cluster
}
async removeCluster(model: ClusterModel) {
await this.removeById(model.id)
} }
@action @action
@ -159,7 +199,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
const cluster = this.getById(clusterId); const cluster = this.getById(clusterId);
if (cluster) { if (cluster) {
this.clusters.delete(clusterId); this.clusters.delete(clusterId);
if (this.activeClusterId === clusterId) { if (this.activeCluster === clusterId) {
this.setActive(null); this.setActive(null);
} }
// remove only custom kubeconfigs (pasted as text) // remove only custom kubeconfigs (pasted as text)
@ -189,6 +229,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
cluster.updateModel(clusterModel); cluster.updateModel(clusterModel);
} else { } else {
cluster = new Cluster(clusterModel); cluster = new Cluster(clusterModel);
if (!cluster.isManaged) {
cluster.enabled = true
}
} }
newClusters.set(clusterModel.id, cluster); newClusters.set(clusterModel.id, cluster);
} }
@ -200,14 +243,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
}); });
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null; this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null;
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
} }
toJSON(): ClusterStoreModel { toJSON(): ClusterStoreModel {
return toJS({ return toJS({
activeCluster: this.activeClusterId, activeCluster: this.activeCluster,
clusters: this.clustersList.map(cluster => cluster.toJSON()), clusters: this.clustersList.map(cluster => cluster.toJSON()),
}, { }, {
recurseEverything: true recurseEverything: true

View File

@ -11,4 +11,4 @@ export * from "./getRandId"
export * from "./splitArray" export * from "./splitArray"
export * from "./saveToAppFiles" export * from "./saveToAppFiles"
export * from "./singleton" export * from "./singleton"
export * from "./cloneJson" export * from "./openExternal"

View File

@ -0,0 +1,6 @@
// Opens a link in external browser
import { shell } from "electron"
export function openExternal(url: string) {
return shell.openExternal(url);
}

View File

@ -1,19 +1,77 @@
import { action, computed, observable, toJS } from "mobx"; import { ipcRenderer } from "electron";
import { action, computed, observable, toJS, reaction } from "mobx";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store" import { clusterStore } from "./cluster-store"
import { appEventBus } from "./event-bus"; import { appEventBus } from "./event-bus";
import { broadcastIpc } from "../common/ipc";
import logger from "../main/logger";
export type WorkspaceId = string; export type WorkspaceId = string;
export interface WorkspaceStoreModel { export interface WorkspaceStoreModel {
currentWorkspace?: WorkspaceId; currentWorkspace?: WorkspaceId;
workspaces: Workspace[] workspaces: WorkspaceModel[]
} }
export interface Workspace { export interface WorkspaceModel {
id: WorkspaceId; id: WorkspaceId;
name: string; name: string;
description?: string; description?: string;
ownerRef?: string;
}
export interface WorkspaceState {
enabled: boolean;
}
export class Workspace implements WorkspaceModel, WorkspaceState {
@observable id: WorkspaceId
@observable name: string
@observable description?: string
@observable ownerRef?: string
@observable enabled: boolean
constructor(data: WorkspaceModel) {
Object.assign(this, data)
if (!ipcRenderer) {
reaction(() => this.getState(), () => {
this.pushState()
})
}
}
get isManaged(): boolean {
return !!this.ownerRef
}
getState(): WorkspaceState {
return {
enabled: this.enabled
}
}
pushState(state = this.getState()) {
logger.silly("[WORKSPACE] pushing state", {...state, id: this.id})
broadcastIpc({
channel: "workspace:state",
args: [this.id, toJS(state)],
});
}
@action
setState(state: WorkspaceState) {
Object.assign(this, state)
}
toJSON(): WorkspaceModel {
return toJS({
id: this.id,
name: this.name,
description: this.description,
ownerRef: this.ownerRef
})
}
} }
export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> { export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@ -23,15 +81,33 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
super({ super({
configName: "lens-workspace-store", configName: "lens-workspace-store",
}); });
if (!ipcRenderer) {
setInterval(() => {
this.pushState()
}, 5000)
}
}
registerIpcListener() {
logger.info("[WORKSPACE-STORE] starting to listen state events")
ipcRenderer.on("workspace:state", (event, workspaceId: string, state: WorkspaceState) => {
this.getById(workspaceId)?.setState(state)
})
}
unregisterIpcListener() {
super.unregisterIpcListener()
ipcRenderer.removeAllListeners("workspace:state")
} }
@observable currentWorkspaceId = WorkspaceStore.defaultId; @observable currentWorkspaceId = WorkspaceStore.defaultId;
@observable workspaces = observable.map<WorkspaceId, Workspace>({ @observable workspaces = observable.map<WorkspaceId, Workspace>({
[WorkspaceStore.defaultId]: { [WorkspaceStore.defaultId]: new Workspace({
id: WorkspaceStore.defaultId, id: WorkspaceStore.defaultId,
name: "default" name: "default"
} })
}); });
@computed get currentWorkspace(): Workspace { @computed get currentWorkspace(): Workspace {
@ -42,6 +118,16 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
return Array.from(this.workspaces.values()); return Array.from(this.workspaces.values());
} }
@computed get enabledWorkspacesList() {
return this.workspacesList.filter((w) => w.enabled);
}
pushState() {
this.workspaces.forEach((w) => {
w.pushState()
})
}
isDefault(id: WorkspaceId) { isDefault(id: WorkspaceId) {
return id === WorkspaceStore.defaultId; return id === WorkspaceStore.defaultId;
} }
@ -61,11 +147,11 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
throw new Error(`workspace ${id} doesn't exist`); throw new Error(`workspace ${id} doesn't exist`);
} }
this.currentWorkspaceId = id; this.currentWorkspaceId = id;
clusterStore.activeClusterId = null; // fixme: handle previously selected cluster from current workspace clusterStore.activeCluster = null; // fixme: handle previously selected cluster from current workspace
} }
@action @action
saveWorkspace(workspace: Workspace) { addWorkspace(workspace: Workspace) {
const { id, name } = workspace; const { id, name } = workspace;
const existingWorkspace = this.getById(id); const existingWorkspace = this.getById(id);
if (!name.trim() || this.getByName(name.trim())) { if (!name.trim() || this.getByName(name.trim())) {
@ -82,7 +168,12 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
} }
@action @action
removeWorkspace(id: WorkspaceId) { removeWorkspace(workspace: Workspace) {
this.removeWorkspaceById(workspace.id)
}
@action
removeWorkspaceById(id: WorkspaceId) {
const workspace = this.getById(id); const workspace = this.getById(id);
if (!workspace) return; if (!workspace) return;
if (this.isDefault(id)) { if (this.isDefault(id)) {
@ -103,7 +194,11 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
} }
if (workspaces.length) { if (workspaces.length) {
this.workspaces.clear(); this.workspaces.clear();
workspaces.forEach(workspace => { workspaces.forEach(ws => {
const workspace = new Workspace(ws)
if (!workspace.isManaged) {
workspace.enabled = true
}
this.workspaces.set(workspace.id, workspace) this.workspaces.set(workspace.id, workspace)
}) })
} }
@ -112,7 +207,7 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
toJSON(): WorkspaceStoreModel { toJSON(): WorkspaceStoreModel {
return toJS({ return toJS({
currentWorkspace: this.currentWorkspaceId, currentWorkspace: this.currentWorkspaceId,
workspaces: this.workspacesList, workspaces: this.workspacesList.map((w) => w.toJSON()),
}, { }, {
recurseEverything: true recurseEverything: true
}) })

View File

@ -1 +1,2 @@
export { ClusterFeature as Feature, ClusterFeatureStatus as FeatureStatus } from "../cluster-feature" export { ClusterFeature as Feature } from "../cluster-feature"
export type { ClusterFeatureStatus as FeatureStatus } from "../cluster-feature"

View File

@ -1,4 +1,6 @@
export { ExtensionStore } from "../extension-store" export { ExtensionStore } from "../extension-store"
export { clusterStore, ClusterModel } from "../../common/cluster-store" export { clusterStore } from "../../common/cluster-store"
export { workspaceStore} from "../../common/workspace-store" export type { ClusterModel } from "../../common/cluster-store"
export type { Cluster } from "../../main/cluster" export { Cluster } from "../../main/cluster"
export { workspaceStore, Workspace } from "../../common/workspace-store"
export type { WorkspaceModel } from "../../common/workspace-store"

View File

@ -1,3 +1,3 @@
export { Singleton } from "../../common/utils" export { Singleton, openExternal } from "../../common/utils"
export { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault" export { prevDefault, stopPropagation } from "../../renderer/utils/prevDefault"
export { cssNames } from "../../renderer/utils/cssNames" export { cssNames } from "../../renderer/utils/cssNames"

View File

@ -1,17 +1,13 @@
import type { ExtensionId, ExtensionManifest, ExtensionModel, LensExtension } from "./lens-extension" import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension"
import type { LensMainExtension } from "./lens-main-extension" import type { LensMainExtension } from "./lens-main-extension"
import type { LensRendererExtension } from "./lens-renderer-extension" import type { LensRendererExtension } from "./lens-renderer-extension"
import type { InstalledExtension } from "./extension-manager";
import path from "path" import path from "path"
import { broadcastIpc } from "../common/ipc" import { broadcastIpc } from "../common/ipc"
import { observable, reaction, toJS, } from "mobx" import { computed, observable, reaction, when } from "mobx"
import logger from "../main/logger" import logger from "../main/logger"
import { app, ipcRenderer, remote } from "electron" import { app, ipcRenderer, remote } from "electron"
import { appPreferenceRegistry, clusterFeatureRegistry, clusterPageRegistry, globalPageRegistry, kubeObjectMenuRegistry, menuRegistry, statusBarRegistry } from "./registries"; import * as registries from "./registries";
export interface InstalledExtension extends ExtensionModel {
manifestPath: string;
manifest: ExtensionManifest;
}
// lazy load so that we get correct userData // lazy load so that we get correct userData
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
@ -19,68 +15,82 @@ export function extensionPackagesRoot() {
} }
export class ExtensionLoader { export class ExtensionLoader {
@observable extensions = observable.map<ExtensionId, InstalledExtension>([], { deep: false }); @observable isLoaded = false;
@observable instances = observable.map<ExtensionId, LensExtension>([], { deep: false }) protected extensions = observable.map<LensExtensionId, InstalledExtension>([], { deep: false });
protected instances = observable.map<LensExtensionId, LensExtension>([], { deep: false })
constructor() { constructor() {
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.on("extensions:loaded", (event, extensions: InstalledExtension[]) => { ipcRenderer.on("extensions:loaded", (event, extensions: InstalledExtension[]) => {
this.isLoaded = true;
extensions.forEach((ext) => { extensions.forEach((ext) => {
if (!this.getById(ext.manifestPath)) { if (!this.extensions.has(ext.manifestPath)) {
this.extensions.set(ext.manifestPath, ext) this.extensions.set(ext.manifestPath, ext)
} }
}) })
}) });
} }
} }
@computed get userExtensions(): LensExtension[] {
return [...this.instances.values()].filter(ext => !ext.isBundled)
}
async init() {
const { extensionManager } = await import("./extension-manager");
const installedExtensions = await extensionManager.load();
this.extensions.replace(installedExtensions);
this.isLoaded = true;
this.loadOnMain();
}
loadOnMain() { loadOnMain() {
logger.info('[EXTENSIONS-LOADER]: load on main') logger.info('[EXTENSIONS-LOADER]: load on main')
this.autoloadExtensions((extension: LensMainExtension) => { this.autoInitExtensions((extension: LensMainExtension) => [
extension.registerTo(menuRegistry, extension.appMenus) registries.menuRegistry.add(...extension.appMenus)
}) ]);
} }
loadOnClusterManagerRenderer() { loadOnClusterManagerRenderer() {
logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)') logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)')
this.autoloadExtensions((extension: LensRendererExtension) => { this.autoInitExtensions((extension: LensRendererExtension) => [
extension.registerTo(globalPageRegistry, extension.globalPages) registries.globalPageRegistry.add(...extension.globalPages),
extension.registerTo(appPreferenceRegistry, extension.appPreferences) registries.appPreferenceRegistry.add(...extension.appPreferences),
extension.registerTo(clusterFeatureRegistry, extension.clusterFeatures) registries.clusterFeatureRegistry.add(...extension.clusterFeatures),
extension.registerTo(statusBarRegistry, extension.statusBarItems) registries.statusBarRegistry.add(...extension.statusBarItems),
}) ]);
} }
loadOnClusterRenderer() { loadOnClusterRenderer() {
logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)') logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)')
this.autoloadExtensions((extension: LensRendererExtension) => { this.autoInitExtensions((extension: LensRendererExtension) => [
extension.registerTo(clusterPageRegistry, extension.clusterPages) registries.clusterPageRegistry.add(...extension.clusterPages),
extension.registerTo(kubeObjectMenuRegistry, extension.kubeObjectMenuItems) registries.kubeObjectMenuRegistry.add(...extension.kubeObjectMenuItems),
}) registries.kubeObjectDetailRegistry.add(...extension.kubeObjectDetailItems),
]);
} }
protected autoloadExtensions(callback: (instance: LensExtension) => void) { protected autoInitExtensions(register: (ext: LensExtension) => Function[]) {
return reaction(() => this.extensions.toJS(), (installedExtensions) => { return reaction(() => this.extensions.toJS(), (installedExtensions) => {
for(const [id, ext] of installedExtensions) { for (const [id, ext] of installedExtensions) {
let instance = this.instances.get(ext.id) let instance = this.instances.get(ext.manifestPath)
if (!instance) { if (!instance) {
const extensionModule = this.requireExtension(ext) const extensionModule = this.requireExtension(ext)
if (!extensionModule) { if (!extensionModule) {
continue continue
} }
const LensExtensionClass = extensionModule.default;
instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest);
try { try {
instance.enable() const LensExtensionClass: LensExtensionConstructor = extensionModule.default;
callback(instance) instance = new LensExtensionClass(ext);
} finally { instance.whenEnabled(() => register(instance));
this.instances.set(ext.id, instance) this.instances.set(ext.manifestPath, instance);
} catch (err) {
logger.error(`[EXTENSIONS-LOADER]: init extension instance error`, { ext, err })
} }
} }
} }
}, { }, {
fireImmediately: true, fireImmediately: true,
delay: 0,
}) })
} }
@ -101,37 +111,17 @@ export class ExtensionLoader {
} }
} }
getById(id: ExtensionId): InstalledExtension { async broadcastExtensions(frameId?: number) {
return this.extensions.get(id); await when(() => this.isLoaded);
}
async removeById(id: ExtensionId) {
const extension = this.getById(id);
if (extension) {
const instance = this.instances.get(extension.id)
if (instance) {
await instance.disable()
}
this.extensions.delete(id);
}
}
broadcastExtensions(frameId?: number) {
broadcastIpc({ broadcastIpc({
channel: "extensions:loaded", channel: "extensions:loaded",
frameId: frameId, frameId: frameId,
frameOnly: !!frameId, frameOnly: !!frameId,
args: [this.toJSON().extensions], args: [
}) Array.from(this.extensions.toJS().values())
} ],
toJSON() {
return toJS({
extensions: Array.from(this.extensions).map(([id, instance]) => instance),
}, {
recurseEverything: true,
}) })
} }
} }
export const extensionLoader = new ExtensionLoader() export const extensionLoader = new ExtensionLoader();

View File

@ -1,12 +1,18 @@
import type { ExtensionManifest } from "./lens-extension" import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"
import path from "path" import path from "path"
import os from "os" import os from "os"
import fs from "fs-extra" import fs from "fs-extra"
import child_process from "child_process";
import logger from "../main/logger" import logger from "../main/logger"
import { extensionPackagesRoot, InstalledExtension } from "./extension-loader" import { extensionPackagesRoot } from "./extension-loader"
import * as child_process from 'child_process';
import { getBundledExtensions } from "../common/utils/app-version" import { getBundledExtensions } from "../common/utils/app-version"
export interface InstalledExtension {
manifest: LensExtensionManifest;
manifestPath: string;
isBundled?: boolean; // defined in package.json
}
type Dependencies = { type Dependencies = {
[name: string]: string; [name: string]: string;
} }
@ -17,6 +23,8 @@ type PackageJson = {
export class ExtensionManager { export class ExtensionManager {
protected bundledFolderPath: string
protected packagesJson: PackageJson = { protected packagesJson: PackageJson = {
dependencies: {} dependencies: {}
} }
@ -45,32 +53,41 @@ export class ExtensionManager {
return __non_webpack_require__.resolve('npm/bin/npm-cli') return __non_webpack_require__.resolve('npm/bin/npm-cli')
} }
async load() { get packageJsonPath() {
return path.join(this.extensionPackagesRoot, "package.json")
}
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot) logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot)
if (this.inTreeFolderPath !== this.inTreeTargetPath) { if (fs.existsSync(path.join(this.extensionPackagesRoot, "package-lock.json"))) {
await fs.remove(path.join(this.extensionPackagesRoot, "package-lock.json"))
}
try {
await fs.access(this.inTreeFolderPath, fs.constants.W_OK)
this.bundledFolderPath = this.inTreeFolderPath
} catch {
// we need to copy in-tree extensions so that we can symlink them properly on "npm install" // we need to copy in-tree extensions so that we can symlink them properly on "npm install"
await fs.remove(this.inTreeTargetPath) await fs.remove(this.inTreeTargetPath)
await fs.ensureDir(this.inTreeTargetPath) await fs.ensureDir(this.inTreeTargetPath)
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath) await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath)
this.bundledFolderPath = this.inTreeTargetPath
} }
await fs.ensureDir(this.nodeModulesPath) await fs.ensureDir(this.nodeModulesPath)
await fs.ensureDir(this.localFolderPath) await fs.ensureDir(this.localFolderPath)
return await this.loadExtensions(); return await this.loadExtensions();
} }
async getExtensionByManifest(manifestPath: string): Promise<InstalledExtension> { protected async getByManifest(manifestPath: string): Promise<InstalledExtension> {
let manifestJson: ExtensionManifest; let manifestJson: LensExtensionManifest;
try { try {
fs.accessSync(manifestPath, fs.constants.F_OK); // check manifest file for existence
manifestJson = __non_webpack_require__(manifestPath) manifestJson = __non_webpack_require__(manifestPath)
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath) this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath)
logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name) logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name)
return { return {
id: manifestJson.name,
version: manifestJson.version,
name: manifestJson.name,
manifestPath: path.join(this.nodeModulesPath, manifestJson.name, "package.json"), manifestPath: path.join(this.nodeModulesPath, manifestJson.name, "package.json"),
manifest: manifestJson manifest: manifestJson,
} }
} catch (err) { } catch (err) {
logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson }); logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson });
@ -79,7 +96,7 @@ export class ExtensionManager {
protected installPackages(): Promise<void> { protected installPackages(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = child_process.fork(this.npmPath, ["install", "--silent"], { const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], {
cwd: extensionPackagesRoot(), cwd: extensionPackagesRoot(),
silent: true silent: true
}) })
@ -95,13 +112,15 @@ export class ExtensionManager {
async loadExtensions() { async loadExtensions() {
const bundledExtensions = await this.loadBundledExtensions() const bundledExtensions = await this.loadBundledExtensions()
const localExtensions = await this.loadFromFolder(this.localFolderPath) const localExtensions = await this.loadFromFolder(this.localFolderPath)
await fs.writeFile(path.join(this.packageJsonPath), JSON.stringify(this.packagesJson, null, 2), { mode: 0o600 })
await this.installPackages()
const extensions = bundledExtensions.concat(localExtensions) const extensions = bundledExtensions.concat(localExtensions)
return new Map(extensions.map(ext => [ext.id, ext])); return new Map(extensions.map(ext => [ext.manifestPath, ext]));
} }
async loadBundledExtensions() { async loadBundledExtensions() {
const extensions: InstalledExtension[] = [] const extensions: InstalledExtension[] = []
const folderPath = this.inTreeTargetPath const folderPath = this.bundledFolderPath
const bundledExtensions = getBundledExtensions() const bundledExtensions = getBundledExtensions()
const paths = await fs.readdir(folderPath); const paths = await fs.readdir(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
@ -110,15 +129,13 @@ export class ExtensionManager {
} }
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
const manifestPath = path.resolve(absPath, "package.json"); const manifestPath = path.resolve(absPath, "package.json");
await fs.access(manifestPath, fs.constants.F_OK) const ext = await this.getByManifest(manifestPath).catch(() => null)
const ext = await this.getExtensionByManifest(manifestPath).catch(() => null)
if (ext) { if (ext) {
ext.isBundled = true;
extensions.push(ext) extensions.push(ext)
} }
} }
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions }); logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
await fs.writeFile(path.join(this.extensionPackagesRoot, "package.json"), JSON.stringify(this.packagesJson), {mode: 0o600})
await this.installPackages()
return extensions return extensions
} }
@ -131,18 +148,21 @@ export class ExtensionManager {
continue continue
} }
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
if (!fs.existsSync(absPath)) {
continue
}
const lstat = await fs.lstat(absPath)
if (!lstat.isDirectory() && !lstat.isSymbolicLink()) { // skip non-directories
continue
}
const manifestPath = path.resolve(absPath, "package.json"); const manifestPath = path.resolve(absPath, "package.json");
await fs.access(manifestPath, fs.constants.F_OK) const ext = await this.getByManifest(manifestPath).catch(() => null)
const ext = await this.getExtensionByManifest(manifestPath).catch(() => null)
if (ext) { if (ext) {
extensions.push(ext) extensions.push(ext)
} }
} }
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions }); logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
await fs.writeFile(path.join(this.extensionPackagesRoot, "package.json"), JSON.stringify(this.packagesJson), {mode: 0o600})
await this.installPackages()
return extensions; return extensions;
} }
} }

View File

@ -15,7 +15,7 @@ export class ExtensionStore<T = any> extends BaseStore<T> {
await super.load() await super.load()
} }
protected storePath() { protected cwd() {
return path.join(super.storePath(), "extension-store", this.extension.name) return path.join(super.cwd(), "extension-store", this.extension.name)
} }
} }

View File

@ -1,75 +1,111 @@
import { readJsonSync } from "fs-extra"; import type { InstalledExtension } from "./extension-manager";
import { action, observable, toJS } from "mobx"; import { action, reaction } from "mobx";
import logger from "../main/logger"; import logger from "../main/logger";
import { BaseRegistry } from "./registries/base-registry"; import { ExtensionStore } from "./extension-store";
export type ExtensionId = string | ExtensionPackageJsonPath; export type LensExtensionId = string; // path to manifest (package.json)
export type ExtensionPackageJsonPath = string; export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
export type ExtensionVersion = string | number;
export interface ExtensionModel { export interface LensExtensionManifest {
id: ExtensionId;
version: ExtensionVersion;
name: string; name: string;
manifestPath: string; version: string;
description?: string; description?: string;
enabled?: boolean; main?: string; // path to %ext/dist/main.js
updateUrl?: string; renderer?: string; // path to %ext/dist/renderer.js
} }
export interface ExtensionManifest extends ExtensionModel { export interface LensExtensionStoreModel {
main?: string; isEnabled: boolean;
renderer?: string;
description?: string; // todo: add more fields similar to package.json + some extra
} }
export class LensExtension implements ExtensionModel { export class LensExtension<S extends ExtensionStore<LensExtensionStoreModel> = any> {
public id: ExtensionId; protected store: S;
public updateUrl: string; readonly manifest: LensExtensionManifest;
protected disposers: (() => void)[] = []; readonly manifestPath: string;
readonly isBundled: boolean;
@observable name = ""; constructor({ manifest, manifestPath, isBundled }: InstalledExtension) {
@observable description = ""; this.manifest = manifest
@observable version: ExtensionVersion = "0.0.0"; this.manifestPath = manifestPath
@observable manifest: ExtensionManifest; this.isBundled = !!isBundled
@observable manifestPath: string; this.init();
@observable isEnabled = false; }
constructor(model: ExtensionModel, manifest: ExtensionManifest) { protected async init(store: S = createBaseStore().getInstance()) {
this.importModel(model, manifest); this.store = store;
await this.store.loadExtension(this);
reaction(() => this.store.data.isEnabled, (isEnabled = true) => {
this.toggle(isEnabled); // handle activation & deactivation
}, {
fireImmediately: true
});
}
get isEnabled() {
return !!this.store.data.isEnabled;
}
get id(): LensExtensionId {
return this.manifestPath;
}
get name() {
return this.manifest.name
}
get version() {
return this.manifest.version
}
get description() {
return this.manifest.description
} }
@action @action
async importModel({ enabled, manifestPath, ...model }: ExtensionModel, manifest?: ExtensionManifest) {
try {
this.manifest = manifest || await readJsonSync(manifestPath, { throws: true })
this.manifestPath = manifestPath;
Object.assign(this, model);
} catch (err) {
logger.error(`[EXTENSION]: cannot read manifest at ${manifestPath}`, { ...model, err: String(err) })
this.disable();
}
}
async migrate(appVersion: string) {
// mock
}
async enable() { async enable() {
this.isEnabled = true; if (this.isEnabled) return;
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); this.store.data.isEnabled = true;
this.onActivate(); this.onActivate();
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
} }
@action
async disable() { async disable() {
if (!this.isEnabled) return;
this.store.data.isEnabled = false;
this.onDeactivate(); this.onDeactivate();
this.isEnabled = false;
this.disposers.forEach(cleanUp => cleanUp());
this.disposers.length = 0;
logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
} }
// todo: add more hooks toggle(enable?: boolean) {
if (typeof enable === "boolean") {
enable ? this.enable() : this.disable()
} else {
this.isEnabled ? this.disable() : this.enable()
}
}
async whenEnabled(handlers: () => Function[]) {
const disposers: Function[] = [];
const unregisterHandlers = () => {
disposers.forEach(unregister => unregister())
disposers.length = 0;
}
const cancelReaction = reaction(() => this.isEnabled, isEnabled => {
if (isEnabled) {
disposers.push(...handlers());
} else {
unregisterHandlers();
}
}, {
fireImmediately: true
})
return () => {
unregisterHandlers();
cancelReaction();
}
}
protected onActivate() { protected onActivate() {
// mock // mock
} }
@ -77,37 +113,14 @@ export class LensExtension implements ExtensionModel {
protected onDeactivate() { protected onDeactivate() {
// mock // mock
} }
}
registerTo<T = any>(registry: BaseRegistry<T>, items: T[] = []) { function createBaseStore() {
const disposers = items.map(item => registry.add(item)); return class extends ExtensionStore<LensExtensionStoreModel> {
this.disposers.push(...disposers); constructor() {
return () => { super({
this.disposers = this.disposers.filter(disposer => !disposers.includes(disposer)) configName: "state"
}; });
} }
getMeta() {
return toJS({
id: this.id,
manifest: this.manifest,
manifestPath: this.manifestPath,
enabled: this.isEnabled
}, {
recurseEverything: true
})
}
toJSON(): ExtensionModel {
return toJS({
id: this.id,
name: this.name,
version: this.version,
description: this.description,
manifestPath: this.manifestPath,
enabled: this.isEnabled,
updateUrl: this.updateUrl,
}, {
recurseEverything: true,
})
} }
} }

View File

@ -1,4 +1,8 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectMenuRegistration, PageRegistration, StatusBarRegistration } from "./registries" import type {
AppPreferenceRegistration, ClusterFeatureRegistration,
KubeObjectMenuRegistration, KubeObjectDetailRegistration,
PageRegistration, StatusBarRegistration
} from "./registries"
import { observable } from "mobx"; import { observable } from "mobx";
import { LensExtension } from "./lens-extension" import { LensExtension } from "./lens-extension"
@ -8,5 +12,6 @@ export class LensRendererExtension extends LensExtension {
@observable.shallow appPreferences: AppPreferenceRegistration[] = [] @observable.shallow appPreferences: AppPreferenceRegistration[] = []
@observable.shallow clusterFeatures: ClusterFeatureRegistration[] = [] @observable.shallow clusterFeatures: ClusterFeatureRegistration[] = []
@observable.shallow statusBarItems: StatusBarRegistration[] = [] @observable.shallow statusBarItems: StatusBarRegistration[] = []
@observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = []
@observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = [] @observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = []
} }

View File

@ -1 +1,2 @@
api.d.ts api.d.ts
yarn.lock

View File

@ -12,5 +12,8 @@
"author": { "author": {
"name": "Mirantis, Inc.", "name": "Mirantis, Inc.",
"email": "info@k8slens.dev" "email": "info@k8slens.dev"
},
"devDependencies": {
"@types/node": "^14.14.6"
} }
} }

View File

@ -1,5 +1,5 @@
// Base class for extensions-api registries // Base class for extensions-api registries
import { observable } from "mobx"; import { action, observable } from "mobx";
export class BaseRegistry<T = any> { export class BaseRegistry<T = any> {
protected items = observable<T>([], { deep: false }); protected items = observable<T>([], { deep: false });
@ -8,10 +8,16 @@ export class BaseRegistry<T = any> {
return this.items.toJS(); return this.items.toJS();
} }
add(item: T) { @action
this.items.push(item); add(...items: T[]) {
return () => { this.items.push(...items);
return () => this.remove(...items);
}
@action
remove(...items: T[]) {
items.forEach(item => {
this.items.remove(item); // works because of {deep: false}; this.items.remove(item); // works because of {deep: false};
} })
} }
} }

View File

@ -4,5 +4,6 @@ export * from "./page-registry"
export * from "./menu-registry" export * from "./menu-registry"
export * from "./app-preference-registry" export * from "./app-preference-registry"
export * from "./status-bar-registry" export * from "./status-bar-registry"
export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry"; export * from "./kube-object-menu-registry";
export * from "./cluster-feature-registry" export * from "./cluster-feature-registry"

View File

@ -0,0 +1,22 @@
import React from "react"
import { BaseRegistry } from "./base-registry";
export interface KubeObjectDetailComponents {
Details: React.ComponentType<any>;
}
export interface KubeObjectDetailRegistration {
kind: string;
apiVersions: string[];
components: KubeObjectDetailComponents;
}
export class KubeObjectDetailRegistry extends BaseRegistry<KubeObjectDetailRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
return this.items.filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion)
})
}
}
export const kubeObjectDetailRegistry = new KubeObjectDetailRegistry()

View File

@ -11,7 +11,8 @@ export * from "../../renderer/components/drawer"
// kube helpers // kube helpers
export { KubeObjectDetailsProps, KubeObjectMenuProps } from "../../renderer/components/kube-object" export { KubeObjectDetailsProps, KubeObjectMenuProps } from "../../renderer/components/kube-object"
export { KubeObjectMeta } from "../../renderer/components/kube-object/kube-object-meta"; export { KubeObjectMeta } from "../../renderer/components/kube-object/kube-object-meta"
export { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../renderer/components/kube-object/kube-object-list-layout";
export { KubeEventDetails } from "../../renderer/components/+events/kube-event-details" export { KubeEventDetails } from "../../renderer/components/+events/kube-event-details"
// specific exports // specific exports

View File

@ -1,4 +1,6 @@
export { isAllowedResource } from "../../common/rbac"
export { apiManager } from "../../renderer/api/api-manager"; export { apiManager } from "../../renderer/api/api-manager";
export { KubeObjectStore } from "../../renderer/kube-object.store"
export { KubeApi, forCluster, IKubeApiCluster } from "../../renderer/api/kube-api"; export { KubeApi, forCluster, IKubeApiCluster } from "../../renderer/api/kube-api";
export { KubeObject } from "../../renderer/api/kube-object"; export { KubeObject } from "../../renderer/api/kube-object";
export { Pod, podsApi, IPodContainer, IPodContainerStatus } from "../../renderer/api/endpoints"; export { Pod, podsApi, IPodContainer, IPodContainerStatus } from "../../renderer/api/endpoints";

View File

@ -1 +1,3 @@
export { navigate, hideDetails, showDetails } from "../../renderer/navigation" export { navigate, hideDetails, showDetails, getDetailsUrl } from "../../renderer/navigation"
export { RouteProps } from "react-router"
export { IURLParams } from "../../common/utils/buildUrl";

4
src/jest.setup.ts Normal file
View File

@ -0,0 +1,4 @@
import fetchMock from "jest-fetch-mock"
// rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks();

View File

@ -31,7 +31,7 @@ export class DistributionDetector extends BaseClusterDetector {
if (this.isCustom()) { if (this.isCustom()) {
return { value: "custom", accuracy: 10} return { value: "custom", accuracy: 10}
} }
return { value: "vanilla", accuracy: 10} return { value: "unknown", accuracy: 10}
} }
public async getKubernetesVersion() { public async getKubernetesVersion() {

View File

@ -5,13 +5,12 @@ export class NodesCountDetector extends BaseClusterDetector {
key = ClusterMetadataKey.NODES_COUNT key = ClusterMetadataKey.NODES_COUNT
public async detect() { public async detect() {
if (!this.cluster.accessible) return null;
const nodeCount = await this.getNodeCount() const nodeCount = await this.getNodeCount()
return { value: nodeCount, accuracy: 100} return { value: nodeCount, accuracy: 100}
} }
protected async getNodeCount(): Promise<number> { protected async getNodeCount(): Promise<number> {
if (!this.cluster.accessible) return null;
const response = await this.k8sRequest("/api/v1/nodes") const response = await this.k8sRequest("/api/v1/nodes")
return response.items.length return response.items.length
} }

View File

@ -1,5 +1,6 @@
import "../common/cluster-ipc"; import "../common/cluster-ipc";
import type http from "http" import type http from "http"
import { ipcMain } from "electron"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { clusterStore, getClusterIdFromHost } from "../common/cluster-store" import { clusterStore, getClusterIdFromHost } from "../common/cluster-store"
import { Cluster } from "./cluster" import { Cluster } from "./cluster"
@ -10,7 +11,7 @@ export class ClusterManager {
constructor(public readonly port: number) { constructor(public readonly port: number) {
// auto-init clusters // auto-init clusters
autorun(() => { autorun(() => {
clusterStore.clusters.forEach(cluster => { clusterStore.enabledClustersList.forEach(cluster => {
if (!cluster.initialized) { if (!cluster.initialized) {
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
cluster.init(port); cluster.init(port);
@ -30,6 +31,29 @@ export class ClusterManager {
}, { }, {
delay: 250 delay: 250
}); });
ipcMain.on("network:offline", () => { this.onNetworkOffline() })
ipcMain.on("network:online", () => { this.onNetworkOnline() })
}
protected onNetworkOffline() {
logger.info("[CLUSTER-MANAGER]: network is offline")
clusterStore.enabledClustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.online = false
cluster.accessible = false
cluster.refreshConnectionStatus().catch((e) => e)
}
})
}
protected onNetworkOnline() {
logger.info("[CLUSTER-MANAGER]: network is online")
clusterStore.enabledClustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.refreshConnectionStatus().catch((e) => e)
}
})
} }
stop() { stop() {

View File

@ -1,3 +1,4 @@
import { ipcMain } from "electron"
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store" import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store"
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store"; import type { WorkspaceId } from "../common/workspace-store";
@ -33,7 +34,7 @@ export type ClusterRefreshOptions = {
refreshMetadata?: boolean refreshMetadata?: boolean
} }
export interface ClusterState extends ClusterModel { export interface ClusterState {
initialized: boolean; initialized: boolean;
apiUrl: string; apiUrl: string;
online: boolean; online: boolean;
@ -47,11 +48,12 @@ export interface ClusterState extends ClusterModel {
allowedResources: string[] allowedResources: string[]
} }
export class Cluster implements ClusterModel { export class Cluster implements ClusterModel, ClusterState {
public id: ClusterId; public id: ClusterId;
public frameId: number; public frameId: number;
public kubeCtl: Kubectl public kubeCtl: Kubectl
public contextHandler: ContextHandler; public contextHandler: ContextHandler;
public ownerRef: string;
protected kubeconfigManager: KubeconfigManager; protected kubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = []; protected eventDisposers: Function[] = [];
protected activated = false; protected activated = false;
@ -65,11 +67,12 @@ export class Cluster implements ClusterModel {
@observable kubeConfigPath: string; @observable kubeConfigPath: string;
@observable apiUrl: string; // cluster server url @observable apiUrl: string; // cluster server url
@observable kubeProxyUrl: string; // lens-proxy to kube-api url @observable kubeProxyUrl: string; // lens-proxy to kube-api url
@observable online = false; @observable enabled = false; // only enabled clusters are visible to users
@observable accessible = false; @observable online = false; // describes if we can detect that cluster is online
@observable ready = false; @observable accessible = false; // if user is able to access cluster resources
@observable ready = false; // cluster is in usable state
@observable reconnecting = false; @observable reconnecting = false;
@observable disconnected = true; @observable disconnected = true; // false if user has selected to connect
@observable failureReason: string; @observable failureReason: string;
@observable isAdmin = false; @observable isAdmin = false;
@observable eventCount = 0; @observable eventCount = 0;
@ -81,6 +84,7 @@ export class Cluster implements ClusterModel {
@computed get available() { @computed get available() {
return this.accessible && !this.disconnected; return this.accessible && !this.disconnected;
} }
get version(): string { get version(): string {
return String(this.metadata?.version) || "" return String(this.metadata?.version) || ""
} }
@ -93,6 +97,10 @@ export class Cluster implements ClusterModel {
} }
} }
get isManaged(): boolean {
return !!this.ownerRef
}
@action @action
updateModel(model: ClusterModel) { updateModel(model: ClusterModel) {
Object.assign(this, model); Object.assign(this, model);
@ -119,17 +127,19 @@ export class Cluster implements ClusterModel {
} }
protected bindEvents() { protected bindEvents() {
logger.info(`[CLUSTER]: bind events`, this.getMeta()); logger.info(`[CLUSTER]: bind events`, this.getMeta())
const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000) // every 30s
const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000) // every 15 minutes
this.eventDisposers.push( if (ipcMain) {
reaction(this.getState, this.pushState), this.eventDisposers.push(
() => { reaction(() => this.getState(), () => this.pushState()),
clearInterval(refreshTimer); () => {
clearInterval(refreshMetadataTimer); clearInterval(refreshTimer)
}, clearInterval(refreshMetadataTimer)
); },
);
}
} }
protected unbindEvents() { protected unbindEvents() {
@ -361,6 +371,7 @@ export class Cluster implements ClusterModel {
workspace: this.workspace, workspace: this.workspace,
preferences: this.preferences, preferences: this.preferences,
metadata: this.metadata, metadata: this.metadata,
ownerRef: this.ownerRef
}; };
return toJS(model, { return toJS(model, {
recurseEverything: true recurseEverything: true
@ -368,9 +379,8 @@ export class Cluster implements ClusterModel {
} }
// serializable cluster-state used for sync btw main <-> renderer // serializable cluster-state used for sync btw main <-> renderer
getState = (): ClusterState => { getState(): ClusterState {
const state: ClusterState = { const state: ClusterState = {
...this.toJSON(),
initialized: this.initialized, initialized: this.initialized,
apiUrl: this.apiUrl, apiUrl: this.apiUrl,
online: this.online, online: this.online,
@ -388,14 +398,18 @@ export class Cluster implements ClusterModel {
}) })
} }
pushState = (state = this.getState()): ClusterState => { @action
setState(state: ClusterState) {
Object.assign(this, state)
}
pushState(state = this.getState()) {
logger.silly(`[CLUSTER]: push-state`, state); logger.silly(`[CLUSTER]: push-state`, state);
broadcastIpc({ broadcastIpc({
channel: "cluster:state", channel: "cluster:state",
frameId: this.frameId, frameId: this.frameId,
args: [state], args: [this.id, state],
}); })
return state;
} }
// get cluster system meta, e.g. use in "logger" // get cluster system meta, e.g. use in "logger"

View File

@ -15,13 +15,12 @@ import { shellSync } from "./shell-sync"
import { getFreePort } from "./port" import { getFreePort } from "./port"
import { mangleProxyEnv } from "./proxy-env" import { mangleProxyEnv } from "./proxy-env"
import { registerFileProtocol } from "../common/register-protocol"; import { registerFileProtocol } from "../common/register-protocol";
import logger from "./logger"
import { clusterStore } from "../common/cluster-store" import { clusterStore } from "../common/cluster-store"
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store"; import { workspaceStore } from "../common/workspace-store";
import { appEventBus } from "../common/event-bus" import { appEventBus } from "../common/event-bus"
import { extensionManager } from "../extensions/extension-manager";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number; let proxyPort: number;
@ -48,7 +47,7 @@ app.on("ready", async () => {
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
// preload isomorphic stores // preload
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
clusterStore.load(), clusterStore.load(),
@ -76,12 +75,8 @@ app.on("ready", async () => {
app.exit(); app.exit();
} }
windowManager = new WindowManager(proxyPort); LensExtensionsApi.windowManager = windowManager = new WindowManager(proxyPort);
extensionLoader.init(); // call after windowManager to see splash earlier
LensExtensionsApi.windowManager = windowManager; // expose to extensions
extensionLoader.loadOnMain()
extensionLoader.extensions.replace(await extensionManager.load())
extensionLoader.broadcastExtensions()
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" }) appEventBus.emit({ name: "app", action: "start" })

View File

@ -82,6 +82,12 @@ export class LensProxy {
proxySocket.write("\r\n") proxySocket.write("\r\n")
proxySocket.write(head) proxySocket.write(head)
}) })
proxySocket.setKeepAlive(true)
socket.setKeepAlive(true)
proxySocket.setTimeout(0)
socket.setTimeout(0)
proxySocket.on('data', function (chunk) { proxySocket.on('data', function (chunk) {
socket.write(chunk) socket.write(chunk)
}) })

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron" import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, webContents } from "electron"
import { autorun } from "mobx"; import { autorun } from "mobx";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { appName, isMac, isWindows } from "../common/vars"; import { appName, isMac, isWindows } from "../common/vars";
@ -6,6 +6,7 @@ import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.r
import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route"; import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
import { menuRegistry } from "../extensions/registries/menu-registry"; import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger"; import logger from "./logger";
@ -70,6 +71,13 @@ export function buildMenu(windowManager: WindowManager) {
navigate(preferencesURL()) navigate(preferencesURL())
} }
}, },
{
label: 'Extensions',
accelerator: 'CmdOrCtrl+Shift+E',
click() {
navigate(extensionsURL())
}
},
{ type: 'separator' }, { type: 'separator' },
{ role: 'services' }, { role: 'services' },
{ type: 'separator' }, { type: 'separator' },
@ -185,6 +193,7 @@ export function buildMenu(windowManager: WindowManager) {
navigate(whatsNewURL()) navigate(whatsNewURL())
}, },
}, },
<<<<<<< HEAD
{ {
label: "Documentation", label: "Documentation",
click: async () => { click: async () => {
@ -197,6 +206,8 @@ export function buildMenu(windowManager: WindowManager) {
shell.openExternal('https://k8slens.dev/licenses/eula.md'); shell.openExternal('https://k8slens.dev/licenses/eula.md');
}, },
}, },
=======
>>>>>>> master
...ignoreOnMac([ ...ignoreOnMac([
{ {
label: "About Lens", label: "About Lens",

View File

@ -80,7 +80,7 @@ export function createTrayMenu(windowManager: WindowManager): Menu {
}, },
{ {
label: "Clusters", label: "Clusters",
submenu: workspaceStore.workspacesList submenu: workspaceStore.enabledWorkspacesList
.filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces .filter(workspace => clusterStore.getByWorkspaceId(workspace.id).length > 0) // hide empty workspaces
.map(workspace => { .map(workspace => {
const clusters = clusterStore.getByWorkspaceId(workspace.id); const clusters = clusterStore.getByWorkspaceId(workspace.id);

View File

@ -1,3 +1,5 @@
import moment from "moment";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
@ -10,7 +12,7 @@ export class DeploymentApi extends KubeApi<Deployment> {
getReplicas(params: { namespace: string; name: string }): Promise<number> { getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request return this.request
.get(this.getScaleApiUrl(params)) .get(this.getScaleApiUrl(params))
.then(({ status }: any) => status.replicas) .then(({ status }: any) => status?.replicas)
} }
scale(params: { namespace: string; name: string }, replicas: number) { scale(params: { namespace: string; name: string }, replicas: number) {
@ -23,6 +25,25 @@ export class DeploymentApi extends KubeApi<Deployment> {
} }
}) })
} }
restart(params: { namespace: string; name: string }) {
return this.request.patch(this.getUrl(params), {
data: {
spec: {
template: {
metadata: {
annotations: {"kubectl.kubernetes.io/restartedAt" : moment.utc().format()}
}
}
}
}
},
{
headers: {
'content-type': 'application/strategic-merge-patch+json'
}
})
}
} }
@autobind() @autobind()
@ -38,6 +59,7 @@ export class Deployment extends WorkloadKubeObject {
metadata: { metadata: {
creationTimestamp?: string; creationTimestamp?: string;
labels: { [app: string]: string }; labels: { [app: string]: string };
annotations?: { [app: string]: string };
}; };
spec: { spec: {
containers: { containers: {

View File

@ -64,7 +64,7 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
} }
patch<T = D>(path: string, params?: P, reqInit: RequestInit = {}) { patch<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "patch" }); return this.request<T>(path, params, { ...reqInit, method: "PATCH" });
} }
del<T = D>(path: string, params?: P, reqInit: RequestInit = {}) { del<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {

View File

@ -1,33 +1 @@
import { observable } from "mobx" export { kubeObjectDetailRegistry } from "../../extensions/registries/kube-object-detail-registry"
import React from "react"
export interface KubeObjectDetailComponents {
Details: React.ComponentType<any>;
}
export interface KubeObjectDetailRegistration {
kind: string;
apiVersions: string[];
components: KubeObjectDetailComponents;
}
export class KubeObjectDetailRegistry {
items = observable.array<KubeObjectDetailRegistration>([], { deep: false });
add(item: KubeObjectDetailRegistration) {
this.items.push(item)
return () => {
this.items.replace(
this.items.filter(c => c !== item)
)
};
}
getItemsForKind(kind: string, apiVersion: string) {
return this.items.filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion)
})
}
}
export const kubeObjectDetailRegistry = new KubeObjectDetailRegistry()

View File

@ -109,17 +109,22 @@ export class KubeWatchApi {
} }
} }
protected async onRouteEvent({ type, url }: IKubeWatchRouteEvent) { protected async onRouteEvent(event: IKubeWatchRouteEvent) {
if (type === "STREAM_END") { if (event.type === "STREAM_END") {
this.disconnect(); this.disconnect();
const { apiBase, namespace } = KubeApi.parseApi(url); const { apiBase, namespace } = KubeApi.parseApi(event.url);
const api = apiManager.getApi(apiBase); const api = apiManager.getApi(apiBase);
if (api) { if (api) {
try { try {
await api.refreshResourceVersion({ namespace }); await api.refreshResourceVersion({ namespace });
this.reconnect(); this.reconnect();
} catch (error) { } catch (error) {
console.debug("failed to refresh resource version", error) console.error("failed to refresh resource version", error)
if (this.subscribers.size > 0) {
setTimeout(() => {
this.onRouteEvent(event)
}, 1000)
}
} }
} }
} }

View File

@ -40,6 +40,7 @@ export async function bootstrap(App: AppComponent) {
// Register additional store listeners // Register additional store listeners
clusterStore.registerIpcListener(); clusterStore.registerIpcListener();
workspaceStore.registerIpcListener();
// init app's dependencies if any // init app's dependencies if any
if (App.init) { if (App.init) {

View File

@ -163,7 +163,7 @@ export class AddCluster extends React.Component {
}) })
runInAction(() => { runInAction(() => {
clusterStore.addCluster(...newClusters); clusterStore.addClusters(...newClusters);
if (newClusters.length === 1) { if (newClusters.length === 1) {
const clusterId = newClusters[0].id; const clusterId = newClusters[0].id;
clusterStore.setActive(clusterId); clusterStore.setActive(clusterId);

View File

@ -3,7 +3,7 @@ import "./helm-chart-details.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { observable, toJS } from "mobx"; import { observable, autorun } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer"; import { Drawer, DrawerItem } from "../drawer";
import { autobind, stopPropagation } from "../../utils"; import { autobind, stopPropagation } from "../../utils";
@ -30,23 +30,23 @@ export class HelmChartDetails extends Component<Props> {
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
async componentDidMount() {
const { chart: { name, repo, version } } = this.props
try {
const { readme, versions } = await (this.chartPromise = helmChartsApi.get(repo, name, version))
this.readme = readme
this.chartVersions = versions
this.selectedChart = versions[0]
} catch (error) {
this.error = error
}
}
componentWillUnmount() { componentWillUnmount() {
this.chartPromise?.cancel(); this.chartPromise?.cancel();
} }
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;
})
});
@autobind() @autobind()
async onVersionChange({ value: version }: SelectOption) { async onVersionChange({ value: version }: SelectOption) {
this.selectedChart = this.chartVersions.find(chart => chart.version === version); this.selectedChart = this.chartVersions.find(chart => chart.version === version);

View File

@ -26,11 +26,11 @@ export class ClusterWorkspaceSetting extends React.Component<Props> {
<Select <Select
value={this.props.cluster.workspace} value={this.props.cluster.workspace}
onChange={({value}) => this.props.cluster.workspace = value} onChange={({value}) => this.props.cluster.workspace = value}
options={workspaceStore.workspacesList.map(w => options={workspaceStore.enabledWorkspacesList.map(w =>
({value: w.id, label: w.name}) ({value: w.id, label: w.name})
)} )}
/> />
</> </>
); );
} }
} }

View File

@ -22,16 +22,17 @@ export class RemoveClusterButton extends React.Component<Props> {
labelOk: <Trans>Yes</Trans>, labelOk: <Trans>Yes</Trans>,
labelCancel: <Trans>No</Trans>, labelCancel: <Trans>No</Trans>,
ok: async () => { ok: async () => {
await clusterStore.removeById(cluster.id); await clusterStore.removeById(cluster.id);
} }
}) })
} }
render() { render() {
const { cluster } = this.props;
return ( return (
<Button accent onClick={this.confirmRemoveCluster} className="button-area"> <Button accent onClick={this.confirmRemoveCluster} className="button-area" disabled={cluster.isManaged}>
Remove Cluster Remove Cluster
</Button> </Button>
); );
} }
} }

View File

@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const extensionsRoute: RouteProps = {
path: "/extensions"
}
export const extensionsURL = buildURL(extensionsRoute.path)

View File

@ -0,0 +1,35 @@
.Extensions {
--width: 100%;
--max-width: auto;
.extension {
--flex-gap: $padding / 3;
padding: $padding $padding * 2;
background: $colorVague;
border-radius: $radius;
}
.extensions-path {
word-break: break-all;
}
.WizardLayout {
padding: 0;
.info-col {
flex: 0.6;
align-self: flex-start;
}
}
.SearchInput {
margin-top: $margin / 2;
margin-bottom: $margin * 2;
max-width: none;
> label {
padding: $padding $padding * 2;
border-radius: $radius;
}
}
}

View File

@ -0,0 +1,112 @@
import "./extensions.scss";
import { shell } from "electron";
import React from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Button } from "../button";
import { WizardLayout } from "../layout/wizard-layout";
import { Input } from "../input";
import { Icon } from "../icon";
import { PageLayout } from "../layout/page-layout";
import { extensionLoader } from "../../../extensions/extension-loader";
import { extensionManager } from "../../../extensions/extension-manager";
@observer
export class Extensions extends React.Component {
@observable search = ""
@computed get extensions() {
const searchText = this.search.toLowerCase();
return extensionLoader.userExtensions.filter(({ name, description }) => {
return [
name.toLowerCase().includes(searchText),
description.toLowerCase().includes(searchText),
].some(v => v)
})
}
get extensionsPath() {
return extensionManager.localFolderPath;
}
renderInfo() {
return (
<div className="flex column gaps">
<h2>Lens Extension API</h2>
<div>
The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core
features of Lens are built as extensions and use the same Extension API.
</div>
<div>
Extensions loaded from:
<div className="extensions-path flex inline">
<code>{this.extensionsPath}</code>
<Icon
material="folder"
tooltip="Open folder"
onClick={() => shell.openPath(this.extensionsPath)}
/>
</div>
</div>
<div>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
</div>
</div>
)
}
renderExtensions() {
const { extensions, extensionsPath, search } = this;
if (!extensions.length) {
return (
<div className="flex align-center box grow justify-center gaps">
{search && <Trans>No search results found</Trans>}
{!search && <p><Trans>There are no extensions in</Trans> <code>{extensionsPath}</code></p>}
</div>
)
}
return extensions.map(ext => {
const { id, name, description, isEnabled } = ext;
return (
<div key={id} className="extension flex gaps align-center">
<div className="box grow flex column gaps">
<div className="package">
Name: <code className="name">{name}</code>
</div>
<div>
Description: <span className="text-secondary">{description}</span>
</div>
</div>
{!isEnabled && (
<Button plain active onClick={() => ext.enable()}>Enable</Button>
)}
{isEnabled && (
<Button accent onClick={() => ext.disable()}>Disable</Button>
)}
</div>
)
})
}
render() {
return (
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
<WizardLayout infoPanel={this.renderInfo()}>
<Input
autoFocus
theme="round-black"
className="SearchInput"
placeholder={_i18n._(t`Search extensions`)}
value={this.search}
onChange={(value) => this.search = value}
/>
<div className="extension-list flex column gaps">
{this.renderExtensions()}
</div>
</WizardLayout>
</PageLayout>
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./extensions.route"
export * from "./extensions"

View File

@ -20,11 +20,18 @@
} }
.desired-scale { .desired-scale {
flex: 1 0; flex: 1.1 0;
} }
.slider-container { .slider-container {
flex: 1.3 0; flex: 1 0;
}
.plus-minus-container {
margin-left: $margin * 2;
.Icon {
--color-active: black;
}
} }
.warning { .warning {
@ -39,4 +46,4 @@
} }
} }
} }
} }

View File

@ -0,0 +1,151 @@
import React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'
import { DeploymentScaleDialog } from "./deployment-scale-dialog";
jest.mock("../../api/endpoints");
import { deploymentApi } from "../../api/endpoints";
const dummyDeployment = {
apiVersion: 'v1',
kind: 'dummy',
metadata: {
uid: 'dummy',
name: 'dummy',
creationTimestamp: 'dummy',
resourceVersion: 'dummy',
selfLink: 'link',
},
selfLink: 'link',
spec: {
replicas: 1,
selector: { matchLabels: { dummy: 'label' } },
template: {
metadata: {
labels: { dummy: 'label' },
},
spec: {
containers: [{
name: 'dummy',
image: 'dummy',
resources: {
requests: {
cpu: '1',
memory: '10Mi',
},
},
terminationMessagePath: 'dummy',
terminationMessagePolicy: 'dummy',
imagePullPolicy: 'dummy',
}],
restartPolicy: 'dummy',
terminationGracePeriodSeconds: 10,
dnsPolicy: 'dummy',
serviceAccountName: 'dummy',
serviceAccount: 'dummy',
securityContext: {},
schedulerName: 'dummy',
},
},
strategy: {
type: 'dummy',
rollingUpdate: {
maxUnavailable: 1,
maxSurge: 10,
},
},
},
status: {
observedGeneration: 1,
replicas: 1,
updatedReplicas: 1,
readyReplicas: 1,
conditions: [{
type: 'dummy',
status: 'dummy',
lastUpdateTime: 'dummy',
lastTransitionTime: 'dummy',
reason: 'dummy',
message: 'dummy',
}],
},
getConditions: jest.fn(),
getConditionsText: 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('<DeploymentScaleDialog />', () => {
it('renders w/o errors', () => {
const { container } = render(<DeploymentScaleDialog />);
expect(container).toBeInstanceOf(HTMLElement);
});
it('inits with a dummy deployment with mocked current/desired scale', async () => {
// mock deploymentApi.getReplicas() which will be called
// when <DeploymentScaleDialog /> rendered.
const initReplicas = 3
deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<DeploymentScaleDialog />);
DeploymentScaleDialog.open(dummyDeployment);
// we need to wait for the DeploymentScaleDialog 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
deploymentApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<DeploymentScaleDialog />);
DeploymentScaleDialog.open(dummyDeployment);
await waitFor(async () => {
const desiredScale = await getByTestId('desired-scale');
expect(desiredScale).toHaveTextContent(`${initReplicas}`);
});
const up = await getByTestId('desired-replicas-up');
const down = await getByTestId('desired-replicas-down')
fireEvent.click(up);
expect(await getByTestId('desired-scale')).toHaveTextContent(`${initReplicas + 1}`);
fireEvent.click(down);
expect(await getByTestId('desired-scale')).toHaveTextContent('1');
// edge case, desiredScale must > 0
fireEvent.click(down);
fireEvent.click(down);
expect(await getByTestId('desired-scale')).toHaveTextContent('1');
const times = 120;
// edge case, desiredScale must < scaleMax (100)
for (let i = 0; i < times; i++) {
fireEvent.click(up);
}
expect(await getByTestId('desired-scale')).toHaveTextContent('100');
});
});

View File

@ -83,21 +83,41 @@ export class DeploymentScaleDialog extends Component<Props> {
} }
} }
desiredReplicasUp = () => {
this.desiredReplicas < this.scaleMax && this.desiredReplicas++
}
desiredReplicasDown = () => {
this.desiredReplicas > 1 && this.desiredReplicas--
};
renderContents() { renderContents() {
const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; const { currentReplicas, desiredReplicas, onChange, scaleMax } = this;
const warning = currentReplicas < 10 && desiredReplicas > 90; const warning = currentReplicas < 10 && desiredReplicas > 90;
return ( return (
<> <>
<div className="current-scale"> <div className="current-scale" data-testid="current-scale">
<Trans>Current replica scale: {currentReplicas}</Trans> <Trans>Current replica scale: {currentReplicas}</Trans>
</div> </div>
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<div className="desired-scale"> <div className="desired-scale" data-testid="desired-scale">
<Trans>Desired number of replicas</Trans>: {desiredReplicas} <Trans>Desired number of replicas</Trans>: {desiredReplicas}
</div> </div>
<div className="slider-container"> <div className="slider-container flex align-center">
<Slider value={desiredReplicas} max={scaleMax} onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}/> <Slider value={desiredReplicas} max={scaleMax} onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}/>
</div> </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> </div>
{warning && {warning &&
<div className="warning"> <div className="warning">
@ -139,4 +159,4 @@ export class DeploymentScaleDialog extends Component<Props> {
</Dialog> </Dialog>
); );
} }
} }

View File

@ -4,11 +4,12 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router"; import { RouteComponentProps } from "react-router";
import { t, Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { Deployment } from "../../api/endpoints"; import { Deployment, deploymentApi } from "../../api/endpoints";
import { KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { DeploymentScaleDialog } from "./deployment-scale-dialog"; import { DeploymentScaleDialog } from "./deployment-scale-dialog";
import { ConfirmDialog } from "../confirm-dialog";
import { deploymentStore } from "./deployments.store"; import { deploymentStore } from "./deployments.store";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
@ -22,6 +23,8 @@ import kebabCase from "lodash/kebabCase";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { KubeEventIcon } from "../+events/kube-event-icon"; import { KubeEventIcon } from "../+events/kube-event-icon";
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry";
import { apiManager } from "../../api/api-manager";
import { Notifications } from "../notifications";
enum sortBy { enum sortBy {
name = "name", name = "name",
@ -96,10 +99,34 @@ export class Deployments extends React.Component<Props> {
export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) { export function DeploymentMenu(props: KubeObjectMenuProps<Deployment>) {
const { object, toolbar } = props; const { object, toolbar } = props;
return ( return (
<MenuItem onClick={() => DeploymentScaleDialog.open(object)}> <>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/> <MenuItem onClick={() => DeploymentScaleDialog.open(object)}>
<span className="title"><Trans>Scale</Trans></span> <Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
</MenuItem> <span className="title"><Trans>Scale</Trans></span>
</MenuItem>
<MenuItem onClick={() => ConfirmDialog.open({
ok: async () =>
{
try {
await deploymentApi.restart({
namespace: object.getNs(),
name: object.getName(),
})
} catch (err) {
Notifications.error(err);
}
},
labelOk: _i18n._(t`Restart`),
message: (
<p>
<Trans>Are you sure you want to restart deployment <b>{object.getName()}</b>?</Trans>
</p>
),
})}>
<Icon material="autorenew" title={_i18n._(t`Restart`)} interactive={toolbar}/>
<span className="title"><Trans>Restart</Trans></span>
</MenuItem>
</>
) )
} }
@ -110,4 +137,3 @@ kubeObjectMenuRegistry.add({
MenuItem: DeploymentMenu MenuItem: DeploymentMenu
} }
}) })

View File

@ -65,7 +65,6 @@ export class PodDetails extends React.Component<Props> {
const { nodeName } = spec; const { nodeName } = spec;
const nodeSelector = pod.getNodeSelectors(); const nodeSelector = pod.getNodeSelectors();
const volumes = pod.getVolumes(); const volumes = pod.getVolumes();
const labels = pod.getLabels();
const metrics = podsStore.metrics; const metrics = podsStore.metrics;
return ( return (
<div className="PodDetails"> <div className="PodDetails">

View File

@ -19,7 +19,7 @@ export class WorkspaceMenu extends React.Component<Props> {
render() { render() {
const { className, ...menuProps } = this.props; const { className, ...menuProps } = this.props;
const { workspacesList, currentWorkspace } = workspaceStore; const { enabledWorkspacesList, currentWorkspace } = workspaceStore;
return ( return (
<Menu <Menu
{...menuProps} {...menuProps}
@ -32,7 +32,7 @@ export class WorkspaceMenu extends React.Component<Props> {
<Link className="workspaces-title" to={workspacesURL()}> <Link className="workspaces-title" to={workspacesURL()}>
<Trans>Workspaces</Trans> <Trans>Workspaces</Trans>
</Link> </Link>
{workspacesList.map(({ id: workspaceId, name, description }) => { {enabledWorkspacesList.map(({ id: workspaceId, name, description }) => {
return ( return (
<MenuItem <MenuItem
key={workspaceId} key={workspaceId}

View File

@ -19,8 +19,12 @@ export class Workspaces extends React.Component {
@observable editingWorkspaces = observable.map<WorkspaceId, Workspace>(); @observable editingWorkspaces = observable.map<WorkspaceId, Workspace>();
@computed get workspaces(): Workspace[] { @computed get workspaces(): Workspace[] {
const currentWorkspaces: Map<WorkspaceId, Workspace> = new Map()
workspaceStore.enabledWorkspacesList.forEach((w) => {
currentWorkspaces.set(w.id, w)
})
const allWorkspaces = new Map([ const allWorkspaces = new Map([
...workspaceStore.workspaces, ...currentWorkspaces,
...this.editingWorkspaces, ...this.editingWorkspaces,
]); ]);
return Array.from(allWorkspaces.values()); return Array.from(allWorkspaces.values());
@ -42,7 +46,7 @@ export class Workspaces extends React.Component {
saveWorkspace = (id: WorkspaceId) => { saveWorkspace = (id: WorkspaceId) => {
const draft = toJS(this.editingWorkspaces.get(id)); const draft = toJS(this.editingWorkspaces.get(id));
const workspace = workspaceStore.saveWorkspace(draft); const workspace = workspaceStore.addWorkspace(draft);
if (workspace) { if (workspace) {
this.clearEditing(id); this.clearEditing(id);
} }
@ -50,11 +54,11 @@ export class Workspaces extends React.Component {
addWorkspace = () => { addWorkspace = () => {
const workspaceId = uuid(); const workspaceId = uuid();
this.editingWorkspaces.set(workspaceId, { this.editingWorkspaces.set(workspaceId, new Workspace({
id: workspaceId, id: workspaceId,
name: "", name: "",
description: "", description: ""
}) }))
} }
editWorkspace = (id: WorkspaceId) => { editWorkspace = (id: WorkspaceId) => {
@ -76,7 +80,7 @@ export class Workspaces extends React.Component {
}, },
ok: () => { ok: () => {
this.clearEditing(id); this.clearEditing(id);
workspaceStore.removeWorkspace(id); workspaceStore.removeWorkspace(workspace);
}, },
message: ( message: (
<div className="confirm flex column gaps"> <div className="confirm flex column gaps">
@ -107,11 +111,12 @@ export class Workspaces extends React.Component {
<Trans>Workspaces</Trans> <Trans>Workspaces</Trans>
</h2> </h2>
<div className="items flex column gaps"> <div className="items flex column gaps">
{this.workspaces.map(({ id: workspaceId, name, description }) => { {this.workspaces.map(({ id: workspaceId, name, description, ownerRef }) => {
const isActive = workspaceStore.currentWorkspaceId === workspaceId; const isActive = workspaceStore.currentWorkspaceId === workspaceId;
const isDefault = workspaceStore.isDefault(workspaceId); const isDefault = workspaceStore.isDefault(workspaceId);
const isEditing = this.editingWorkspaces.has(workspaceId); const isEditing = this.editingWorkspaces.has(workspaceId);
const editingWorkspace = this.editingWorkspaces.get(workspaceId); const editingWorkspace = this.editingWorkspaces.get(workspaceId);
const managed = !!ownerRef
const className = cssNames("workspace flex gaps", { const className = cssNames("workspace flex gaps", {
active: isActive, active: isActive,
editing: isEditing, editing: isEditing,
@ -130,7 +135,7 @@ export class Workspaces extends React.Component {
{isActive && <span> <Trans>(current)</Trans></span>} {isActive && <span> <Trans>(current)</Trans></span>}
</span> </span>
<span className="description">{description}</span> <span className="description">{description}</span>
{!isDefault && ( {!isDefault && !managed && (
<Fragment> <Fragment>
<Icon <Icon
material="edit" material="edit"

View File

@ -15,7 +15,7 @@ export interface AnimateProps {
@observer @observer
export class Animate extends React.Component<AnimateProps> { export class Animate extends React.Component<AnimateProps> {
static VISIBILITY_DELAY_MS = 100; static VISIBILITY_DELAY_MS = 0;
static defaultProps: AnimateProps = { static defaultProps: AnimateProps = {
name: "opacity", name: "opacity",

View File

@ -54,6 +54,9 @@ export class App extends React.Component {
appEventBus.emit({name: "cluster", action: "open", params: { appEventBus.emit({name: "cluster", action: "open", params: {
clusterId: clusterId clusterId: clusterId
}}) }})
window.addEventListener("online", () => {
window.location.reload()
})
} }
get startURL() { get startURL() {

View File

@ -4,8 +4,9 @@
font-size: $font-size-small; font-size: $font-size-small;
background-color: #3d90ce; background-color: #3d90ce;
padding: 0 $padding; padding: 0 2px;
color: white; color: white;
height: 22px;
#current-workspace { #current-workspace {
padding: $padding / 4 $padding / 2; padding: $padding / 4 $padding / 2;

View File

@ -14,7 +14,7 @@ export class BottomBar extends React.Component {
return ( return (
<div className="BottomBar flex gaps"> <div className="BottomBar flex gaps">
<div id="current-workspace" className="flex gaps align-center hover-highlight"> <div id="current-workspace" className="flex gaps align-center hover-highlight">
<Icon small material="layers"/> <Icon smallest material="layers"/>
<span className="workspace-name">{currentWorkspace.name}</span> <span className="workspace-name">{currentWorkspace.name}</span>
</div> </div>
<WorkspaceMenu <WorkspaceMenu

View File

@ -16,6 +16,7 @@ import { clusterViewRoute, clusterViewURL } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
import { globalPageRegistry } from "../../../extensions/registries/page-registry"; import { globalPageRegistry } from "../../../extensions/registries/page-registry";
import { Extensions, extensionsRoute } from "../+extensions";
import { getMatchedClusterId } from "../../navigation"; import { getMatchedClusterId } from "../../navigation";
@observer @observer
@ -63,6 +64,7 @@ export class ClusterManager extends React.Component {
<Switch> <Switch>
<Route component={LandingPage} {...landingRoute} /> <Route component={LandingPage} {...landingRoute} />
<Route component={Preferences} {...preferencesRoute} /> <Route component={Preferences} {...preferencesRoute} />
<Route component={Extensions} {...extensionsRoute} />
<Route component={Workspaces} {...workspacesRoute} /> <Route component={Workspaces} {...workspacesRoute} />
<Route component={AddCluster} {...addClusterRoute} /> <Route component={AddCluster} {...addClusterRoute} />
<Route component={ClusterView} {...clusterViewRoute} /> <Route component={ClusterView} {...clusterViewRoute} />

View File

@ -101,9 +101,11 @@ export class ClustersMenu extends React.Component<Props> {
} }
render() { render() {
const { className } = this.props; const { className } = this.props
const { newContexts } = userStore; const { newContexts } = userStore
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId)
const clusters = clusterStore.getByWorkspaceId(workspace.id)
const activeClusterId = clusterStore.activeCluster
return ( return (
<div className={cssNames("ClustersMenu flex column", className)}> <div className={cssNames("ClustersMenu flex column", className)}>
<div className="clusters flex column gaps"> <div className="clusters flex column gaps">
@ -112,7 +114,7 @@ export class ClustersMenu extends React.Component<Props> {
{({ innerRef, droppableProps, placeholder }: DroppableProvided) => ( {({ innerRef, droppableProps, placeholder }: DroppableProvided) => (
<div ref={innerRef} {...droppableProps}> <div ref={innerRef} {...droppableProps}>
{clusters.map((cluster, index) => { {clusters.map((cluster, index) => {
const isActive = cluster.id === clusterStore.activeClusterId; const isActive = cluster.id === activeClusterId;
return ( return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}> <Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => ( {({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => (
@ -136,11 +138,11 @@ export class ClustersMenu extends React.Component<Props> {
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
</div> </div>
<div className="add-cluster" onClick={this.addCluster}> <div className="add-cluster" >
<Tooltip targetId="add-cluster-icon"> <Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans> <Trans>Add Cluster</Trans>
</Tooltip> </Tooltip>
<Icon big material="add" id="add-cluster-icon"/> <Icon big material="add" id="add-cluster-icon" disabled={workspace.isManaged} onClick={this.addCluster}/>
{newContexts.size > 0 && ( {newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/> <Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>}/>
)} )}

View File

@ -1,6 +1,7 @@
.Icon { .Icon {
--size: 21px; --size: 21px;
--small-size: 18px; --small-size: 18px;
--smallest-size: 16px;
--big-size: 32px; --big-size: 32px;
--color-active: #{$iconActiveColor}; --color-active: #{$iconActiveColor};
--bgc-active: #{$iconActiveBackground}; --bgc-active: #{$iconActiveBackground};
@ -21,6 +22,12 @@
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
&.smallest {
font-size: var(--smallest-size);
width: var(--smallest-size);
height: var(--smallest-size);
}
&.small { &.small {
font-size: var(--small-size); font-size: var(--small-size);
width: var(--small-size); width: var(--small-size);

View File

@ -15,6 +15,7 @@ export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorPr
href?: string; // render icon as hyperlink href?: string; // render icon as hyperlink
size?: string | number; // icon-size size?: string | number; // icon-size
small?: boolean; // pre-defined icon-size small?: boolean; // pre-defined icon-size
smallest?: boolean; // pre-defined icon-size
big?: boolean; // pre-defined icon-size big?: boolean; // pre-defined icon-size
active?: boolean; // apply active-state styles active?: boolean; // apply active-state styles
interactive?: boolean; // indicates that icon is interactive and highlight it on focus/hover interactive?: boolean; // indicates that icon is interactive and highlight it on focus/hover
@ -63,7 +64,7 @@ export class Icon extends React.PureComponent<IconProps> {
const { isInteractive } = this; const { isInteractive } = this;
const { const {
// skip passing props to icon's html element // skip passing props to icon's html element
className, href, link, material, svg, size, small, big, className, href, link, material, svg, size, smallest, small, big,
disabled, sticker, active, focusable, children, disabled, sticker, active, focusable, children,
interactive: _interactive, interactive: _interactive,
onClick: _onClick, onClick: _onClick,
@ -75,7 +76,7 @@ export class Icon extends React.PureComponent<IconProps> {
const iconProps: Partial<IconProps> = { const iconProps: Partial<IconProps> = {
className: cssNames("Icon", className, className: cssNames("Icon", className,
{ svg, material, interactive: isInteractive, disabled, sticker, active, focusable }, { svg, material, interactive: isInteractive, disabled, sticker, active, focusable },
!size ? { small, big } : {} !size ? { smallest, small, big } : {}
), ),
onClick: isInteractive ? this.onClick : undefined, onClick: isInteractive ? this.onClick : undefined,
onKeyDown: isInteractive ? this.onKeyDown : undefined, onKeyDown: isInteractive ? this.onKeyDown : undefined,

Some files were not shown because too many files have changed in this diff Show More