1
0
mirror of https://github.com/lensapp/lens.git synced 2024-09-19 05:17:22 +03:00

Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2020-03-15 09:52:02 +02:00 committed by GitHub
parent 936cbd53d4
commit 1d0815abd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
797 changed files with 73714 additions and 215 deletions

141
.azure-pipelines.yml Normal file
View File

@ -0,0 +1,141 @@
variables:
YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn
AZURE_CACHE_FOLDER: $(Pipeline.Workspace)/.azure-cache
pr: none
trigger:
branches:
include:
- '*'
tags:
include:
- "*"
jobs:
- job: Windows
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
pool:
vmImage: windows-2019
strategy:
matrix:
node_12.x:
node_version: 12.x
steps:
- powershell: |
$CI_BUILD_TAG = git describe --tags
Write-Output ("##vso[task.setvariable variable=CI_BUILD_TAG;]$CI_BUILD_TAG")
displayName: 'Set the tag name as an environment variable'
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
- task: NodeTool@0
inputs:
versionSpec: $(node_version)
displayName: Install Node.js
- task: CacheBeta@0
inputs:
key: yarn | $(Agent.OS) | yarn.lock
path: $(YARN_CACHE_FOLDER)
displayName: Cache Yarn packages
- script: make deps
displayName: Install dependencies
- script: make lint
displayName: Lint
- script: make test
displayName: Run tests
- script: make build
displayName: Build
env:
WIN_CSC_LINK: $(WIN_CSC_LINK)
WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD)
GH_TOKEN: $(GH_TOKEN)
- job: macOS
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
pool:
vmImage: macOS-10.14
strategy:
matrix:
node_12.x:
node_version: 12.x
steps:
- script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG"
displayName: Set the tag name as an environment variable
- task: NodeTool@0
inputs:
versionSpec: $(node_version)
displayName: Install Node.js
- task: CacheBeta@0
inputs:
key: cache | $(Agent.OS) | yarn.lock
path: $(AZURE_CACHE_FOLDER)
cacheHitVar: CACHE_RESTORED
displayName: Cache Yarn packages
- bash: |
mkdir -p "$YARN_CACHE_FOLDER"
tar -xzf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" -C /
displayName: "Unpack cache"
condition: eq(variables.CACHE_RESTORED, 'true')
- script: make deps
displayName: Install dependencies
- script: make lint
displayName: Lint
- script: make test
displayName: Run tests
- script: make build
displayName: Build
env:
APPLEID: $(APPLEID)
APPLEIDPASS: $(APPLEIDPASS)
CSC_LINK: $(CSC_LINK)
CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD)
GH_TOKEN: $(GH_TOKEN)
- bash: |
mkdir -p "$AZURE_CACHE_FOLDER"
tar -czf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" "$YARN_CACHE_FOLDER"
displayName: Pack cache
- job: Linux
pool:
vmImage: ubuntu-16.04
strategy:
matrix:
node_12.x:
node_version: 12.x
steps:
- script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG"
displayName: Set the tag name as an environment variable
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
- task: NodeTool@0
inputs:
versionSpec: $(node_version)
displayName: Install Node.js
- task: CacheBeta@0
inputs:
key: cache | $(Agent.OS) | yarn.lock
path: $(AZURE_CACHE_FOLDER)
cacheHitVar: CACHE_RESTORED
displayName: Cache Yarn packages
- bash: |
mkdir -p "$YARN_CACHE_FOLDER"
tar -xzf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" -C /
displayName: "Unpack cache"
condition: eq(variables.CACHE_RESTORED, 'true')
- script: make deps
displayName: Install dependencies
- script: make lint
displayName: Lint
- script: make test
displayName: Run tests
- bash: |
sudo chown root:root /
sudo apt-get update && sudo apt-get install -y snapd
sudo snap install snapcraft --classic
echo -n "${SNAP_LOGIN}" | base64 -d > snap_login
snapcraft login --with snap_login
displayName: Setup snapcraft
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
env:
SNAP_LOGIN: $(SNAP_LOGIN)
- script: make build
displayName: Build
env:
GH_TOKEN: $(GH_TOKEN)
- bash: |
mkdir -p "$AZURE_CACHE_FOLDER"
tar -czf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" "$YARN_CACHE_FOLDER"
displayName: Pack cache

75
.eslintrc.js Normal file
View File

@ -0,0 +1,75 @@
module.exports = {
overrides: [
{
files: [
"src/renderer/**/*.js",
"build/**/*.js",
"src/renderer/**/*.vue"
],
extends: [
'eslint:recommended',
'plugin:vue/recommended'
],
env: {
node: true
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {
"indent": ["error", 2],
"no-unused-vars": "off",
"vue/order-in-components": "off",
"vue/attributes-order": "off",
"vue/max-attributes-per-line": "off"
}
},
{
files: [
"src/**/*.ts",
"spec/**/*.ts"
],
parser: "@typescript-eslint/parser",
extends: [
'plugin:@typescript-eslint/recommended',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"indent": ["error", 2]
},
},
{
files: [
"dashboard/**/*.ts",
"dashboard/**/*.tsx",
],
parser: "@typescript-eslint/parser",
extends: [
'plugin:@typescript-eslint/recommended',
],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
jsx: true,
},
rules: {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"indent": ["error", 2]
},
}
]
};

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
dist/
node_modules/
.DS_Store
yarn-error.log
coverage/
tmp/
static/build/client/
binaries/client/
binaries/server/

3
.yarnrc Normal file
View File

@ -0,0 +1,3 @@
disturl "https://atom.io/download/electron"
target "6.0.12"
runtime "electron"

204
LICENSE
View File

@ -1,191 +1,23 @@
MIT License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2020 Lakend Labs, Inc.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
All rights reserved.
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2019 Kontena, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

63
Makefile Normal file
View File

@ -0,0 +1,63 @@
ifeq ($(OS),Windows_NT)
DETECTED_OS := Windows
else
DETECTED_OS := $(shell uname)
endif
.PHONY: dev build test clean
dev: app-deps dashboard-deps build-dashboard-server
yarn dev
test: test-app test-dashboard
lint:
yarn lint
test-app:
yarn test
deps: app-deps dashboard-deps
app-deps:
yarn install --frozen-lockfile
build: build-dashboard app-deps
yarn install
ifeq "$(DETECTED_OS)" "Windows"
yarn dist:win
else
yarn dist
endif
dashboard-deps:
cd dashboard && yarn install --frozen-lockfile
clean-dashboard:
rm -rf dashboard/build/ && rm -rf static/build/client
test-dashboard: dashboard-deps
cd dashboard && yarn test
build-dashboard: build-dashboard-server build-dashboard-client
build-dashboard-server: dashboard-deps clean-dashboard
cd dashboard && yarn build-server
ifeq "$(DETECTED_OS)" "Linux"
rm binaries/server/linux/lens-server || true
cd dashboard && yarn pkg-server-linux
endif
ifeq "$(DETECTED_OS)" "Darwin"
rm binaries/server/darwin/lens-server || true
cd dashboard && yarn pkg-server-macos
endif
ifeq "$(DETECTED_OS)" "Windows"
rm binaries/server/windows/*.exe || true
cd dashboard && yarn pkg-server-win
endif
build-dashboard-client: dashboard-deps clean-dashboard
cd dashboard && yarn build-client
clean:
rm -rf dist/*

View File

@ -1,13 +1,8 @@
# Kontena Lens
# Lens
[Kontena Lens](https://www.kontena.io/lens/) provides all necessary tools and technology to take control of your Kubernetes clusters. Ensure your cluster is properly setup and configured. Enjoy increased visibility and hands-on troubleshooting capabilities. Use built-in user management and integration APIs with support to most standard external authentication systems.
Lens - The free, smart desktop application for managing Kubernetes clusters.
[![Kontena Lens - The Ultimate Dashboard for Kubernetes](./images/screenshot.png)](https://youtu.be/04v2ODsmtIs)
## What makes Kontena Lens special?
Many people might compare Kontena Lens to [Kubernetes Dashboard](https://github.com/kubernetes/dashboard) open source project since they both deliver UI to Kubernetes. But Kontena Lens is more than just an UI. It's the only management system youll ever need to take control of your Kubernetes clusters:
## What makes Lens special?
* Amazing usability and end user experience
* Real-time cluster state visualization
@ -15,11 +10,18 @@ Many people might compare Kontena Lens to [Kubernetes Dashboard](https://github.
* Terminal access to nodes and containers
* Fully featured role based access control management
* Dashboard access and functionality limited by RBAC
* Professional support available
## Further Information
- [Website](https://www.kontena.io/lens)
- [Slack](https://slack.kontena.io/)
## Installation
Download a pre-built package from the [releases](https://github.com/kontena/lens/releases) page. Lens can be also installed via [snapcraft](https://snapcraft.io/kontena-lens) (Linux only).
## Development
> Prerequisities: Nodejs v12, make, yarn
* `make dev` - builds and starts the app
* `make test` - run tests
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/kontena/lens.

104
build/download_kubectl.ts Normal file
View File

@ -0,0 +1,104 @@
import * as request from "request"
import * as fs from "fs"
import { ensureDir, pathExists } from "fs-extra"
import * as md5File from "md5-file"
import * as requestPromise from "request-promise-native"
import * as path from "path"
class KubectlDownloader {
public kubectlVersion: string
protected url: string
protected path: string;
protected dirname: string
constructor(clusterVersion: string, platform: string, arch: string, target: string) {
this.kubectlVersion = clusterVersion;
const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl"
this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`;
this.dirname = path.dirname(target);
this.path = target;
}
protected async urlEtag() {
const response = await requestPromise({
method: "HEAD",
uri: this.url,
resolveWithFullResponse: true
}).catch((error) => { console.log(error) })
if (response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "")
}
return ""
}
public async checkBinary() {
const exists = await pathExists(this.path)
if (exists) {
const hash = md5File.sync(this.path)
const etag = await this.urlEtag()
if(hash == etag) {
console.log("Kubectl md5sum matches the remote etag")
return true
}
console.log("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again")
await fs.promises.unlink(this.path)
}
return false
}
public async downloadKubectl() {
const exists = await this.checkBinary();
if(exists) {
console.log("Already exists and is valid")
return
}
await ensureDir(path.dirname(this.path), 0o755)
const file = fs.createWriteStream(this.path)
console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: this.url,
gzip: true
}
const stream = request(requestOpts)
stream.on("complete", () => {
console.log("kubectl binary download finished")
file.end(() => {})
})
stream.on("error", (error) => {
console.log(error)
fs.unlink(this.path, () => {})
throw(error)
})
return new Promise((resolve, reject) => {
file.on("close", () => {
console.log("kubectl binary download closed")
fs.chmod(this.path, 0o755, () => {})
resolve()
})
stream.pipe(file)
})
}
}
const downloadVersion: string = require("../package.json").config.bundledKubectlVersion
const baseDir = path.join(process.env.INIT_CWD, 'binaries', 'client')
const downloads = [
{ platform: 'linux', arch: 'amd64', target: path.join(baseDir, 'linux', 'x64', 'kubectl') },
{ platform: 'darwin', arch: 'amd64', target: path.join(baseDir, 'darwin', 'x64', 'kubectl') },
{ platform: 'windows', arch: 'amd64', target: path.join(baseDir, 'windows', 'x64', 'kubectl.exe') },
{ platform: 'windows', arch: '386', target: path.join(baseDir, 'windows', 'ia32', 'kubectl.exe') }
]
downloads.forEach((dlOpts) => {
console.log(dlOpts)
const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target);
console.log("Downloading: " + JSON.stringify(dlOpts));
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")))
})

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

BIN
build/icon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

20
build/notarize.js Normal file
View File

@ -0,0 +1,20 @@
const { notarize } = require('electron-notarize')
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
if (!process.env.APPLEID || !process.env.APPLEIDPASS) {
return;
}
const appName = context.packager.appInfo.productFilename;
return await notarize({
appBundleId: 'io.kontena.lens-app',
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLEID,
appleIdPassword: process.env.APPLEIDPASS,
});
};

10
dashboard/.babelrc Normal file
View File

@ -0,0 +1,10 @@
{
"plugins": [
"macros",
"@babel/plugin-transform-runtime",
],
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}

12
dashboard/.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.idea
node_modules/
build/
dist/
wireframes/
backup
npm-debug.log
.vscode
.env
/tslint.json
*.DS_Store
docker-compose.yml

14
dashboard/.gitignore vendored Executable file
View File

@ -0,0 +1,14 @@
.idea
node_modules
build/
dist/
wireframes/
backup
npm-debug.log
.vscode
dump.rdb
*.env
/tslint.json
*.DS_Store
locales/_build/
locales/**/*.js

18
dashboard/.linguirc Normal file
View File

@ -0,0 +1,18 @@
{
"locales": ["en", "ru"],
"sourceLocale": "en",
"fallbackLocale": "en",
"compileNamespace": "cjs",
"format": "po",
"extractBabelOptions": {
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
},
"catalogs": [
{
"path": "./locales/{locale}/messages",
"include": "./client"
}
]
}

View File

@ -0,0 +1,79 @@
import React from "react";
import { observable } from "mobx";
import { autobind } from "../utils/autobind";
import { KubeApi } from "./kube-api";
import { KubeObjectStore } from "../kube-object.store";
import { KubeObjectDetailsProps, KubeObjectListLayoutProps, KubeObjectMenuProps } from "../components/kube-object";
export interface ApiComponents {
List?: React.ComponentType<KubeObjectListLayoutProps>;
Menu?: React.ComponentType<KubeObjectMenuProps>;
Details?: React.ComponentType<KubeObjectDetailsProps>;
}
@autobind()
export class ApiManager {
private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>();
private views = observable.map<KubeApi, ApiComponents>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
const apis = this.apis;
if (typeof pathOrCallback === "string") {
let api = apis.get(pathOrCallback);
if (!api) {
const { apiBase } = KubeApi.parseApi(pathOrCallback);
api = apis.get(apiBase);
}
return api;
}
else {
return Array.from(apis.values()).find(pathOrCallback);
}
}
registerApi(apiBase: string, api: KubeApi) {
if (this.apis.has(apiBase)) return;
this.apis.set(apiBase, api);
}
protected resolveApi(api: string | KubeApi): KubeApi {
if (typeof api === "string") return this.getApi(api)
return api;
}
unregisterApi(api: string | KubeApi) {
if (typeof api === "string") this.apis.delete(api);
else {
const apis = Array.from(this.apis.entries());
const entry = apis.find(entry => entry[1] === api);
if (entry) this.unregisterApi(entry[0]);
}
}
registerStore(api: KubeApi, store: KubeObjectStore) {
this.stores.set(api, store);
}
getStore(api: string | KubeApi): KubeObjectStore {
return this.stores.get(this.resolveApi(api));
}
registerViews(api: KubeApi | KubeApi[], views: ApiComponents) {
if (Array.isArray(api)) {
api.forEach(api => this.registerViews(api, views));
return;
}
const currentViews = this.views.get(api) || {};
this.views.set(api, {
...currentViews,
...views,
});
}
getViews(api: string | KubeApi): ApiComponents {
return this.views.get(this.resolveApi(api)) || {}
}
}
export const apiManager = new ApiManager();

View File

@ -0,0 +1,47 @@
import { CronJob } from "../";
//jest.mock('../../../components/+login/auth.store.ts', () => 'authStore');
jest.mock('../../kube-watch-api.ts', () => 'kube-watch-api');
const cronJob = new CronJob({
metadata: {
name: "hello",
namespace: "default",
selfLink: "/apis/batch/v1beta1/namespaces/default/cronjobs/hello",
uid: "cd3af13f-0b70-11ea-93da-9600002795a0",
resourceVersion: "51394448",
creationTimestamp: "2019-11-20T08:36:09Z",
},
spec: {
schedule: "30 06 31 12 *",
concurrencyPolicy: "Allow",
suspend: false,
},
status: {}
} as any)
describe("Check for CronJob schedule never run", () => {
test("Should be false with normal schedule", () => {
expect(cronJob.isNeverRun()).toBeFalsy();
});
test("Should be false with other normal schedule", () => {
cronJob.spec.schedule = "0 1 * * *";
expect(cronJob.isNeverRun()).toBeFalsy();
});
test("Should be true with date 31 of February", () => {
cronJob.spec.schedule = "30 06 31 2 *"
expect(cronJob.isNeverRun()).toBeTruthy();
});
test("Should be true with date 32 of July", () => {
cronJob.spec.schedule = "0 30 06 32 7 *"
expect(cronJob.isNeverRun()).toBeTruthy();
});
test("Should be false with predefined schedule", () => {
cronJob.spec.schedule = "@hourly";
expect(cronJob.isNeverRun()).toBeFalsy();
});
});

View File

@ -0,0 +1,261 @@
// Kubernetes certificate management controller apis
// Reference: https://docs.cert-manager.io/en/latest/reference/index.html
// API docs: https://docs.cert-manager.io/en/latest/reference/api-docs/index.html
import { KubeObject } from "../kube-object";
import { ISecretRef, secretsApi } from "./secret.api";
import { getDetailsUrl } from "../../navigation";
import { KubeApi } from "../kube-api";
export class Certificate extends KubeObject {
static kind = "Certificate"
spec: {
secretName: string;
commonName?: string;
dnsNames?: string[];
organization?: string[];
ipAddresses?: string[];
duration?: string;
renewBefore?: string;
isCA?: boolean;
keySize?: number;
keyAlgorithm?: "rsa" | "ecdsa";
issuerRef: {
kind?: string;
name: string;
};
acme?: {
config: {
domains: string[];
http01: {
ingress?: string;
ingressClass?: string;
};
dns01?: {
provider: string;
};
}[];
};
}
status: {
conditions?: {
lastTransitionTime: string; // 2019-06-04T07:35:58Z,
message: string; // Certificate is up to date and has not expired,
reason: string; // Ready,
status: string; // True,
type: string; // Ready
}[];
notAfter: string; // 2019-11-01T05:36:27Z
lastFailureTime?: string;
}
getType(): string {
const { isCA, acme } = this.spec;
if (isCA) return "CA"
if (acme) return "ACME"
}
getCommonName() {
return this.spec.commonName || ""
}
getIssuerName() {
return this.spec.issuerRef.name;
}
getSecretName() {
return this.spec.secretName;
}
getIssuerDetailsUrl() {
return getDetailsUrl(issuersApi.getUrl({
namespace: this.getNs(),
name: this.getIssuerName(),
}))
}
getSecretDetailsUrl() {
return getDetailsUrl(secretsApi.getUrl({
namespace: this.getNs(),
name: this.getSecretName(),
}))
}
getConditions() {
const { conditions = [] } = this.status;
return conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
}
export class Issuer extends KubeObject {
static kind = "Issuer"
spec: {
acme?: {
email: string;
server: string;
skipTLSVerify?: boolean;
privateKeySecretRef: ISecretRef;
solvers?: {
dns01?: {
cnameStrategy: string;
acmedns?: {
host: string;
accountSecretRef: ISecretRef;
};
akamai?: {
accessTokenSecretRef: ISecretRef;
clientSecretSecretRef: ISecretRef;
clientTokenSecretRef: ISecretRef;
serviceConsumerDomain: string;
};
azuredns?: {
clientID: string;
clientSecretSecretRef: ISecretRef;
hostedZoneName: string;
resourceGroupName: string;
subscriptionID: string;
tenantID: string;
};
clouddns?: {
project: string;
serviceAccountSecretRef: ISecretRef;
};
cloudflare?: {
email: string;
apiKeySecretRef: ISecretRef;
};
digitalocean?: {
tokenSecretRef: ISecretRef;
};
rfc2136?: {
nameserver: string;
tsigAlgorithm: string;
tsigKeyName: string;
tsigSecretSecretRef: ISecretRef;
};
route53?: {
accessKeyID: string;
hostedZoneID: string;
region: string;
secretAccessKeySecretRef: ISecretRef;
};
webhook?: {
config: object; // arbitrary json
groupName: string;
solverName: string;
};
};
http01?: {
ingress: {
class: string;
name: string;
serviceType: string;
};
};
selector?: {
dnsNames: string[];
matchLabels: {
[label: string]: string;
};
};
}[];
};
ca?: {
secretName: string;
};
vault?: {
path: string;
server: string;
caBundle: string; // <base64 encoded caBundle PEM file>
auth: {
appRole: {
path: string;
roleId: string;
secretRef: ISecretRef;
};
};
};
selfSigned?: {};
venafi?: {
zone: string;
cloud?: {
apiTokenSecretRef: ISecretRef;
};
tpp?: {
url: string;
caBundle: string; // <base64 encoded caBundle PEM file>
credentialsRef: {
name: string;
};
};
};
}
status: {
acme?: {
uri: string;
};
conditions?: {
lastTransitionTime: string; // 2019-06-05T07:10:42Z,
message: string; // The ACME account was registered with the ACME server,
reason: string; // ACMEAccountRegistered,
status: string; // True,
type: string; // Ready
}[];
}
getType() {
const { acme, ca, selfSigned, vault, venafi } = this.spec;
if (acme) return "ACME"
if (ca) return "CA"
if (selfSigned) return "SelfSigned"
if (vault) return "Vault"
if (venafi) return "Venafi"
}
getConditions() {
const { conditions = [] } = this.status;
return conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`,
}
});
}
}
export class ClusterIssuer extends Issuer {
static kind = "ClusterIssuer"
}
export const certificatesApi = new KubeApi({
kind: Certificate.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/certificates",
isNamespaced: true,
objectConstructor: Certificate,
});
export const issuersApi = new KubeApi({
kind: Issuer.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/issuers",
isNamespaced: true,
objectConstructor: Issuer,
});
export const clusterIssuersApi = new KubeApi({
kind: ClusterIssuer.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/clusterissuers",
isNamespaced: false,
objectConstructor: ClusterIssuer,
});

View File

@ -0,0 +1,13 @@
import { RoleBinding } from "./role-binding.api";
import { KubeApi } from "../kube-api";
export class ClusterRoleBinding extends RoleBinding {
static kind = "ClusterRoleBinding"
}
export const clusterRoleBindingApi = new KubeApi({
kind: ClusterRoleBinding.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings",
isNamespaced: false,
objectConstructor: ClusterRoleBinding,
});

View File

@ -0,0 +1,15 @@
import { autobind } from "../../utils";
import { Role } from "./role.api";
import { KubeApi } from "../kube-api";
@autobind()
export class ClusterRole extends Role {
static kind = "ClusterRole"
}
export const clusterRoleApi = new KubeApi({
kind: ClusterRole.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterroles",
isNamespaced: false,
objectConstructor: ClusterRole,
});

View File

@ -0,0 +1,114 @@
import { IMetrics, IMetricsReqParams, metricsApi } from "./metrics.api";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class ClusterApi extends KubeApi<Cluster> {
async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise<IClusterMetrics> {
const nodes = nodeNames.join("|");
const memoryUsage = `
sum(
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (kubernetes_name)
`.replace(/_bytes/g, `_bytes{kubernetes_node=~"${nodes}"}`);
const memoryRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="memory"}) by (component)`;
const memoryLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="memory"}) by (component)`;
const memoryCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="memory"}) by (component)`;
const cpuUsage = `sum(rate(node_cpu_seconds_total{kubernetes_node=~"${nodes}", mode=~"user|system"}[1m]))`;
const cpuRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="cpu"}) by (component)`;
const cpuLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="cpu"}) by (component)`;
const cpuCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="cpu"}) by (component)`;
const podUsage = `sum(kubelet_running_pod_count{instance=~"${nodes}"})`;
const podCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="pods"}) by (component)`;
const fsSize = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`;
const fsUsage = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`;
return metricsApi.getMetrics({
memoryUsage,
memoryRequests,
memoryLimits,
memoryCapacity,
cpuUsage,
cpuRequests,
cpuLimits,
cpuCapacity,
podUsage,
podCapacity,
fsSize,
fsUsage
}, params);
}
}
export enum ClusterStatus {
ACTIVE = "Active",
CREATING = "Creating",
REMOVING = "Removing",
ERROR = "Error"
}
export interface IClusterMetrics<T = IMetrics> {
[metric: string]: T;
memoryUsage: T;
memoryRequests: T;
memoryLimits: T;
memoryCapacity: T;
cpuUsage: T;
cpuRequests: T;
cpuLimits: T;
cpuCapacity: T;
podUsage: T;
podCapacity: T;
fsSize: T;
fsUsage: T;
}
export class Cluster extends KubeObject {
static kind = "Cluster";
spec: {
clusterNetwork?: {
serviceDomain?: string;
pods?: {
cidrBlocks?: string[];
};
services?: {
cidrBlocks?: string[];
};
};
providerSpec: {
value: {
profile: string;
};
};
}
status?: {
apiEndpoints: {
host: string;
port: string;
}[];
providerStatus: {
adminUser?: string;
adminPassword?: string;
kubeconfig?: string;
processState?: string;
lensAddress?: string;
};
errorMessage?: string;
errorReason?: string;
}
getStatus() {
if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING;
if (!this.status || !this.status) return ClusterStatus.CREATING;
if (this.status.errorMessage) return ClusterStatus.ERROR;
return ClusterStatus.ACTIVE;
}
}
export const clusterApi = new ClusterApi({
kind: Cluster.kind,
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
isNamespaced: true,
objectConstructor: Cluster,
});

View File

@ -0,0 +1,25 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export interface IComponentStatusCondition {
type: string;
status: string;
message: string;
}
export class ComponentStatus extends KubeObject {
static kind = "ComponentStatus"
conditions: IComponentStatusCondition[]
getTruthyConditions() {
return this.conditions.filter(c => c.status === "True");
}
}
export const componentStatusApi = new KubeApi({
kind: ComponentStatus.kind,
apiBase: "/api/v1/componentstatuses",
isNamespaced: false,
objectConstructor: ComponentStatus,
});

View File

@ -0,0 +1,9 @@
// App configuration api
import { apiBase } from "../index";
import { IConfig } from "../../../server/common/config";
export const configApi = {
getConfig() {
return apiBase.get<IConfig>("/config")
},
};

View File

@ -0,0 +1,29 @@
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class ConfigMap extends KubeObject {
static kind = "ConfigMap";
constructor(data: KubeJsonApiData) {
super(data);
this.data = this.data || {};
}
data: {
[param: string]: string;
}
getKeys(): string[] {
return Object.keys(this.data);
}
}
export const configMapApi = new KubeApi({
kind: ConfigMap.kind,
apiBase: "/api/v1/configmaps",
isNamespaced: true,
objectConstructor: ConfigMap,
});

View File

@ -0,0 +1,138 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { crdResourcesURL } from "../../components/+custom-resources/crd.route";
export class CustomResourceDefinition extends KubeObject {
static kind = "CustomResourceDefinition";
spec: {
group: string;
version: string;
names: {
plural: string;
singular: string;
kind: string;
listKind: string;
};
scope: "Namespaced" | "Cluster" | string;
validation?: any;
versions: {
name: string;
served: boolean;
storage: boolean;
}[];
conversion: {
strategy?: string;
webhook?: any;
};
additionalPrinterColumns?: {
name: string;
type: "integer" | "number" | "string" | "boolean" | "date";
priority: number;
description: string;
JSONPath: string;
}[];
}
status: {
conditions: {
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}[];
acceptedNames: {
plural: string;
singular: string;
kind: string;
shortNames: string[];
listKind: string;
};
storedVersions: string[];
}
getResourceUrl() {
return crdResourcesURL({
params: {
group: this.getGroup(),
name: this.getPluralName(),
}
})
}
getResourceApiBase() {
const { version, group } = this.spec;
return `/apis/${group}/${version}/${this.getPluralName()}`
}
getPluralName() {
return this.getNames().plural
}
getResourceKind() {
return this.spec.names.kind
}
getResourceTitle() {
const name = this.getPluralName();
return name[0].toUpperCase() + name.substr(1)
}
getGroup() {
return this.spec.group;
}
getScope() {
return this.spec.scope;
}
getVersion() {
return this.spec.version;
}
isNamespaced() {
return this.getScope() === "Namespaced";
}
getStoredVersions() {
return this.status.storedVersions.join(", ");
}
getNames() {
return this.spec.names;
}
getConversion() {
return JSON.stringify(this.spec.conversion);
}
getPrinterColumns(ignorePriority = true) {
const columns = this.spec.additionalPrinterColumns || [];
return columns
.filter(column => column.name != "Age")
.filter(column => ignorePriority ? true : !column.priority);
}
getValidation() {
return JSON.stringify(this.spec.validation, null, 2);
}
getConditions() {
if (!this.status.conditions) return [];
return this.status.conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
}
export const crdApi = new KubeApi<CustomResourceDefinition>({
kind: CustomResourceDefinition.kind,
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
isNamespaced: false,
objectConstructor: CustomResourceDefinition,
});

View File

@ -0,0 +1,88 @@
import moment from "moment";
import { KubeObject } from "../kube-object";
import { IPodContainer } from "./pods.api";
import { formatDuration } from "../../utils/formatDuration";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class CronJob extends KubeObject {
static kind = "CronJob"
kind: string
apiVersion: string
metadata: {
name: string;
namespace: string;
selfLink: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
labels: {
[key: string]: string;
};
annotations: {
[key: string]: string;
};
}
spec: {
schedule: string;
concurrencyPolicy: string;
suspend: boolean;
jobTemplate: {
metadata: {
creationTimestamp?: string;
};
spec: {
template: {
metadata: {
creationTimestamp?: string;
};
spec: {
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
schedulerName: string;
};
};
};
};
successfulJobsHistoryLimit: number;
failedJobsHistoryLimit: number;
}
status: {
lastScheduleTime: string;
}
getSuspendFlag() {
return this.spec.suspend.toString()
}
getLastScheduleTime() {
const diff = moment().diff(this.status.lastScheduleTime)
return formatDuration(diff, true)
}
getSchedule() {
return this.spec.schedule
}
isNeverRun() {
const schedule = this.getSchedule();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const stamps = schedule.split(" ");
const day = Number(stamps[stamps.length - 3]); // 1-31
const month = Number(stamps[stamps.length - 2]); // 1-12
if (schedule.startsWith("@")) return false;
return day > daysInMonth[month - 1];
}
}
export const cronJobApi = new KubeApi({
kind: CronJob.kind,
apiBase: "/apis/batch/v1beta1/cronjobs",
isNamespaced: true,
objectConstructor: CronJob,
});

View File

@ -0,0 +1,76 @@
import get from "lodash/get";
import { IPodContainer } from "./pods.api";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class DaemonSet extends WorkloadKubeObject {
static kind = "DaemonSet"
spec: {
selector: {
matchLabels: {
[name: string]: string;
};
};
template: {
metadata: {
creationTimestamp?: string;
labels: {
name: string;
};
};
spec: {
containers: IPodContainer[];
initContainers?: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
securityContext: {};
schedulerName: string;
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
};
};
updateStrategy: {
type: string;
rollingUpdate: {
maxUnavailable: number;
};
};
revisionHistoryLimit: number;
}
status: {
currentNumberScheduled: number;
numberMisscheduled: number;
desiredNumberScheduled: number;
numberReady: number;
observedGeneration: number;
updatedNumberScheduled: number;
numberAvailable: number;
numberUnavailable: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", [])
return [...containers, ...initContainers].map(container => container.image)
}
}
export const daemonSetApi = new KubeApi({
kind: DaemonSet.kind,
apiBase: "/apis/apps/v1/daemonsets",
isNamespaced: true,
objectConstructor: DaemonSet,
});

View File

@ -0,0 +1,171 @@
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export class DeploymentApi extends KubeApi<Deployment> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return this.getUrl(params) + "/scale"
}
getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request
.get(this.getScaleApiUrl(params))
.then(({ status }: any) => status.replicas)
}
scale(params: { namespace: string; name: string }, replicas: number) {
return this.request.put(this.getScaleApiUrl(params), {
data: {
metadata: params,
spec: {
replicas: replicas
}
}
})
}
}
@autobind()
export class Deployment extends WorkloadKubeObject {
static kind = "Deployment"
spec: {
replicas: number;
selector: { matchLabels: { [app: string]: string } };
template: {
metadata: {
creationTimestamp?: string;
labels: { [app: string]: string };
};
spec: {
containers: {
name: string;
image: string;
args?: string[];
ports?: {
name: string;
containerPort: number;
protocol: string;
}[];
env?: {
name: string;
value: string;
}[];
resources: {
limits?: {
cpu: string;
memory: string;
};
requests: {
cpu: string;
memory: string;
};
};
volumeMounts?: {
name: string;
mountPath: string;
}[];
livenessProbe?: {
httpGet: {
path: string;
port: number;
scheme: string;
};
initialDelaySeconds: number;
timeoutSeconds: number;
periodSeconds: number;
successThreshold: number;
failureThreshold: number;
};
readinessProbe?: {
httpGet: {
path: string;
port: number;
scheme: string;
};
initialDelaySeconds: number;
timeoutSeconds: number;
periodSeconds: number;
successThreshold: number;
failureThreshold: number;
};
terminationMessagePath: string;
terminationMessagePolicy: string;
imagePullPolicy: string;
}[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
serviceAccountName: string;
serviceAccount: string;
securityContext: {};
schedulerName: string;
tolerations?: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
volumes?: {
name: string;
configMap: {
name: string;
defaultMode: number;
optional: boolean;
};
}[];
};
};
strategy: {
type: string;
rollingUpdate: {
maxUnavailable: number;
maxSurge: number;
};
};
}
status: {
observedGeneration: number;
replicas: number;
updatedReplicas: number;
readyReplicas: number;
availableReplicas?: number;
unavailableReplicas?: number;
conditions: {
type: string;
status: string;
lastUpdateTime: string;
lastTransitionTime: string;
reason: string;
message: string;
}[];
}
getConditions(activeOnly = false) {
const { conditions } = this.status
if (!conditions) return []
if (activeOnly) {
return conditions.filter(c => c.status === "True")
}
return conditions
}
getConditionsText(activeOnly = true) {
return this.getConditions(activeOnly).map(({ type }) => type).join(" ")
}
getReplicas() {
return this.spec.replicas || 0;
}
}
export const deploymentApi = new DeploymentApi({
kind: Deployment.kind,
apiBase: "/apis/apps/v1/deployments",
isNamespaced: true,
objectConstructor: Deployment,
});

View File

@ -0,0 +1,59 @@
import moment from "moment";
import { KubeObject } from "../kube-object";
import { formatDuration } from "../../utils/formatDuration";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class KubeEvent extends KubeObject {
static kind = "Event"
involvedObject: {
kind: string;
namespace: string;
name: string;
uid: string;
apiVersion: string;
resourceVersion: string;
fieldPath: string;
}
reason: string
message: string
source: {
component: string;
host: string;
}
firstTimestamp: string
lastTimestamp: string
count: number
type: string
eventTime: null
reportingComponent: string
reportingInstance: string
isWarning() {
return this.type === "Warning";
}
getSource() {
const { component, host } = this.source
return `${component} ${host || ""}`
}
getFirstSeenTime() {
const diff = moment().diff(this.firstTimestamp)
return formatDuration(diff, true)
}
getLastSeenTime() {
const diff = moment().diff(this.lastTimestamp)
return formatDuration(diff, true)
}
}
export const eventApi = new KubeApi({
kind: KubeEvent.kind,
apiBase: "/api/v1/events",
isNamespaced: true,
objectConstructor: KubeEvent,
})

View File

@ -0,0 +1,129 @@
import pathToRegExp from "path-to-regexp";
import { apiKubeHelm } from "../index";
import { stringify } from "querystring";
import { autobind } from "../../utils";
interface IHelmChartList {
[repo: string]: {
[name: string]: HelmChart;
};
}
export interface IHelmChartDetails {
readme: string;
versions: HelmChart[];
}
const endpoint = pathToRegExp.compile(`/v2/charts/:repo?/:name?`) as (params?: {
repo?: string;
name?: string;
}) => string;
export const helmChartsApi = {
list() {
return apiKubeHelm
.get<IHelmChartList>(endpoint())
.then(data => {
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(HelmChart.create);
});
},
get(repo: string, name: string, readmeVersion?: string) {
const path = endpoint({ repo, name });
return apiKubeHelm
.get<IHelmChartDetails>(path + "?" + stringify({ version: readmeVersion }))
.then(data => {
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
return {
readme,
versions,
}
});
},
getValues(repo: string, name: string, version: string) {
return apiKubeHelm
.get<string>(`/v2/charts/${repo}/${name}/values?` + stringify({ version }));
}
};
@autobind()
export class HelmChart {
constructor(data: any) {
Object.assign(this, data);
}
static create(data: any) {
return new HelmChart(data);
}
apiVersion: string
name: string
version: string
repo: string
kubeVersion?: string
created: string
description?: string
digest: string
keywords?: string[]
home?: string
sources?: string[]
maintainers?: {
name: string;
email: string;
url: string;
}[]
engine?: string
icon?: string
appVersion?: string
deprecated?: boolean
tillerVersion?: string
getId() {
return this.digest;
}
getName() {
return this.name;
}
getFullName(splitter = "/") {
return [this.getRepository(), this.getName()].join(splitter);
}
getDescription() {
return this.description;
}
getIcon() {
return this.icon;
}
getHome() {
return this.home;
}
getMaintainers() {
return this.maintainers || [];
}
getVersion() {
return this.version;
}
getRepository() {
return this.repo;
}
getAppVersion() {
return this.appVersion || "";
}
getKeywords() {
return this.keywords || [];
}
}

View File

@ -0,0 +1,213 @@
import jsYaml from "js-yaml";
import pathToRegExp from "path-to-regexp";
import { autobind, formatDuration } from "../../utils";
import capitalize from "lodash/capitalize";
import { apiKubeHelm } from "../index";
import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.store";
import { ItemObject } from "../../item.store";
import { KubeObject } from "../kube-object";
interface IReleasePayload {
name: string;
namespace: string;
version: string;
config: string; // release values
manifest: string;
info: {
deleted: string;
description: string;
first_deployed: string;
last_deployed: string;
notes: string;
status: string;
};
}
interface IReleaseRawDetails extends IReleasePayload {
resources: string;
}
export interface IReleaseDetails extends IReleasePayload {
resources: KubeObject[];
}
export interface IReleaseCreatePayload {
name?: string;
repo: string;
chart: string;
namespace: string;
version: string;
values: string;
}
export interface IReleaseUpdatePayload {
repo: string;
chart: string;
version: string;
values: string;
}
export interface IReleaseUpdateDetails {
log: string;
release: IReleaseDetails;
}
export interface IReleaseRevision {
revision: number;
updated: string;
status: string;
chart: string;
description: string;
}
const endpoint = pathToRegExp.compile(`/v2/releases/:namespace?/:name?`) as (
params?: {
namespace?: string;
name?: string;
}
) => string;
export const helmReleasesApi = {
list(namespace?: string) {
return apiKubeHelm
.get<HelmRelease[]>(endpoint({ namespace }))
.then(releases => releases.map(HelmRelease.create));
},
get(name: string, namespace: string) {
const path = endpoint({ name, namespace });
return apiKubeHelm.get<IReleaseRawDetails>(path).then(details => {
const items: KubeObject[] = JSON.parse(details.resources).items;
const resources = items.map(item => KubeObject.create(item));
return {
...details,
resources
}
});
},
create(payload: IReleaseCreatePayload): Promise<IReleaseUpdateDetails> {
const { repo, ...data } = payload;
data.chart = `${repo}/${data.chart}`;
data.values = jsYaml.safeLoad(data.values);
return apiKubeHelm.post(endpoint(), { data });
},
update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise<IReleaseUpdateDetails> {
const { repo, ...data } = payload;
data.chart = `${repo}/${data.chart}`;
data.values = jsYaml.safeLoad(data.values);
return apiKubeHelm.put(endpoint({ name, namespace }), { data });
},
async delete(name: string, namespace: string) {
const path = endpoint({ name, namespace });
return apiKubeHelm.del(path);
},
getValues(name: string, namespace: string) {
const path = endpoint({ name, namespace }) + "/values";
return apiKubeHelm.get<string>(path);
},
getHistory(name: string, namespace: string): Promise<IReleaseRevision[]> {
const path = endpoint({ name, namespace }) + "/history";
return apiKubeHelm.get(path);
},
rollback(name: string, namespace: string, revision: number) {
const path = endpoint({ name, namespace }) + "/rollback";
return apiKubeHelm.put(path, {
data: {
revision: revision
}
});
}
};
@autobind()
export class HelmRelease implements ItemObject {
constructor(data: any) {
Object.assign(this, data);
}
static create(data: any) {
return new HelmRelease(data);
}
appVersion: string
name: string
namespace: string
chart: string
status: string
updated: string
revision: number
getId() {
return this.namespace + this.name;
}
getName() {
return this.name;
}
getNs() {
return this.namespace;
}
getChart(withVersion = false) {
return withVersion ?
this.chart :
this.chart.substr(0, this.chart.lastIndexOf("-"));
}
getRevision() {
return this.revision;
}
getStatus() {
return capitalize(this.status);
}
getVersion() {
return this.chart.match(/(\d+)[^-]*$/)[0];
}
getUpdated(humanize = true, compact = true) {
const now = new Date().getTime();
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = now - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
}
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const chartName = this.getChart();
const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(chartVersion => chartVersion.version === version);
return chartVersion ? chartVersion.repo : "";
}
getLastVersion(): string | null {
const chartName = this.getChart();
const versions = helmChartStore.versions.get(chartName);
if (!versions) {
return null; // checking new version state
}
if (versions.length) {
return versions[0].version; // versions already sorted when loaded, the first is latest
}
return this.getVersion();
}
hasNewVersion() {
const lastVersion = this.getLastVersion();
return lastVersion && lastVersion !== this.getVersion();
}
}

View File

@ -0,0 +1,140 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export enum HpaMetricType {
Resource = "Resource",
Pods = "Pods",
Object = "Object",
External = "External",
}
export type IHpaMetricData<T = any> = T & {
target?: {
kind: string;
name: string;
apiVersion: string;
};
name?: string;
metricName?: string;
currentAverageUtilization?: number;
currentAverageValue?: string;
targetAverageUtilization?: number;
targetAverageValue?: string;
}
export interface IHpaMetric {
[kind: string]: IHpaMetricData;
type: HpaMetricType;
resource?: IHpaMetricData<{ name: string }>;
pods?: IHpaMetricData;
external?: IHpaMetricData;
object?: IHpaMetricData<{
describedObject: {
apiVersion: string;
kind: string;
name: string;
};
}>;
}
export class HorizontalPodAutoscaler extends KubeObject {
static kind = "HorizontalPodAutoscaler";
spec: {
scaleTargetRef: {
kind: string;
name: string;
apiVersion: string;
};
minReplicas: number;
maxReplicas: number;
metrics: IHpaMetric[];
}
status: {
currentReplicas: number;
desiredReplicas: number;
currentMetrics: IHpaMetric[];
conditions: {
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}[];
}
getMaxPods() {
return this.spec.maxReplicas || 0;
}
getMinPods() {
return this.spec.minReplicas || 0;
}
getReplicas() {
return this.status.currentReplicas;
}
getConditions() {
if (!this.status.conditions) return [];
return this.status.conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
getMetrics() {
return this.spec.metrics || [];
}
getCurrentMetrics() {
return this.status.currentMetrics || [];
}
protected getMetricName(metric: IHpaMetric): string {
const { type, resource, pods, object, external } = metric;
switch (type) {
case HpaMetricType.Resource:
return resource.name
case HpaMetricType.Pods:
return pods.metricName;
case HpaMetricType.Object:
return object.metricName;
case HpaMetricType.External:
return external.metricName;
}
}
// todo: refactor
getMetricValues(metric: IHpaMetric): string {
const metricType = metric.type.toLowerCase();
const currentMetric = this.getCurrentMetrics().find(current =>
metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current)
);
const current = currentMetric ? currentMetric[metricType] : null;
const target = metric[metricType];
let currentValue = "unknown";
let targetValue = "unknown";
if (current) {
currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue;
if (current.currentAverageUtilization) currentValue += "%";
}
if (target) {
targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue;
if (target.targetAverageUtilization) targetValue += "%"
}
return `${currentValue} / ${targetValue}`;
}
}
export const hpaApi = new KubeApi({
kind: HorizontalPodAutoscaler.kind,
apiBase: "/apis/autoscaling/v2beta1/horizontalpodautoscalers",
isNamespaced: true,
objectConstructor: HorizontalPodAutoscaler,
});

View File

@ -0,0 +1,32 @@
// Local express.js & kontena endpoints
export * from "./config.api"
export * from "./cluster.api"
export * from "./kubeconfig.api"
// Kubernetes endpoints
// Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/
export * from "./namespaces.api"
export * from "./cluster-role.api"
export * from "./cluster-role-binding.api"
export * from "./role.api"
export * from "./role-binding.api"
export * from "./secret.api"
export * from "./service-accounts.api"
export * from "./nodes.api"
export * from "./pods.api"
export * from "./deployment.api"
export * from "./daemon-set.api"
export * from "./stateful-set.api"
export * from "./replica-set.api"
export * from "./job.api"
export * from "./cron-job.api"
export * from "./configmap.api"
export * from "./ingress.api"
export * from "./network-policy.api"
export * from "./persistent-volume-claims.api"
export * from "./persistent-volume.api"
export * from "./service.api"
export * from "./storage-class.api"
export * from "./pod-metrics.api"
export * from "./podsecuritypolicy.api"
export * from "./selfsubjectrulesreviews.api"

View File

@ -0,0 +1,118 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class IngressApi extends KubeApi<Ingress> {
getMetrics(ingress: string, namespace: string): Promise<IIngressMetrics> {
const bytesSent = (statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[1m])) by (ingress)`;
const bytesSentSuccess = bytesSent("^2\\\\d*"); // Requests with status 2**
const bytesSentFailure = bytesSent("^5\\\\d*"); // Requests with status 5**
const requestDurationSeconds = `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`;
const responseDurationSeconds = `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`;
return metricsApi.getMetrics({
bytesSentSuccess,
bytesSentFailure,
requestDurationSeconds,
responseDurationSeconds
}, {
namespace,
});
}
}
export interface IIngressMetrics<T = IMetrics> {
[metric: string]: T;
bytesSentSuccess: T;
bytesSentFailure: T;
requestDurationSeconds: T;
responseDurationSeconds: T;
}
@autobind()
export class Ingress extends KubeObject {
static kind = "Ingress"
spec: {
tls: {
secretName: string;
}[];
rules?: {
host?: string;
http: {
paths: {
path?: string;
backend: {
serviceName: string;
servicePort: number;
};
}[];
};
}[];
backend?: {
serviceName: string;
servicePort: number;
};
}
status: {
loadBalancer: {
ingress: any[];
};
}
getRoutes() {
const { spec: { tls, rules } } = this
if (!rules) return []
let protocol = "http"
const routes: string[] = []
if (tls && tls.length > 0) {
protocol += "s"
}
rules.map(rule => {
const host = rule.host ? rule.host : "*"
if (rule.http && rule.http.paths) {
rule.http.paths.forEach(path => {
routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + path.backend.serviceName + ":" + path.backend.servicePort)
})
}
})
return routes;
}
getHosts() {
const { spec: { rules } } = this
if (!rules) return []
return rules.filter(rule => rule.host).map(rule => rule.host)
}
getPorts() {
const ports: number[] = []
const { spec: { tls, rules, backend } } = this
const httpPort = 80
const tlsPort = 443
if (rules && rules.length > 0) {
if (rules.some(rule => rule.hasOwnProperty("http"))) {
ports.push(httpPort)
}
}
else {
if (backend && backend.servicePort) {
ports.push(backend.servicePort)
}
}
if (tls && tls.length > 0) {
ports.push(tlsPort)
}
return ports.join(", ")
}
}
export const ingressApi = new IngressApi({
kind: Ingress.kind,
apiBase: "/apis/extensions/v1beta1/ingresses",
isNamespaced: true,
objectConstructor: Ingress,
});

View File

@ -0,0 +1,98 @@
import get from "lodash/get";
import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api";
import { KubeApi } from "../kube-api";
@autobind()
export class Job extends WorkloadKubeObject {
static kind = "Job"
spec: {
parallelism?: number;
completions?: number;
backoffLimit?: number;
selector: {
matchLabels: {
[name: string]: string;
};
};
template: {
metadata: {
creationTimestamp?: string;
labels: {
name: string;
};
};
spec: {
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
schedulerName: string;
};
};
containers?: IPodContainer[];
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
serviceAccountName?: string;
serviceAccount?: string;
schedulerName?: string;
}
status: {
conditions: {
type: string;
status: string;
lastProbeTime: string;
lastTransitionTime: string;
message?: string;
}[];
startTime: string;
completionTime: string;
succeeded: number;
}
getDesiredCompletions() {
return this.spec.completions || 0;
}
getCompletions() {
return this.status.succeeded || 0;
}
getParallelism() {
return this.spec.parallelism;
}
getCondition() {
// Type of Job condition could be only Complete or Failed
// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch
const { conditions } = this.status;
if (!conditions) return;
return conditions.find(({ status }) => status === "True");
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const jobApi = new KubeApi({
kind: Job.kind,
apiBase: "/apis/batch/v1/jobs",
isNamespaced: true,
objectConstructor: Job,
});

View File

@ -0,0 +1,12 @@
// Kubeconfig api
import { apiBase } from "../index";
export const kubeConfigApi = {
getUserConfig() {
return apiBase.get("/kubeconfig/user");
},
getServiceAccountConfig(account: string, namespace: string) {
return apiBase.get(`/kubeconfig/service-account/${namespace}/${account}`);
},
};

View File

@ -0,0 +1,112 @@
// Metrics api
import moment from "moment";
import { apiBase } from "../index";
import { IMetricsQuery } from "../../../server/common/metrics";
export interface IMetrics {
status: string;
data: {
resultType: string;
result: IMetricsResult[];
};
}
export interface IMetricsResult {
metric: {
[name: string]: string;
instance: string;
node?: string;
pod?: string;
kubernetes?: string;
kubernetes_node?: string;
kubernetes_namespace?: string;
};
values: [number, string][];
}
export interface IMetricsReqParams {
start?: number | string; // timestamp in seconds or valid date-string
end?: number | string;
step?: number; // step in seconds (default: 60s = each point 1m)
range?: number; // time-range in seconds for data aggregation (default: 3600s = last 1h)
namespace?: string; // rbac-proxy validation param
}
export const metricsApi = {
async getMetrics<T = IMetricsQuery>(query: T, reqParams: IMetricsReqParams = {}): Promise<T extends object ? { [K in keyof T]: IMetrics } : IMetrics> {
const { range = 3600, step = 60, namespace } = reqParams;
let { start, end } = reqParams;
if (!start && !end) {
const timeNow = Date.now() / 1000;
const now = moment.unix(timeNow).startOf('minute').unix(); // round date to minutes
start = now - range;
end = now;
}
return apiBase.post("/metrics", {
data: query,
query: {
start, end, step,
"kubernetes_namespace": namespace,
}
});
},
};
export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics {
const { result } = metrics.data;
if (result.length) {
if (frames > 0) {
// fill the gaps
result.forEach(res => {
if (!res.values || !res.values.length) return;
while (res.values.length < frames) {
const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix();
res.values.unshift([timestamp, "0"])
}
});
}
} else {
// always return at least empty values array
result.push({
metric: {},
values: []
} as IMetricsResult);
}
return metrics;
}
export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) {
return Object.values(metrics).every(metric => !metric.data.result.length);
}
export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string) {
if (!metrics) return;
const itemMetrics = { ...metrics };
for (const metric in metrics) {
const results = metrics[metric].data.result;
const result = results.find(res => Object.values(res.metric)[0] == itemName);
itemMetrics[metric].data.result = result ? [result] : [];
}
return itemMetrics;
}
export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) {
const result: Partial<{[metric: string]: number}> = {};
Object.keys(metrics).forEach(metricName => {
try {
const metric = metrics[metricName];
if (metric.data.result.length) {
result[metricName] = +metric.data.result[0].values.slice(-1)[0][1];
}
} catch (e) {
}
return result;
}, {});
return result;
}

View File

@ -0,0 +1,28 @@
import { KubeApi } from "../kube-api";
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
export enum NamespaceStatus {
ACTIVE = "Active",
TERMINATING = "Terminating",
}
@autobind()
export class Namespace extends KubeObject {
static kind = "Namespace";
status?: {
phase: string;
}
getStatus() {
return this.status ? this.status.phase : "-";
}
}
export const namespacesApi = new KubeApi({
kind: Namespace.kind,
apiBase: "/api/v1/namespaces",
isNamespaced: false,
objectConstructor: Namespace,
});

View File

@ -0,0 +1,72 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export interface IPolicyIpBlock {
cidr: string;
except?: string[];
}
export interface IPolicySelector {
matchLabels: {
[label: string]: string;
};
}
export interface IPolicyIngress {
from: {
ipBlock?: IPolicyIpBlock;
namespaceSelector?: IPolicySelector;
podSelector?: IPolicySelector;
}[];
ports: {
protocol: string;
port: number;
}[];
}
export interface IPolicyEgress {
to: {
ipBlock: IPolicyIpBlock;
}[];
ports: {
protocol: string;
port: number;
}[];
}
@autobind()
export class NetworkPolicy extends KubeObject {
static kind = "NetworkPolicy"
spec: {
podSelector: {
matchLabels: {
[label: string]: string;
role: string;
};
};
policyTypes: string[];
ingress: IPolicyIngress[];
egress: IPolicyEgress[];
}
getMatchLabels(): string[] {
if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return [];
return Object
.entries(this.spec.podSelector.matchLabels)
.map(data => data.join(":"))
}
getTypes(): string[] {
if (!this.spec.policyTypes) return [];
return this.spec.policyTypes;
}
}
export const networkPolicyApi = new KubeApi({
kind: NetworkPolicy.kind,
apiBase: "/apis/networking.k8s.io/v1/networkpolicies",
isNamespaced: true,
objectConstructor: NetworkPolicy,
});

View File

@ -0,0 +1,158 @@
import { KubeObject } from "../kube-object";
import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class NodesApi extends KubeApi<Node> {
getMetrics(): Promise<INodeMetrics> {
const memoryUsage = `
sum (
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (kubernetes_node)
`;
const memoryCapacity = `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
const cpuUsage = `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[1m])) by(kubernetes_node)`;
const cpuCapacity = `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
const fsSize = `sum(node_filesystem_size_bytes{mountpoint="/"}) by (kubernetes_node)`;
const fsUsage = `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (kubernetes_node)`;
return metricsApi.getMetrics({
memoryUsage,
memoryCapacity,
cpuUsage,
cpuCapacity,
fsSize,
fsUsage
});
}
}
export interface INodeMetrics<T = IMetrics> {
[metric: string]: T;
memoryUsage: T;
memoryCapacity: T;
cpuUsage: T;
cpuCapacity: T;
fsUsage: T;
fsSize: T;
}
@autobind()
export class Node extends KubeObject {
static kind = "Node"
spec: {
podCIDR: string;
externalID: string;
taints?: {
key: string;
value: string;
effect: string;
}[];
unschedulable?: boolean;
}
status: {
capacity: {
cpu: string;
memory: string;
pods: string;
};
allocatable: {
cpu: string;
memory: string;
pods: string;
};
conditions: {
type: string;
status?: string;
lastHeartbeatTime?: string;
lastTransitionTime?: string;
reason?: string;
message?: string;
}[];
addresses: {
type: string;
address: string;
}[];
nodeInfo: {
machineID: string;
systemUUID: string;
bootID: string;
kernelVersion: string;
osImage: string;
containerRuntimeVersion: string;
kubeletVersion: string;
kubeProxyVersion: string;
operatingSystem: string;
architecture: string;
};
images: {
names: string[];
sizeBytes: number;
}[];
}
getNodeConditionText() {
const { conditions } = this.status
if (!conditions) return ""
return conditions.reduce((types, current) => {
if (current.status !== "True") return ""
return types += ` ${current.type}`
}, "")
}
getTaints() {
return this.spec.taints || [];
}
getRoleLabels() {
const roleLabels = Object.keys(this.metadata.labels).filter(key =>
key.includes("node-role.kubernetes.io")
).map(key => key.match(/([^/]+$)/)[0]) // all after last slash
return roleLabels.join(", ")
}
getCpuCapacity() {
if (!this.status.capacity || !this.status.capacity.cpu) return 0
return cpuUnitsToNumber(this.status.capacity.cpu)
}
getMemoryCapacity() {
if (!this.status.capacity || !this.status.capacity.memory) return 0
return unitsToBytes(this.status.capacity.memory)
}
getConditions() {
const conditions = this.status.conditions || [];
if (this.isUnschedulable()) {
return [{ type: "SchedulingDisabled", status: "True" }, ...conditions];
}
return conditions;
}
getActiveConditions() {
return this.getConditions().filter(c => c.status === "True");
}
getWarningConditions() {
const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"];
return this.getActiveConditions().filter(condition => {
return !goodConditions.includes(condition.type);
});
}
getKubeletVersion() {
return this.status.nodeInfo.kubeletVersion;
}
isUnschedulable() {
return this.spec.unschedulable
}
}
export const nodesApi = new NodesApi({
kind: Node.kind,
apiBase: "/api/v1/nodes",
isNamespaced: false,
objectConstructor: Node,
});

View File

@ -0,0 +1,91 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { Pod } from "./pods.api";
import { KubeApi } from "../kube-api";
export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
getMetrics(pvcName: string, namespace: string): Promise<IPvcMetrics> {
const diskUsage = `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`;
const diskCapacity = `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`;
return metricsApi.getMetrics({
diskUsage,
diskCapacity
}, {
namespace
});
}
}
export interface IPvcMetrics<T = IMetrics> {
[key: string]: T;
diskUsage: T;
diskCapacity: T;
}
@autobind()
export class PersistentVolumeClaim extends KubeObject {
static kind = "PersistentVolumeClaim"
spec: {
accessModes: string[];
storageClassName: string;
selector: {
matchLabels: {
release: string;
};
matchExpressions: {
key: string; // environment,
operator: string; // In,
values: string[]; // [dev]
}[];
};
resources: {
requests: {
storage: string; // 8Gi
};
};
}
status: {
phase: string; // Pending
}
getPods(allPods: Pod[]): Pod[] {
const pods = allPods.filter(pod => pod.getNs() === this.getNs())
return pods.filter(pod => {
return pod.getVolumes().filter(volume =>
volume.persistentVolumeClaim &&
volume.persistentVolumeClaim.claimName === this.getName()
).length > 0
})
}
getStorage(): string {
if (!this.spec.resources || !this.spec.resources.requests) return "-";
return this.spec.resources.requests.storage;
}
getMatchLabels(): string[] {
if (!this.spec.selector || !this.spec.selector.matchLabels) return [];
return Object.entries(this.spec.selector.matchLabels)
.map(([name, val]) => `${name}:${val}`);
}
getMatchExpressions() {
if (!this.spec.selector || !this.spec.selector.matchExpressions) return [];
return this.spec.selector.matchExpressions;
}
getStatus(): string {
if (this.status) return this.status.phase;
return "-"
}
}
export const pvcApi = new PersistentVolumeClaimsApi({
kind: PersistentVolumeClaim.kind,
apiBase: "/api/v1/persistentvolumeclaims",
isNamespaced: true,
objectConstructor: PersistentVolumeClaim,
});

View File

@ -0,0 +1,71 @@
import { KubeObject } from "../kube-object";
import { unitsToBytes } from "../../utils/convertMemory";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class PersistentVolume extends KubeObject {
static kind = "PersistentVolume"
spec: {
capacity: {
storage: string; // 8Gi
};
flexVolume: {
driver: string; // ceph.rook.io/rook-ceph-system,
options: {
clusterNamespace: string; // rook-ceph,
image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
pool: string; // replicapool,
storageClass: string; // rook-ceph-block
};
};
mountOptions?: string[];
accessModes: string[]; // [ReadWriteOnce]
claimRef: {
kind: string; // PersistentVolumeClaim,
namespace: string; // storage,
name: string; // nfs-provisioner,
uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
apiVersion: string; // v1,
resourceVersion: string; // 292180
};
persistentVolumeReclaimPolicy: string; // Delete,
storageClassName: string; // rook-ceph-block
nfs?: {
path: string;
server: string;
};
}
status: {
phase: string;
reason?: string;
}
getCapacity(inBytes = false) {
const capacity = this.spec.capacity;
if (capacity) {
if (inBytes) return unitsToBytes(capacity.storage)
return capacity.storage;
}
return 0;
}
getStatus() {
if (!this.status) return;
return this.status.phase || "-";
}
getClaimRefName() {
const { claimRef } = this.spec;
return claimRef ? claimRef.name : "";
}
}
export const persistentVolumeApi = new KubeApi({
kind: PersistentVolume.kind,
apiBase: "/api/v1/persistentvolumes",
isNamespaced: false,
objectConstructor: PersistentVolume,
});

View File

@ -0,0 +1,21 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class PodMetrics extends KubeObject {
timestamp: string
window: string
containers: {
name: string;
usage: {
cpu: string;
memory: string;
};
}[]
}
export const podMetricsApi = new KubeApi({
kind: PodMetrics.kind,
apiBase: "/apis/metrics.k8s.io/v1beta1/pods",
isNamespaced: true,
objectConstructor: PodMetrics,
});

View File

@ -0,0 +1,411 @@
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class PodsApi extends KubeApi<Pod> {
async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise<string> {
const path = this.getUrl(params) + "/log";
return this.request.get(path, { query });
}
getMetrics(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise<IPodMetrics> {
const podSelector = pods.map(pod => pod.getName()).join("|");
const cpuUsage = `sum(rate(container_cpu_usage_seconds_total{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
const cpuRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`;
const cpuLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`;
const memoryUsage = `sum(container_memory_working_set_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`;
const memoryRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`;
const memoryLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`;
const fsUsage = `sum(container_fs_usage_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`;
const networkReceive = `sum(rate(container_network_receive_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
const networkTransit = `sum(rate(container_network_transmit_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
return metricsApi.getMetrics({
cpuUsage,
cpuRequests,
cpuLimits,
memoryUsage,
memoryRequests,
memoryLimits,
fsUsage,
networkReceive,
networkTransit,
}, {
namespace,
});
}
}
export interface IPodMetrics<T = IMetrics> {
[metric: string]: T;
cpuUsage: T;
cpuRequests: T;
cpuLimits: T;
memoryUsage: T;
memoryRequests: T;
memoryLimits: T;
fsUsage: T;
networkReceive: T;
networkTransit: T;
}
export interface IPodLogsQuery {
container?: string;
tailLines?: number;
timestamps?: boolean;
sinceTime?: string; // Date.toISOString()-format
}
export enum PodStatus {
TERMINATED = "Terminated",
FAILED = "Failed",
PENDING = "Pending",
RUNNING = "Running",
SUCCEEDED = "Succeeded",
EVICTED = "Evicted"
}
export interface IPodContainer {
name: string;
image: string;
command?: string[];
args?: string[];
ports: {
name?: string;
containerPort: number;
protocol: string;
}[];
resources?: {
limits: {
cpu: string;
memory: string;
};
requests: {
cpu: string;
memory: string;
};
};
env?: {
name: string;
value?: string;
valueFrom?: {
fieldRef?: {
apiVersion: string;
fieldPath: string;
};
secretKeyRef?: {
key: string;
name: string;
};
configMapKeyRef?: {
key: string;
name: string;
};
};
}[];
envFrom?: {
configMapRef?: {
name: string;
};
}[];
volumeMounts?: {
name: string;
readOnly: boolean;
mountPath: string;
}[];
livenessProbe?: IContainerProbe;
readinessProbe?: IContainerProbe;
imagePullPolicy: string;
}
interface IContainerProbe {
httpGet?: {
path?: string;
port: number;
scheme: string;
host?: string;
};
exec?: {
command: string[];
};
tcpSocket?: {
port: number;
};
initialDelaySeconds?: number;
timeoutSeconds?: number;
periodSeconds?: number;
successThreshold?: number;
failureThreshold?: number;
}
export interface IPodContainerStatus {
name: string;
state: {
[index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
lastState: {};
ready: boolean;
restartCount: number;
image: string;
imageID: string;
containerID: string;
}
@autobind()
export class Pod extends WorkloadKubeObject {
static kind = "Pod"
spec: {
volumes?: {
name: string;
persistentVolumeClaim: {
claimName: string;
};
secret: {
secretName: string;
defaultMode: number;
};
}[];
initContainers: IPodContainer[];
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
serviceAccountName: string;
serviceAccount: string;
priority: number;
priorityClassName: string;
nodeName: string;
nodeSelector?: {
[selector: string]: string;
};
securityContext: {};
schedulerName: string;
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
affinity: IAffinity;
}
status: {
phase: string;
conditions: {
type: string;
status: string;
lastProbeTime: number;
lastTransitionTime: string;
}[];
hostIP: string;
podIP: string;
startTime: string;
initContainerStatuses?: IPodContainerStatus[];
containerStatuses?: IPodContainerStatus[];
qosClass: string;
reason?: string;
}
getInitContainers() {
return this.spec.initContainers || [];
}
getContainers() {
return this.spec.containers || [];
}
getAllContainers() {
return this.getContainers().concat(this.getInitContainers());
}
getRunningContainers() {
const statuses = this.getContainerStatuses()
return this.getAllContainers().filter(container => {
return statuses.find(status => status.name === container.name && !!status.state["running"])
}
)
}
getContainerStatuses(includeInitContainers = true) {
const statuses: IPodContainerStatus[] = [];
const { containerStatuses, initContainerStatuses } = this.status;
if (containerStatuses) {
statuses.push(...containerStatuses);
}
if (includeInitContainers && initContainerStatuses) {
statuses.push(...initContainerStatuses);
}
return statuses;
}
getRestartsCount(): number {
const { containerStatuses } = this.status;
if (!containerStatuses) return 0;
return containerStatuses.reduce((count, item) => count + item.restartCount, 0);
}
getQosClass() {
return this.status.qosClass || "";
}
getReason() {
return this.status.reason || "";
}
getPriorityClassName() {
return this.spec.priorityClassName || "";
}
// Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted
getStatus() {
const phase = this.getStatusPhase();
const reason = this.getReason();
const goodConditions = ["Initialized", "Ready"].every(condition =>
!!this.getConditions().find(item => item.type === condition && item.status === "True")
);
if (reason === PodStatus.EVICTED) {
return PodStatus.EVICTED;
}
if (phase === PodStatus.FAILED) {
return PodStatus.FAILED;
}
if (phase === PodStatus.SUCCEEDED) {
return PodStatus.SUCCEEDED;
}
if (phase === PodStatus.RUNNING && goodConditions) {
return PodStatus.RUNNING;
}
return PodStatus.PENDING;
}
// Returns pod phase or container error if occured
getStatusMessage() {
let message = "";
const statuses = this.getContainerStatuses(false); // not including initContainers
if (statuses.length) {
statuses.forEach(status => {
const { state } = status;
if (state.waiting) {
const { reason } = state.waiting;
message = reason ? reason : "Waiting";
}
if (state.terminated) {
const { reason } = state.terminated;
message = reason ? reason : "Terminated";
}
})
}
if (this.getReason() === PodStatus.EVICTED) return "Evicted";
if (message) return message;
return this.getStatusPhase();
}
getStatusPhase() {
return this.status.phase;
}
getConditions() {
return this.status.conditions || [];
}
getVolumes() {
return this.spec.volumes || [];
}
getSecrets(): string[] {
return this.getVolumes()
.filter(vol => vol.secret)
.map(vol => vol.secret.secretName);
}
getNodeSelectors(): string[] {
const { nodeSelector } = this.spec
if (!nodeSelector) return []
return Object.entries(nodeSelector).map(values => values.join(": "))
}
getTolerations() {
return this.spec.tolerations || []
}
getAffinity(): IAffinity {
return this.spec.affinity
}
hasIssues() {
const notReady = !!this.getConditions().find(condition => {
return condition.type == "Ready" && condition.status !== "True"
});
const crashLoop = !!this.getContainerStatuses().find(condition => {
const waiting = condition.state.waiting
return (waiting && waiting.reason == "CrashLoopBackOff")
})
return (
notReady ||
crashLoop ||
this.getStatusPhase() !== "Running"
)
}
getLivenessProbe(container: IPodContainer) {
return this.getProbe(container.livenessProbe);
}
getReadinessProbe(container: IPodContainer) {
return this.getProbe(container.readinessProbe);
}
getProbe(probeData: IContainerProbe) {
if (!probeData) return [];
const {
httpGet, exec, tcpSocket, initialDelaySeconds, timeoutSeconds,
periodSeconds, successThreshold, failureThreshold
} = probeData;
const probe = [];
// HTTP Request
if (httpGet) {
const { path, port, host, scheme } = httpGet;
probe.push(
"http-get",
`${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`,
);
}
// Command
if (exec && exec.command) {
probe.push(`exec [${exec.command.join(" ")}]`);
}
// TCP Probe
if (tcpSocket && tcpSocket.port) {
probe.push(`tcp-socket :${tcpSocket.port}`);
}
probe.push(
`delay=${initialDelaySeconds || "0"}s`,
`timeout=${timeoutSeconds || "0"}s`,
`period=${periodSeconds || "0"}s`,
`#success=${successThreshold || "0"}`,
`#failure=${failureThreshold || "0"}`,
);
return probe;
}
}
export const podsApi = new PodsApi({
kind: Pod.kind,
apiBase: "/api/v1/pods",
isNamespaced: true,
objectConstructor: Pod,
});

View File

@ -0,0 +1,94 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class PodSecurityPolicy extends KubeObject {
static kind = "PodSecurityPolicy"
spec: {
allowPrivilegeEscalation?: boolean;
allowedCSIDrivers?: {
name: string;
}[];
allowedCapabilities: string[];
allowedFlexVolumes?: {
driver: string;
}[];
allowedHostPaths?: {
pathPrefix: string;
readOnly: boolean;
}[];
allowedProcMountTypes?: string[];
allowedUnsafeSysctls?: string[];
defaultAddCapabilities?: string[];
defaultAllowPrivilegeEscalation?: boolean;
forbiddenSysctls?: string[];
fsGroup?: {
rule: string;
ranges: { max: number; min: number }[];
};
hostIPC?: boolean;
hostNetwork?: boolean;
hostPID?: boolean;
hostPorts?: {
max: number;
min: number;
}[];
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
requiredDropCapabilities?: string[];
runAsGroup?: {
ranges: { max: number; min: number }[];
rule: string;
};
runAsUser?: {
rule: string;
ranges: { max: number; min: number }[];
};
runtimeClass?: {
allowedRuntimeClassNames: string[];
defaultRuntimeClassName: string;
};
seLinux?: {
rule: string;
seLinuxOptions: {
level: string;
role: string;
type: string;
user: string;
};
};
supplementalGroups?: {
rule: string;
ranges: { max: number; min: number }[];
};
volumes?: string[];
}
isPrivileged() {
return !!this.spec.privileged;
}
getVolumes() {
return this.spec.volumes || [];
}
getRules() {
const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec;
return {
fsGroup: fsGroup ? fsGroup.rule : "",
runAsGroup: runAsGroup ? runAsGroup.rule : "",
runAsUser: runAsUser ? runAsUser.rule : "",
supplementalGroups: supplementalGroups ? supplementalGroups.rule : "",
seLinux: seLinux ? seLinux.rule : "",
};
}
}
export const pspApi = new KubeApi({
kind: PodSecurityPolicy.kind,
apiBase: "/apis/policy/v1beta1/podsecuritypolicies",
isNamespaced: false,
objectConstructor: PodSecurityPolicy,
});

View File

@ -0,0 +1,58 @@
import get from "lodash/get";
import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api";
import { KubeApi } from "../kube-api";
@autobind()
export class ReplicaSet extends WorkloadKubeObject {
static kind = "ReplicaSet"
spec: {
replicas?: number;
selector?: {
matchLabels: {
[key: string]: string;
};
};
containers?: IPodContainer[];
template?: {
spec?: {
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
containers: IPodContainer[];
};
};
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
schedulerName?: string;
}
status: {
replicas: number;
fullyLabeledReplicas: number;
readyReplicas: number;
availableReplicas: number;
observedGeneration: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const replicaSetApi = new KubeApi({
kind: ReplicaSet.kind,
apiBase: "/apis/apps/v1/replicasets",
isNamespaced: true,
objectConstructor: ReplicaSet,
});

View File

@ -0,0 +1,26 @@
import jsYaml from "js-yaml"
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { apiKubeResourceApplier } from "../index";
import { apiManager } from "../api-manager";
export const resourceApplierApi = {
annotations: [
"kubectl.kubernetes.io/last-applied-configuration"
],
async update<D extends KubeObject>(resource: object | string): Promise<D> {
if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource);
}
return apiKubeResourceApplier
.post<KubeJsonApiData[]>("/stack", { data: resource })
.then(data => {
const items = data.map(obj => {
const api = apiManager.getApi(obj.metadata.selfLink);
return new api.objectConstructor(obj);
});
return items.length === 1 ? items[0] : items;
});
}
};

View File

@ -0,0 +1,68 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { KubeJsonApiData } from "../kube-json-api";
export interface IResourceQuotaValues {
[quota: string]: string;
// Compute Resource Quota
"limits.cpu"?: string;
"limits.memory"?: string;
"requests.cpu"?: string;
"requests.memory"?: string;
// Storage Resource Quota
"requests.storage"?: string;
"persistentvolumeclaims"?: string;
// Object Count Quota
"count/pods"?: string;
"count/persistentvolumeclaims"?: string;
"count/services"?: string;
"count/secrets"?: string;
"count/configmaps"?: string;
"count/replicationcontrollers"?: string;
"count/deployments.apps"?: string;
"count/replicasets.apps"?: string;
"count/statefulsets.apps"?: string;
"count/jobs.batch"?: string;
"count/cronjobs.batch"?: string;
"count/deployments.extensions"?: string;
}
export class ResourceQuota extends KubeObject {
static kind = "ResourceQuota"
constructor(data: KubeJsonApiData) {
super(data);
this.spec = this.spec || {} as any
}
spec: {
hard: IResourceQuotaValues;
scopeSelector?: {
matchExpressions: {
operator: string;
scopeName: string;
values: string[];
}[];
};
}
status: {
hard: IResourceQuotaValues;
used: IResourceQuotaValues;
}
getScopeSelector() {
const { matchExpressions = [] } = this.spec.scopeSelector || {};
return matchExpressions;
}
}
export const resourceQuotaApi = new KubeApi({
kind: ResourceQuota.kind,
apiBase: "/api/v1/resourcequotas",
isNamespaced: true,
objectConstructor: ResourceQuota,
});

View File

@ -0,0 +1,37 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export interface IRoleBindingSubject {
kind: string;
name: string;
namespace?: string;
apiGroup?: string;
}
@autobind()
export class RoleBinding extends KubeObject {
static kind = "RoleBinding"
subjects?: IRoleBindingSubject[]
roleRef: {
kind: string;
name: string;
apiGroup?: string;
}
getSubjects() {
return this.subjects || [];
}
getSubjectNames(): string {
return this.getSubjects().map(subject => subject.name).join(", ")
}
}
export const roleBindingApi = new KubeApi({
kind: RoleBinding.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/rolebindings",
isNamespaced: true,
objectConstructor: RoleBinding,
});

View File

@ -0,0 +1,24 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class Role extends KubeObject {
static kind = "Role"
rules: {
verbs: string[];
apiGroups: string[];
resources: string[];
resourceNames?: string[];
}[]
getRules() {
return this.rules || [];
}
}
export const roleApi = new KubeApi({
kind: Role.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/roles",
isNamespaced: true,
objectConstructor: Role,
});

View File

@ -0,0 +1,51 @@
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export enum SecretType {
Opaque = "Opaque",
ServiceAccountToken = "kubernetes.io/service-account-token",
Dockercfg = "kubernetes.io/dockercfg",
DockerConfigJson = "kubernetes.io/dockerconfigjson",
BasicAuth = "kubernetes.io/basic-auth",
SSHAuth = "kubernetes.io/ssh-auth",
TLS = "kubernetes.io/tls",
BootstrapToken = "bootstrap.kubernetes.io/token",
}
export interface ISecretRef {
key?: string;
name: string;
}
@autobind()
export class Secret extends KubeObject {
static kind = "Secret"
type: SecretType;
data: {
[prop: string]: string;
token?: string;
}
constructor(data: KubeJsonApiData) {
super(data);
this.data = this.data || {};
}
getKeys(): string[] {
return Object.keys(this.data);
}
getToken() {
return this.data.token;
}
}
export const secretsApi = new KubeApi({
kind: Secret.kind,
apiBase: "/api/v1/secrets",
isNamespaced: true,
objectConstructor: Secret,
});

View File

@ -0,0 +1,68 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class SelfSubjectRulesReviewApi extends KubeApi<SelfSubjectRulesReview> {
create({ namespace = "default" }): Promise<SelfSubjectRulesReview> {
return super.create({}, {
spec: {
namespace
},
}
);
}
}
export interface ISelfSubjectReviewRule {
verbs: string[];
apiGroups?: string[];
resources?: string[];
resourceNames?: string[];
nonResourceURLs?: string[];
}
export class SelfSubjectRulesReview extends KubeObject {
static kind = "SelfSubjectRulesReview"
spec: {
// fixme: add more types from api docs
namespace?: string;
}
status: {
resourceRules: ISelfSubjectReviewRule[];
nonResourceRules: ISelfSubjectReviewRule[];
incomplete: boolean;
}
getResourceRules() {
const rules = this.status && this.status.resourceRules || [];
return rules.map(rule => this.normalize(rule));
}
getNonResourceRules() {
const rules = this.status && this.status.nonResourceRules || [];
return rules.map(rule => this.normalize(rule));
}
protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule {
const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule;
return {
apiGroups,
nonResourceURLs,
resourceNames,
verbs,
resources: resources.map((resource, index) => {
const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0];
const separator = apiGroup == "" ? "" : ".";
return resource + separator + apiGroup;
})
}
}
}
export const selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({
kind: SelfSubjectRulesReview.kind,
apiBase: "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews",
isNamespaced: false,
objectConstructor: SelfSubjectRulesReview,
});

View File

@ -0,0 +1,30 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class ServiceAccount extends KubeObject {
static kind = "ServiceAccount";
secrets?: {
name: string;
}[]
imagePullSecrets?: {
name: string;
}[]
getSecrets() {
return this.secrets || [];
}
getImagePullSecrets() {
return this.imagePullSecrets || [];
}
}
export const serviceAccountsApi = new KubeApi<ServiceAccount>({
kind: ServiceAccount.kind,
apiBase: "/api/v1/serviceaccounts",
isNamespaced: true,
objectConstructor: ServiceAccount,
});

View File

@ -0,0 +1,75 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class Service extends KubeObject {
static kind = "Service"
spec: {
type: string;
clusterIP: string;
externalTrafficPolicy?: string;
loadBalancerIP?: string;
sessionAffinity: string;
selector: { [key: string]: string };
ports: { name?: string; protocol: string; port: number; targetPort: number }[];
externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips
}
status: {
loadBalancer?: {
ingress?: {
ip?: string;
hostname?: string;
}[];
};
}
getClusterIp() {
return this.spec.clusterIP;
}
getExternalIps() {
const lb = this.getLoadBalancer();
if (lb && lb.ingress) {
return lb.ingress.map(val => val.ip || val.hostname)
}
return this.spec.externalIPs || [];
}
getType() {
return this.spec.type || "-";
}
getSelector(): string[] {
if (!this.spec.selector) return [];
return Object.entries(this.spec.selector).map(val => val.join("="));
}
getPorts(): string[] {
const ports = this.spec.ports || [];
return ports.map(({ port, protocol, targetPort }) => {
return `${port}${port === targetPort ? "" : ":" + targetPort}/${protocol}`
})
}
getLoadBalancer() {
return this.status.loadBalancer;
}
isActive() {
return this.getType() !== "LoadBalancer" || this.getExternalIps().length > 0;
}
getStatus() {
return this.isActive() ? "Active" : "Pending";
}
}
export const serviceApi = new KubeApi({
kind: Service.kind,
apiBase: "/api/v1/services",
isNamespaced: true,
objectConstructor: Service,
});

View File

@ -0,0 +1,84 @@
import get from "lodash/get";
import { IPodContainer } from "./pods.api";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class StatefulSet extends WorkloadKubeObject {
static kind = "StatefulSet"
spec: {
serviceName: string;
replicas: number;
selector: {
matchLabels: {
[key: string]: string;
};
};
template: {
metadata: {
labels: {
app: string;
};
};
spec: {
containers: {
name: string;
image: string;
ports: {
containerPort: number;
name: string;
}[];
volumeMounts: {
name: string;
mountPath: string;
}[];
}[];
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
};
};
volumeClaimTemplates: {
metadata: {
name: string;
};
spec: {
accessModes: string[];
resources: {
requests: {
storage: string;
};
};
};
}[];
}
status: {
observedGeneration: number;
replicas: number;
currentReplicas: number;
currentRevision: string;
updateRevision: string;
collisionCount: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const statefulSetApi = new KubeApi({
kind: StatefulSet.kind,
apiBase: "/apis/apps/v1/statefulsets",
isNamespaced: true,
objectConstructor: StatefulSet,
});

View File

@ -0,0 +1,39 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class StorageClass extends KubeObject {
static kind = "StorageClass"
provisioner: string; // e.g. "storage.k8s.io/v1"
mountOptions?: string[];
volumeBindingMode: string;
reclaimPolicy: string;
parameters: {
[param: string]: string; // every provisioner has own set of these parameters
}
isDefault() {
const annotations = this.metadata.annotations || {};
return (
annotations["storageclass.kubernetes.io/is-default-class"] === "true" ||
annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true"
)
}
getVolumeBindingMode() {
return this.volumeBindingMode || "-"
}
getReclaimPolicy() {
return this.reclaimPolicy || "-"
}
}
export const storageClassApi = new KubeApi({
kind: StorageClass.kind,
apiBase: "/apis/storage.k8s.io/v1/storageclasses",
isNamespaced: false,
objectConstructor: StorageClass,
});

View File

@ -0,0 +1,43 @@
import { JsonApi, JsonApiErrorParsed } from "./json-api";
import { KubeJsonApi } from "./kube-json-api";
import { Notifications } from "../components/notifications";
import { clientVars } from "../../server/config";
//-- JSON HTTP APIS
export const apiBase = new JsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.BASE,
});
export const apiKube = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_BASE,
});
export const apiKubeUsers = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_USERS,
});
export const apiKubeHelm = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_HELM,
});
export const apiKubeResourceApplier = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_RESOURCE_APPLIER,
});
// Common handler for HTTP api errors
function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;
Notifications.error(error);
break;
}
}
apiBase.onError.addListener(onApiError);
apiKube.onError.addListener(onApiError);
apiKubeUsers.onError.addListener(onApiError);
apiKubeHelm.onError.addListener(onApiError);
apiKubeResourceApplier.onError.addListener(onApiError);

View File

@ -0,0 +1,154 @@
// Base http-service / json-api class
import { stringify } from "querystring";
import { EventEmitter } from "../utils/eventEmitter";
import { cancelableFetch } from "../utils/cancelableFetch";
export interface JsonApiData {
}
export interface JsonApiError {
code?: number;
message?: string;
errors?: { id: string; title: string; status?: number }[];
}
export interface JsonApiParams<D = any> {
query?: { [param: string]: string | number | any };
data?: D; // request body
}
export interface JsonApiLog {
method: string;
reqUrl: string;
reqInit: RequestInit;
data?: any;
error?: any;
}
export interface JsonApiConfig {
apiPrefix: string;
debug?: boolean;
}
export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
static reqInitDefault: RequestInit = {
headers: {
'content-type': 'application/json'
}
};
static configDefault: Partial<JsonApiConfig> = {
debug: false
};
constructor(protected config: JsonApiConfig, protected reqInit?: RequestInit) {
this.config = Object.assign({}, JsonApi.configDefault, config);
this.reqInit = Object.assign({}, JsonApi.reqInitDefault, reqInit);
this.parseResponse = this.parseResponse.bind(this);
}
public onData = new EventEmitter<[D, Response]>();
public onError = new EventEmitter<[JsonApiErrorParsed, Response]>();
get<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "get" });
}
post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "post" });
}
put<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "put" });
}
patch<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "patch" });
}
del<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiPrefix + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
if (data && !reqInit.body) {
reqInit.body = JSON.stringify(data);
}
if (query) {
const queryString = stringify(query);
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
method: reqInit.method.toUpperCase(),
reqUrl: reqUrl,
reqInit: reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
return this.parseResponse<D>(res, infoLog);
});
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
let data;
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
return data;
}
else {
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error })
throw error;
}
})
}
protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") {
return [error]
}
else if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title)
}
else if (error.message) {
return [error.message]
}
return [res.statusText || "Error!"]
}
protected writeLog(log: JsonApiLog) {
if (!this.config.debug) return;
const { method, reqUrl, ...params } = log;
let textStyle = 'font-weight: bold;';
if (params.data) textStyle += 'background: green; color: white;';
if (params.error) textStyle += 'background: red; color: white;';
console.log(`%c${method} ${reqUrl}`, textStyle, params);
}
}
export class JsonApiErrorParsed {
isUsedForNotification = false;
constructor(private error: JsonApiError | DOMException, private messages: string[]) {
}
get isAborted() {
return this.error.code === DOMException.ABORT_ERR;
}
toString() {
return this.messages.join("\n");
}
}

View File

@ -0,0 +1,233 @@
// Base class for building all kubernetes apis
import merge from "lodash/merge"
import { stringify } from "querystring";
import { IKubeObjectConstructor, KubeObject } from "./kube-object";
import { IKubeObjectRef, KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { apiKube } from "./index";
import { kubeWatchApi } from "./kube-watch-api";
import { apiManager } from "./api-manager";
export interface IKubeApiOptions<T extends KubeObject> {
kind: string; // resource type within api-group, e.g. "Namespace"
apiBase: string; // base api-path for listing all resources, e.g. "/api/v1/pods"
isNamespaced: boolean;
objectConstructor?: IKubeObjectConstructor<T>;
request?: KubeJsonApi;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
timeoutSeconds?: number;
limit?: number; // doesn't work with ?watch
continue?: string; // might be used with ?limit from second request
}
export interface IKubeApiLinkRef {
apiPrefix?: string;
apiVersion: string;
resource: string;
name: string;
namespace?: string;
}
export class KubeApi<T extends KubeObject = any> {
static matcher = /(\/apis?.*?)\/(?:(.*?)\/)?(v.*?)(?:\/namespaces\/(.+?))?\/([^\/]+)(?:\/([^\/?]+))?.*$/
static parseApi(apiPath = "") {
apiPath = new URL(apiPath, location.origin).pathname;
const [, apiPrefix, apiGroup = "", apiVersion, namespace, resource, name] = apiPath.match(KubeApi.matcher) || [];
const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/");
const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/");
return {
apiBase,
apiPrefix, apiGroup,
apiVersion, apiVersionWithGroup,
namespace, resource, name,
}
}
static createLink(ref: IKubeApiLinkRef): string {
const { apiPrefix = "/apis", resource, apiVersion, name } = ref;
let { namespace } = ref;
if (namespace) {
namespace = `namespaces/${namespace}`
}
return [apiPrefix, apiVersion, namespace, resource, name]
.filter(v => !!v)
.join("/")
}
static watchAll(...apis: KubeApi[]) {
const disposers = apis.map(api => api.watch());
return () => disposers.forEach(unwatch => unwatch());
}
readonly kind: string
readonly apiBase: string
readonly apiPrefix: string
readonly apiGroup: string
readonly apiVersion: string
readonly apiVersionWithGroup: string
readonly apiResource: string
readonly isNamespaced: boolean
public objectConstructor: IKubeObjectConstructor<T>;
protected request: KubeJsonApi;
protected resourceVersions = new Map<string, string>();
constructor(protected options: IKubeApiOptions<T>) {
const {
kind,
isNamespaced = false,
objectConstructor = KubeObject as IKubeObjectConstructor,
request = apiKube
} = options || {};
const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase);
this.kind = kind;
this.isNamespaced = isNamespaced;
this.apiBase = apiBase;
this.apiPrefix = apiPrefix;
this.apiGroup = apiGroup;
this.apiVersion = apiVersion;
this.apiVersionWithGroup = apiVersionWithGroup;
this.apiResource = resource;
this.request = request;
this.objectConstructor = objectConstructor;
this.parseResponse = this.parseResponse.bind(this);
apiManager.registerApi(apiBase, this);
}
setResourceVersion(namespace = "", newVersion: string) {
this.resourceVersions.set(namespace, newVersion);
}
getResourceVersion(namespace = "") {
return this.resourceVersions.get(namespace);
}
async refreshResourceVersion(params?: { namespace: string }) {
return this.list(params, { limit: 1 });
}
getUrl({ name = "", namespace = "" } = {}, query?: Partial<IKubeApiQueryParams>) {
const { apiPrefix, apiVersionWithGroup, apiResource } = this;
const resourcePath = KubeApi.createLink({
apiPrefix: apiPrefix,
apiVersion: apiVersionWithGroup,
resource: apiResource,
namespace: this.isNamespaced ? namespace : undefined,
name: name,
});
return resourcePath + (query ? `?` + stringify(query) : "");
}
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) {
return new KubeObjectConstructor(data);
}
// process items list response
else if (KubeObject.isJsonApiDataList(data)) {
const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion);
this.setResourceVersion("", metadata.resourceVersion);
return items.map(item => new KubeObjectConstructor({
kind: this.kind,
apiVersion: apiVersion,
...item,
}))
}
// custom apis might return array for list response, e.g. users, groups, etc.
else if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(data));
}
return data;
}
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
return this.request
.get(this.getUrl({ namespace }), { query })
.then(data => this.parseResponse(data, namespace));
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
const apiUrl = this.getUrl({ namespace });
return this.request.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
metadata: {
name,
namespace
}
}, data)
}).then(this.parseResponse);
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
const apiUrl = this.getUrl({ namespace, name });
return this.request
.put(apiUrl, { data })
.then(this.parseResponse)
}
async delete({ name = "", namespace = "default" }) {
const apiUrl = this.getUrl({ namespace, name });
return this.request.del(apiUrl)
}
getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) {
return this.getUrl({ namespace }, {
watch: 1,
resourceVersion: this.getResourceVersion(namespace),
...query,
})
}
watch(): () => void {
return kubeWatchApi.subscribe(this);
}
}
export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): string {
const {
kind, apiVersion, name,
namespace = parentObject.getNs()
} = ref;
// search in registered apis by 'kind' & 'apiVersion'
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion)
if (api) {
return api.getUrl({ namespace, name })
}
// lookup api by generated resource link
const apiPrefixes = ["/apis", "/api"];
const resource = kind.toLowerCase() + kind.endsWith("s") ? "es" : "s";
for (const apiPrefix of apiPrefixes) {
const apiLink = KubeApi.createLink({ apiPrefix, apiVersion, name, namespace, resource });
if (apiManager.getApi(apiLink)) {
return apiLink;
}
}
// resolve by kind only (hpa's might use refs to older versions of resources for example)
const apiByKind = apiManager.getApi(api => api.kind === kind);
if (apiByKind) {
return apiByKind.getUrl({ name, namespace })
}
// otherwise generate link with default prefix
// resource still might exists in k8s, but api is not registered in the app
return KubeApi.createLink({ apiVersion, name, namespace, resource })
}

View File

@ -0,0 +1,68 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
kind: string;
apiVersion: string;
items: T[];
metadata: {
resourceVersion: string;
selfLink: string;
};
}
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: {
uid: string;
name: string;
namespace?: string;
creationTimestamp?: string;
resourceVersion: string;
continue?: string;
finalizers?: string[];
selfLink: string;
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
};
}
export interface IKubeObjectRef {
kind: string;
apiVersion: string;
name: string;
namespace?: string;
}
export interface KubeJsonApiError extends JsonApiError {
code: number;
status: string;
message?: string;
reason: string;
details: {
name: string;
kind: string;
};
}
export interface IKubeJsonApiQuery {
watch?: any;
resourceVersion?: string;
timeoutSeconds?: number;
limit?: number; // doesn't work with ?watch
continue?: string; // might be used with ?limit from second request
}
export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
protected parseError(error: KubeJsonApiError | any, res: Response): string[] {
const { status, reason, message } = error;
if (status && reason) {
return [message || `${status}: ${reason}`];
}
return super.parseError(error, res);
}
}

View File

@ -0,0 +1,142 @@
// Base class for all kubernetes objects
import moment from "moment";
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store";
import { apiKube } from "./index";
import { resourceApplierApi } from "./endpoints/resource-applier.api";
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string;
};
export interface IKubeObjectMetadata {
uid: string;
name: string;
namespace?: string;
creationTimestamp: string;
resourceVersion: string;
selfLink: string;
deletionTimestamp?: string;
finalizers?: string[];
continue?: string; // provided when used "?limit=" query param to fetch objects list
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
}
export type IKubeMetaField = keyof KubeObject["metadata"];
@autobind()
export class KubeObject implements ItemObject {
static readonly kind: string;
static create(data: any) {
return new KubeObject(data);
}
static isNonSystem(item: KubeJsonApiData | KubeObject) {
return !item.metadata.name.startsWith("system:");
}
static isJsonApiData(object: any): object is KubeJsonApiData {
return !object.items && object.metadata;
}
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
return object.items && object.metadata;
}
static stringifyLabels(labels: { [name: string]: string }): string[] {
if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`)
}
constructor(data: KubeJsonApiData) {
Object.assign(this, data);
}
apiVersion: string
kind: string
metadata: IKubeObjectMetadata;
get selfLink() {
return this.metadata.selfLink
}
getId() {
return this.metadata.uid;
}
getResourceVersion() {
return this.metadata.resourceVersion;
}
getName() {
return this.metadata.name;
}
getNs() {
// avoid "null" serialization via JSON.stringify when post data
return this.metadata.namespace || undefined;
}
// todo: refactor with named arguments
getAge(humanize = true, compact = true, fromNow = false) {
if (fromNow) {
return moment(this.metadata.creationTimestamp).fromNow();
}
const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
}
getFinalizers(): string[] {
return this.metadata.finalizers || [];
}
getLabels(): string[] {
return KubeObject.stringifyLabels(this.metadata.labels);
}
getAnnotations(): string[] {
const labels = KubeObject.stringifyLabels(this.metadata.annotations);
return labels.filter(label => {
const skip = resourceApplierApi.annotations.some(key => label.startsWith(key));
return !skip;
})
}
getSearchFields() {
const { getName, getId, getNs, getAnnotations, getLabels } = this
return [
getName(),
getNs(),
getId(),
...getLabels(),
...getAnnotations(),
]
}
toPlainObject(): object {
return JSON.parse(JSON.stringify(this));
}
// use unified resource-applier api for updating all k8s objects
async update<T extends KubeObject>(data: Partial<T>) {
return resourceApplierApi.update<T>({
...this.toPlainObject(),
...data,
});
}
delete() {
return apiKube.del(this.selfLink);
}
}

View File

@ -0,0 +1,151 @@
// Kubernetes watch-api consumer
import { computed, observable, reaction } from "mobx";
import { stringify } from "querystring"
import { autobind, EventEmitter, interval } from "../utils";
import { KubeJsonApiData } from "./kube-json-api";
import { IKubeWatchEvent, IKubeWatchRouteEvent, IKubeWatchRouteQuery } from "../../server/common/kubewatch";
import { KubeObjectStore } from "../kube-object.store";
import { KubeApi } from "./kube-api";
import { configStore } from "../config.store";
import { apiManager } from "./api-manager";
export {
IKubeWatchEvent
}
@autobind()
export class KubeWatchApi {
protected evtSource: EventSource;
protected onData = new EventEmitter<[IKubeWatchEvent]>();
protected apiUrl = configStore.apiPrefix.BASE + "/watch";
protected subscribers = observable.map<KubeApi, number>();
protected reconnectInterval = interval(60 * 5, this.reconnect); // background reconnect every 5min
protected reconnectTimeoutMs = 5000;
protected maxReconnectsOnError = 10;
protected reconnectAttempts = this.maxReconnectsOnError;
constructor() {
reaction(() => this.activeApis, () => this.connect(), {
fireImmediately: true,
delay: 500,
});
}
@computed get activeApis() {
return Array.from(this.subscribers.keys());
}
getSubscribersCount(api: KubeApi) {
return this.subscribers.get(api) || 0;
}
subscribe(...apis: KubeApi[]) {
apis.forEach(api => {
this.subscribers.set(api, this.getSubscribersCount(api) + 1);
});
return () => apis.forEach(api => {
const count = this.getSubscribersCount(api) - 1;
if (count <= 0) this.subscribers.delete(api);
else this.subscribers.set(api, count);
});
}
protected getQuery(): Partial<IKubeWatchRouteQuery> {
const { isClusterAdmin, allowedNamespaces } = configStore;
return {
api: this.activeApis.map(api => {
if (isClusterAdmin) return api.getWatchUrl();
return allowedNamespaces.map(namespace => api.getWatchUrl(namespace))
}).flat()
}
}
// todo: maybe switch to websocket to avoid often reconnects
@autobind()
protected connect() {
if (this.evtSource) this.disconnect(); // close previous connection
if (!this.activeApis.length) {
return;
}
const query = this.getQuery();
const apiUrl = this.apiUrl + "?" + stringify(query);
this.evtSource = new EventSource(apiUrl);
this.evtSource.onmessage = this.onMessage;
this.evtSource.onerror = this.onError;
this.writeLog("CONNECTING", query.api);
}
reconnect() {
if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) {
this.reconnectAttempts = this.maxReconnectsOnError;
this.connect();
}
}
protected disconnect() {
if (!this.evtSource) return;
this.evtSource.close();
this.evtSource.onmessage = null;
this.evtSource = null;
}
protected onMessage(evt: MessageEvent) {
if (!evt.data) return;
const data = JSON.parse(evt.data);
if ((data as IKubeWatchEvent).object) {
this.onData.emit(data);
}
else {
this.onRouteEvent(data);
}
}
protected async onRouteEvent({ type, url }: IKubeWatchRouteEvent) {
if (type === "STREAM_END") {
this.disconnect();
const { apiBase, namespace } = KubeApi.parseApi(url);
const api = apiManager.getApi(apiBase);
if (api) {
await api.refreshResourceVersion({ namespace });
this.reconnect();
}
}
}
protected onError(evt: MessageEvent) {
const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this;
if (evt.eventPhase === EventSource.CLOSED) {
if (attemptsRemain > 0) {
this.reconnectAttempts--;
setTimeout(() => this.connect(), reconnectTimeoutMs);
}
}
}
protected writeLog(...data: any[]) {
if (configStore.isDevelopment) {
console.log('%cKUBE-WATCH-API:', `font-weight: bold`, ...data);
}
}
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
const { selfLink, namespace, resourceVersion } = evt.object.metadata;
const api = apiManager.getApi(selfLink);
api.setResourceVersion(namespace, resourceVersion);
api.setResourceVersion("", resourceVersion);
if (store == apiManager.getStore(api)) {
callback(evt);
}
};
this.onData.addListener(listener);
return () => this.onData.removeListener(listener);
}
reset() {
this.subscribers.clear();
}
}
export const kubeWatchApi = new KubeWatchApi();

View File

@ -0,0 +1,172 @@
import { stringify } from "querystring";
import { autobind, base64, EventEmitter, interval } from "../utils";
import { WebSocketApi } from "./websocket-api";
import { configStore } from "../config.store";
import isEqual from "lodash/isEqual"
export enum TerminalChannels {
STDIN = 0,
STDOUT = 1,
STDERR = 2,
TERMINAL_SIZE = 4,
TOKEN = 9,
}
enum TerminalColor {
RED = "\u001b[31m",
GREEN = "\u001b[32m",
YELLOW = "\u001b[33m",
BLUE = "\u001b[34m",
MAGENTA = "\u001b[35m",
CYAN = "\u001b[36m",
GRAY = "\u001b[90m",
LIGHT_GRAY = "\u001b[37m",
NO_COLOR = "\u001b[0m",
}
export interface ITerminalApiOptions {
id: string;
node?: string;
colorTheme?: "light" | "dark";
}
export class TerminalApi extends WebSocketApi {
protected size: { Width: number; Height: number };
protected currentToken: string;
protected tokenInterval = interval(60, this.sendNewToken); // refresh every minute
public onReady = new EventEmitter<[]>();
public isReady = false;
constructor(protected options: ITerminalApiOptions) {
super({
logging: configStore.isDevelopment,
flushOnOpen: false,
pingIntervalSeconds: 30,
});
}
async getUrl(token: string) {
const { hostname, protocol } = location;
const { id, node } = this.options;
const apiPrefix = configStore.apiPrefix.TERMINAL;
const wss = `ws${protocol === "https:" ? "s" : ""}://`;
const queryParams = { token, id };
if (node) {
Object.assign(queryParams, {
node: node,
type: "node"
});
}
return `${wss}${hostname}${configStore.serverPort}${apiPrefix}/api?${stringify(queryParams)}`;
}
async connect() {
const token = await configStore.getToken();
const apiUrl = await this.getUrl(token);
const { colorTheme } = this.options;
this.emitStatus("Connecting...", {
color: colorTheme == "light" ? TerminalColor.GRAY : TerminalColor.LIGHT_GRAY
});
this.onData.addListener(this._onReady, { prepend: true });
this.currentToken = token;
this.tokenInterval.start();
return super.connect(apiUrl);
}
@autobind()
async sendNewToken() {
const token = await configStore.getToken();
if (!this.isReady || token == this.currentToken) return;
this.sendCommand(token, TerminalChannels.TOKEN);
this.currentToken = token;
}
destroy() {
if (!this.socket) return;
const exitCode = String.fromCharCode(4); // ctrl+d
this.sendCommand(exitCode);
this.tokenInterval.stop();
setTimeout(() => super.destroy(), 2000);
}
removeAllListeners() {
super.removeAllListeners();
this.onReady.removeAllListeners();
}
@autobind()
protected _onReady(data: string) {
if (!data) return;
this.isReady = true;
this.onReady.emit();
this.onData.removeListener(this._onReady);
this.flush();
this.onData.emit(data); // re-emit data
return false; // prevent calling rest of listeners
}
reconnect() {
const { reconnectDelaySeconds } = this.params;
if (reconnectDelaySeconds) {
this.emitStatus(`Reconnect in ${reconnectDelaySeconds} seconds`, {
color: TerminalColor.YELLOW,
showTime: true,
});
}
super.reconnect();
}
sendCommand(key: string, channel = TerminalChannels.STDIN) {
return this.send(channel + base64.encode(key));
}
sendTerminalSize(cols: number, rows: number) {
const newSize = { Width: cols, Height: rows };
if (!isEqual(this.size, newSize)) {
this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE);
this.size = newSize;
}
}
protected parseMessage(data: string) {
data = data.substr(1); // skip channel
return base64.decode(data);
}
protected _onOpen(evt: Event) {
// Client should send terminal size in special channel 4,
// But this size will be changed by terminal.fit()
this.sendTerminalSize(120, 80);
super._onOpen(evt);
}
protected _onClose(evt: CloseEvent) {
const { code, reason, wasClean } = evt;
if (code !== 1000 || !wasClean) {
this.emitStatus("\r\n");
this.emitError(`Closed by "${reason}" (code: ${code}) at ${new Date()}.`);
}
super._onClose(evt);
this.isReady = false;
}
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
const { color, showTime } = options;
if (color) {
data = `${color}${data}${TerminalColor.NO_COLOR}`;
}
let time;
if (showTime) {
time = (new Date()).toLocaleString() + " ";
}
this.onData.emit(`${showTime ? time : ""}${data}\r\n`);
}
protected emitError(error: string) {
this.emitStatus(error, {
color: TerminalColor.RED
});
}
}

View File

@ -0,0 +1,169 @@
import { observable } from "mobx";
import { EventEmitter } from "../utils/eventEmitter";
interface IParams {
url?: string; // connection url, starts with ws:// or wss://
autoConnect?: boolean; // auto-connect in constructor
flushOnOpen?: boolean; // flush pending commands on open socket
reconnectDelaySeconds?: number; // reconnect timeout in case of error (0 - don't reconnect)
pingIntervalSeconds?: number; // send ping message for keeping connection alive in some env, e.g. AWS (0 - don't ping)
logging?: boolean; // show logs in console
}
interface IMessage {
id: string;
data: string;
}
export enum WebSocketApiState {
PENDING = -1,
OPEN,
CONNECTING,
RECONNECTING,
CLOSED,
}
export class WebSocketApi {
protected socket: WebSocket;
protected pendingCommands: IMessage[] = [];
protected reconnectTimer: any;
protected pingTimer: any;
protected pingMessage = "PING";
@observable readyState = WebSocketApiState.PENDING;
public onOpen = new EventEmitter<[]>();
public onData = new EventEmitter<[string]>();
public onClose = new EventEmitter<[]>();
static defaultParams: Partial<IParams> = {
autoConnect: true,
logging: false,
reconnectDelaySeconds: 10,
pingIntervalSeconds: 0,
flushOnOpen: true,
};
constructor(protected params: IParams) {
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
const { autoConnect, pingIntervalSeconds } = this.params;
if (autoConnect) {
setTimeout(() => this.connect());
}
if (pingIntervalSeconds) {
this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000);
}
}
get isConnected() {
const state = this.socket ? this.socket.readyState : -1;
return state === WebSocket.OPEN && this.isOnline;
}
get isOnline() {
return navigator.onLine;
}
setParams(params: Partial<IParams>) {
Object.assign(this.params, params);
}
connect(url = this.params.url) {
if (this.socket) {
this.socket.close(); // close previous connection first
}
this.socket = new WebSocket(url);
this.socket.onopen = this._onOpen.bind(this);
this.socket.onmessage = this._onMessage.bind(this);
this.socket.onerror = this._onError.bind(this);
this.socket.onclose = this._onClose.bind(this);
this.readyState = WebSocketApiState.CONNECTING;
}
ping() {
if (!this.isConnected) return;
this.send(this.pingMessage);
}
reconnect() {
const { reconnectDelaySeconds } = this.params;
if (!reconnectDelaySeconds) return;
this.writeLog('reconnect after', reconnectDelaySeconds + "ms");
this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000);
this.readyState = WebSocketApiState.RECONNECTING;
}
destroy() {
if (!this.socket) return;
this.socket.close();
this.socket = null;
this.pendingCommands = [];
this.removeAllListeners();
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
this.readyState = WebSocketApiState.PENDING;
}
removeAllListeners() {
this.onOpen.removeAllListeners();
this.onData.removeAllListeners();
this.onClose.removeAllListeners();
}
send(command: string) {
const msg: IMessage = {
id: (Math.random() * Date.now()).toString(16).replace(".", ""),
data: command,
};
if (this.isConnected) {
this.socket.send(msg.data);
}
else {
this.pendingCommands.push(msg);
}
}
protected flush() {
this.pendingCommands.forEach(msg => this.send(msg.data));
this.pendingCommands.length = 0;
}
protected parseMessage(data: string) {
return data;
}
protected _onOpen(evt: Event) {
this.onOpen.emit();
if (this.params.flushOnOpen) this.flush();
this.readyState = WebSocketApiState.OPEN;
this.writeLog('%cOPEN', 'color:green;font-weight:bold;', evt);
}
protected _onMessage(evt: MessageEvent) {
const data = this.parseMessage(evt.data);
this.onData.emit(data);
this.writeLog('%cMESSAGE', 'color:black;font-weight:bold;', data);
}
protected _onError(evt: Event) {
this.writeLog('%cERROR', 'color:red;font-weight:bold;', evt)
}
protected _onClose(evt: CloseEvent) {
const error = evt.code !== 1000 || !evt.wasClean;
if (error) {
this.reconnect();
}
else {
this.readyState = WebSocketApiState.CLOSED;
this.onClose.emit();
}
this.writeLog('%cCLOSE', `color:${error ? "red" : "black"};font-weight:bold;`, evt);
}
protected writeLog(...data: any[]) {
if (this.params.logging) {
console.log(...data);
}
}
}

View File

@ -0,0 +1,100 @@
import get from "lodash/get";
import { IKubeObjectMetadata, KubeObject } from "./kube-object";
interface IToleration {
key?: string;
operator?: string;
effect?: string;
tolerationSeconds?: number;
}
interface IMatchExpression {
key: string;
operator: string;
values: string[];
}
interface INodeAffinity {
nodeSelectorTerms?: {
matchExpressions: IMatchExpression[];
}[];
weight: number;
preference: {
matchExpressions: IMatchExpression[];
};
}
interface IPodAffinity {
labelSelector: {
matchExpressions: IMatchExpression[];
};
topologyKey: string;
}
export interface IAffinity {
nodeAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
};
podAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
};
podAntiAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
};
}
export class WorkloadKubeObject extends KubeObject {
metadata: IKubeObjectMetadata & {
ownerReferences?: {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller: boolean;
blockOwnerDeletion: boolean;
}[];
}
// fixme: add type
spec: any;
getOwnerRefs() {
const refs = this.metadata.ownerReferences || [];
return refs.map(ownerRef => ({
...ownerRef,
namespace: this.getNs(),
}))
}
getSelectors(): string[] {
const selector = this.spec.selector;
return KubeObject.stringifyLabels(selector ? selector.matchLabels : null);
}
getNodeSelectors(): string[] {
const nodeSelector = get(this, "spec.template.spec.nodeSelector");
return KubeObject.stringifyLabels(nodeSelector);
}
getTemplateLabels(): string[] {
const labels = get(this, "spec.template.metadata.labels");
return KubeObject.stringifyLabels(labels);
}
getTolerations(): IToleration[] {
return get(this, "spec.template.spec.tolerations", [])
}
getAffinity(): IAffinity {
return get(this, "spec.template.spec.affinity")
}
getAffinityNumber() {
const affinity = this.getAffinity()
if (!affinity) return 0
return Object.keys(affinity).length
}
}

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { Notifications } from "./components/notifications";
import { Trans } from "@lingui/macro";
export function browserCheck() {
const ua = window.navigator.userAgent
const msie = ua.indexOf('MSIE ') // IE < 11
const trident = ua.indexOf('Trident/') // IE 11
const edge = ua.indexOf('Edge') // Edge
if (msie > 0 || trident > 0 || edge > 0) {
Notifications.info(
<p>
<Trans>
<b>Your browser does not support all Kontena Lens features. </b>{" "}
Please consider using another browser.
</Trans>
</p>
)
}
}

View File

@ -0,0 +1 @@
export * from "./not-found"

View File

@ -0,0 +1,15 @@
import * as React from "react";
import { Trans } from "@lingui/macro";
import { MainLayout } from "../layout/main-layout";
export class NotFound extends React.Component {
render() {
return (
<MainLayout className="NotFound" contentClass="flex" footer={null}>
<p className="box center">
<Trans>Page not found</Trans>
</p>
</MainLayout>
)
}
}

View File

@ -0,0 +1,53 @@
.HelmChartDetails {
.intro-logo {
margin-right: $margin * 2;
background: $helmLogoBackground;
border-radius: $radius;
max-width: 150px;
max-height: 100px;
padding: $padding;
box-sizing: content-box;
}
.intro-contents {
.description {
font-weight: bold;
color: $textColorAccent;
padding-bottom: $padding;
.Button {
padding-left: $padding * 3;
padding-right: $padding * 3;
margin-left: $margin * 2;
align-self: flex-start;
}
}
.version {
.Select {
width: 80px;
min-width: 80px;
white-space: nowrap;
}
.Icon {
margin-right: $margin;
}
}
.maintainers {
a {
display: inline-block;
margin-right: $margin;
}
}
.DrawerItem {
align-items: center;
}
}
.chart-description {
margin-top: $margin * 2;
}
}

View File

@ -0,0 +1,138 @@
import "./helm-chart-details.scss";
import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { t, Trans } from "@lingui/macro";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { autobind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer";
import { Spinner } from "../spinner";
import { CancelablePromise } from "../../utils/cancelableFetch";
import { Button } from "../button";
import { Select, SelectOption } from "../select";
import { createInstallChartTab } from "../dock/install-chart.store";
import { Badge } from "../badge";
import { _i18n } from "../../i18n";
interface Props {
chart: HelmChart;
hideDetails(): void;
}
@observer
export class HelmChartDetails extends Component<Props> {
@observable chartVersions: HelmChart[];
@observable selectedChart: HelmChart;
@observable description: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
@disposeOnUnmount
chartSelector = autorun(async () => {
if (!this.props.chart) return;
this.chartVersions = null;
this.selectedChart = null;
this.description = null;
this.loadChartData();
this.chartPromise.then(data => {
this.description = data.readme;
this.chartVersions = data.versions;
this.selectedChart = data.versions[0];
});
});
loadChartData(version?: string) {
const { chart: { name, repo } } = this.props;
if (this.chartPromise) this.chartPromise.cancel();
this.chartPromise = helmChartsApi.get(repo, name, version);
}
@autobind()
onVersionChange(opt: SelectOption) {
const version = opt.value;
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.description = null;
this.loadChartData(version);
this.chartPromise.then(data => {
this.description = data.readme
});
}
@autobind()
install() {
createInstallChartTab(this.selectedChart);
this.props.hideDetails()
}
renderIntroduction() {
const { selectedChart, chartVersions, onVersionChange } = this;
const placeholder = require("./helm-placeholder.svg");
return (
<div className="introduction flex align-flex-start">
<img
className="intro-logo"
src={selectedChart.getIcon() || placeholder}
onError={(event) => event.currentTarget.src = placeholder}
/>
<div className="intro-contents box grow">
<div className="description flex align-center justify-space-between">
{selectedChart.getDescription()}
<Button primary label={_i18n._(t`Install`)} onClick={this.install}/>
</div>
<DrawerItem name={_i18n._(t`Version`)} className="version" onClick={stopPropagation}>
<Select
themeName="outlined"
menuPortalTarget={null}
options={chartVersions.map(chart => chart.version)}
value={selectedChart.getVersion()}
onChange={onVersionChange}
/>
</DrawerItem>
<DrawerItem name={_i18n._(t`Home`)}>
<a href={selectedChart.getHome()} target="_blank">{selectedChart.getHome()}</a>
</DrawerItem>
<DrawerItem name={_i18n._(t`Maintainers`)} className="maintainers">
{selectedChart.getMaintainers().map(({ name, email, url }) =>
<a key={name} href={url ? url : `mailto:${email}`} target="_blank">{name}</a>
)}
</DrawerItem>
{selectedChart.getKeywords().length > 0 && (
<DrawerItem name={_i18n._(t`Keywords`)} labelsOnly>
{selectedChart.getKeywords().map(key => <Badge key={key} label={key}/>)}
</DrawerItem>
)}
</div>
</div>
);
}
renderContent() {
if (this.selectedChart === null || this.description === null) return <Spinner center/>;
return (
<div className="box grow">
{this.renderIntroduction()}
<div className="chart-description">
<MarkdownViewer markdown={this.description}/>
</div>
</div>
);
}
render() {
const { chart, hideDetails } = this.props;
const title = chart ? <Trans>Chart: {chart.getFullName()}</Trans> : "";
return (
<Drawer
className="HelmChartDetails"
usePortal={true}
open={!!chart}
title={title}
onClose={hideDetails}
>
{this.renderContent()}
</Drawer>
);
}
}

View File

@ -0,0 +1,67 @@
import { observable } from "mobx";
import { autobind } from "../../utils";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { ItemStore } from "../../item.store";
import flatten from "lodash/flatten"
export interface IChartVersion {
repo: string;
version: string;
}
@autobind()
export class HelmChartStore extends ItemStore<HelmChart> {
@observable versions = observable.map<string, IChartVersion[]>();
loadAll() {
return this.loadItems(() => helmChartsApi.list());
}
getByName(name: string, repo: string) {
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
}
protected sortVersions = (versions: IChartVersion[]) => {
return versions.sort((first, second) => {
const firstVersion = first.version.replace(/[^\d.]/g, "").split(".").map(Number);
const secondVersion = second.version.replace(/[^\d.]/g, "").split(".").map(Number);
return firstVersion.every((version, index) => {
return version > secondVersion[index];
}) ? -1 : 0;
});
};
async getVersions(chartName: string, force?: boolean): Promise<IChartVersion[]> {
let versions = this.versions.get(chartName);
if (versions && !force) {
return versions;
}
const loadVersions = (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
return versions.map(chart => ({
repo: repo,
version: chart.getVersion()
}))
})
};
if (!this.isLoaded) {
await this.loadAll();
}
const repos = this.items
.filter(chart => chart.getName() === chartName)
.map(chart => chart.getRepository());
versions = await Promise.all(repos.map(loadVersions))
.then(flatten)
.then(this.sortVersions);
this.versions.set(chartName, versions);
return versions;
}
reset() {
super.reset();
this.versions.clear();
}
}
export const helmChartStore = new HelmChartStore();

View File

@ -0,0 +1,14 @@
import { RouteProps } from "react-router"
import { appsRoute } from "../+apps/apps.route";
import { buildURL } from "../../navigation";
export const helmChartsRoute: RouteProps = {
path: appsRoute.path + "/charts/:repo?/:chartName?"
}
export interface IHelmChartsRouteParams {
chartName?: string;
repo?: string;
}
export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path)

View File

@ -0,0 +1,62 @@
.HelmCharts {
.SearchInput {
width: 70%;
margin: auto;
> label {
padding: $padding $padding * 2;
}
}
.TableCell {
text-overflow: ellipsis;
&.name {
flex-grow: 1.3;
}
&.icon {
display: flex;
flex-grow: 0.3;
padding: 0;
figure {
$iconSize: $unit * 3.5;
width: $iconSize;
height: $iconSize;
border-radius: 50%;
background: $helmImgBackground url(./helm-placeholder.svg) center center no-repeat;
background-size: 72%; // bg size looks same as image on top of it
margin: auto;
img {
object-fit: contain;
width: inherit;
height: inherit;
visibility: hidden;
border-radius: inherit;
background-color: $helmImgBackground;
padding: $padding / 2;
&.visible {
visibility: visible;
}
}
}
}
&.description {
flex-grow: 2.5;
}
&.repository {
&.stable {
color: $helmStableRepo;
}
&.incubator {
color: $helmIncubatorRepo;
}
}
}
}

View File

@ -0,0 +1,109 @@
import "./helm-charts.scss";
import React, { Component } from "react";
import { RouteComponentProps } from "react-router";
import { observer } from "mobx-react";
import { helmChartsURL, IHelmChartsRouteParams } from "./helm-charts.route";
import { helmChartStore } from "./helm-chart.store";
import { HelmChart } from "../../api/endpoints/helm-charts.api";
import { HelmChartDetails } from "./helm-chart-details";
import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { SearchInput } from "../input";
enum sortBy {
name = "name",
repo = "repo",
}
interface Props extends RouteComponentProps<IHelmChartsRouteParams> {
}
@observer
export class HelmCharts extends Component<Props> {
componentDidMount() {
helmChartStore.loadAll();
}
get selectedChart() {
const { match: { params: { chartName, repo } } } = this.props
return helmChartStore.getByName(chartName, repo);
}
showDetails = (chart: HelmChart) => {
if (!chart) {
navigation.merge(helmChartsURL())
}
else {
navigation.merge(helmChartsURL({
params: {
chartName: chart.getName(),
repo: chart.getRepository(),
}
}))
}
}
hideDetails = () => {
this.showDetails(null);
}
render() {
return (
<>
<ItemListLayout
className="HelmCharts"
store={helmChartStore}
isClusterScoped={true}
isSelectable={false}
sortingCallbacks={{
[sortBy.name]: (chart: HelmChart) => chart.getName(),
[sortBy.repo]: (chart: HelmChart) => chart.getRepository(),
}}
searchFilters={[
(chart: HelmChart) => chart.getName(),
(chart: HelmChart) => chart.getVersion(),
(chart: HelmChart) => chart.getAppVersion(),
(chart: HelmChart) => chart.getKeywords(),
]}
filterItems={[
(items: HelmChart[]) => items.filter(item => !item.deprecated)
]}
customizeHeader={() => (
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)}/>
)}
renderTableHeader={[
{ className: "icon" },
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Description</Trans>, className: "description" },
{ title: <Trans>Version</Trans>, className: "version" },
{ title: <Trans>App Version</Trans>, className: "app-version" },
{ title: <Trans>Repository</Trans>, className: "repository", sortBy: sortBy.repo },
]}
renderTableContents={(chart: HelmChart) => [
<figure>
<img
src={chart.getIcon() || require("./helm-placeholder.svg")}
onLoad={evt => evt.currentTarget.classList.add("visible")}
/>
</figure>,
chart.getName(),
chart.getDescription(),
chart.getVersion(),
chart.getAppVersion(),
{ title: chart.getRepository(), className: chart.getRepository().toLowerCase() }
]}
detailsItem={this.selectedChart}
onDetails={this.showDetails}
/>
<HelmChartDetails
chart={this.selectedChart}
hideDetails={this.hideDetails}
/>
</>
);
}
}

View File

@ -0,0 +1 @@
<svg enable-background="new 0 0 722.8 702" viewBox="0 0 722.8 702" xmlns="http://www.w3.org/2000/svg"><g fill="#929ba6"><path d="m318 299.5c2.1 1.6 4.8 2.5 7.6 2.5 6.9 0 12.6-5.5 12.9-12.3l.3-.2 4.3-76.7c-5.2.6-10.4 1.5-15.6 2.7-28.5 6.5-53.2 20.5-72.6 39.5l62.9 44.6z"/><path d="m309.5 411.9c-1.4-5.9-6.6-9.9-12.4-10-.8 0-1.7.1-2.5.2l-.1-.2-75.5 12.8c11.7 32.2 33.4 58.5 60.8 76.1l29.2-70.7-.2-.3c1.1-2.4 1.4-5.2.7-7.9z"/><path d="m284.4 357.5c2.5-.7 4.9-2.2 6.7-4.4 4.3-5.4 3.6-13.2-1.6-17.8l.1-.3-57.4-51.4c-17 27.8-25.1 61.1-21.4 95.3l73.6-21.2z"/><path d="m340.2 380 21.2 10.2 21.1-10.1 5.3-22.9-14.6-18.2h-23.6l-14.6 18.2z"/><path d="m384.2 289.4c.1 2.6 1 5.2 2.8 7.5 4.3 5.4 12.1 6.4 17.7 2.4l.2.1 62.5-44.3c-23.6-23.1-54.4-38.2-87.6-42.2z"/><path d="m490.3 283.7-57.1 51.1v.2c-2 1.7-3.5 4.1-4.1 6.8-1.5 6.8 2.5 13.5 9.2 15.3l.1.3 74 21.3c1.6-16 .6-32.5-3.2-49-3.9-16.8-10.4-32.2-18.9-46z"/><path d="m372.8 439.6c-1.2-2.3-3.2-4.3-5.8-5.5-2-.9-4-1.4-6-1.3-4.5.2-8.7 2.6-10.9 6.8h-.1l-37.1 67.1c25.7 8.8 54.1 10.7 82.5 4.2 5.1-1.2 10-2.5 14.9-4.2l-37.3-67.1z"/><path d="m711.7 425-60.4-262.2c-3.2-13.7-12.5-25.3-25.3-31.4l-244.4-116.8c-7.1-3.4-14.8-4.9-22.7-4.5-6.2.3-12.3 1.9-17.9 4.5l-244.3 116.7c-12.8 6.1-22.1 17.7-25.3 31.4l-60.2 262.3c-2.8 12.2-.5 25 6.3 35.5.8 1.3 1.7 2.5 2.7 3.7l169.1 210.3c8.9 11 22.3 17.4 36.5 17.4l271.2-.1c14.2 0 27.7-6.4 36.5-17.4l169.1-210.3c8.9-10.9 12.2-25.4 9.1-39.1zm-93-3.2c-1.8 7.8-10.2 12.6-18.9 10.7-.1 0-.2 0-.2 0-.1 0-.2-.1-.3-.1-1.2-.3-2.7-.5-3.8-.8-5-1.3-8.6-3.3-13.1-5.1-9.7-3.5-17.7-6.4-25.5-7.5-4-.3-6 1.6-8.2 3-1.1-.2-4.4-.8-6.2-1.1-14 44-43.9 82.2-84.3 106.1.7 1.7 1.9 5.3 2.4 5.9-.9 2.5-2.3 4.8-1.1 8.6 2.8 7.4 7.4 14.6 13 23.2 2.7 4 5.4 7.1 7.8 11.7.6 1.1 1.3 2.8 1.9 3.9 3.8 8 1 17.3-6.2 20.8-7.3 3.5-16.3-.2-20.2-8.3-.6-1.1-1.3-2.7-1.8-3.8-2.1-4.7-2.8-8.8-4.2-13.4-3.3-9.7-6-17.8-10-24.6-2.2-3.3-5-3.7-7.5-4.5-.5-.8-2.2-4-3.1-5.6-8.1 3.1-16.4 5.6-25.1 7.6-37.9 8.6-75.9 5.1-109.9-7.9l-3.3 6c-2.5.7-4.8 1.3-6.3 3.1-5.3 6.4-7.5 16.6-11.3 26.3-1.5 4.6-2.1 8.7-4.2 13.4-.5 1.1-1.3 2.6-1.8 3.7-3.9 8.1-12.9 11.7-20.2 8.2-7.2-3.5-10-12.7-6.2-20.8.6-1.2 1.3-2.8 1.9-3.9 2.4-4.6 5.2-7.7 7.8-11.7 5.5-8.7 10.4-16.4 13.2-23.8.7-2.4-.3-5.8-1.3-8.3l2.7-6.4c-38.9-23.1-69.7-59.8-84.3-105.3l-6.4 1.1c-1.7-1-5.1-3.2-8.4-3-7.8 1.1-15.8 4-25.5 7.5-4.5 1.7-8.1 3.7-13.1 5-1.1.3-2.6.6-3.8.8-.1 0-.2.1-.3.1s-.2 0-.2 0c-8.7 1.9-17.1-2.9-18.9-10.7s3.8-15.7 12.4-17.8c.1 0 .2 0 .2-.1h.1c1.2-.3 2.8-.7 3.9-.9 5.1-1 9.2-.7 14-1.1 10.2-1.1 18.7-1.9 26.2-4.3 2.4-1 4.7-4.3 6.3-6.3l6.1-1.8c-6.9-47.5 4.8-94.2 29.8-131.9l-4.7-4.2c-.3-1.8-.7-6-2.9-8.4-5.8-5.4-13-9.9-21.8-15.3-4.2-2.4-8-4-12.1-7.1-.9-.7-2.1-1.7-3-2.4-.1-.1-.1-.1-.2-.2-7-5.6-8.6-15.2-3.6-21.6 2.8-3.6 7.2-5.3 11.7-5.2 3.5.1 7.1 1.4 10.2 3.8 1 .8 2.4 1.8 3.2 2.6 3.9 3.4 6.3 6.7 9.6 10.2 7.2 7.3 13.2 13.4 19.7 17.8 3.4 2 6.1 1.2 8.7.8.8.6 3.7 2.6 5.3 3.8 24.9-26.4 57.6-46 95.6-54.6 8.8-2 17.7-3.3 26.4-4.1l.3-6.2c1.9-1.9 4.1-4.6 4.8-7.6.6-7.9-.4-16.3-1.6-26.5-.7-4.8-1.8-8.7-2-13.9 0-1.1 0-2.5 0-3.8 0-.1 0-.3 0-.4 0-9 6.5-16.2 14.6-16.2s14.6 7.3 14.6 16.2c0 1.3.1 3 0 4.2-.2 5.2-1.3 9.1-2 13.9-1.2 10.2-2.3 18.7-1.7 26.5.6 3.9 2.9 5.5 4.8 7.3 0 1.1.2 4.6.3 6.5 46.5 4.1 89.7 25.4 121.4 58.7l5.6-4c1.9.1 6 .7 8.9-1 6.5-4.4 12.5-10.5 19.7-17.8 3.3-3.5 5.7-6.8 9.7-10.2.9-.8 2.3-1.8 3.2-2.6 7-5.6 16.8-5 21.8 1.3s3.4 16-3.6 21.6c-1 .8-2.3 1.9-3.2 2.6-4.2 3.1-8 4.7-12.2 7.1-8.7 5.4-16 9.9-21.8 15.3-2.7 2.9-2.5 5.7-2.8 8.3-.8.7-3.7 3.3-5.2 4.7 12.6 18.8 22.1 40.1 27.4 63.3 5.3 23.1 6.1 46.1 3.1 68.3l5.9 1.7c1.1 1.5 3.2 5.2 6.3 6.3 7.5 2.4 16 3.2 26.2 4.3 4.8.4 8.9.2 14 1.1 1.2.2 3 .7 4.2 1 8.9 2.4 14.4 10.4 12.6 18.2z"/><path d="m428 401.7c-1-.2-2-.3-3-.2-1.7.1-3.3.5-4.9 1.3-6.2 3-9 10.4-6.2 16.7l-.1.1 29.6 71.4c28.5-18.2 49.8-45.3 61-76.6l-76.2-12.9z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,2 @@
export * from "./helm-charts";
export * from "./helm-charts.route";

View File

@ -0,0 +1,2 @@
export * from "./releases";
export * from "./release.route";

View File

@ -0,0 +1,86 @@
@import "./release.mixins.scss";
.ReleaseDetails {
&.light {
.AceEditor {
border: 1px solid gainsboro;
border-radius: $radius;
}
}
.DrawerItem {
align-items: center;
}
.status {
.Badge {
@include release-status-bgs;
&:first-child {
margin-left: 0;
}
}
}
.chart {
.upgrade {
min-width: 150px;
}
}
.resources {
.SubTitle {
text-transform: none;
padding-left: 0;
}
.Table {
margin-bottom: $margin * 4;
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
overflow: auto;
@include custom-scrollbar();
.TableHead {
border-bottom: none;
.TableCell {
background-color: var(--drawerSubtitleBackground);
word-break: unset;
}
}
.TableCell {
text-overflow: unset;
word-break: break-word;
min-width: 100px;
&.name {
flex-basis: auto;
flex-grow: 0;
width: 230px;
}
&.volume {
flex-basis: 30%;
}
}
}
}
.notes {
white-space: pre-line;
font-family: "RobotoMono", monospace;
font-size: small;
}
.values {
.AceEditor {
min-height: 300px;
}
.AceEditor + .Button {
align-self: flex-start;
}
}
}

View File

@ -0,0 +1,254 @@
import "./release-details.scss";
import React, { Component } from "react";
import groupBy from "lodash/groupBy";
import isEqual from "lodash/isEqual";
import { observable, reaction } from "mobx";
import { Link } from "react-router-dom";
import { t, Trans } from "@lingui/macro";
import kebabCase from "lodash/kebabCase";
import { HelmRelease, helmReleasesApi, IReleaseDetails } from "../../api/endpoints/helm-releases.api";
import { HelmReleaseMenu } from "./release-menu";
import { Drawer, DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { cssNames, stopPropagation } from "../../utils";
import { disposeOnUnmount, observer } from "mobx-react";
import { Spinner } from "../spinner";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { releaseStore } from "./release.store";
import { Notifications } from "../notifications";
import { Icon } from "../icon";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { getDetailsUrl } from "../../navigation";
import { _i18n } from "../../i18n";
import { themeStore } from "../../theme.store";
import { apiManager } from "../../api/api-manager";
import { SubTitle } from "../layout/sub-title";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
interface Props {
release: HelmRelease;
hideDetails(): void;
}
@observer
export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails;
@observable values = "";
@observable saving = false;
@observable releaseSecret: Secret;
@disposeOnUnmount
releaseSelector = reaction(() => this.props.release, release => {
if (!release) return;
this.loadDetails();
this.loadValues();
this.releaseSecret = null;
}
);
@disposeOnUnmount
secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
});
async loadDetails() {
const { release } = this.props;
this.details = null;
this.details = await helmReleasesApi.get(release.getName(), release.getNs());
}
async loadValues() {
const { release } = this.props;
this.values = "";
this.values = await helmReleasesApi.getValues(release.getName(), release.getNs());
}
updateValues = async () => {
const { release } = this.props;
const name = release.getName();
const namespace = release.getNs()
const data = {
chart: release.getChart(),
repo: await release.getRepo(),
version: release.getVersion(),
values: this.values
};
this.saving = true;
try {
await releaseStore.update(name, namespace, data);
Notifications.ok(
<p>Release <b>{name}</b> successfully updated!</p>
);
} catch (err) {
Notifications.error(err);
}
this.saving = false;
}
upgradeVersion = () => {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails();
}
renderValues() {
const { values, saving } = this;
return (
<div className="values">
<DrawerTitle title={_i18n._(t`Values`)}/>
<div className="flex column gaps">
<AceEditor
mode="yaml"
value={values}
onChange={values => this.values = values}
/>
<Button
primary
label={_i18n._(t`Save`)}
waiting={saving}
onClick={this.updateValues}
/>
</div>
</div>
)
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources() {
const { resources } = this.details;
if (!resources) return null;
const groups = groupBy(resources, item => item.kind);
const tables = Object.entries(groups).map(([kind, items]) => {
return (
<React.Fragment key={kind}>
<SubTitle title={kind}/>
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].getNs() && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const name = item.getName();
const namespace = item.getNs();
const api = apiManager.getApi(item.metadata.selfLink);
const detailsUrl = api ? getDetailsUrl(api.getUrl({
name,
namespace,
})) : "";
return (
<TableRow key={item.getId()}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && <TableCell className="namespace">{namespace}</TableCell>}
<TableCell className="age">{item.getAge()}</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
);
});
return (
<div className="resources">
{tables}
</div>
);
}
renderContent() {
const { release } = this.props;
const { details } = this;
if (!release) return null;
if (!details) {
return <Spinner center/>;
}
return (
<div>
<DrawerItem name={<Trans>Chart</Trans>} className="chart">
<div className="flex gaps align-center">
<span>{release.getChart()}</span>
{release.hasNewVersion() && (
<Button
primary
label={_i18n._(t`Upgrade`)}
className="box right upgrade"
onClick={this.upgradeVersion}
/>
)}
</div>
</DrawerItem>
<DrawerItem name={<Trans>Updated</Trans>}>
{release.getUpdated()} <Trans>ago</Trans> ({release.updated})
</DrawerItem>
<DrawerItem name={<Trans>Namespace</Trans>}>
{release.getNs()}
</DrawerItem>
<DrawerItem name={<Trans>Version</Trans>} onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>
{release.getVersion()}
</span>
{!release.getLastVersion() && (
<Icon svg="spinner" small/>
)}
{release.hasNewVersion() && (
<span><Trans>New version available:</Trans> <b>{release.getLastVersion()}</b></span>
)}
</div>
</DrawerItem>
<DrawerItem name={<Trans>Status</Trans>} className="status" labelsOnly>
<Badge
label={release.getStatus()}
className={cssNames("status", kebabCase(release.getStatus()))}
/>
</DrawerItem>
{this.renderValues()}
<DrawerTitle title={_i18n._(t`Notes`)}/>
{this.renderNotes()}
<DrawerTitle title={_i18n._(t`Resources`)}/>
{this.renderResources()}
</div>
)
}
render() {
const { release, hideDetails } = this.props
const title = release ? <Trans>Release: {release.getName()}</Trans> : ""
const toolbar = <HelmReleaseMenu release={release} toolbar hideDetails={hideDetails}/>
return (
<Drawer
className={cssNames("ReleaseDetails", themeStore.activeTheme.type)}
usePortal={true}
open={!!release}
title={title}
onClose={hideDetails}
toolbar={toolbar}
>
{this.renderContent()}
</Drawer>
)
}
}

View File

@ -0,0 +1,70 @@
import * as React from "react";
import { t, Trans } from "@lingui/macro";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { autobind, cssNames } from "../../utils";
import { releaseStore } from "./release.store";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { _i18n } from "../../i18n";
interface Props extends MenuActionsProps {
release: HelmRelease;
hideDetails?(): void;
}
export class HelmReleaseMenu extends React.Component<Props> {
@autobind()
remove() {
return releaseStore.remove(this.props.release);
}
@autobind()
upgrade() {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails && hideDetails();
}
@autobind()
rollback() {
ReleaseRollbackDialog.open(this.props.release);
}
renderContent() {
const { release, toolbar } = this.props;
if (!release) return;
const hasRollback = release && release.getRevision() > 1;
const hasNewVersion = release.hasNewVersion();
return (
<>
{hasRollback && (
<MenuItem onClick={this.rollback}>
<Icon material="history" interactive={toolbar} title={_i18n._(t`Rollback`)}/>
<span className="title"><Trans>Rollback</Trans></span>
</MenuItem>
)}
{hasNewVersion && (
<MenuItem onClick={this.upgrade}>
<Icon material="build" interactive={toolbar} title={_i18n._(t`Upgrade`)}/>
<span className="title"><Trans>Upgrade</Trans></span>
</MenuItem>
)}
</>
)
}
render() {
const { className, release, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("HelmReleaseMenu", className)}
removeAction={this.remove}
children={this.renderContent()}
/>
);
}
}

View File

@ -0,0 +1,9 @@
.ReleaseRollbackDialog {
.WizardStep {
text-align: center;
.step-content {
padding: var(--wizard-spacing);
}
}
}

View File

@ -0,0 +1,109 @@
import "./release-rollback-dialog.scss";
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { HelmRelease, helmReleasesApi, IReleaseRevision } from "../../api/endpoints/helm-releases.api";
import { releaseStore } from "./release.store";
import { Select, SelectOption } from "../select";
import { Notifications } from "../notifications";
import orderBy from "lodash/orderBy"
interface Props extends DialogProps {
}
@observer
export class ReleaseRollbackDialog extends React.Component<Props> {
@observable static isOpen = false;
@observable.ref static release: HelmRelease = null;
@observable isLoading = false;
@observable revision: IReleaseRevision;
@observable revisions = observable.array<IReleaseRevision>();
static open(release: HelmRelease) {
ReleaseRollbackDialog.isOpen = true;
ReleaseRollbackDialog.release = release;
}
static close() {
ReleaseRollbackDialog.isOpen = false;
}
get release(): HelmRelease {
return ReleaseRollbackDialog.release;
}
onOpen = async () => {
this.isLoading = true;
const currentRevision = this.release.getRevision();
let releases = await helmReleasesApi.getHistory(this.release.getName(), this.release.getNs());
releases = releases.filter(item => item.revision !== currentRevision); // remove current
releases = orderBy(releases, "revision", "desc"); // sort
this.revisions.replace(releases);
this.revision = this.revisions[0];
this.isLoading = false;
}
rollback = async () => {
const revisionNumber = this.revision.revision;
try {
await releaseStore.rollback(this.release.getName(), this.release.getNs(), revisionNumber);
this.close();
} catch (err) {
Notifications.error(err);
}
};
close = () => {
ReleaseRollbackDialog.close();
}
renderContent() {
const { revision, revisions } = this;
if (!revision) {
return <p><Trans>No revisions to rollback.</Trans></p>
}
return (
<div className="flex gaps align-center">
<b><Trans>Revision</Trans></b>
<Select
themeName="light"
value={revision}
options={revisions}
formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}`}
onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value}
/>
</div>
)
}
render() {
const { ...dialogProps } = this.props;
const releaseName = this.release ? this.release.getName() : "";
const header = <h5><Trans>Rollback <b>{releaseName}</b></Trans></h5>
return (
<Dialog
{...dialogProps}
className="ReleaseRollbackDialog"
isOpen={ReleaseRollbackDialog.isOpen}
onOpen={this.onOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
scrollable={false}
nextLabel={<Trans>Rollback</Trans>}
next={this.rollback}
loading={this.isLoading}
>
{this.renderContent()}
</WizardStep>
</Wizard>
</Dialog>
)
}
}

View File

@ -0,0 +1,25 @@
$release-status-color-list: (
deployed: $colorSuccess,
failed: $colorError,
deleting: $colorWarning,
pendingInstall: $colorInfo,
pendingUpgrade: $colorInfo,
pendingRollback: $colorInfo,
);
@mixin release-status-bgs {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: white;
background: $color;
}
}
}
@mixin release-status-colors {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: $color;
}
}
}

View File

@ -0,0 +1,14 @@
import { RouteProps } from "react-router"
import { appsRoute } from "../+apps/apps.route";
import { buildURL } from "../../navigation";
export const releaseRoute: RouteProps = {
path: appsRoute.path + "/releases/:namespace?/:name?"
}
export interface IReleaseRouteParams {
name?: string;
namespace?: string;
}
export const releaseURL = buildURL<IReleaseRouteParams>(releaseRoute.path);

View File

@ -0,0 +1,112 @@
import isEqual from "lodash/isEqual";
import { action, observable, when, IReactionDisposer, reaction } from "mobx";
import { autobind } from "../../utils";
import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayload } from "../../api/endpoints/helm-releases.api";
import { ItemStore } from "../../item.store";
import { configStore } from "../../config.store";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
@autobind()
export class ReleaseStore extends ItemStore<HelmRelease> {
@observable releaseSecrets: Secret[] = [];
@observable secretWatcher: IReactionDisposer;
constructor() {
super();
when(() => secretsStore.isLoaded, () => {
this.releaseSecrets = this.getReleaseSecrets();
});
}
watch() {
this.secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (this.isLoading) return;
const secrets = this.getReleaseSecrets();
const amountChanged = secrets.length !== this.releaseSecrets.length;
const labelsChanged = this.releaseSecrets.some(item => {
const secret = secrets.find(secret => secret.getId() == item.getId());
if (!secret) return;
return !isEqual(item.getLabels(), secret.getLabels());
});
if (amountChanged || labelsChanged) {
this.loadAll();
}
this.releaseSecrets = [...secrets];
})
}
unwatch() {
this.secretWatcher();
}
getReleaseSecrets() {
return secretsStore.getByLabel({ owner: "helm" });
}
getReleaseSecret(release: HelmRelease) {
const labels = {
owner: "helm",
name: release.getName()
}
return secretsStore.getByLabel(labels)
.filter(secret => secret.getNs() == release.getNs())[0];
}
@action
async loadAll() {
this.isLoading = true;
let items;
try {
const { isClusterAdmin, allowedNamespaces } = configStore;
items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null);
} finally {
if (items) {
items = this.sortItems(items);
this.items.replace(items);
}
this.isLoaded = true;
this.isLoading = false;
}
}
async loadItems(namespaces?: string[]) {
if (!namespaces) {
return helmReleasesApi.list();
}
else {
return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat());
}
}
async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload);
if (this.isLoaded) this.loadAll();
return response;
}
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await helmReleasesApi.update(name, namespace, payload);
if (this.isLoaded) this.loadAll();
return response;
}
async rollback(name: string, namespace: string, revision: number) {
const response = await helmReleasesApi.rollback(name, namespace, revision);
if (this.isLoaded) this.loadAll();
return response;
}
async remove(release: HelmRelease) {
return super.removeItem(release, () => helmReleasesApi.delete(release.getName(), release.getNs()));
}
async removeSelectedItems() {
if (!this.selectedItems.length) return;
return Promise.all(this.selectedItems.map(this.remove));
}
}
export const releaseStore = new ReleaseStore();

View File

@ -0,0 +1,19 @@
@import "./release.mixins.scss";
.HelmReleases {
.TableCell {
&.status {
@include release-status-colors;
}
&.version {
> .Icon {
margin-left: $margin;
&.new-version {
color: $colorInfo;
}
}
}
}
}

View File

@ -0,0 +1,183 @@
import "./releases.scss";
import React, { Component } from "react";
import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { autobind, interval } from "../../utils";
import { releaseStore } from "./release.store";
import { helmChartStore } from "../+apps-helm-charts/helm-chart.store";
import { IReleaseRouteParams, releaseURL } from "./release.route";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { ReleaseDetails } from "./release-details";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu";
import { Icon } from "../icon";
import { secretsStore } from "../+config-secrets/secrets.store";
import { when } from "mobx";
enum sortBy {
name = "name",
namespace = "namespace",
revision = "revision",
chart = "chart",
status = "status",
updated = "update"
}
interface Props extends RouteComponentProps<IReleaseRouteParams> {
}
@observer
export class HelmReleases extends Component<Props> {
private versionsWatcher = interval(3600, this.checkVersions);
componentDidMount() {
// Watch for secrets associated with releases and react to their changes
releaseStore.watch();
this.versionsWatcher.start();
when(() => releaseStore.isLoaded, this.checkVersions);
}
componentWillUnmount() {
releaseStore.unwatch();
this.versionsWatcher.stop();
}
// Check all available versions every 1 hour for installed releases.
// This required to show "upgrade" icon in the list and upgrade button in the details view.
@autobind()
checkVersions() {
const charts = releaseStore.items.map(release => release.getChart());
return charts.reduce((promise, chartName) => {
const loadVersions = () => helmChartStore.getVersions(chartName, true);
return promise.then(loadVersions, loadVersions);
}, Promise.resolve({}))
};
get selectedRelease() {
const { match: { params: { name, namespace } } } = this.props;
return releaseStore.items.find(release => {
return release.getName() == name && release.getNs() == namespace;
});
}
showDetails = (item: HelmRelease) => {
if (!item) {
navigation.merge(releaseURL())
}
else {
navigation.merge(releaseURL({
params: {
name: item.getName(),
namespace: item.getNs()
}
}))
}
}
hideDetails = () => {
this.showDetails(null);
}
renderRemoveDialogMessage(selectedItems: HelmRelease[]) {
const releaseNames = selectedItems.map(item => item.getName()).join(", ");
return (
<div>
<Trans>Remove <b>{releaseNames}</b>?</Trans>
<p className="warning">
<Trans>Note: StatefulSet Volumes won't be deleted automatically</Trans>
</p>
</div>
)
}
render() {
return (
<>
<ItemListLayout
className="HelmReleases"
store={releaseStore}
dependentStores={[secretsStore]}
sortingCallbacks={{
[sortBy.name]: (release: HelmRelease) => release.getName(),
[sortBy.namespace]: (release: HelmRelease) => release.getNs(),
[sortBy.revision]: (release: HelmRelease) => release.getRevision(),
[sortBy.chart]: (release: HelmRelease) => release.getChart(),
[sortBy.status]: (release: HelmRelease) => release.getStatus(),
[sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false),
}}
searchFilters={[
(release: HelmRelease) => release.getName(),
(release: HelmRelease) => release.getNs(),
(release: HelmRelease) => release.getChart(),
(release: HelmRelease) => release.getStatus(),
(release: HelmRelease) => release.getVersion(),
]}
renderHeaderTitle={<Trans>Releases</Trans>}
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Chart</Trans>, className: "chart", sortBy: sortBy.chart },
{ title: <Trans>Revision</Trans>, className: "revision", sortBy: sortBy.revision },
{ title: <Trans>Version</Trans>, className: "version" },
{ title: <Trans>App Version</Trans>, className: "app-version" },
{ title: <Trans>Status</Trans>, className: "status", sortBy: sortBy.status },
{ title: <Trans>Updated</Trans>, className: "updated", sortBy: sortBy.updated },
]}
renderTableContents={(release: HelmRelease) => {
const version = release.getVersion();
const lastVersion = release.getLastVersion();
return [
release.getName(),
release.getNs(),
release.getChart(),
release.getRevision(),
<>
{version}
{!lastVersion && (
<Icon
small svg="spinner"
className="checking-update"
tooltip={<Trans>Checking update</Trans>}
/>
)}
{release.hasNewVersion() && (
<Icon
material="new_releases"
className="new-version"
tooltip={<Trans>New version: {lastVersion}</Trans>}
/>
)}
</>,
release.appVersion,
{ title: release.getStatus(), className: kebabCase(release.getStatus()) },
release.getUpdated(),
]
}}
renderItemMenu={(release: HelmRelease) => {
return (
<HelmReleaseMenu
release={release}
removeConfirmationMessage={this.renderRemoveDialogMessage([release])}
/>
)
}}
customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({
message: this.renderRemoveDialogMessage(selectedItems)
})}
detailsItem={this.selectedRelease}
onDetails={this.showDetails}
/>
<ReleaseDetails
release={this.selectedRelease}
hideDetails={this.hideDetails}
/>
<ReleaseRollbackDialog/>
</>
);
}
}

View File

@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const appsRoute: RouteProps = {
path: "/apps",
};
export const appsURL = buildURL(appsRoute.path);

View File

@ -0,0 +1,41 @@
import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
import { namespaceStore } from "../+namespaces/namespace.store";
@observer
export class Apps extends React.Component {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams();
return [
{
title: <Trans>Charts</Trans>,
component: HelmCharts,
url: helmChartsURL(),
path: helmChartsRoute.path,
},
{
title: <Trans>Releases</Trans>,
component: HelmReleases,
url: releaseURL({ query }),
path: releaseRoute.path,
},
]
}
render() {
const tabRoutes = Apps.tabRoutes;
return (
<MainLayout className="Apps" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={tabRoutes[0].url}/>
</Switch>
</MainLayout>
)
}
}

View File

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

View File

@ -0,0 +1,58 @@
.ClusterIssues {
min-height: 350px;
position: relative;
@include media("<1024px") {
grid-column-start: 1!important;
grid-column-end: 1!important;
}
&.wide {
grid-column-start: 1;
grid-column-end: 3;
}
.SubHeader {
.Icon {
font-size: 130%;
color: $colorError;
}
}
.Table {
.TableHead {
background-color: transparent;
border-bottom: 1px solid $borderFaintColor;
.TableCell {
padding-top: 0;
padding-bottom: floor($padding / 1.33);
}
}
.TableCell {
white-space: nowrap;
text-overflow: ellipsis;
&.message {
flex-grow: 3;
}
&.object {
flex-grow: 2;
}
}
}
.no-issues {
.Icon {
color: white;
}
.ok-title {
font-size: large;
color: $textColorAccent;
font-weight: bold;
}
}
}

View File

@ -0,0 +1,149 @@
import "./cluster-issues.scss"
import * as React from "react";
import { observer } from "mobx-react";
import { computed } from "mobx";
import { Trans } from "@lingui/macro";
import { Icon } from "../icon";
import { SubHeader } from "../layout/sub-header";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { nodesStore } from "../+nodes/nodes.store";
import { eventStore } from "../+events/event.store";
import { autobind, cssNames, prevDefault } from "../../utils";
import { getSelectedDetails, showDetails } from "../../navigation";
import { ItemObject } from "../../item.store";
import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store";
import { lookupApiLink } from "../../api/kube-api";
interface Props {
className?: string;
}
interface IWarning extends ItemObject {
kind: string;
message: string;
selfLink: string;
}
enum sortBy {
type = "type",
object = "object"
}
@observer
export class ClusterIssues extends React.Component<Props> {
private sortCallbacks = {
[sortBy.type]: (warning: IWarning) => warning.kind,
[sortBy.object]: (warning: IWarning) => warning.getName(),
};
@computed get warnings() {
const warnings: IWarning[] = [];
// Node bad conditions
nodesStore.items.forEach(node => {
const { kind, selfLink, getId, getName } = node
node.getWarningConditions().forEach(({ message }) => {
warnings.push({
kind,
getId,
getName,
selfLink,
message,
})
})
});
// Warning events for Workloads
const events = eventStore.getWarnings();
events.forEach(error => {
const { message, involvedObject } = error;
const { uid, name, kind } = involvedObject;
warnings.push({
getId: () => uid,
getName: () => name,
message,
kind,
selfLink: lookupApiLink(involvedObject, error),
});
})
return warnings;
}
@autobind()
getTableRow(uid: string) {
const { warnings } = this;
const warning = warnings.find(warn => warn.getId() == uid);
const { getId, getName, message, kind, selfLink } = warning;
return (
<TableRow
key={getId()}
sortItem={warning}
selected={selfLink === getSelectedDetails()}
onClick={prevDefault(() => showDetails(selfLink))}
>
<TableCell className="message">
{message}
</TableCell>
<TableCell className="object">
{getName()}
</TableCell>
<TableCell className="kind">
{kind}
</TableCell>
</TableRow>
);
}
renderContent() {
const { warnings } = this;
if (!eventStore.isLoaded) {
return (
<Spinner center/>
);
}
if (!warnings.length) {
return (
<div className="no-issues flex column box grow gaps align-center justify-center">
<div><Icon material="check" big sticker/></div>
<div className="ok-title"><Trans>No issues found</Trans></div>
<span><Trans>Everything is fine in the Cluster</Trans></span>
</div>
);
}
return (
<>
<SubHeader>
<Icon material="error_outline"/>{" "}
<Trans>Warnings: {warnings.length}</Trans>
</SubHeader>
<Table
items={warnings}
virtual
selectable
sortable={this.sortCallbacks}
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className={cssNames("box grow", themeStore.activeTheme.type)}
>
<TableHead nowrap>
<TableCell className="message"><Trans>Message</Trans></TableCell>
<TableCell className="object" sortBy={sortBy.object}><Trans>Object</Trans></TableCell>
<TableCell className="kind" sortBy={sortBy.type}><Trans>Type</Trans></TableCell>
</TableHead>
</Table>
</>
);
}
render() {
return (
<div className={cssNames("ClusterIssues flex column", this.props.className)}>
{this.renderContent()}
</div>
);
}
}

View File

@ -0,0 +1,7 @@
.ClusterMetricSwitchers {
margin-bottom: $margin * 2;
.metric-switch {
text-align: right;
}
}

View File

@ -0,0 +1,43 @@
import "./cluster-metric-switchers.scss";
import React from "react";
import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store";
import { cssNames } from "../../utils";
import { Radio, RadioGroup } from "../radio";
import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store";
export const ClusterMetricSwitchers = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore;
const { masterNodes, workerNodes } = nodesStore;
const metricsValues = getMetricsValues(metrics);
const disableRoles = !masterNodes.length || !workerNodes.length;
const disableMetrics = !metricsValues.length;
return (
<div className="ClusterMetricSwitchers flex gaps">
<div className="box grow">
<RadioGroup
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
value={metricNodeRole}
onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric}
>
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
<Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/>
</RadioGroup>
</div>
<div className="box grow metric-switch">
<RadioGroup
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
value={metricType}
onChange={(value: MetricType) => clusterStore.metricType = value}
>
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>
</RadioGroup>
</div>
</div>
);
});

View File

@ -0,0 +1,16 @@
.ClusterMetrics {
position: relative;
min-height: 280px;
.Chart {
.chart-container {
width: 100%;
height: 100%;
}
}
.empty {
margin-top: -45px;
text-align: center;
}
}

View File

@ -0,0 +1,95 @@
import "./cluster-metrics.scss";
import React from "react";
import { observer } from "mobx-react";
import { ChartOptions, ChartPoint } from "chart.js";
import { clusterStore, MetricType } from "./cluster.store";
import { BarChart } from "../chart";
import { bytesToUnits } from "../../utils";
import { Spinner } from "../spinner";
import { ZebraStripes } from "../chart/zebra-stripes.plugin";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
export const ClusterMetrics = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore;
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics);
const metricValues = getMetricsValues(metrics);
const liveMetricValues = getMetricsValues(liveMetrics);
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({
x: value[0],
y: parseFloat(value[1]).toFixed(3)
}));
const datasets = [{
id: metricType + metricNodeRole,
label: metricType.toUpperCase() + " usage",
borderColor: colors[metricType],
data: data
}];
const cpuOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: cpuCapacity,
callback: (value) => value
}
}]
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
return value.y.toString();
}
}
}
};
const memoryOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: memoryCapacity,
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value))
}
}]
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
return bytesToUnits(parseInt(value.y as string), 3);
}
}
}
};
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => {
if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) {
return <Spinner center/>;
}
if (!memoryCapacity || !cpuCapacity) {
return <ClusterNoMetrics className="empty"/>
}
return (
<BarChart
name={`${metricNodeRole}-${metricType}`}
options={options}
data={{ datasets }}
timeLabelStep={5}
showLegend={false}
plugins={[ZebraStripes]}
/>
);
};
return (
<div className="ClusterMetrics flex column">
<ClusterMetricSwitchers/>
{renderMetrics()}
</div>
);
});

View File

@ -0,0 +1,18 @@
import React from "react";
import { Icon } from "../icon";
import { Trans } from "@lingui/macro";
import { cssNames } from "../../utils";
interface Props {
className: string;
}
export function ClusterNoMetrics({ className }: Props) {
return (
<div className={cssNames("ClusterNoMetrics flex column box grow justify-center align-center", className)}>
<Icon material="info"/>
<p><Trans>Metrics are not available due to missing or invalid Prometheus configuration.</Trans></p>
<p><Trans>Right click cluster icon to open cluster settings.</Trans></p>
</div>
);
}

View File

@ -0,0 +1,29 @@
.ClusterPieCharts {
background: transparent!important;
padding: 0!important;
.empty {
background: $contentColor;
min-height: 280px;
text-align: center;
padding: $padding * 2;
}
.NodeCharts {
margin-bottom: 0;
}
.chart {
--flex-gap: #{$padding * 2};
background: $contentColor;
padding: $padding * 2 $padding;
.chart-title {
margin-bottom: 0;
}
.legend {
--flex-gap: #{$padding};
}
}
}

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