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:
parent
936cbd53d4
commit
1d0815abd2
141
.azure-pipelines.yml
Normal file
141
.azure-pipelines.yml
Normal 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
75
.eslintrc.js
Normal 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
9
.gitignore
vendored
Normal 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
3
.yarnrc
Normal file
@ -0,0 +1,3 @@
|
||||
disturl "https://atom.io/download/electron"
|
||||
target "6.0.12"
|
||||
runtime "electron"
|
204
LICENSE
204
LICENSE
@ -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
63
Makefile
Normal 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/*
|
26
README.md
26
README.md
@ -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 you’ll 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
104
build/download_kubectl.ts
Normal 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")))
|
||||
})
|
||||
|
12
build/entitlements.mac.plist
Normal file
12
build/entitlements.mac.plist
Normal 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
BIN
build/icon.ico
Executable file
Binary file not shown.
After Width: | Height: | Size: 8.8 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
build/icons/512x512.png
Normal file
BIN
build/icons/512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
20
build/notarize.js
Normal file
20
build/notarize.js
Normal 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
10
dashboard/.babelrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"plugins": [
|
||||
"macros",
|
||||
"@babel/plugin-transform-runtime",
|
||||
],
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
]
|
||||
}
|
12
dashboard/.dockerignore
Normal file
12
dashboard/.dockerignore
Normal 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
14
dashboard/.gitignore
vendored
Executable 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
18
dashboard/.linguirc
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
79
dashboard/client/api/api-manager.ts
Normal file
79
dashboard/client/api/api-manager.ts
Normal 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();
|
@ -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();
|
||||
});
|
||||
});
|
261
dashboard/client/api/endpoints/cert-manager.api.ts
Normal file
261
dashboard/client/api/endpoints/cert-manager.api.ts
Normal 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,
|
||||
});
|
13
dashboard/client/api/endpoints/cluster-role-binding.api.ts
Normal file
13
dashboard/client/api/endpoints/cluster-role-binding.api.ts
Normal 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,
|
||||
});
|
15
dashboard/client/api/endpoints/cluster-role.api.ts
Normal file
15
dashboard/client/api/endpoints/cluster-role.api.ts
Normal 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,
|
||||
});
|
114
dashboard/client/api/endpoints/cluster.api.ts
Normal file
114
dashboard/client/api/endpoints/cluster.api.ts
Normal 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,
|
||||
});
|
25
dashboard/client/api/endpoints/component-status.api.ts
Normal file
25
dashboard/client/api/endpoints/component-status.api.ts
Normal 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,
|
||||
});
|
9
dashboard/client/api/endpoints/config.api.ts
Normal file
9
dashboard/client/api/endpoints/config.api.ts
Normal 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")
|
||||
},
|
||||
};
|
29
dashboard/client/api/endpoints/configmap.api.ts
Normal file
29
dashboard/client/api/endpoints/configmap.api.ts
Normal 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,
|
||||
});
|
138
dashboard/client/api/endpoints/crd.api.ts
Normal file
138
dashboard/client/api/endpoints/crd.api.ts
Normal 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,
|
||||
});
|
88
dashboard/client/api/endpoints/cron-job.api.ts
Normal file
88
dashboard/client/api/endpoints/cron-job.api.ts
Normal 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,
|
||||
});
|
76
dashboard/client/api/endpoints/daemon-set.api.ts
Normal file
76
dashboard/client/api/endpoints/daemon-set.api.ts
Normal 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,
|
||||
});
|
171
dashboard/client/api/endpoints/deployment.api.ts
Normal file
171
dashboard/client/api/endpoints/deployment.api.ts
Normal 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,
|
||||
});
|
59
dashboard/client/api/endpoints/events.api.ts
Normal file
59
dashboard/client/api/endpoints/events.api.ts
Normal 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,
|
||||
})
|
129
dashboard/client/api/endpoints/helm-charts.api.ts
Normal file
129
dashboard/client/api/endpoints/helm-charts.api.ts
Normal 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 || [];
|
||||
}
|
||||
}
|
213
dashboard/client/api/endpoints/helm-releases.api.ts
Normal file
213
dashboard/client/api/endpoints/helm-releases.api.ts
Normal 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();
|
||||
}
|
||||
}
|
140
dashboard/client/api/endpoints/hpa.api.ts
Normal file
140
dashboard/client/api/endpoints/hpa.api.ts
Normal 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,
|
||||
});
|
32
dashboard/client/api/endpoints/index.ts
Normal file
32
dashboard/client/api/endpoints/index.ts
Normal 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"
|
118
dashboard/client/api/endpoints/ingress.api.ts
Normal file
118
dashboard/client/api/endpoints/ingress.api.ts
Normal 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,
|
||||
});
|
98
dashboard/client/api/endpoints/job.api.ts
Normal file
98
dashboard/client/api/endpoints/job.api.ts
Normal 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,
|
||||
});
|
12
dashboard/client/api/endpoints/kubeconfig.api.ts
Normal file
12
dashboard/client/api/endpoints/kubeconfig.api.ts
Normal 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}`);
|
||||
},
|
||||
};
|
112
dashboard/client/api/endpoints/metrics.api.ts
Normal file
112
dashboard/client/api/endpoints/metrics.api.ts
Normal 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;
|
||||
}
|
28
dashboard/client/api/endpoints/namespaces.api.ts
Normal file
28
dashboard/client/api/endpoints/namespaces.api.ts
Normal 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,
|
||||
});
|
72
dashboard/client/api/endpoints/network-policy.api.ts
Normal file
72
dashboard/client/api/endpoints/network-policy.api.ts
Normal 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,
|
||||
});
|
158
dashboard/client/api/endpoints/nodes.api.ts
Normal file
158
dashboard/client/api/endpoints/nodes.api.ts
Normal 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,
|
||||
});
|
@ -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,
|
||||
});
|
71
dashboard/client/api/endpoints/persistent-volume.api.ts
Normal file
71
dashboard/client/api/endpoints/persistent-volume.api.ts
Normal 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,
|
||||
});
|
21
dashboard/client/api/endpoints/pod-metrics.api.ts
Normal file
21
dashboard/client/api/endpoints/pod-metrics.api.ts
Normal 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,
|
||||
});
|
411
dashboard/client/api/endpoints/pods.api.ts
Normal file
411
dashboard/client/api/endpoints/pods.api.ts
Normal 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,
|
||||
});
|
94
dashboard/client/api/endpoints/podsecuritypolicy.api.ts
Normal file
94
dashboard/client/api/endpoints/podsecuritypolicy.api.ts
Normal 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,
|
||||
});
|
58
dashboard/client/api/endpoints/replica-set.api.ts
Normal file
58
dashboard/client/api/endpoints/replica-set.api.ts
Normal 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,
|
||||
});
|
26
dashboard/client/api/endpoints/resource-applier.api.ts
Normal file
26
dashboard/client/api/endpoints/resource-applier.api.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
};
|
68
dashboard/client/api/endpoints/resource-quota.api.ts
Normal file
68
dashboard/client/api/endpoints/resource-quota.api.ts
Normal 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,
|
||||
});
|
37
dashboard/client/api/endpoints/role-binding.api.ts
Normal file
37
dashboard/client/api/endpoints/role-binding.api.ts
Normal 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,
|
||||
});
|
24
dashboard/client/api/endpoints/role.api.ts
Normal file
24
dashboard/client/api/endpoints/role.api.ts
Normal 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,
|
||||
});
|
51
dashboard/client/api/endpoints/secret.api.ts
Normal file
51
dashboard/client/api/endpoints/secret.api.ts
Normal 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,
|
||||
});
|
@ -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,
|
||||
});
|
30
dashboard/client/api/endpoints/service-accounts.api.ts
Normal file
30
dashboard/client/api/endpoints/service-accounts.api.ts
Normal 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,
|
||||
});
|
75
dashboard/client/api/endpoints/service.api.ts
Normal file
75
dashboard/client/api/endpoints/service.api.ts
Normal 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,
|
||||
});
|
84
dashboard/client/api/endpoints/stateful-set.api.ts
Normal file
84
dashboard/client/api/endpoints/stateful-set.api.ts
Normal 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,
|
||||
});
|
39
dashboard/client/api/endpoints/storage-class.api.ts
Normal file
39
dashboard/client/api/endpoints/storage-class.api.ts
Normal 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,
|
||||
});
|
43
dashboard/client/api/index.ts
Normal file
43
dashboard/client/api/index.ts
Normal 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);
|
154
dashboard/client/api/json-api.ts
Normal file
154
dashboard/client/api/json-api.ts
Normal 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");
|
||||
}
|
||||
}
|
233
dashboard/client/api/kube-api.ts
Normal file
233
dashboard/client/api/kube-api.ts
Normal 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 })
|
||||
}
|
68
dashboard/client/api/kube-json-api.ts
Normal file
68
dashboard/client/api/kube-json-api.ts
Normal 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);
|
||||
}
|
||||
}
|
142
dashboard/client/api/kube-object.ts
Normal file
142
dashboard/client/api/kube-object.ts
Normal 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);
|
||||
}
|
||||
}
|
151
dashboard/client/api/kube-watch-api.ts
Normal file
151
dashboard/client/api/kube-watch-api.ts
Normal 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();
|
172
dashboard/client/api/terminal-api.ts
Normal file
172
dashboard/client/api/terminal-api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
169
dashboard/client/api/websocket-api.ts
Normal file
169
dashboard/client/api/websocket-api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
100
dashboard/client/api/workload-kube-object.ts
Normal file
100
dashboard/client/api/workload-kube-object.ts
Normal 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
|
||||
}
|
||||
}
|
20
dashboard/client/browser-check.tsx
Normal file
20
dashboard/client/browser-check.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
1
dashboard/client/components/+404/index.ts
Normal file
1
dashboard/client/components/+404/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./not-found"
|
15
dashboard/client/components/+404/not-found.tsx
Normal file
15
dashboard/client/components/+404/not-found.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
@ -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)
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
109
dashboard/client/components/+apps-helm-charts/helm-charts.tsx
Normal file
109
dashboard/client/components/+apps-helm-charts/helm-charts.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -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 |
2
dashboard/client/components/+apps-helm-charts/index.ts
Normal file
2
dashboard/client/components/+apps-helm-charts/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./helm-charts";
|
||||
export * from "./helm-charts.route";
|
2
dashboard/client/components/+apps-releases/index.ts
Normal file
2
dashboard/client/components/+apps-releases/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./releases";
|
||||
export * from "./release.route";
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
254
dashboard/client/components/+apps-releases/release-details.tsx
Normal file
254
dashboard/client/components/+apps-releases/release-details.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
70
dashboard/client/components/+apps-releases/release-menu.tsx
Normal file
70
dashboard/client/components/+apps-releases/release-menu.tsx
Normal 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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.ReleaseRollbackDialog {
|
||||
.WizardStep {
|
||||
text-align: center;
|
||||
|
||||
.step-content {
|
||||
padding: var(--wizard-spacing);
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
14
dashboard/client/components/+apps-releases/release.route.ts
Normal file
14
dashboard/client/components/+apps-releases/release.route.ts
Normal 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);
|
112
dashboard/client/components/+apps-releases/release.store.ts
Normal file
112
dashboard/client/components/+apps-releases/release.store.ts
Normal 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();
|
19
dashboard/client/components/+apps-releases/releases.scss
Normal file
19
dashboard/client/components/+apps-releases/releases.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
183
dashboard/client/components/+apps-releases/releases.tsx
Normal file
183
dashboard/client/components/+apps-releases/releases.tsx
Normal 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/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
8
dashboard/client/components/+apps/apps.route.ts
Normal file
8
dashboard/client/components/+apps/apps.route.ts
Normal 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);
|
41
dashboard/client/components/+apps/apps.tsx
Normal file
41
dashboard/client/components/+apps/apps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
2
dashboard/client/components/+apps/index.ts
Normal file
2
dashboard/client/components/+apps/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./apps";
|
||||
export * from "./apps.route";
|
58
dashboard/client/components/+cluster/cluster-issues.scss
Normal file
58
dashboard/client/components/+cluster/cluster-issues.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
149
dashboard/client/components/+cluster/cluster-issues.tsx
Normal file
149
dashboard/client/components/+cluster/cluster-issues.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
.ClusterMetricSwitchers {
|
||||
margin-bottom: $margin * 2;
|
||||
|
||||
.metric-switch {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
});
|
16
dashboard/client/components/+cluster/cluster-metrics.scss
Normal file
16
dashboard/client/components/+cluster/cluster-metrics.scss
Normal 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;
|
||||
}
|
||||
}
|
95
dashboard/client/components/+cluster/cluster-metrics.tsx
Normal file
95
dashboard/client/components/+cluster/cluster-metrics.tsx
Normal 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>
|
||||
);
|
||||
});
|
18
dashboard/client/components/+cluster/cluster-no-metrics.tsx
Normal file
18
dashboard/client/components/+cluster/cluster-no-metrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
29
dashboard/client/components/+cluster/cluster-pie-charts.scss
Normal file
29
dashboard/client/components/+cluster/cluster-pie-charts.scss
Normal 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
Loading…
Reference in New Issue
Block a user