Rename frontend directory (#472)

* Rename directory

* Cleanup

* Recover lost images

* remove lint
This commit is contained in:
Andrey Pokhilko
2023-09-26 10:04:44 +01:00
committed by GitHub
parent 133eef6745
commit dd7aca70ff
146 changed files with 595 additions and 309 deletions

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_SERVER_PORT=8080

44
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ["enpitech", "plugin:@typescript-eslint/recommended"],
globals: {
heap: "writable",
DD_RUM: "writable",
},
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["@typescript-eslint", "react"],
rules: {
// please don't make an error occur here we use console.error
"no-console": ["error", { allow: ["error"] }],
"no-alert": "error",
"no-debugger": "error",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ vars: "all", args: "after-used", ignoreRestSiblings: true },
],
"react/react-in-jsx-scope": "off", // Vite does not require you to import React into each component file
"linebreak-style": ["error", "unix"],
quotes: ["error", "double"],
semi: ["error", "always"],
"@typescript-eslint/no-explicit-any": "warn",
},
};

27
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
static
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/storybook-static

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

3
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

View File

@@ -0,0 +1,4 @@
trailingComma: "es5"
tabWidth: 2
semi: true
singleQuote: false

View File

@@ -0,0 +1,32 @@
module.exports = {
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
],
framework: "@storybook/react",
core: {
builder: "@storybook/builder-vite",
},
webpackFinal: async (config) => {
config.module.rules.push({
test: /\.css$/,
use: [
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [require("tailwindcss"), require("autoprefixer")],
},
},
},
],
include: path.resolve(__dirname, "../"),
});
return config;
},
features: {
storyStoreV7: true,
},
};

View File

@@ -0,0 +1,25 @@
// .storybook/main.ts
import type { StorybookViteConfig } from "@storybook/builder-vite";
import path from "path";
const config: StorybookViteConfig = {
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-styling",
{
name: "@storybook/addon-styling",
},
"@storybook/addon-mdx-gfm",
],
core: {},
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: true,
},
};
export default config;

View File

@@ -0,0 +1 @@
<div id="portal"></div>

View File

@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>

View File

@@ -0,0 +1,12 @@
import "../src/index.css";
import "tailwindcss/tailwind.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

View File

@@ -0,0 +1,12 @@
import "tailwindcss/tailwind.css";
import "../src/index.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

124
frontend/README.md Normal file
View File

@@ -0,0 +1,124 @@
# Helm dashboard V2
Welcome to our new project, upgrading helm dashbord, we call it, Helm Dashboard version... 2! 🤩
Helm dashboard V2 is an open source effort to modernize the helm-dashboard.
Our goals are to create a version which is more:
1. Maintable
2. Extendable
3. Contributor friendly
## What is helm?
First thing first, if you are new here please check out these resources to see what helm is all about
[Video](https://www.youtube.com/watch?v=fy8SHvNZGeE)
[Article](https://kruschecompany.com/helm-kubernetes/)
# Legacy dashboard vs dashboard V2
The legacy dashboard found [here](https://github.com/komodorio/helm-dashboard/tree/main/pkg/dashboard/static) is a static webapp and was written vanilla css, jquery, javascript and html. If you inspect the code abit you may notice that its relitvly hard to extend and maintain such a project.
Our goal with dashboard V2 is to improve the ability to maintain and extend our dashboard app. To achive this we are using a more modern frontend stack.
- Vite, as our build tool.
- React will be used to make this project more inviting for developers to contribute too.
- TypeScript and ESLint will keep the project safe, please keep them clean.
- Tailwind will be used for styling.
- React-Query will be used to fetch data from the backend.
- Storybook is utilized to develop a component library.
Please follow through the file structure to understand how things are structured and should be used.
# Contribution guide
## Running legacy dashboard
The legacy dashboard is great for refrence and checking that you have implemented the UI correctly.
1. Install [helm](https://helm.sh/docs/intro/install/) and [kubectl](https://kubernetes.io/docs/tasks/tools/).
2. `git clone https://github.com/komodorio/helm-dashboard.git`.
3. `go build -o bin/dashboard .`
4. `bin/dashboard`
The UI should now be running on http://localhost:8080/
If you're having issues with that please follow the main README in the main folder.
If you still having troubles please contact us on our [Slack community channel](https://join.slack.com/t/komodorkommunity/shared_invite/zt-1lz4cme86-2zIKTRtTFnzL_UNxaUS9yw)
## Setting up your development environment
1. First you should fork this repositroy.
2. Clone your new repository using `git clone <https_or_ssh_url>`.
3. Make sure to checkout branch `helm-dashboard-v2`.
- `git fetch`
- `git checkout helm-dashboard-v2`
## Running dashboard V2
1. Make sure you cloned the project correctly. This is explained in this [stage](https://github.com/komodorio/helm-dashboard/blob/helm-dashboard-v2/dashboard/README.md#setting-up-your-development-environment).
2. go to `helm-dashboard-v2/dashboard` in your local project.
3. inorder to install dependncies and start the development server
- `npm i`
- `npm run dev`
4. with the default integration the dashboard should run on http://localhost:5173/
## Setting up a local cluster with ease
1. Install [Docker](https://docs.docker.com/engine/install/ubuntu/)
2. Install [Minikube](https://minikube.sigs.k8s.io/docs/start/)
3. Start your cluster `minikube start`
You should now be able to follow the [Helm tutorial](https://helm.sh/docs/intro/quickstart/) and interact with Helm normally.
## Choosing a task
If you are completely new to the project its recommended to look for tasks labled: `good first issue`.
These tasks should be simple enough for a begginer or for someone looking to learn the code base.
You are also free to reachout to us on [Slack](https://join.slack.com/t/komodorkommunity/shared_invite/zt-1lz4cme86-2zIKTRtTFnzL_UNxaUS9yw), we can help you find a task that suits your perfectly.
## Opening a pull request
Inorder to open a pull request with your changes. \
1. make sure you are synced with `helm-dashboard-v2` and that all conflicts are resolved. \
2. commit your changes and push to your fork. \
3. then navigate to https://github.com/komodorio/helm-dashboard and open a pull request. Make sure you are merging from your branch to `helm-dashboard-v2`. \
4. you should now tag a main developer (@chad11111
for example) and get your pull request reviewed.
# Component library
We created a components library to have a consistent design system throughout the project. Please rely on these components.
Additional information and examples on how to use them are available when you run Storybook, which shows them in an interactive way and in different scenarios.
Once you run it, you'll be able to see pre-made scenarios, documentation, and play with the component properties.
To run Storybook, make sure that all the dependencies are installed and run:
```shell
npm run storybook
```
Refer to the [official documentation](https://storybook.js.org/docs/react/get-started/install) for more information.
# Helpers
- Icons: https://react-icons.github.io/react-icons/
- Tailwind: https://tailwindcss.com/docs
- Typescript: https://www.typescriptlang.org/docs/handbook/intro.html
- React-query: https://react-query.tanstack.com/overview
# Coding Conventions
- Use only functional components
- Please prefer async/await over .then
- wrap every function with try/catch unless you want to display the error to the user
in such case we have a general error handler in the app.tsx file which will display the error to the user in a modal
- Please use the component library we created, it will help us keep a consistent design system
- Please use the react-query library to fetch data from the backend
- Prefer use fetch API over axios, if you see axios in the code, replace it with fetch.
- Use <Outlet> for inner routes
- User query params in the url for filters or any other state that can be represented
- Hooks:
- useCustomSearchParams - for search params

28
frontend/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Helm Dashboard</title>
<script src="/assets/analytics.js"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/github.min.css"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/lightfair.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css"
/>
</head>
<body>
<div id="root"></div>
<div id="portal"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19880
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

83
frontend/package.json Normal file
View File

@@ -0,0 +1,83 @@
{
"name": "dashboard",
"version": "1.0.0",
"type": "module",
"description": "",
"main": "index.js",
"dependencies": {
"@tanstack/react-query": "^4.35.3",
"@types/luxon": "^3.3.0",
"@types/marked": "^5.0.0",
"compare-versions": "^6.0.0-rc.2",
"diff2html": "^3.4.35",
"eslint-config-enpitech": "^1.0.9",
"flowbite": "^1.6.6",
"flowbite-react": "^0.4.9",
"highlight.js": "^11.8.0",
"html-react-parser": "^4.0.0",
"luxon": "^3.3.0",
"marked": "^5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.10",
"react-icons": "^4.8.0",
"react-modern-drawer": "^1.2.0",
"react-router-dom": "^6.9.0",
"react-select": "^5.7.4",
"swagger-ui-react": "^5.1.1",
"uuid": "^9.0.1",
"vite-plugin-static-copy": "^0.17.0"
},
"devDependencies": {
"@babel/core": "^7.21.0",
"@storybook/addon-actions": "^7.0.24",
"@storybook/addon-essentials": "^7.0.24",
"@storybook/addon-interactions": "^7.0.24",
"@storybook/addon-links": "^7.0.24",
"@storybook/addon-mdx-gfm": "7.0.24",
"@storybook/addon-styling": "^1.3.2",
"@storybook/react": "^7.0.24",
"@storybook/react-vite": "7.0.24",
"@storybook/testing-library": "^0.2.0",
"@tailwindcss/line-clamp": "^0.4.4",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/swagger-ui-react": "^4.18.0",
"@types/uuid": "^9.0.4",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-storybook": "^0.6.12",
"lint-staged": "^13.2.3",
"postcss": "^8.4.24",
"prettier": "2.8.4",
"react-icons": "^4.8.0",
"storybook": "7.0.24",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.1.0",
"vite-plugin-html-config": "^1.0.11"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "echo \"Error: no test specified\" && exit 1",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"lint": "npx eslint src/",
"lint:fix": "npm run lint -- --fix",
"prettier": "npx prettier src/ --check",
"prettier:fix": "npm run prettier -- --write"
},
"keywords": [],
"author": "",
"license": "Apache-2.0"
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,167 @@
const xhr = new XMLHttpRequest();
const TRACK_EVENT_TYPE = "track";
const IDENTIFY_EVENT_TYPE = "identify";
const BASE_ANALYTIC_MSG = {
method: "POST",
mode: "cors",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
"api-key": "komodor.analytics@admin.com"
},
redirect: "follow",
referrerPolicy: "no-referrer"
};
xhr.onload = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
const status = JSON.parse(xhr.responseText);
const version = status.CurVer;
if (status.Analytics) {
enableDD(version);
enableHeap(version, status.ClusterMode);
enableSegmentBackend(version, status.ClusterMode);
} else {
console.log("Analytics is disabled in this session");
}
}
};
xhr.open("GET", "/status", true);
xhr.send(null);
function enableDD(version) {
(function(h, o, u, n, d) {
h = h[d] = h[d] || {
q: [],
onReady: function(c) {
h.q.push(c);
}
};
d = o.createElement(u);
d.async = true;
d.src = n;
n = o.getElementsByTagName(u)[0];
n.parentNode.insertBefore(d, n);
})(
window,
document,
"script",
"https://www.datadoghq-browser-agent.com/datadog-rum-v4.js",
"DD_RUM"
);
DD_RUM.onReady(function() {
DD_RUM.init({
clientToken: "pub16d64cd1c00cf073ce85af914333bf72",
applicationId: "e75439e5-e1b3-46ba-a9e9-a2e58579a2e2",
site: "datadoghq.com",
service: "helm-dashboard",
version: version,
trackInteractions: true,
trackResources: true,
trackLongTasks: true,
defaultPrivacyLevel: "mask",
sessionReplaySampleRate: 0
});
});
}
function enableHeap(version, inCluster) {
(window.heap = window.heap || []),
(heap.load = function(e, t) {
(window.heap.appid = e), (window.heap.config = t = t || {});
let r = document.createElement("script");
(r.type = "text/javascript"),
(r.async = !0),
(r.src = "https://cdn.heapanalytics.com/js/heap-" + e + ".js");
let a = document.getElementsByTagName("script")[0];
a.parentNode.insertBefore(r, a);
for (
let n = function(e) {
return function() {
heap.push([e].concat(Array.prototype.slice.call(arguments, 0)));
};
},
p = [
"addEventProperties",
"addUserProperties",
"clearEventProperties",
"identify",
"resetIdentity",
"removeEventProperty",
"setEventProperties",
"track",
"unsetEventProperty"
],
o = 0;
o < p.length;
o++
)
heap[p[o]] = n(p[o]);
});
heap.load("4249623943");
window.heap.addEventProperties({
version: version,
installationMode: inCluster ? "cluster" : "local"
});
}
function sendStats(name, prop) {
if (window.heap) {
window.heap.track(name, prop);
}
}
function enableSegmentBackend(version, ClusterMode) {
sendToSegmentThroughAPI(
"helm dashboard loaded",
{ version, installationMode: ClusterMode ? "cluster" : "local" },
TRACK_EVENT_TYPE
);
}
function sendToSegmentThroughAPI(eventName, properties, segmentCallType) {
const userId = getUserId();
try {
sendData(properties, segmentCallType, userId, eventName);
} catch (e) {
console.log("failed sending data to segment", e);
}
}
function sendData(data, eventType, userId, eventName) {
const body = createBody(eventType, userId, data, eventName);
return fetch(`https://api.komodor.com/analytics/segment/${eventType}`, {
...BASE_ANALYTIC_MSG,
body: JSON.stringify(body)
});
}
function createBody(segmentCallType, userId, params, eventName) {
const data = { userId: userId };
if (segmentCallType === IDENTIFY_EVENT_TYPE) {
data["traits"] = params;
} else if (segmentCallType === TRACK_EVENT_TYPE) {
if (!eventName) {
throw new Error("no eventName parameter on segment track call");
}
data["properties"] = params;
data["eventName"] = eventName;
}
return data;
}
const getUserId = (() => {
let userId = null;
return () => {
if (!userId) {
userId = crypto.randomUUID ? crypto.randomUUID() : uuid();
}
return userId;
};
})();
function uuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
let r = Math.random() * 16 | 0, v = c === "x" ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}

92
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="179"
height="164"
viewBox="0 0 179 164"
fill="none"
version="1.1"
id="svg20"
sodipodi:docname="logo.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs24" />
<sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="3.9268293"
inkscape:cx="39.090062"
inkscape:cy="109.24845"
inkscape:window-width="3840"
inkscape:window-height="2059"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg20" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke-width:22.5327"
id="rect967"
width="113.34328"
height="68.482964"
x="32.284447"
y="47.989918" />
<path
d="M106.129 93.4776C112.242 93.4776 117.336 86.7533 117.336 78.1951C117.336 69.637 112.242 62.9127 106.129 62.9127C100.016 62.9127 94.9216 69.637 94.9216 78.1951C94.9216 86.7533 100.016 93.4776 106.129 93.4776Z"
fill="#1347FF"
id="path2" />
<path
d="M84.1221 78.1951C84.1221 86.5495 79.0279 93.4776 72.915 93.4776C66.802 93.4776 61.7078 86.7533 61.7078 78.1951C61.7078 69.8408 66.802 62.9127 72.915 62.9127C79.0279 62.9127 84.1221 69.8408 84.1221 78.1951Z"
fill="#1347FF"
id="path4" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.5848 48.8529L34.607 37.2383H144.437L156.459 48.6492V114.873L144.437 126.488H34.607L22.5848 114.873V48.8529ZM42.3501 49.8717L37.6635 54.3546V109.575L42.3501 113.854H136.897L141.38 109.371V54.1508L136.897 49.668H42.3501V49.8717Z"
fill="#1347FF"
id="path6" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M175.817 28.68L167.87 36.8306L155.848 25.2159L163.794 17.0653C172.353 8.09963 184.579 19.918 175.817 28.68Z"
fill="#1347FF"
id="path8" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M97.7744 9.32228V20.5294H81.0656V9.32228C81.0656 -3.10743 97.9781 -3.10743 97.7744 9.32228Z"
fill="#1347FF"
id="path10" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M81.2693 154.2V142.993H97.9781V154.2C97.9781 166.629 81.0655 166.629 81.2693 154.2Z"
fill="#1347FF"
id="path12" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.2493 17.0653L23.1961 25.2159L11.1739 36.8306L3.22708 28.68C-5.53484 19.918 6.6911 8.09963 15.2493 17.0653Z"
fill="#1347FF"
id="path14" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.0233 134.842L10.9702 126.691L22.9923 138.306L15.0455 146.457C6.48732 155.422 -5.73862 143.604 3.0233 134.842Z"
fill="#1347FF"
id="path16" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M163.591 146.457L155.644 138.306L167.666 126.691L175.613 134.842C184.375 143.604 172.149 155.422 163.591 146.457Z"
fill="#1347FF"
id="path18" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,681 @@
{
"openapi": "3.0.3",
"info": {
"title": "Helm Dashboard API",
"version": ""
},
"tags": [
{
"name": "Releases"
},
{
"name": "Repositories"
},
{
"name": "K8s"
},
{
"name": "Miscellaneous"
}
],
"paths": {
"/api/helm/releases": {
"get": {
"tags": [
"Releases"
],
"description": "Get list of installed releases",
"responses": {
"200": {
"description": "Returns list of installed releases"
}
}
}
},
"/api/helm/releases/{ns}": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace, use '[empty]' if you want to use k8s context default"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Install new release",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"required": true
},
"chart": {
"type": "string",
"required": true
},
"version": {
"type": "string"
},
"values": {
"type": "string",
"description": "Text of values.yaml to use"
},
"preview": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "In case preview=true, the preview diff is generated",
"content": {
"text/plain": {}
}
},
"202": {
"description": "In case preview=false, the actial install is performed and resulting release object is returned",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Upgrade/reconfigure existing release",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"required": true
},
"chart": {
"type": "string",
"required": true
},
"version": {
"type": "string"
},
"values": {
"type": "string",
"description": "Text of values.yaml to use"
},
"preview": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "In case preview=true, the preview diff is generated",
"content": {
"text/plain": {}
}
},
"202": {
"description": "In case preview=false, the actial install is performed and resulting release object is returned",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}/history": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get revision history for release",
"responses": {
"200": {
"description": "List of release revisions"
}
}
}
},
"/api/helm/releases/{ns}/{name}/manifest": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get manifest for release",
"responses": {
"200": {
"description": "Manifest text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/values": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
},
{
"name": "userDefined",
"in": "query",
"description": "If set, only user-defined values will be listed"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get values for release",
"responses": {
"200": {
"description": "Values YAML text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/notes": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get textual notes for release",
"responses": {
"200": {
"description": "Notes text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/resources": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "health",
"in": "query",
"description": "Flag to query k8s health status of resources"
}
],
"get": {
"tags": [
"Releases"
],
"description": "List of installed k8s resources for this release",
"responses": {
"200": {
"description": "Structured list of resources",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}/rollback": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Rollback the release to a previous revision",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"revision": {
"type": "integer"
}
}
}
}
}
},
"responses": {
"202": {
"description": "Rolled back successfully"
}
}
}
},
"/api/helm/releases/{ns}/{name}/test": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Run the tests on a release",
"responses": {
"200": {
"description": "Logs of a test run"
}
}
}
},
"/api/helm/repositories": {
"get": {
"tags": [
"Repositories"
],
"description": "Get list of Helm repositories",
"responses": {
"200": {
"description": "Returns list of Helm repositories"
}
}
},
"post": {
"tags": [
"Repositories"
],
"description": "Adds new repository",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"name",
"url"
]
}
}
}
},
"responses": {
"204": {
"description": "Empty response in case repository were added"
}
}
}
},
"/api/helm/repositories/{repo}": {
"parameters": [
{
"name": "repo",
"in": "path",
"description": "Name of Helm repository"
}
],
"get": {
"tags": [
"Repositories"
],
"description": "Get list of charts in repository",
"responses": {
"200": {
"description": "Returns list of charts"
}
}
},
"post": {
"tags": [
"Repositories"
],
"description": "Update repository from remote",
"responses": {
"204": {
"description": "Empty response"
}
}
},
"delete": {
"tags": [
"Repositories"
],
"description": "Remove repository",
"responses": {
"204": {
"description": "Empty response"
}
}
}
},
"/api/helm/repositories/latestver": {
"parameters": [
{
"name": "name",
"in": "query",
"description": "Name of Helm chart to search for",
"required": true
}
],
"description": "Find the latest available version of specified chart through all the repositories",
"get": {
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The object with latest available version is returned"
},
"204": {
"description": "In case no matching repository found, the response is empty with status 204"
}
}
}
},
"/api/helm/repositories/versions": {
"parameters": [
{
"name": "name",
"in": "query",
"description": "Name of Helm chart to search for",
"required": true
}
],
"get": {
"description": "Get the list of versions for specified chart across the repositories",
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The list if chart versions is returned"
}
}
}
},
"/api/helm/repositories/values": {
"parameters": [
{
"name": "chart",
"in": "query",
"description": "Name of Helm chart to search for, in format of <repository>/<chart-name>",
"required": true
},
{
"name": "version",
"in": "query",
"description": "Version of Helm chart to get values from",
"required": true
}
],
"get": {
"description": "Get the original values.yaml file for the chart",
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The content of values.yaml"
}
}
}
},
"/api/k8s/contexts": {
"get": {
"tags": [
"K8s"
],
"description": "Get list of kubectl contexts configured locally",
"responses": {
"200": {
"description": "Returns list of contexts"
}
}
}
},
"/api/k8s/{kind}/get": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource"
},
{
"name": "name",
"in": "query",
"description": "Name of kubernetes resource",
"required": true
},
{
"name": "namespace",
"in": "query",
"description": "Namespace of kubernetes resource",
"required": true
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"description": "Returns resources information"
}
}
}
},
"/api/k8s/{kind}/list": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource",
"schema": {
"enum": [
"namespaces"
]
}
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"description": "Returns list of resources"
}
}
}
},
"/api/k8s/{kind}/describe": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource"
},
{
"name": "name",
"in": "query",
"description": "Name of kubernetes resource",
"required": true
},
{
"name": "namespace",
"in": "query",
"description": "Namespace of kubernetes resource",
"required": true
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"content": {
"text/plain": {}
},
"description": "Returns describe text"
}
}
}
},
"/": {
"delete": {
"tags": [
"Miscellaneous"
],
"description": "Shuts down the Helm Dashboard application",
"responses": {
"202": {
"description": "Shutdown command has been accepted"
}
}
}
},
"/status": {
"get": {
"tags": [
"Miscellaneous"
],
"description": "Gets application status",
"responses": {
"200": {
"description": "Returns JSON with some options",
"headers": {
"X-Application-Name": {
"description": "A string to self-identify the application"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,155 @@
import {
Chart,
ChartVersion,
Release,
ReleaseHealthStatus,
ReleaseRevision,
Repository,
} from "../data/types";
import { type QueryFunctionContext } from "@tanstack/react-query";
interface ClustersResponse {
AuthInfo: string;
Cluster: string;
IsCurrent: boolean;
Name: string;
Namespace: string;
}
class ApiService {
currentCluster = "";
constructor(protected readonly isMockMode: boolean = false) {}
setCluster = (cluster: string) => {
this.currentCluster = cluster;
};
public async fetchWithDefaults<T>(
url: string,
options?: RequestInit
): Promise<T> {
let response;
if (this.currentCluster) {
const headers = new Headers(options?.headers);
if (!headers.has("X-Kubecontext")) {
headers.set("X-Kubecontext", this.currentCluster);
}
response = await fetch(url, { ...options, headers });
} else {
response = await fetch(url, options);
}
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
let data;
if (!response.headers.get("Content-Type")) {
return {} as T;
} else if (response.headers.get("Content-Type")?.includes("text/plain")) {
data = await response.text();
} else {
data = await response.json();
}
return data;
}
getToolVersion = async () => {
const response = await fetch("/status");
const data = await response.json();
return data;
};
getRepositoryLatestVersion = async (repositoryName: string) => {
const data = await this.fetchWithDefaults(
`/api/helm/repositories/latestver?name=${repositoryName}`
);
return data;
};
getInstalledReleases = async () => {
const data = await this.fetchWithDefaults("/api/helm/releases");
return data;
};
getClusters = async () => {
const response = await fetch("/api/k8s/contexts");
const data = (await response.json()) as ClustersResponse[];
return data;
};
getNamespaces = async () => {
const data = await this.fetchWithDefaults("/api/k8s/namespaces/list");
return data;
};
getRepositories = async () => {
const data = await this.fetchWithDefaults("/api/helm/repositories");
return data;
};
getRepositoryCharts = async ({
queryKey,
}: QueryFunctionContext<Chart[], Repository>) => {
const [, repository] = queryKey;
const data = await this.fetchWithDefaults(
`/api/helm/repositories/${repository}`
);
return data;
};
getChartVersions = async ({
queryKey,
}: QueryFunctionContext<ChartVersion[], Chart>) => {
const [, chart] = queryKey;
const data = await this.fetchWithDefaults(
`/api/helm/repositories/versions?name=${chart.name}`
);
return data;
};
getResourceStatus = async ({
release,
}: {
release: Release;
}): Promise<ReleaseHealthStatus[] | null> => {
if (!release) return null;
const data = await this.fetchWithDefaults(
`/api/helm/releases/${release.namespace}/${release.name}/resources?health=true`
);
return data;
};
getReleasesHistory = async ({
queryKey,
}: QueryFunctionContext<Release[], Release>): Promise<ReleaseRevision[]> => {
const [, params] = queryKey;
if (!params.namespace || !params.chart) return [];
const data = await this.fetchWithDefaults(
`/api/helm/releases/${params.namespace}/${params.chart}/history`
);
return data;
};
getValues = async ({ queryKey }: any) => {
const [, params] = queryKey;
const { namespace, chart, version } = params;
if (!namespace || !chart || !chart.name || version === undefined)
return Promise.reject(new Error("missing parameters"));
const url = `/api/helm/repositories/values?chart=${namespace}/${chart.name}&version=${version}`;
const data = await this.fetchWithDefaults(url);
return data;
};
}
const apiService = new ApiService();
export default apiService;

View File

@@ -0,0 +1,97 @@
export interface HelmRepository {
name: string;
url: string;
}
export interface ChartVersion {
name: string;
version: string;
}
export interface K8sContext {
name: string;
}
export interface K8sResource {
kind: string;
name: string;
namespace: string;
}
export interface Scanner {
id: string;
name: string;
type: string;
}
export interface ScanResult {
scannerType: string;
result: any;
}
export interface ScannersList {
scanners: Scanner[];
}
export interface ScanResults {
[scannerType: string]: ScanResult;
}
export interface ApplicationStatus {
Analytics: boolean;
CacheHitRatio: number;
ClusterMode: boolean;
CurVer: string;
LatestVer: string;
}
export interface KubectlContexts {
contexts: string[];
}
export interface K8sResourceList {
items: K8sResource[];
}
export type HelmRepositories = Repository[];
export interface ChartList {
charts: Chart[];
}
export interface LatestChartVersion {
name: string;
version: string;
app_version: string;
description: string;
installed_namespace: string;
installed_name: string;
repository: string;
urls: string[];
isSuggestedRepo: boolean;
}
export interface ChartVersions {
versions: string[];
}
export interface ValuesYamlText {
content: string;
}
export interface Repository {
name: string;
url: string;
}
export interface Chart {
name: string;
repo: string;
version: string;
appVersion: string;
description: string;
created: string;
digest: string;
urls: string[];
icon: string;
}

65
frontend/src/API/k8s.ts Normal file
View File

@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import { K8sResource, K8sResourceList, KubectlContexts } from "./interfaces";
import apiService from "./apiService";
// Get list of kubectl contexts configured locally
function useGetKubectlContexts(options?: UseQueryOptions<KubectlContexts>) {
return useQuery<KubectlContexts>(
["k8s", "contexts"],
() => apiService.fetchWithDefaults<KubectlContexts>("/api/k8s/contexts"),
options
);
}
// Get resources information
function useGetK8sResource(
kind: string,
name: string,
namespace: string,
options?: UseQueryOptions<K8sResource>
) {
return useQuery<K8sResource>(
["k8s", kind, "get", name, namespace],
() =>
apiService.fetchWithDefaults<K8sResource>(
`/api/k8s/${kind}/get?name=${name}&namespace=${namespace}`
),
options
);
}
// Get list of resources
function useGetK8sResourceList(
kind: string,
options?: UseQueryOptions<K8sResourceList>
) {
return useQuery<K8sResourceList>(
["k8s", kind, "list"],
() =>
apiService.fetchWithDefaults<K8sResourceList>(`/api/k8s/${kind}/list`),
options
);
}
// Get describe text for kubernetes resource
function useGetK8sResourceDescribe(
kind: string,
name: string,
namespace: string,
options?: UseQueryOptions<string>
) {
return useQuery<string>(
["k8s", kind, "describe", name, namespace],
() =>
apiService.fetchWithDefaults<string>(
`/api/k8s/${kind}/describe?name=${name}&namespace=${namespace}`,
{
headers: {
Accept: "text/plain",
},
}
),
options
);
}

34
frontend/src/API/other.ts Normal file
View File

@@ -0,0 +1,34 @@
import {
type UseMutationOptions,
type UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { ApplicationStatus } from "./interfaces";
import apiService from "./apiService";
// Shuts down the Helm Dashboard application
export function useShutdownHelmDashboard(
options?: UseMutationOptions<void, Error>
) {
return useMutation<void, Error>(
() =>
apiService.fetchWithDefaults("/", {
method: "DELETE",
}),
options
);
}
// Gets application status
export function useGetApplicationStatus(
options?: UseQueryOptions<ApplicationStatus>
) {
return useQuery<ApplicationStatus>(
["status"],
() => apiService.fetchWithDefaults<ApplicationStatus>("/status"),
{
...options,
}
);
}

View File

@@ -0,0 +1,333 @@
import {
useQuery,
type UseQueryOptions,
useMutation,
type UseMutationOptions,
} from "@tanstack/react-query";
import { ChartVersion, Release } from "../data/types";
import { LatestChartVersion } from "./interfaces";
import apiService from "./apiService";
import { getVersionManifestFormData } from "./shared";
export const HD_RESOURCE_CONDITION_TYPE = "hdHealth"; // it's our custom condition type, only one exists
export function useGetInstalledReleases(
context: string,
options?: UseQueryOptions<Release[]>
) {
return useQuery<Release[]>(
["installedReleases", context],
() =>
apiService.fetchWithDefaults<Release[]>("/api/helm/releases", {
headers: {
"X-Kubecontext": context,
},
}),
options
);
}
export function useGetReleaseManifest({
namespace,
chartName,
options,
}: {
namespace: string;
chartName: string;
options?: UseQueryOptions<any>;
}) {
return useQuery<any>(
["manifest", namespace, chartName],
() =>
apiService.fetchWithDefaults<any>(
`/api/helm/releases/${namespace}/${chartName}/manifests`
),
options
);
}
// List of installed k8s resources for this release
export function useGetResources(
ns: string,
name: string,
options?: UseQueryOptions<StructuredResources[]>
) {
const { data, ...rest } = useQuery<StructuredResources[]>(
["resources", ns, name],
() =>
apiService.fetchWithDefaults<StructuredResources[]>(
`/api/helm/releases/${ns}/${name}/resources?health=true`
),
options
);
return {
data: data
?.map((resource) => ({
...resource,
status: {
...resource.status,
conditions: resource.status.conditions.filter(
(c) => c.type === HD_RESOURCE_CONDITION_TYPE
),
},
}))
.sort((a, b) => {
const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"];
return (
interestingResources.indexOf(b.kind.toUpperCase()) -
interestingResources.indexOf(a.kind.toUpperCase())
);
}),
...rest,
};
}
export function useGetResourceDescription(
type: string,
ns: string,
name: string,
options?: UseQueryOptions<string>
) {
return useQuery<string>(
["describe", type, ns, name],
() =>
apiService.fetchWithDefaults<string>(
`/api/k8s/${type}/describe?name=${name}&namespace=${ns}`,
{
headers: { "Content-Type": "text/plain; charset=utf-8" },
}
),
options
);
}
export function useGetLatestVersion(
chartName: string,
options?: UseQueryOptions<ChartVersion[]>
) {
return useQuery<ChartVersion[]>(
["latestver", chartName],
() =>
apiService.fetchWithDefaults<ChartVersion[]>(
`/api/helm/repositories/latestver?name=${chartName}`
),
options
);
}
export function useGetVersions(
chartName: string,
options?: UseQueryOptions<LatestChartVersion[]>
) {
return useQuery<LatestChartVersion[]>(
["versions", chartName],
() =>
apiService.fetchWithDefaults<LatestChartVersion[]>(
`/api/helm/repositories/versions?name=${chartName}`
),
options
);
}
export function useGetReleaseInfoByType(
params: ReleaseInfoParams,
additionalParams = "",
options?: UseQueryOptions<string>
) {
const { chart, namespace, tab, revision } = params;
return useQuery<string>(
[tab, namespace, chart, revision, additionalParams],
() =>
apiService.fetchWithDefaults<string>(
`/api/helm/releases/${namespace}/${chart}/${tab}?revision=${revision}${additionalParams}`,
{
headers: { "Content-Type": "text/plain; charset=utf-8" },
}
),
options
);
}
export function useGetDiff(
formData: FormData,
options?: UseQueryOptions<string>
) {
return useQuery<string>(
["diff", formData],
() => {
return apiService.fetchWithDefaults<string>("/diff", {
body: formData,
method: "POST",
});
},
options
);
}
// Rollback the release to a previous revision
export function useRollbackRelease(
options?: UseMutationOptions<
void,
unknown,
{ ns: string; name: string; revision: number }
>
) {
return useMutation<
void,
unknown,
{ ns: string; name: string; revision: number }
>(({ ns, name, revision }) => {
const formData = new FormData();
formData.append("revision", revision.toString());
return apiService.fetchWithDefaults<void>(
`/api/helm/releases/${ns}/${name}/rollback`,
{
method: "POST",
body: formData,
}
);
}, options);
}
// Run the tests on a release
export function useTestRelease(
options?: UseMutationOptions<void, unknown, { ns: string; name: string }>
) {
return useMutation<void, unknown, { ns: string; name: string }>(
({ ns, name }) => {
return apiService.fetchWithDefaults<void>(
`/api/helm/releases/${ns}/${name}/test`,
{
method: "POST",
}
);
},
options
);
}
export function useChartReleaseValues({
namespace = "default",
release,
userDefinedValue,
revision,
options,
version,
}: {
namespace?: string;
release: string;
userDefinedValue?: string;
revision?: number;
version?: string;
options?: UseQueryOptions<any>;
}) {
return useQuery<any>(
["values", namespace, release, userDefinedValue, version],
() =>
apiService.fetchWithDefaults<any>(
`/api/helm/releases/${namespace}/${release}/values?${"userDefined=true"}${
revision ? `&revision=${revision}` : ""
}`,
{
headers: { "Content-Type": "text/plain; charset=utf-8" },
}
),
options
);
}
export const useVersionData = ({
version,
userValues,
chartAddress,
releaseValues,
namespace,
releaseName,
isInstallRepoChart = false,
options,
}: {
version: string;
userValues: string;
chartAddress: string;
releaseValues: string;
namespace: string;
releaseName: string;
isInstallRepoChart?: boolean;
options?: UseQueryOptions<any>;
}) => {
return useQuery(
[
version,
userValues,
chartAddress,
releaseValues,
namespace,
releaseName,
isInstallRepoChart,
],
async () => {
const formData = getVersionManifestFormData({
version,
userValues,
chart: chartAddress,
releaseValues,
releaseName,
});
const fetchUrl = isInstallRepoChart
? `/api/helm/releases/${namespace || "default"}`
: `/api/helm/releases/${
namespace ? namespace : "[empty]"
}${`/${releaseName}`}`;
const data = await apiService.fetchWithDefaults(fetchUrl, {
method: "post",
body: formData,
});
return data;
},
options
);
};
// Request objects
interface ReleaseInfoParams {
chart?: string;
tab: string;
namespace?: string;
revision?: string;
}
export interface StructuredResources {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}
export interface Metadata {
name: string;
namespace: string;
creationTimestamp: any;
labels: any;
}
export interface Spec {
[key: string]: any;
}
export interface Status {
conditions: Condition[];
}
export interface Condition {
type: string;
status: string;
lastProbeTime: any;
lastTransitionTime: any;
reason: string;
message: string;
}

View File

@@ -0,0 +1,72 @@
import {
type UseMutationOptions,
type UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { HelmRepositories } from "./interfaces";
import apiService from "./apiService";
// Get list of Helm repositories
export function useGetRepositories(
options?: UseQueryOptions<HelmRepositories>
) {
return useQuery<HelmRepositories>(
["helm", "repositories"],
() =>
apiService.fetchWithDefaults<HelmRepositories>("/api/helm/repositories"),
options
);
}
// Update repository from remote
export function useUpdateRepo(
repo: string,
options?: UseMutationOptions<void, unknown, void>
) {
return useMutation<void, unknown, void>(() => {
return apiService.fetchWithDefaults<void>(
`/api/helm/repositories/${repo}`,
{
method: "POST",
}
);
}, options);
}
// Remove repository
export function useDeleteRepo(
repo: string,
options?: UseMutationOptions<void, unknown, void>
) {
return useMutation<void, unknown, void>(() => {
return apiService.fetchWithDefaults<void>(
`/api/helm/repositories/${repo}`,
{
method: "DELETE",
}
);
}, options);
}
export function useChartRepoValues({
version,
chart,
}: {
version: string;
chart: string;
}) {
return useQuery<any>(
["helm", "repositories", "values", chart, version],
() =>
apiService.fetchWithDefaults<any>(
`/api/helm/repositories/values?chart=${chart}&version=${version}`,
{
headers: { "Content-Type": "text/plain; charset=utf-8" },
}
),
{
enabled: Boolean(version) && Boolean(chart),
}
);
}

View File

@@ -0,0 +1,54 @@
/** DO NOT DELETE THESE FUNCTIONS - we left this until we support scan ops again */
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
type UseMutationOptions,
type UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { ScanResult, ScanResults, ScannersList } from "./interfaces";
import apiService from "./apiService";
// Get list of discovered scanners
function useGetDiscoveredScanners(options?: UseQueryOptions<ScannersList>) {
return useQuery<ScannersList>(
["scanners"],
() => apiService.fetchWithDefaults<ScannersList>("/api/scanners"),
options
);
}
// Scan manifests using all applicable scanners
function useScanManifests(
manifest: string,
options?: UseMutationOptions<ScanResults, Error, string>
) {
const formData = new FormData();
formData.append("manifest", manifest);
return useMutation<ScanResults, Error, string>(
() =>
apiService.fetchWithDefaults<ScanResults>("/api/scanners/manifests", {
method: "POST",
body: formData,
}),
options
);
}
// Scan specified k8s resource in cluster
function useScanK8sResource(
kind: string,
namespace: string,
name: string,
options?: UseQueryOptions<ScanResults>
) {
return useQuery<ScanResults>(
["scanners", "resource", kind, namespace, name],
() =>
apiService.fetchWithDefaults<ScanResults>(
`/api/scanners/resource/${kind}?namespace=${namespace}&name=${name}`
),
options
);
}

View File

@@ -0,0 +1,64 @@
import { useQuery } from "@tanstack/react-query";
import apiService from "./apiService";
export const getVersionManifestFormData = ({
version,
userValues,
chart,
releaseValues,
releaseName,
}: {
version: string;
userValues?: string;
chart: string;
releaseValues?: string;
releaseName?: string;
}) => {
const formData = new FormData();
// preview needs to come first, for some reason it has a meaning at the backend
formData.append("preview", "true");
formData.append("chart", chart);
formData.append("version", version);
formData.append(
"values",
userValues ? userValues : releaseValues ? releaseValues : ""
);
if (releaseName) {
formData.append("name", releaseName);
}
return formData;
};
export const useDiffData = ({
selectedRepo,
versionsError,
currentVerManifest,
selectedVerData,
chart,
}: {
selectedRepo: string;
versionsError: string;
currentVerManifest: string;
selectedVerData: any;
chart: string;
}) => {
return useQuery(
[selectedRepo, versionsError, chart, currentVerManifest, selectedVerData],
async () => {
const formData = new FormData();
formData.append("a", currentVerManifest);
formData.append("b", selectedVerData.manifest);
const diff = await apiService.fetchWithDefaults("/diff", {
method: "post",
body: formData,
});
return diff;
},
{
enabled: Boolean(selectedVerData),
}
);
};

96
frontend/src/App.css Normal file
View File

@@ -0,0 +1,96 @@
.app-header {
display: flex;
justify-content: space-between;
margin: 5px;
padding: 10px;
}
.header-left {
display: flex;
align-items: center;
justify-content: space-evenly;
flex: 0.6;
}
.header-items {
display: flex;
flex: 0.8;
justify-content: space-evenly;
}
.header-right {
display: flex;
align-items: center;
flex: 0.2;
justify-content: space-around;
}
.redirect {
display: flex;
flex: 0.8;
}
.redirect > img {
margin-right: 5px;
}
.signout-btn {
display: flex;
align-items: center;
}
.signout-btn:hover {
cursor: pointer;
}
.signout-btn > span {
font-weight: bolder;
font-size: x-large;
color: gray;
}
.card {
display: flex;
height: 100vh;
}
.card-left {
flex: 0.2;
margin-top: 5px;
margin-left: 4px;
margin-right: 4px;
}
.card-left > h2,
form {
margin-bottom: 10px;
}
.btn {
margin-bottom: 10px;
}
.card-right {
flex: 0.8;
margin-top: 5px;
margin-left: 4px;
margin-right: 1px;
}
.card-right-header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.card-right-header-right-btn {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.content-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.title {
flex: 0.2;
}
.description {
flex: 0.6;
}
.version {
flex: 0.2;
}
.charts {
display: flex;
justify-content: space-between;
}
.charts > h3 {
flex: 0.2;
}

85
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,85 @@
import Header from "./layout/Header";
import { HashRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
import "./index.css";
import Installed from "./pages/Installed";
import RepositoryPage from "./pages/Repository";
import Revision from "./pages/Revision";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { ErrorAlert, ErrorModalContext } from "./context/ErrorModalContext";
import GlobalErrorModal from "./components/modal/GlobalErrorModal";
import { AppContextProvider } from "./context/AppContext";
import apiService from "./API/apiService";
import DocsPage from "./pages/DocsPage";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
});
const PageLayout = () => {
return (
<div className="flex flex-col h-screen">
<Header />
<div className="bg-body-background bg-no-repeat bg-[url('./assets/body-background.svg')] flex-1">
<Outlet />
</div>
</div>
);
};
const SyncContext: React.FC = () => {
const { context } = useParams();
if (context) {
apiService.setCluster(context);
}
return <Outlet />;
};
export default function App() {
const [shouldShowErrorModal, setShowErrorModal] = useState<
ErrorAlert | undefined
>(undefined);
const value = { shouldShowErrorModal, setShowErrorModal };
return (
<AppContextProvider>
<ErrorModalContext.Provider value={value}>
<QueryClientProvider client={queryClient}>
<HashRouter>
<Routes>
<Route path="docs/" element={<DocsPage />} />
<Route path="*" element={<PageLayout />}>
<Route path=":context/*" element={<SyncContext />}>
<Route path="installed/?" element={<Installed />} />
<Route
path=":namespace/:chart/installed/revision/:revision"
element={<Revision />}
/>
<Route path="repository/" element={<RepositoryPage />} />
<Route
path="repository/:selectedRepo?"
element={<RepositoryPage />}
/>
<Route path="*" element={<Installed />} />
</Route>
<Route path="*" element={<Installed />} />
</Route>
</Routes>
<GlobalErrorModal
isOpen={!!shouldShowErrorModal}
onClose={() => setShowErrorModal(undefined)}
titleText={shouldShowErrorModal?.title || ""}
contentText={shouldShowErrorModal?.msg || ""}
/>
</HashRouter>
</QueryClientProvider>
</ErrorModalContext.Provider>
</AppContextProvider>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512.02 319.26"><path d="M5.9 48.96 48.97 5.89c7.86-7.86 20.73-7.84 28.56 0l178.48 178.48L434.5 5.89c7.86-7.86 20.74-7.82 28.56 0l43.07 43.07c7.83 7.84 7.83 20.72 0 28.56l-192.41 192.4-.36.37-43.07 43.07c-7.83 7.82-20.7 7.86-28.56 0l-43.07-43.07-.36-.37L5.9 77.52c-7.87-7.86-7.87-20.7 0-28.56z"/></svg>

After

Width:  |  Height:  |  Size: 501 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/code-brackets</title><g id="illustration/code-brackets" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#87E6E5" d="M11.4139325,12 C11.7605938,12 12,12.5059743 12,13.3779712 L12,17.4951758 L6.43502246,23.3839989 C5.85499251,23.9978337 5.85499251,25.0021663 6.43502246,25.6160011 L12,31.5048242 L12,35.6220288 C12,36.4939606 11.7605228,37 11.4139325,37 C11.2725831,37 11.1134406,36.9158987 10.9453839,36.7379973 L0.435022463,25.6160011 C-0.145007488,25.0021663 -0.145007488,23.9978337 0.435022463,23.3839989 L10.9453839,12.2620027 C11.1134051,12.0841663 11.2725831,12 11.4139325,12 Z M36.5860675,12 C36.7274169,12 36.8865594,12.0841013 37.0546161,12.2620027 L47.5649775,23.3839989 C48.1450075,23.9978337 48.1450075,25.0021663 47.5649775,25.6160011 L37.0546161,36.7379973 C36.8865949,36.9158337 36.7274169,37 36.5860675,37 C36.2394062,37 36,36.4940257 36,35.6220288 L36,31.5048242 L41.5649775,25.6160011 C42.1450075,25.0021663 42.1450075,23.9978337 41.5649775,23.3839989 L36,17.4951758 L36,13.3779712 C36,12.5060394 36.2394772,12 36.5860675,12 Z"/><rect id="Rectangle-7-Copy-5" width="35.57" height="4" x="5.009" y="22.662" fill="#A0DB77" rx="2" transform="translate(22.793959, 24.662305) rotate(-75.000000) translate(-22.793959, -24.662305)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/comments</title><g id="illustration/comments" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Path" fill="#96D07C" d="M2.52730803,17.9196415 C2.44329744,17.9745167 2.36370847,18.000488 2.29303375,18.000488 C2.1197031,18.000488 2,17.8443588 2,17.5752855 L2,4 C2,1.790861 3.790861,3.23296945e-13 6,3.23296945e-13 L33.9995117,3.23296945e-13 C36.2086507,3.23296945e-13 37.9995117,1.790861 37.9995117,4 L37.9995117,9.999512 C37.9995117,12.208651 36.2086507,13.999512 33.9995117,13.999512 L8,13.999512 C7.83499225,13.999512 7.6723181,13.9895206 7.51254954,13.9701099 L2.52730803,17.9196415 Z"/><path id="Path" fill="#73E1E0" d="M7.51066,44.9703679 L2.52730803,47.9186655 C2.44329744,47.9735407 2.36370847,47.999512 2.29303375,47.999512 C2.1197031,47.999512 2,47.8433828 2,47.5743095 L2,35 C2,32.790861 3.790861,31 6,31 L26,31 C28.209139,31 30,32.790861 30,35 L30,41 C30,43.209139 28.209139,45 26,45 L8,45 C7.8343417,45 7.67103544,44.9899297 7.51066,44.9703679 Z"/><path id="Path" fill="#FFD476" d="M46,19.5 L46,33.0747975 C46,33.3438708 45.8802969,33.5 45.7069663,33.5 C45.6362915,33.5 45.5567026,33.4740287 45.472692,33.4191535 L40.4887103,29.4704446 C40.3285371,29.489956 40.1654415,29.5 40,29.5 L18,29.5 C15.790861,29.5 14,27.709139 14,25.5 L14,19.5 C14,17.290861 15.790861,15.5 18,15.5 L42,15.5 C44.209139,15.5 46,17.290861 46,19.5 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/direction</title><g id="illustration/direction" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#FFD476" d="M23.4917015,33.6030641 L2.93840258,31.4321033 C2.38917316,31.3740904 1.99096346,30.8818233 2.04897631,30.3325939 C2.0747515,30.0885705 2.18934861,29.8625419 2.37095722,29.6975265 L34.2609105,0.721285325 C34.6696614,0.349881049 35.3021022,0.38015648 35.6735064,0.788907393 C35.9232621,1.06377731 36.0001133,1.45442096 35.8730901,1.80341447 L24.5364357,32.9506164 C24.3793473,33.3822133 23.9484565,33.6513092 23.4917015,33.6030641 L23.4917015,33.6030641 Z"/><path id="Combined-Shape-Copy" fill="#FFC445" d="M24.3163597,33.2881029 C24.0306575,33.0138462 23.9337246,32.5968232 24.069176,32.2246735 L35.091923,1.9399251 C35.2266075,1.56988243 35.5659249,1.31333613 35.9586669,1.28460955 C36.5094802,1.24432106 36.9886628,1.65818318 37.0289513,2.20899647 L40.2437557,46.1609256 C40.2644355,46.4436546 40.1641446,46.7218752 39.9678293,46.9263833 C39.5853672,47.3248067 38.9523344,47.3377458 38.5539111,46.9552837 L24.3163597,33.2881029 L24.3163597,33.2881029 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/flow</title><g id="illustration/flow" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#79C9FC" fill-rule="nonzero" d="M30,29 C32.7614237,29 35,26.7614237 35,24 C35,14.6111593 27.3888407,7 18,7 C8.61115925,7 1,14.6111593 1,24 C1,33.3888407 8.61115925,41 18,41 C19.3333404,41 20.6447683,40.8466238 21.9154603,40.5471706 C19.5096374,39.3319645 17.5510566,37.8612875 16.0456579,36.1314815 C14.1063138,33.9030427 12.769443,31.0725999 12.0293806,27.6556449 C11.360469,26.565281 11,25.3082308 11,24 C11,20.1340068 14.1340068,17 18,17 C21.8659932,17 25,20.1340068 25,24 C25,26.125 27.7040312,29 30,29 Z"/><path id="Combined-Shape-Copy" fill="#FFC445" fill-rule="nonzero" d="M42,29 C44.7614237,29 47,26.7614237 47,24 C47,14.6111593 39.3888407,7 30,7 C20.6111593,7 13,14.6111593 13,24 C13,33.3888407 20.6111593,41 30,41 C31.3333404,41 32.6447683,40.8466238 33.9154603,40.5471706 C31.5096374,39.3319645 29.4051056,37.9781963 28.0456579,36.1314815 C26.0625,33.4375 23,27.1875 23,24 C23,20.1340068 26.1340068,17 30,17 C33.8659932,17 37,20.1340068 37,24 C37.02301,26.3435241 39.7040312,29 42,29 Z" transform="translate(30.000000, 24.000000) scale(-1, -1) translate(-30.000000, -24.000000)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
version="1.1"
id="svg886"
sodipodi:docname="helm-gray-50.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs890" />
<sodipodi:namedview
id="namedview888"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="64.928571"
inkscape:cx="13.992299"
inkscape:cy="14"
inkscape:window-width="3840"
inkscape:window-height="2059"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg886" />
<path
d="M7.64558 6.78334C7.61355 6.75296 7.57868 6.72027 7.54422 6.68716C6.83768 6.00837 6.29089 5.22352 5.9606 4.29585C5.86816 4.03621 5.79837 3.7714 5.81075 3.49176C5.81193 3.46522 5.81185 3.4386 5.81368 3.41212C5.8386 3.05114 6.08019 2.86874 6.43295 2.95422C6.54422 2.98304 6.65188 3.0243 6.75391 3.07723C7.13976 3.27073 7.45426 3.55679 7.74346 3.87051C8.25713 4.41715 8.66907 5.05112 8.95989 5.74257C8.96624 5.75904 8.97352 5.77514 8.9817 5.79078C8.98569 5.79798 8.99415 5.8027 9.01302 5.81984C10.3898 4.99388 11.9471 4.51602 13.5501 4.42764C13.5403 4.37858 13.5344 4.34108 13.5251 4.30444C13.3615 3.62754 13.3113 2.9282 13.3765 2.23488C13.4052 1.81941 13.4889 1.40957 13.6254 1.01611C13.6914 0.808874 13.7954 0.615724 13.9321 0.446513C13.9837 0.386073 14.0433 0.33292 14.1092 0.288509C14.1749 0.242074 14.2532 0.216879 14.3337 0.216318C14.4141 0.215757 14.4927 0.239858 14.5591 0.285373C14.6992 0.380274 14.8119 0.510259 14.8861 0.662357C15.0243 0.920312 15.1236 1.19726 15.1808 1.48424C15.3112 2.09109 15.3513 2.71388 15.2997 3.33244C15.2741 3.70997 15.2086 4.08373 15.1042 4.44745C15.503 4.52092 15.8999 4.57785 16.2884 4.67017C16.6761 4.76031 17.0583 4.87256 17.4331 5.00635C17.811 5.14505 18.1807 5.30527 18.5402 5.48623C18.8956 5.66344 19.2338 5.87491 19.5884 6.07638C19.5999 6.05213 19.6167 6.02319 19.628 5.99226C19.9973 4.97054 20.6335 4.06633 21.4704 3.37357C21.6658 3.20585 21.8901 3.07522 22.1324 2.98812C22.1992 2.96486 22.2682 2.94899 22.3384 2.9408C22.6892 2.90063 22.8365 3.12136 22.8624 3.38498C22.8818 3.57931 22.8672 3.77555 22.8191 3.96484C22.6909 4.45379 22.4883 4.92009 22.2182 5.34735C21.8379 5.96177 21.3866 6.51522 20.813 6.96185C20.796 6.97503 20.7812 6.99087 20.7525 7.01731C21.3135 7.53479 21.8132 8.11507 22.2417 8.74672C22.2106 8.75474 22.179 8.76031 22.1471 8.76339C21.5538 8.76423 20.9604 8.76237 20.3671 8.76597C20.3321 8.76513 20.2979 8.75614 20.267 8.73971C20.2362 8.72327 20.2096 8.69986 20.1894 8.67133C18.8949 7.25696 17.1496 6.33565 15.2515 6.06462C14.6902 5.98295 14.1218 5.961 13.5558 5.99914C11.8683 6.10218 10.2539 6.72438 8.93373 7.78053C8.59283 8.04942 8.27485 8.34616 7.98306 8.66767C7.95717 8.6998 7.92412 8.72543 7.88656 8.74252C7.84899 8.75961 7.80796 8.76768 7.76672 8.76609C7.19997 8.76215 6.63318 8.76413 6.0664 8.76413H5.94588C5.98049 8.62928 6.32893 8.15161 6.72336 7.72518C7.01749 7.40716 7.32909 7.10529 7.64558 6.78334Z"
fill="#3B3D45"
id="path874"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
<path
d="M22.0936 19.4833C21.6995 20.035 21.2496 20.5447 20.7511 21.0044C20.7909 21.0375 20.8231 21.0644 20.8554 21.0913C21.7206 21.799 22.3734 22.7321 22.7417 23.7874C22.8397 24.0448 22.8817 24.3201 22.865 24.595C22.8598 24.6654 22.8458 24.7348 22.8231 24.8017C22.7946 24.8964 22.7324 24.9774 22.6482 25.0292C22.564 25.0811 22.4636 25.1002 22.3663 25.083C22.2348 25.0659 22.107 25.028 21.9875 24.9706C21.8051 24.8806 21.6327 24.7715 21.4734 24.645C20.634 23.9554 19.9967 23.0516 19.629 22.0294C19.6185 22.0007 19.607 21.9723 19.5885 21.9243C19.1415 22.2198 18.6734 22.482 18.1878 22.7087C17.7046 22.929 17.205 23.1111 16.6934 23.2533C16.1735 23.3944 15.6436 23.4952 15.1083 23.555C15.1177 23.6021 15.1231 23.6396 15.1328 23.6759C15.3026 24.342 15.3588 25.032 15.2992 25.7167C15.2768 26.146 15.1934 26.5698 15.0515 26.9755C14.9839 27.1434 14.906 27.3069 14.8183 27.4652C14.7827 27.5266 14.7386 27.5826 14.6873 27.6315C14.4644 27.8617 14.1983 27.8636 13.9811 27.6274C13.8952 27.5322 13.8217 27.4265 13.7623 27.3128C13.59 26.9894 13.5013 26.6377 13.438 26.2791C13.3565 25.7895 13.331 25.2923 13.3619 24.797C13.3787 24.4343 13.4328 24.0743 13.5234 23.7226C13.5312 23.6928 13.5384 23.6627 13.5442 23.6325C13.5457 23.6248 13.5406 23.6158 13.5346 23.5911C11.9318 23.5005 10.3754 23.0201 9.0004 22.1915C8.97745 22.2424 8.95773 22.2853 8.93871 22.3284C8.55337 23.2275 7.96033 24.0225 7.20825 24.648C7.00915 24.8181 6.78047 24.9502 6.53361 25.0377C6.41792 25.0841 6.29167 25.0977 6.16876 25.0769C6.10089 25.0647 6.03733 25.0352 5.9843 24.9911C5.93126 24.9471 5.89056 24.89 5.86618 24.8255C5.78687 24.6338 5.80092 24.4343 5.82785 24.2366C5.87203 23.9609 5.95317 23.6925 6.06908 23.4385C6.41254 22.6386 6.91804 21.9186 7.55371 21.3238C7.57941 21.2995 7.60578 21.2758 7.63103 21.251C7.63906 21.2395 7.64591 21.2272 7.65149 21.2143C7.05256 20.6949 6.51739 20.1062 6.05723 19.4606C6.11237 19.4561 6.14925 19.4505 6.18615 19.4505C6.77495 19.4499 7.36377 19.452 7.95254 19.448C7.99171 19.4469 8.03065 19.4544 8.06652 19.4702C8.1024 19.4859 8.13431 19.5095 8.15994 19.5391C8.80004 20.1976 9.54586 20.7444 10.3665 21.1567C11.2347 21.6034 12.1787 21.884 13.1499 21.984C15.7886 22.2405 18.0585 21.4405 19.9595 19.5841C20.002 19.5382 20.0541 19.5021 20.1121 19.4785C20.1701 19.4548 20.2325 19.4442 20.2951 19.4473C20.844 19.4541 21.393 19.4501 21.9419 19.4501H22.0838L22.0936 19.4833Z"
fill="#3B3D45"
id="path876"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
<path
d="M19.6412 11.0745C19.7972 11.0745 19.9475 11.0851 20.0956 11.0717C20.2633 11.0565 20.3834 11.1165 20.5057 11.2292C21.212 11.88 21.9257 12.5229 22.637 13.1683C22.6728 13.2008 22.7093 13.2324 22.7552 13.273C22.798 13.2362 22.8381 13.2034 22.8764 13.1686C23.6096 12.5012 24.3422 11.8331 25.0743 11.1645C25.1047 11.1332 25.1414 11.1088 25.182 11.0929C25.2226 11.077 25.2662 11.07 25.3097 11.0724C25.49 11.0797 25.6707 11.0745 25.8608 11.0745V16.9751C25.7643 17.0033 24.4678 17.0089 24.313 16.9785V13.9903L24.283 13.976C23.7784 14.4362 23.2738 14.8964 22.7577 15.3671C22.241 14.9017 21.7305 14.4418 21.22 13.9819L21.1906 13.9927C21.1893 14.2421 21.1902 14.4915 21.19 14.7409C21.1899 14.9888 21.1899 15.2367 21.19 15.4846V16.9894H19.654C19.6253 16.8901 19.6119 11.4083 19.6412 11.0745Z"
fill="#3B3D45"
id="path878"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
<path
d="M5.46747 11.0815H6.99421C7.02504 11.1797 7.03107 16.8479 6.99951 16.9909H5.47141C5.46299 16.6155 5.46875 16.2414 5.4677 15.8675C5.46665 15.4966 5.46747 15.1258 5.46747 14.7453H3.57536V16.9708C3.46003 17.0052 2.15667 17.0085 2.0271 16.9777V11.0822H3.56924V13.1648C3.67944 13.1966 5.30094 13.2025 5.46607 13.172C5.46653 13.0053 5.46719 12.8346 5.46742 12.6638C5.46765 12.4867 5.46767 12.3096 5.46747 12.1325C5.46747 11.9599 5.46747 11.7872 5.46747 11.6145C5.46747 11.4422 5.46747 11.2698 5.46747 11.0815Z"
fill="#3B3D45"
id="path880"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
<path
d="M8.82422 16.9889V11.0991C8.91477 11.0695 12.2707 11.0579 12.4901 11.0877V12.3429C12.4409 12.3464 12.3901 12.3531 12.3393 12.3532C11.7417 12.354 11.144 12.3541 10.5463 12.3537H10.3801V13.33H12.2476V14.6287H10.3968C10.3659 14.7399 10.3574 15.5145 10.3825 15.7289C10.4298 15.7321 10.4805 15.7384 10.5312 15.7384C11.1289 15.7391 11.7265 15.7393 12.3242 15.7389H12.4905V16.9889H8.82422Z"
fill="#3B3D45"
id="path882"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
<path
d="M14.2399 16.991C14.2118 16.833 14.2175 11.1893 14.2453 11.082H15.7664V15.4368C15.832 15.4403 15.8835 15.4452 15.935 15.4452C16.5371 15.4458 17.1392 15.4459 17.7413 15.4456C17.7932 15.4456 17.845 15.4456 17.9042 15.4456V16.991L14.2399 16.991Z"
fill="#3B3D45"
id="path884"
style="opacity:1;fill:#3b3d45;fill-opacity:0.5" />
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -0,0 +1,5 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M47.1106 45.775C49.9376 45.775 52.2294 42.4134 52.2294 38.2667C52.2294 34.1199 49.9376 30.7583 47.1106 30.7583C44.2835 30.7583 41.9918 34.1199 41.9918 38.2667C41.9918 42.4134 44.2835 45.775 47.1106 45.775Z" fill="#1347FF"/>
<path d="M37.0077 38.2667C37.0077 42.4134 34.7159 45.775 31.8888 45.775C29.0618 45.775 26.77 42.4134 26.77 38.2667C26.77 34.12 29.0618 30.7584 31.8888 30.7584C34.7159 30.7584 37.0077 34.12 37.0077 38.2667Z" fill="#1347FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 23.7087L14.4407 18H64.5597L70 23.6838V56.3162L64.5597 62H14.4403L9 56.3162V23.7087ZM17.9923 24.2134L15.8664 26.3861V53.6519L17.9923 55.8246H61.0736L63.1212 53.6483V26.3897L61.0736 24.2134H17.9923Z" fill="#1347FF"/>
</svg>

After

Width:  |  Height:  |  Size: 827 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,8 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.64558 6.78334C7.61355 6.75296 7.57868 6.72027 7.54422 6.68716C6.83768 6.00837 6.29089 5.22352 5.9606 4.29585C5.86816 4.03621 5.79837 3.7714 5.81075 3.49176C5.81193 3.46522 5.81185 3.4386 5.81368 3.41212C5.8386 3.05114 6.08019 2.86874 6.43295 2.95422C6.54422 2.98304 6.65188 3.0243 6.75391 3.07723C7.13976 3.27073 7.45426 3.55679 7.74346 3.87051C8.25713 4.41715 8.66907 5.05112 8.95989 5.74257C8.96624 5.75904 8.97352 5.77514 8.9817 5.79078C8.98569 5.79798 8.99415 5.8027 9.01302 5.81984C10.3898 4.99388 11.9471 4.51602 13.5501 4.42764C13.5403 4.37858 13.5344 4.34108 13.5251 4.30444C13.3615 3.62754 13.3113 2.9282 13.3765 2.23488C13.4052 1.81941 13.4889 1.40957 13.6254 1.01611C13.6914 0.808874 13.7954 0.615724 13.9321 0.446513C13.9837 0.386073 14.0433 0.33292 14.1092 0.288509C14.1749 0.242074 14.2532 0.216879 14.3337 0.216318C14.4141 0.215757 14.4927 0.239858 14.5591 0.285373C14.6992 0.380274 14.8119 0.510259 14.8861 0.662357C15.0243 0.920312 15.1236 1.19726 15.1808 1.48424C15.3112 2.09109 15.3513 2.71388 15.2997 3.33244C15.2741 3.70997 15.2086 4.08373 15.1042 4.44745C15.503 4.52092 15.8999 4.57785 16.2884 4.67017C16.6761 4.76031 17.0583 4.87256 17.4331 5.00635C17.811 5.14505 18.1807 5.30527 18.5402 5.48623C18.8956 5.66344 19.2338 5.87491 19.5884 6.07638C19.5999 6.05213 19.6167 6.02319 19.628 5.99226C19.9973 4.97054 20.6335 4.06633 21.4704 3.37357C21.6658 3.20585 21.8901 3.07522 22.1324 2.98812C22.1992 2.96486 22.2682 2.94899 22.3384 2.9408C22.6892 2.90063 22.8365 3.12136 22.8624 3.38498C22.8818 3.57931 22.8672 3.77555 22.8191 3.96484C22.6909 4.45379 22.4883 4.92009 22.2182 5.34735C21.8379 5.96177 21.3866 6.51522 20.813 6.96185C20.796 6.97503 20.7812 6.99087 20.7525 7.01731C21.3135 7.53479 21.8132 8.11507 22.2417 8.74672C22.2106 8.75474 22.179 8.76031 22.1471 8.76339C21.5538 8.76423 20.9604 8.76237 20.3671 8.76597C20.3321 8.76513 20.2979 8.75614 20.267 8.73971C20.2362 8.72327 20.2096 8.69986 20.1894 8.67133C18.8949 7.25696 17.1496 6.33565 15.2515 6.06462C14.6902 5.98295 14.1218 5.961 13.5558 5.99914C11.8683 6.10218 10.2539 6.72438 8.93373 7.78053C8.59283 8.04942 8.27485 8.34616 7.98306 8.66767C7.95717 8.6998 7.92412 8.72543 7.88656 8.74252C7.84899 8.75961 7.80796 8.76768 7.76672 8.76609C7.19997 8.76215 6.63318 8.76413 6.0664 8.76413H5.94588C5.98049 8.62928 6.32893 8.15161 6.72336 7.72518C7.01749 7.40716 7.32909 7.10529 7.64558 6.78334Z" fill="#3B3D45"/>
<path d="M22.0936 19.4833C21.6995 20.035 21.2496 20.5447 20.7511 21.0044C20.7909 21.0375 20.8231 21.0644 20.8554 21.0913C21.7206 21.799 22.3734 22.7321 22.7417 23.7874C22.8397 24.0448 22.8817 24.3201 22.865 24.595C22.8598 24.6654 22.8458 24.7348 22.8231 24.8017C22.7946 24.8964 22.7324 24.9774 22.6482 25.0292C22.564 25.0811 22.4636 25.1002 22.3663 25.083C22.2348 25.0659 22.107 25.028 21.9875 24.9706C21.8051 24.8806 21.6327 24.7715 21.4734 24.645C20.634 23.9554 19.9967 23.0516 19.629 22.0294C19.6185 22.0007 19.607 21.9723 19.5885 21.9243C19.1415 22.2198 18.6734 22.482 18.1878 22.7087C17.7046 22.929 17.205 23.1111 16.6934 23.2533C16.1735 23.3944 15.6436 23.4952 15.1083 23.555C15.1177 23.6021 15.1231 23.6396 15.1328 23.6759C15.3026 24.342 15.3588 25.032 15.2992 25.7167C15.2768 26.146 15.1934 26.5698 15.0515 26.9755C14.9839 27.1434 14.906 27.3069 14.8183 27.4652C14.7827 27.5266 14.7386 27.5826 14.6873 27.6315C14.4644 27.8617 14.1983 27.8636 13.9811 27.6274C13.8952 27.5322 13.8217 27.4265 13.7623 27.3128C13.59 26.9894 13.5013 26.6377 13.438 26.2791C13.3565 25.7895 13.331 25.2923 13.3619 24.797C13.3787 24.4343 13.4328 24.0743 13.5234 23.7226C13.5312 23.6928 13.5384 23.6627 13.5442 23.6325C13.5457 23.6248 13.5406 23.6158 13.5346 23.5911C11.9318 23.5005 10.3754 23.0201 9.0004 22.1915C8.97745 22.2424 8.95773 22.2853 8.93871 22.3284C8.55337 23.2275 7.96033 24.0225 7.20825 24.648C7.00915 24.8181 6.78047 24.9502 6.53361 25.0377C6.41792 25.0841 6.29167 25.0977 6.16876 25.0769C6.10089 25.0647 6.03733 25.0352 5.9843 24.9911C5.93126 24.9471 5.89056 24.89 5.86618 24.8255C5.78687 24.6338 5.80092 24.4343 5.82785 24.2366C5.87203 23.9609 5.95317 23.6925 6.06908 23.4385C6.41254 22.6386 6.91804 21.9186 7.55371 21.3238C7.57941 21.2995 7.60578 21.2758 7.63103 21.251C7.63906 21.2395 7.64591 21.2272 7.65149 21.2143C7.05256 20.6949 6.51739 20.1062 6.05723 19.4606C6.11237 19.4561 6.14925 19.4505 6.18615 19.4505C6.77495 19.4499 7.36377 19.452 7.95254 19.448C7.99171 19.4469 8.03065 19.4544 8.06652 19.4702C8.1024 19.4859 8.13431 19.5095 8.15994 19.5391C8.80004 20.1976 9.54586 20.7444 10.3665 21.1567C11.2347 21.6034 12.1787 21.884 13.1499 21.984C15.7886 22.2405 18.0585 21.4405 19.9595 19.5841C20.002 19.5382 20.0541 19.5021 20.1121 19.4785C20.1701 19.4548 20.2325 19.4442 20.2951 19.4473C20.844 19.4541 21.393 19.4501 21.9419 19.4501H22.0838L22.0936 19.4833Z" fill="#3B3D45"/>
<path d="M19.6412 11.0745C19.7972 11.0745 19.9475 11.0851 20.0956 11.0717C20.2633 11.0565 20.3834 11.1165 20.5057 11.2292C21.212 11.88 21.9257 12.5229 22.637 13.1683C22.6728 13.2008 22.7093 13.2324 22.7552 13.273C22.798 13.2362 22.8381 13.2034 22.8764 13.1686C23.6096 12.5012 24.3422 11.8331 25.0743 11.1645C25.1047 11.1332 25.1414 11.1088 25.182 11.0929C25.2226 11.077 25.2662 11.07 25.3097 11.0724C25.49 11.0797 25.6707 11.0745 25.8608 11.0745V16.9751C25.7643 17.0033 24.4678 17.0089 24.313 16.9785V13.9903L24.283 13.976C23.7784 14.4362 23.2738 14.8964 22.7577 15.3671C22.241 14.9017 21.7305 14.4418 21.22 13.9819L21.1906 13.9927C21.1893 14.2421 21.1902 14.4915 21.19 14.7409C21.1899 14.9888 21.1899 15.2367 21.19 15.4846V16.9894H19.654C19.6253 16.8901 19.6119 11.4083 19.6412 11.0745Z" fill="#3B3D45"/>
<path d="M5.46747 11.0815H6.99421C7.02504 11.1797 7.03107 16.8479 6.99951 16.9909H5.47141C5.46299 16.6155 5.46875 16.2414 5.4677 15.8675C5.46665 15.4966 5.46747 15.1258 5.46747 14.7453H3.57536V16.9708C3.46003 17.0052 2.15667 17.0085 2.0271 16.9777V11.0822H3.56924V13.1648C3.67944 13.1966 5.30094 13.2025 5.46607 13.172C5.46653 13.0053 5.46719 12.8346 5.46742 12.6638C5.46765 12.4867 5.46767 12.3096 5.46747 12.1325C5.46747 11.9599 5.46747 11.7872 5.46747 11.6145C5.46747 11.4422 5.46747 11.2698 5.46747 11.0815Z" fill="#3B3D45"/>
<path d="M8.82422 16.9889V11.0991C8.91477 11.0695 12.2707 11.0579 12.4901 11.0877V12.3429C12.4409 12.3464 12.3901 12.3531 12.3393 12.3532C11.7417 12.354 11.144 12.3541 10.5463 12.3537H10.3801V13.33H12.2476V14.6287H10.3968C10.3659 14.7399 10.3574 15.5145 10.3825 15.7289C10.4298 15.7321 10.4805 15.7384 10.5312 15.7384C11.1289 15.7391 11.7265 15.7393 12.3242 15.7389H12.4905V16.9889H8.82422Z" fill="#3B3D45"/>
<path d="M14.2399 16.991C14.2118 16.833 14.2175 11.1893 14.2453 11.082H15.7664V15.4368C15.832 15.4403 15.8835 15.4452 15.935 15.4452C16.5371 15.4458 17.1392 15.4459 17.7413 15.4456C17.7932 15.4456 17.845 15.4456 17.9042 15.4456V16.991L14.2399 16.991Z" fill="#3B3D45"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/plugin</title><g id="illustration/plugin" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#79C9FC" d="M26,15.3994248 C26,15.4091303 26,15.4188459 26,15.4285714 L26,21.4694881 C25.8463595,21.4969567 25.6941676,21.51275 25.5873784,21.51275 C25.4974117,21.51275 25.4230979,21.4768034 25.377756,21.4206259 L25.2660784,21.2822603 L25.1317423,21.1657666 C24.2436317,20.3956144 23.100098,19.9633214 21.895551,19.9633214 C19.2039137,19.9633214 17,22.1075558 17,24.7804643 C17,27.4533728 19.2039137,29.5976071 21.895551,29.5976071 C23.1972122,29.5976071 24.3149423,29.2878193 25.1231445,28.3613697 C25.4542273,27.9818463 25.568273,27.9073214 25.5873784,27.9073214 C25.681532,27.9073214 25.8352452,27.9239643 26,27.9524591 L26,32.5714286 C26,32.5811541 26,32.5908697 26,32.6005752 L26,33 C26,35.209139 24.209139,37 22,37 L4,37 C1.790861,37 0,35.209139 0,33 L0,15 C0,12.790861 1.790861,11 4,11 L22,11 C24.209139,11 26,12.790861 26,15 L26,15.3994248 Z"/><path id="Path" fill="#87E6E5" d="M27.9998779,32.5714286 C27.9998779,33.3604068 28.6572726,34 29.4682101,34 L46.5315458,34 C47.3424832,34 47.9998779,33.3604068 47.9998779,32.5714286 L47.9998779,15.4285714 C47.9998779,14.6395932 47.3424832,14 46.5315458,14 L29.4682101,14 C28.6572726,14 27.9998779,14.6395932 27.9998779,15.4285714 L27.9998779,21.8355216 C27.9334367,22.2650514 27.8567585,22.6454496 27.746391,22.8084643 C27.4245309,23.2838571 26.2402709,23.51275 25.5873784,23.51275 C24.8705773,23.51275 24.2322714,23.1857725 23.8214379,22.6767605 C23.3096996,22.2329909 22.6349941,21.9633214 21.895551,21.9633214 C20.2963823,21.9633214 19,23.2245992 19,24.7804643 C19,26.3363293 20.2963823,27.5976071 21.895551,27.5976071 C22.5398535,27.5976071 23.2399343,27.477727 23.6160247,27.0466112 C24.1396029,26.4464286 24.7367044,25.9073214 25.5873784,25.9073214 C26.2402709,25.9073214 27.5912951,26.1766031 27.8226692,26.6116071 C27.8819199,26.7230038 27.9403239,26.921677 27.9998779,27.1556219 L27.9998779,32.5714286 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img"
class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228">
<path fill="#00D8FF"
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/repo</title><g id="illustration/repo" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Rectangle-62-Copy" fill="#B7F0EF" d="M27.2217723,9.04506931 L41.2217723,6.2682098 C43.3886973,5.83840648 45.4937616,7.2466219 45.9235649,9.41354696 C45.9743993,9.66983721 46,9.93049166 46,10.1917747 L46,32.581381 C46,34.4904961 44.650862,36.1335143 42.7782277,36.5049459 L28.7782277,39.2818054 C26.6113027,39.7116087 24.5062384,38.3033933 24.0764351,36.1364682 C24.0256007,35.880178 24,35.6195235 24,35.3582405 L24,12.9686342 C24,11.0595191 25.349138,9.4165009 27.2217723,9.04506931 Z" opacity=".7"/><path id="Combined-Shape" fill="#87E6E5" d="M6.77822775,6.2682098 L20.7782277,9.04506931 C22.650862,9.4165009 24,11.0595191 24,12.9686342 L24,35.3582405 C24,37.5673795 22.209139,39.3582405 20,39.3582405 C19.738717,39.3582405 19.4780625,39.3326398 19.2217723,39.2818054 L5.22177225,36.5049459 C3.34913798,36.1335143 2,34.4904961 2,32.581381 L2,10.1917747 C2,7.98263571 3.790861,6.19177471 6,6.19177471 C6.26128305,6.19177471 6.5219375,6.21737537 6.77822775,6.2682098 Z"/><path id="Rectangle-63-Copy-2" fill="#61C1FD" d="M22,10 C23.1666667,10.2291667 24.0179036,10.625 24.5537109,11.1875 C25.0895182,11.75 25.5716146,12.875 26,14.5625 C26,29.3020833 26,37.5208333 26,39.21875 C26,40.9166667 26.4241536,42.9583333 27.2724609,45.34375 L24.5537109,41.875 L22.9824219,45.34375 C22.327474,43.1979167 22,41.2291667 22,39.4375 C22,37.6458333 22,27.8333333 22,10 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/stackalt</title><g id="illustration/stackalt" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#FFAE00" d="M23.8628277,0 L23.8628277,48 L3.32291648,36.2491883 L3.32155653,11.9499781 L23.8628277,0 Z M23.8670509,0 L44.408322,11.9499781 L44.4069621,36.2491883 L23.8670509,48 L23.8670509,0 Z" opacity=".196"/><path id="Rectangle-46-Copy-3" fill="#66BF3C" d="M15.8232279,19.1155258 L24.7368455,21.4714881 C29.6053842,22.7582937 33.4077423,26.5606518 34.694548,31.4291905 L37.0505103,40.3428082 C37.6150232,42.4786032 36.3412474,44.6676353 34.2054524,45.2321482 C33.5569474,45.4035549 32.87575,45.4091235 32.2245294,45.2483418 L23.3459013,43.0562718 C18.2976962,41.809906 14.3561301,37.8683399 13.1097642,32.8201348 L10.9176943,23.9415066 C10.3881737,21.7967682 11.6975664,19.6288529 13.8423049,19.0993322 C14.4935255,18.9385505 15.1747229,18.9441191 15.8232279,19.1155258 Z" opacity=".5" transform="translate(23.999997, 32.166058) rotate(-45.000000) translate(-23.999997, -32.166058)"/><path id="Rectangle-46-Copy-2" fill="#FFAE00" d="M15.8232279,11.2216893 L24.7368455,13.5776516 C29.6053842,14.8644572 33.4077423,18.6668153 34.694548,23.5353541 L37.0505103,32.4489717 C37.6150232,34.5847667 36.3412474,36.7737988 34.2054524,37.3383117 C33.5569474,37.5097184 32.87575,37.515287 32.2245294,37.3545053 L23.3459013,35.1624353 C18.2976962,33.9160695 14.3561301,29.9745034 13.1097642,24.9262983 L10.9176943,16.0476701 C10.3881737,13.9029317 11.6975664,11.7350164 13.8423049,11.2054957 C14.4935255,11.044714 15.1747229,11.0502826 15.8232279,11.2216893 Z" opacity=".5" transform="translate(23.999997, 24.272222) rotate(-45.000000) translate(-23.999997, -24.272222)"/><path id="Rectangle-46-Copy" fill="#FC521F" d="M15.8232279,3.32785281 L24.7368455,5.68381509 C29.6053842,6.97062075 33.4077423,10.7729788 34.694548,15.6415176 L37.0505103,24.5551352 C37.6150232,26.6909302 36.3412474,28.8799623 34.2054524,29.4444752 C33.5569474,29.6158819 32.87575,29.6214505 32.2245294,29.4606688 L23.3459013,27.2685988 C18.2976962,26.022233 14.3561301,22.0806669 13.1097642,17.0324618 L10.9176943,8.15383364 C10.3881737,6.00909519 11.6975664,3.84117987 13.8423049,3.31165925 C14.4935255,3.15087753 15.1747229,3.15644615 15.8232279,3.32785281 Z" opacity=".5" transform="translate(23.999997, 16.378385) rotate(-45.000000) translate(-23.999997, -16.378385)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,44 @@
/*
* @file Badge.stories.tsx
* @description Badge stories, using Storybook.
* We create a story for the componenet badge,
* and we can use it to test the component in Storybook.
* There, we can see the component in different states, and
* play with the props to see how it behaves.
* We'll use a generic story for the component, and we'll
* use the args to pass the props.
* We'll use a template to create the story.
* Refer to Badge.tsx and the BadgeProps interface to see what props
* the component accepts. The story works with the same props.
*
* @see https://storybook.js.org/docs/react/writing-stories/introduction
*/
import { ComponentStory } from "@storybook/react";
import Badge, { BadgeProps } from "./Badge";
// We create a generic template for the component.
const Template: ComponentStory<typeof Badge> = (args: BadgeProps) => (
<Badge {...args} />
);
// We export the story, and we pass the template to it. For now,
// we are only going to use the default story.
export const Default = Template.bind({});
// We set the props for the story. Recall that the props are the same as the
// ones in BadgeProps, which we impoted.
Default.args = {
type: "success",
children: "Success",
};
// We set the metadata for the story.
// Refer to https://storybook.js.org/docs/react/writing-stories/introduction
// for more information.
export default {
title: "Badge",
component: Badge,
args: {
type: "success",
children: "Success",
},
};

View File

@@ -0,0 +1,72 @@
/**
* This is a generic badge component.
* By passing props you can customize the badge.
* The basic custom types are:
* warning, success, error, info, default.
* You can use this badge like any other html element.
*
* behind the scenes, it uses tailwindcss classes to imlement the badge,
* with the correct styles.
*
* @example
* <Badge type="warning">Warning</Badge>
*
* @param {string} type - The type of the badge.
* @param {string} children - The content of the badge.
* @returns {JSX.Element} - The badge component.
*
*
*/
export type BadgeCode = "success" | "warning" | "error" | "unknown";
export const BadgeCodes = Object.freeze({
ERROR: "error",
WARNING: "warning",
SUCCESS: "success",
UNKNOWN: "unknown",
});
export interface BadgeProps {
type: BadgeCode;
children: React.ReactNode;
additionalClassNames?: string;
}
export default function Badge(props: BadgeProps): JSX.Element {
const colorVariants = {
[BadgeCodes.SUCCESS]: "bg-text-success text-black-800",
[BadgeCodes.WARNING]: "bg-text-warning text-white",
[BadgeCodes.ERROR]: "bg-text-danger text-white",
[BadgeCodes.UNKNOWN]: "bg-secondary text-danger",
};
const badgeBase =
"inline-flex items-center px-1 py-1 rounded text-xs font-light";
const badgeElem = (
<span
className={`${badgeBase} ${colorVariants[props.type]} ${
props.additionalClassNames ?? ""
}`}
>
{props.children}
</span>
);
return badgeElem;
}
export const getBadgeType = (status: string): BadgeCode => {
if (status === "Unknown") {
return BadgeCodes.UNKNOWN;
} else if (
status === "Healthy" ||
status.toLowerCase().includes("exists") ||
status === "available"
) {
return BadgeCodes.SUCCESS;
} else if (status === "Progressing") {
return BadgeCodes.WARNING;
} else {
return BadgeCodes.ERROR;
}
};

View File

@@ -0,0 +1,35 @@
/* eslint-disable no-console */
// Status.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import Button, { ButtonProps } from "./Button";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "Button",
component: Button,
} as ComponentMeta<typeof Button>;
// Recall that Button has 'props' which is of type ButtonProps
// We want to past theme to the story with the name 'Default', so we
// create a template for it.
// We want to declare default values for the props, so we create a
// default args object.
const Template: ComponentStory<typeof Button> = (args: ButtonProps) => (
<Button {...args} />
);
export const Default = Template.bind({});
Default.args = {
children: (
<>
<span>&uarr;</span>
<span>Update</span>
</>
),
onClick: () => {
console.log("click");
},
};

View File

@@ -0,0 +1,36 @@
/**
* @file Button.tsx
* This component is a generic button component using tailwind.
* You can include an optional icon.
* You can pass the action to be done when the button is clicked using
* the onClick prop.
*
* Props:
*
* @param children: children
* @param onClick: () => void
*
*
*/
// this is a type declaration for the action prop.
// it is a function that takes a string as an argument and returns void.
export interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
className?: string;
}
export default function Button(props: ButtonProps): JSX.Element {
return (
<>
<button
onClick={props.onClick}
className={`${props.className} bg-white border border-gray-300 hover:bg-gray-50 text-black py-1 px-4 rounded `}
disabled={props.disabled}
>
{props.children}
</button>
</>
);
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable no-console */
// ClustersListBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ClustersList from "./ClustersList";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ClustersList",
component: ClustersList,
} as ComponentMeta<typeof ClustersList>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ClustersList> = () => (
<ClustersList
filteredNamespaces={[""]}
installedReleases={[]}
onClusterChange={() => {
console.log("onClusterChange called");
}}
selectedCluster={""}
/>
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,165 @@
import { useMemo } from "react";
import { Cluster, Release } from "../data/types";
import apiService from "../API/apiService";
import { useQuery } from "@tanstack/react-query";
import useCustomSearchParams from "../hooks/useCustomSearchParams";
import { useAppContext } from "../context/AppContext";
import { v4 as uuidv4 } from "uuid";
type ClustersListProps = {
onClusterChange: (clusterName: string) => void;
selectedCluster: string;
filteredNamespaces: string[];
installedReleases?: Release[];
};
function getCleanClusterName(rawClusterName: string) {
if (rawClusterName.indexOf("arn") === 0) {
// AWS cluster
const clusterSplit = rawClusterName.split(":");
const clusterName = clusterSplit.slice(-1)[0].replace("cluster/", "");
const region = clusterSplit.at(-3);
return region + "/" + clusterName + " [AWS]";
}
if (rawClusterName.indexOf("gke") === 0) {
// GKE cluster
return (
rawClusterName.split("_").at(-2) +
"/" +
rawClusterName.split("_").at(-1) +
" [GKE]"
);
}
return rawClusterName;
}
function ClustersList({
installedReleases,
selectedCluster,
filteredNamespaces,
onClusterChange,
}: ClustersListProps) {
const { upsertSearchParams, removeSearchParam } = useCustomSearchParams();
const { clusterMode } = useAppContext();
const { data: clusters } = useQuery<Cluster[]>({
queryKey: ["clusters", selectedCluster],
queryFn: apiService.getClusters,
onSuccess(data) {
const sortedData = data?.sort((a, b) =>
getCleanClusterName(a.Name).localeCompare(getCleanClusterName(b.Name))
);
if (sortedData && sortedData.length > 0 && !selectedCluster) {
onClusterChange(sortedData[0].Name);
}
if (selectedCluster) {
const cluster = data.find(
(cluster) => getCleanClusterName(cluster.Name) === selectedCluster
);
if (!filteredNamespaces && cluster?.Namespace) {
upsertSearchParams("filteredNamespace", cluster.Namespace);
}
}
},
});
const namespaces = useMemo(() => {
const mapNamespaces = new Map<string, number>();
installedReleases?.forEach((release) => {
const amount = mapNamespaces.get(release.namespace)
? Number(mapNamespaces.get(release.namespace)) + 1
: 1;
mapNamespaces.set(release.namespace, amount);
});
return Array.from(mapNamespaces, ([key, value]) => ({
id: uuidv4(),
name: key,
amount: value,
}));
}, [installedReleases]);
const onNamespaceChange = (namespace: string) => {
const newSelectedNamespaces = filteredNamespaces?.includes(namespace)
? filteredNamespaces?.filter((ns) => ns !== namespace)
: [...(filteredNamespaces ?? []), namespace];
removeSearchParam("filteredNamespace");
if (newSelectedNamespaces.length > 0) {
upsertSearchParams(
"filteredNamespace",
newSelectedNamespaces.map((ns) => ns).join("+")
);
}
};
return (
<div className="bg-white flex flex-col p-2 rounded custom-shadow text-cluster-list w-48 m-5 h-fit pb-4 custom-">
{!clusterMode ? (
<>
<label className="font-bold">Clusters</label>
{clusters
?.sort((a, b) =>
getCleanClusterName(a.Name).localeCompare(
getCleanClusterName(b.Name)
)
)
?.map((cluster) => {
return (
<span
key={cluster.Name}
className="flex items-center mt-2 text-xs"
>
<input
className="cursor-pointer"
onChange={(e) => {
onClusterChange(e.target.value);
}}
type="radio"
id={cluster.Name}
value={cluster.Name}
checked={cluster.Name === selectedCluster}
name="clusters"
/>
<label htmlFor={cluster.Name} className="ml-1 ">
{getCleanClusterName(cluster.Name)}
</label>
</span>
);
})}
</>
) : null}
<label className="font-bold mt-4">Namespaces</label>
{namespaces
?.sort((a, b) => a.name.localeCompare(b.name))
?.map((namespace) => (
<span key={namespace.name} className="flex items-center mt-2 text-xs">
<input
type="checkbox"
id={namespace.name}
onChange={(event) => {
onNamespaceChange(event.target.value);
}}
value={namespace.name}
checked={
filteredNamespaces
? filteredNamespaces.includes(namespace.name)
: false
}
/>
<label
htmlFor={namespace.name}
className="ml-1"
>{`${namespace.name} [${namespace.amount}]`}</label>
</span>
))}
</div>
);
}
export default ClustersList;

View File

@@ -0,0 +1,41 @@
import { HD_RESOURCE_CONDITION_TYPE } from "../../API/releases";
import { Tooltip } from "flowbite-react";
import { ReleaseHealthStatus } from "../../data/types";
import { v4 as uuidv4 } from "uuid";
interface Props {
statusData: ReleaseHealthStatus[];
}
const HealthStatus = ({ statusData }: Props) => {
const statuses = statusData.map((item) => {
for (let i = 0; i < item.status.conditions.length; i++) {
const cond = item.status.conditions[i];
if (cond.type !== HD_RESOURCE_CONDITION_TYPE) {
continue;
}
return (
<Tooltip
key={uuidv4()} // this is not a good practice, we need to fetch some unique id from the backend
content={`${cond.status} ${item.kind} ${item.metadata.name}`}
>
<span
className={`inline-block ${
cond.status === "Healthy"
? "bg-success"
: cond.status === "Progressing"
? "bg-warning"
: "bg-danger"
} w-2.5 h-2.5 rounded-sm`}
></span>
</Tooltip>
);
}
});
return <div className="flex flex-wrap gap-1">{statuses}</div>;
};
export default HealthStatus;

View File

@@ -0,0 +1,41 @@
// InstalledPackageCard.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackageCard from "./InstalledPackageCard";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackageCard",
component: InstalledPackageCard,
} as ComponentMeta<typeof InstalledPackageCard>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackageCard> = (args) => (
<InstalledPackageCard {...args} />
);
export const Default = Template.bind({});
Default.args = {
release: {
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the backend, we need to resolve this
},
};

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Release } from "../../data/types";
import { BsArrowUpCircleFill, BsPlusCircleFill } from "react-icons/bs";
import { getAge } from "../../timeUtils";
import StatusLabel, {
DeploymentStatus,
getStatusColor,
} from "../common/StatusLabel";
import { useQuery } from "@tanstack/react-query";
import apiService from "../../API/apiService";
import HealthStatus from "./HealthStatus";
import HelmGrayIcon from "../../assets/helm-gray-50.svg";
import Spinner from "../Spinner";
import { useGetLatestVersion } from "../../API/releases";
import { isNewerVersion } from "../../utils";
import { LatestChartVersion } from "../../API/interfaces";
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
import { useParams } from "react-router-dom";
type InstalledPackageCardProps = {
release: Release;
};
export default function InstalledPackageCard({
release,
}: InstalledPackageCardProps) {
const navigate = useNavigateWithSearchParams();
const { context: selectedCluster } = useParams();
const [isMouseOver, setIsMouseOver] = useState(false);
const { data: latestVersionResult } = useGetLatestVersion(release.chartName, {
queryKey: ["chartName", release.chartName],
cacheTime: 0,
});
const { data: statusData } = useQuery<any>({
queryKey: ["resourceStatus", release],
queryFn: () => apiService.getResourceStatus({ release }),
});
const latestVersionData: LatestChartVersion | undefined =
latestVersionResult?.[0];
const canUpgrade =
!latestVersionData?.version || !release.chartVersion
? false
: isNewerVersion(release.chartVersion, latestVersionData?.version);
const installRepoSuggestion = latestVersionData?.isSuggestedRepo
? latestVersionData.repository
: null;
const handleMouseOver = () => {
setIsMouseOver(true);
};
const handleMouseOut = () => {
setIsMouseOver(false);
};
const handleOnClick = () => {
const { name, namespace } = release;
navigate(
`/${selectedCluster}/${namespace}/${name}/installed/revision/${release.revision}`,
{ state: release }
);
};
const statusColor = getStatusColor(release.status as DeploymentStatus);
const borderLeftColor: { [key: string]: string } = {
[DeploymentStatus.DEPLOYED]: "border-l-border-deployed",
[DeploymentStatus.FAILED]: "border-l-text-danger",
[DeploymentStatus.PENDING]: "border-l-border",
};
return (
<div
className={`${
borderLeftColor[release.status]
} text-xs grid grid-cols-12 items-center bg-white rounded-md p-2 py-6 my-2 custom-shadow border-l-4 border-l-[${statusColor}] cursor-pointer ${
isMouseOver && "custom-shadow-lg"
}`}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onClick={handleOnClick}
>
<img
src={release.icon || HelmGrayIcon}
alt="helm release icon"
className="w-[45px] mx-4 col-span-1 min-w-[45px]"
/>
<div className="col-span-11 -mb-5">
<div className="grid grid-cols-11">
<div className="col-span-3 font-bold text-xl mr-0.5 font-roboto-slab">
{release.name}
</div>
<div className="col-span-3">
<StatusLabel status={release.status} />
</div>
<div className="col-span-2 font-bold">{release.chart}</div>
<div className="col-span-1 font-bold text-xs">
#{release.revision}
</div>
<div className="col-span-1 font-bold text-xs">
{release.namespace}
</div>
<div className="col-span-1 font-bold text-xs">{getAge(release)}</div>
</div>
<div
className="grid grid-cols-11 text-xs mt-3"
style={{ marginBottom: "12px" }}
>
<div className="col-span-3 h-12 line-clamp-3 mr-1">
{release.description}
</div>
<div className="col-span-3 mr-2">
{statusData ? (
<HealthStatus statusData={statusData} />
) : (
<Spinner size={4} />
)}
</div>
<div className="col-span-2 text-muted flex flex-col items">
<span>CHART VERSION</span>
{(canUpgrade || installRepoSuggestion) && (
<div
className="text-upgradable flex flex-row items-center gap-1 font-bold"
title={`upgrade available: ${latestVersionData?.version} from ${latestVersionData?.repository}`}
>
{canUpgrade && !installRepoSuggestion ? (
<>
<BsArrowUpCircleFill />
UPGRADE
</>
) : (
<>
<BsPlusCircleFill />
ADD REPO
</>
)}
</div>
)}
</div>
<div className="col-span-1 text-muted">REVISION</div>
<div className="col-span-1 text-muted">NAMESPACE</div>
<div className="col-span-1 text-muted">UPDATED</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
// InstalledPackagesHeader.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackagesHeader from "./InstalledPackagesHeader";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackagesHeader",
component: InstalledPackagesHeader,
} as ComponentMeta<typeof InstalledPackagesHeader>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackagesHeader> = (args) => (
<InstalledPackagesHeader {...args} />
);
export const Default = Template.bind({});
Default.args = {
filteredReleases: [
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
],
};

View File

@@ -0,0 +1,51 @@
import HeaderLogo from "../../assets/packges-header.svg";
import { Release } from "../../data/types";
type InstalledPackagesHeaderProps = {
filteredReleases?: Release[];
setFilterKey: React.Dispatch<React.SetStateAction<string>>;
isLoading: boolean;
};
export default function InstalledPackagesHeader({
filteredReleases,
setFilterKey,
isLoading,
}: InstalledPackagesHeaderProps) {
const numOfPackages = filteredReleases?.length;
const showNoPackageAlert = Boolean(
!isLoading && (numOfPackages === undefined || numOfPackages === 0)
);
return (
<div className="custom-shadow rounded-t-md ">
<div className="flex items-center justify-between bg-white px-2 py-0.5 font-inter rounded-t-md ">
<div className="flex items-center">
<img
src={HeaderLogo}
alt="Helm-DashBoard"
className="display-inline h-12 ml-3 mr-3 w-[28px] "
/>
<h2 className="display-inline font-bold text-base ">{`Installed Charts (${
numOfPackages || "0"
})`}</h2>
</div>
<div className="w-1/3">
<input
className="border-installed-charts-filter rounded p-1 text-sm w-11/12"
placeholder="Filter..."
type="text"
onChange={(ev) => setFilterKey(ev.target.value)}
/>
</div>
</div>
{showNoPackageAlert && (
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
Looks like you don&apos;t have any charts installed.
&quot;Repository&quot; section may be a good place to start.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
// InstalledPackagesList.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackagesList from "./InstalledPackagesList";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackagesList",
component: InstalledPackagesList,
} as ComponentMeta<typeof InstalledPackagesList>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackagesList> = (args) => (
<InstalledPackagesList {...args} />
);
export const Default = Template.bind({});
Default.args = {
installedReleases: [
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
],
};

View File

@@ -0,0 +1,23 @@
import InstalledPackageCard from "./InstalledPackageCard";
import { Release } from "../../data/types";
type InstalledPackagesListProps = {
filteredReleases: Release[];
};
export default function InstalledPackagesList({
filteredReleases,
}: InstalledPackagesListProps) {
return (
<div>
{filteredReleases.map((installedPackage: Release) => {
return (
<InstalledPackageCard
key={installedPackage.name}
release={installedPackage}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { NavLink, useLocation } from "react-router-dom";
const LinkWithSearchParams = ({
to,
...props
}: {
to: string;
end?: boolean;
exclude?: string[];
className?: string;
children: React.ReactNode;
}) => {
const { search } = useLocation();
const params = new URLSearchParams(search);
// For state we don't want to keep while navigating
props.exclude?.forEach((key) => {
params.delete(key);
});
return <NavLink to={`${to}/?${params.toString()}`} {...props} />;
};
export default LinkWithSearchParams;

View File

@@ -0,0 +1,38 @@
/* eslint-disable no-console */
/**
* @file SelectMenu.stories.tsx
* @description This file contains the SelectMenu
* component stories.
* currently there is only the default story.
* The default story renders the component with the default props.
*/
import { ComponentStory, ComponentMeta } from "@storybook/react";
import SelectMenu, { SelectMenuItem, SelectMenuProps } from "./SelectMenu";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "SelectMenu",
component: SelectMenu,
} as ComponentMeta<typeof SelectMenu>;
//👇 We create a "template" of how args map to rendering
const Template: ComponentStory<typeof SelectMenu> = (args: SelectMenuProps) => (
<SelectMenu {...args} />
);
export const Default = Template.bind({});
Default.args = {
header: "Header",
children: [
<SelectMenuItem label="Item 1" id={1} key="item1" />,
<SelectMenuItem label="Item 2" id={2} key="item2" />,
<SelectMenuItem label="Item 3" id={3} key="item3" />,
],
selected: 1,
onSelect: (id: number) => console.log(id),
};

View File

@@ -0,0 +1,82 @@
/**
*
* @file SelectMenu.tsx
* @description SelectMenu component
* This component is used to render a select menu. This is a component
* with two parts: The menu header and the menu items. The menu header
* is just text, and the menu items are radio toggles.
* The menu items are passed as children of type SelectMenuItem,
* which is an object with a label and id.
* SelectMenuItem is defined in this file.
* The menu header is a string.
*
* We use an interface to define the props that are passed to the component.
* The interface name is SelectMenuProps. The interface is defined in the
* same file as the component.
*
* @interface SelectMenuProps:
* @property {string} header - The menu header
* @property {SelectMenuItem[]} children - The menu items
* @property {number} selected - The id of the selected menu item
* @property {Function} onSelect - The function to call when a menu item is selected
*
* @return {JSX.Element} - The component
*
*
*/
// define the SelectMenuItem type:
// This is an object with a label and id.
// The label is a string, and the id is a number.
// The id is used to identify the selected menu item.
export interface SelectMenuItemProps {
label: string;
id: number;
}
// define the SelectMenuProps interface:
export interface SelectMenuProps {
header: string;
children: React.ReactNode;
selected: number;
onSelect: (id: number) => void;
}
// define the SelectMenu component:
// remember to use tailwind classes for styling
// recall that the menu items are radio buttons
// the selected menu item is the one that is checked
// the onSelect function is called when a menu item is selected
// the onSelect function is passed the id of the selected menu item
export function SelectMenuItem({
label,
id,
}: SelectMenuItemProps): JSX.Element {
return (
<div className="flex flex-row">
<input
type="radio"
name="menu"
id={id.toString()} // id must be a string
value={id}
checked={false}
onChange={() => {
return;
}}
/>
<label htmlFor={id.toString()}>{label}</label>
</div>
);
}
export default function SelectMenu(props: SelectMenuProps): JSX.Element {
const { header, children } = props;
return (
<div className="card flex flex-col">
<h2 className="text-xl font-bold">{header}</h2>
<div className="flex flex-col">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ShutDownButton from "./ShutDownButton";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ShutDownButton",
component: ShutDownButton,
} as ComponentMeta<typeof ShutDownButton>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ShutDownButton> = () => (
<ShutDownButton />
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,33 @@
import { BsPower } from "react-icons/bs";
import Modal from "./modal/Modal";
import { useShutdownHelmDashboard } from "../API/other";
function ShutDownButton() {
const { mutate: signOut, status } = useShutdownHelmDashboard();
const handleClick = async () => {
signOut();
};
return (
<div className="w-full">
<Modal title="Session Ended" isOpen={status === "success"}>
<p>
The Helm Dashboard application has been shut down. You can now close
the browser tab.
</p>
</Modal>
<button
onClick={handleClick}
title="Shut down the Helm Dashboard application"
className="flex justify-center w-full mr-5 py-3 border border-transparent hover:border hover:border-gray-500 rounded hover:rounded-lg"
>
<BsPower className="w-6" />
</button>
</div>
);
}
export default ShutDownButton;

View File

@@ -0,0 +1,22 @@
export default function Spinner({ size = 8 }: { size?: number }) {
return (
<div role="status">
<svg
aria-hidden="true"
className={`w-${size} h-${size} mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,39 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import Tabs from "./Tabs";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "Tabs",
component: Tabs,
} as ComponentMeta<typeof Tabs>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof Tabs> = (args) => <Tabs {...args} />;
export const Default = Template.bind({});
const defaultArgs = {
tabs: [
{
label: "tab1",
content: <div>tab1</div>,
},
{
label: "tab2",
content: <div>tab2</div>,
},
{
label: "tab3",
content: <div>tab3</div>,
},
],
};
//@ts-ignore
Default.args = defaultArgs;

View File

@@ -0,0 +1,43 @@
import { ReactNode } from "react";
import useCustomSearchParams from "../hooks/useCustomSearchParams";
export interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface TabsProps {
tabs: Tab[];
selectedTab: Tab;
}
export default function Tabs({ tabs, selectedTab }: TabsProps) {
const { upsertSearchParams } = useCustomSearchParams();
const moveTab = (tab: Tab) => {
upsertSearchParams("tab", tab.value);
};
return (
<div className="flex flex-col">
<div className="flex pb-2">
{tabs.map((tab) => (
<button
key={tab.label}
className={`cursor-pointer px-4 py-2 text-sm font-normal text-tab-color focus:outline-none"
${
selectedTab.value === tab.value &&
"border-b-[3px] border-tab-color"
}
`}
onClick={() => moveTab(tab)}
>
{tab.label}
</button>
))}
</div>
<div>{selectedTab.content}</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import TabsBar from "./TabsBar";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "TabsBar",
component: TabsBar,
} as ComponentMeta<typeof TabsBar>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof TabsBar> = (args) => (
<TabsBar {...args} />
);
export const Default = Template.bind({});
Default.args = {
tabs: [
{
name: "tab1",
component: <div className="w-250 h-250 bg-green-400">tab1</div>,
},
{
name: "tab2",
component: <div className="w-250 h-250 bg-red-400">tab2</div>,
},
{
name: "tab3",
component: <div className="w-250 h-250 bg-blue-400">tab3</div>,
},
],
activeTab: "tab1",
};

View File

@@ -0,0 +1,47 @@
/**
* @file TabsBar.tsx
*
* This component is the bar that contains the tabs.
* it gets the tabs as a prop that contains a list with the name of the tabs
* and the component that should be rendered when the tab is clicked.
*
* @param {Array} tabs - the tabs that should be rendered
* @param {string} activeTab - the name of the active tab
* @param {Function} setActiveTab - the function that should be called when a tab is clicked
* @param {Function} setTabContent - the function that should be called when a tab is clicked
*
* @returns {JSX.Element} - the tabs bar
*
*
*/
interface TabsBarProps {
tabs: Array<{ name: string; component: JSX.Element }>;
activeTab: string;
setActiveTab: (tab: string) => void;
setTabContent: (tab: string) => void;
}
export default function TabsBar({
tabs,
activeTab,
setActiveTab,
setTabContent,
}: TabsBarProps): JSX.Element {
return (
<div className="relative">
{tabs.map((tab) => (
<div
className={`tab ${activeTab === tab.name ? "active" : ""}`}
onClick={() => {
setActiveTab(tab.name);
setTabContent(tab.name);
}}
key={tab.name}
>
{tab.name}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* @file TextInput.stories.tsx
* @description This file contains the TextInput component stories.
* the first story simply renders the component with the default props.
*/
import { ComponentStory, ComponentMeta } from "@storybook/react";
import TextInput, { TextInputProps } from "./TextInput";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "TextInput",
component: TextInput,
} as ComponentMeta<typeof TextInput>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof TextInput> = (args: TextInputProps) => (
<TextInput {...args} />
);
export const Default = Template.bind({});
Default.args = {
label: "Label",
placeholder: "Placeholder",
isMandatory: false,
};

View File

@@ -0,0 +1,38 @@
/**
*
* @file TextInput.tsx
* @description This is a single-lined text field.
* You can choose a placeholder, label,
* and whether the field is mandatory.
* @interface TextInputProps:
* - label: the label to be displayed
* - placeholder: placeholder text
* - isMandatory: adds a red star if is.
*
* @return JSX.Element
*
*/
export interface TextInputProps {
label: string;
placeholder: string;
isMandatory?: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function TextInput(props: TextInputProps): JSX.Element {
return (
<div className="mb-6">
<label className="block ml-1 mb-1 text-sm font-medium text-gray-900dark:text-white">
{props.label}
{/* if prop.isMandatory is true, add a whitespace and a red star to signify it*/}
{props.isMandatory ? <span className="text-red-500"> *</span> : ""}
</label>
<input
type="text"
placeholder={props.placeholder}
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 "
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { type ReactElement, cloneElement } from "react";
export default function Tooltip({
id,
title,
element,
}: {
title: string;
id: string;
element: ReactElement;
}) {
return (
<>
{cloneElement(element, { "data-tooltip-target": id })}
<div
id={id}
role="tooltip"
className="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
{title}
<div className="tooltip-arrow" data-popper-arrow></div>
</div>
<button
data-tooltip-target="tooltip-default"
type="button"
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Default tooltip
</button>
<div
id="tooltip-default"
role="tooltip"
className="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
Tooltip content
<div className="tooltip-arrow" data-popper-arrow></div>
</div>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { Meta, StoryFn } from "@storybook/react";
import { Troubleshoot } from "./Troubleshoot";
export default {
title: "Troubleshoot",
component: Troubleshoot,
} as Meta<typeof Troubleshoot>;
const Template: StoryFn<typeof Troubleshoot> = () => <Troubleshoot />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,18 @@
import { RiExternalLinkLine } from "react-icons/ri";
export const Troubleshoot = () => {
return (
<div>
<a
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank"
rel="noreferrer"
>
<button className="bg-primary text-white p-2 flex items-center rounded text-sm font-medium font-roboto">
Troubleshoot in Komodor
<RiExternalLinkLine className="ml-2 text-lg" />
</button>
</a>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Button } from "./Button";
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: "Example/Button",
component: Button,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
argTypes: {
backgroundColor: { control: "color" },
},
} as ComponentMeta<typeof Button>;
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Primary = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Primary.args = {
primary: true,
label: "Button",
};
export const Secondary = Template.bind({});
Secondary.args = {
label: "Button",
};
export const Large = Template.bind({});
Large.args = {
size: "large",
label: "Button",
};
export const Small = Template.bind({});
Small.args = {
size: "small",
label: "Button",
};

View File

@@ -0,0 +1,51 @@
import "./button.css";
interface ButtonProps {
/**
* Is this the principal call to action on the page?
*/
primary?: boolean;
/**
* What background color to use
*/
backgroundColor?: string;
/**
* How large should the button be?
*/
size?: "small" | "medium" | "large";
/**
* Button contents
*/
label: string;
/**
* Optional click handler
*/
onClick?: () => void;
}
/**
* Primary UI component for user interaction
*/
export const Button = ({
primary = false,
size = "medium",
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary
? "storybook-button--primary"
: "storybook-button--secondary";
return (
<button
type="button"
className={["storybook-button", `storybook-button--${size}`, mode].join(
" "
)}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};

View File

@@ -0,0 +1,30 @@
.storybook-button {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}

View File

@@ -0,0 +1,35 @@
/* eslint-disable no-console */
// DropDown.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import DropDown from "./DropDown";
import { BsSlack, BsGithub } from "react-icons/bs";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "DropDown",
component: DropDown,
} as ComponentMeta<typeof DropDown>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof DropDown> = (args) => (
<DropDown {...args} />
);
export const Default = Template.bind({});
const onClick = () => {
console.log("drop down clicked");
};
Default.args = {
items: [
{ id: "1", text: "Menu Item 1", onClick: onClick, icon: <BsSlack /> },
{ id: "2 ", isSeparator: true },
{ id: "3", text: "Menu Item 3", isDisabled: true, icon: <BsGithub /> },
],
};

View File

@@ -0,0 +1,107 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import ArrowDownIcon from "../../assets/arrow-down-icon.svg";
export type DropDownItem = {
id: string;
text?: string;
icon?: ReactNode;
onClick?: () => void;
isSeparator?: boolean;
isDisabled?: boolean;
};
export type DropDownProps = {
items: DropDownItem[];
};
type PopupState = {
isOpen: boolean;
X: number;
Y: number;
};
function DropDown({ items }: DropDownProps) {
const [popupState, setPopupState] = useState<PopupState>({
isOpen: false,
X: 0,
Y: 0,
});
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (popupState.isOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [popupState.isOpen]);
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setPopupState((prev) => ({
...prev,
isOpen: false,
}));
}
};
return (
<>
<div className="relative flex flex-col items-center">
<button
onClick={(e) => {
setPopupState((prev) => ({
...prev,
isOpen: !prev.isOpen,
X: e.pageX,
Y: e.pageY,
}));
}}
className="flex items-center justify-between"
>
Help
<img src={ArrowDownIcon} className="ml-2 w-[10px] h-[10px]" />
</button>
</div>
{popupState.isOpen && (
<div
ref={modalRef}
className={`z-10 flex flex-col py-1 gap-1 bg-white mt-3 absolute rounded border top-[${popupState.Y}] left-[${popupState.X}] border-gray-200`}
>
{items.map((item) => (
<>
{item.isSeparator ? (
<div className="bg-gray-300 h-[1px]" />
) : (
<div
onClick={() => {
item.onClick?.();
setPopupState((prev) => ({
...prev,
isOpen: false,
}));
}}
className={`cursor-pointer font-normal flex items-center gap-2 py-1 pl-3 pr-7 hover:bg-dropdown ${
item.isDisabled
? "cursor-default hover:bg-transparent text-gray-400"
: ""
}`}
>
{item.icon && <span> {item.icon ?? null}</span>}
<span>{item.text}</span>
</div>
)}
</>
))}
</div>
)}
</>
);
}
export default DropDown;

View File

@@ -0,0 +1,24 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Header } from "./Header";
export default {
title: "Example/Header",
component: Header,
parameters: {
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
} as ComponentMeta<typeof Header>;
const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;
export const LoggedIn = Template.bind({});
LoggedIn.args = {
user: {
name: "Jane Doe",
},
};
export const LoggedOut = Template.bind({});
LoggedOut.args = {};

View File

@@ -0,0 +1,69 @@
import { Button } from "../Button/Button";
import "./header.css";
type User = {
name: string;
};
interface HeaderProps {
user?: User;
onLogin: () => void;
onLogout: () => void;
onCreateAccount: () => void;
}
export const Header = ({
user,
onLogin,
onLogout,
onCreateAccount,
}: HeaderProps) => (
<header>
<div className="wrapper">
<div>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="none" fillRule="evenodd">
<path
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
fill="#FFF"
/>
<path
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
fill="#555AB9"
/>
<path
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
fill="#91BAF8"
/>
</g>
</svg>
<h1>Acme</h1>
</div>
<div>
{user ? (
<>
<span className="welcome">
Welcome, <b>{user.name}</b>!
</span>
<Button size="small" onClick={onLogout} label="Log out" />
</>
) : (
<>
<Button size="small" onClick={onLogin} label="Log in" />
<Button
primary
size="small"
onClick={onCreateAccount}
label="Sign up"
/>
</>
)}
</div>
</div>
</header>
);

View File

@@ -0,0 +1,32 @@
.wrapper {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 15px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
svg {
display: inline-block;
vertical-align: top;
}
h1 {
font-weight: 900;
font-size: 20px;
line-height: 1;
margin: 6px 0 6px 10px;
display: inline-block;
vertical-align: top;
}
button + button {
margin-left: 10px;
}
.welcome {
color: #333;
font-size: 14px;
margin-right: 10px;
}

View File

@@ -0,0 +1,25 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { within, userEvent } from "@storybook/testing-library";
import { Page } from "./Page";
export default {
title: "Example/Page",
component: Page,
parameters: {
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
} as ComponentMeta<typeof Page>;
const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
export const LoggedOut = Template.bind({});
export const LoggedIn = Template.bind({});
// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing
LoggedIn.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const loginButton = await canvas.getByRole("button", { name: /Log in/i });
await userEvent.click(loginButton);
};

View File

@@ -0,0 +1,92 @@
import React from "react";
import { Header } from "../Header/Header";
import "./page.css";
type User = {
name: string;
};
export const Page: React.VFC = () => {
const [user, setUser] = React.useState<User>();
return (
<article>
<Header
user={user}
onLogin={() => setUser({ name: "Jane Doe" })}
onLogout={() => setUser(undefined)}
onCreateAccount={() => setUser({ name: "Jane Doe" })}
/>
<section>
<h2>Pages in Storybook</h2>
<p>
We recommend building UIs with a{" "}
<a
href="https://componentdriven.org"
target="_blank"
rel="noopener noreferrer"
>
<strong>component-driven</strong>
</a>{" "}
process starting with atomic components and ending with pages.
</p>
<p>
Render pages with mock data. This makes it easy to build and review
page states without needing to navigate to them in your app. Here are
some handy patterns for managing page data in Storybook:
</p>
<ul>
<li>
Use a higher-level connected component. Storybook helps you compose
such data from the &quot;args&quot; of child component stories
</li>
<li>
Assemble data in the page component from your services. You can mock
these services out using Storybook.
</li>
</ul>
<p>
Get a guided tutorial on component-driven development at{" "}
<a
href="https://storybook.js.org/tutorials/"
target="_blank"
rel="noopener noreferrer"
>
Storybook tutorials
</a>
. Read more in the{" "}
<a
href="https://storybook.js.org/docs"
target="_blank"
rel="noopener noreferrer"
>
docs
</a>
.
</p>
<div className="tip-wrapper">
<span className="tip">Tip</span> Adjust the width of the canvas with
the{" "}
<svg
width="10"
height="10"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="none" fillRule="evenodd">
<path
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
id="a"
fill="#999"
/>
</g>
</svg>
Viewports addon in the toolbar
</div>
</section>
</article>
);
};

View File

@@ -0,0 +1,69 @@
section {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 24px;
padding: 48px 20px;
margin: 0 auto;
max-width: 600px;
color: #333;
}
section h2 {
font-weight: 900;
font-size: 32px;
line-height: 1;
margin: 0 0 4px;
display: inline-block;
vertical-align: top;
}
section p {
margin: 1em 0;
}
section a {
text-decoration: none;
color: #1ea7fd;
}
section ul {
padding-left: 30px;
margin: 1em 0;
}
section li {
margin-bottom: 8px;
}
section .tip {
display: inline-block;
border-radius: 1em;
font-size: 11px;
line-height: 12px;
font-weight: 700;
background: #e7fdd8;
color: #66bf3c;
padding: 4px 12px;
margin-right: 10px;
vertical-align: top;
}
section .tip-wrapper {
font-size: 13px;
line-height: 20px;
margin-top: 40px;
margin-bottom: 40px;
}
section .tip-wrapper svg {
display: inline-block;
height: 12px;
width: 12px;
margin-right: 4px;
vertical-align: top;
margin-top: 3px;
}
section .tip-wrapper svg path {
fill: #1ea7fd;
}

View File

@@ -0,0 +1,39 @@
import { ComponentStory } from "@storybook/react";
import StatusLabel, { DeploymentStatus } from "./StatusLabel";
export default {
title: "StatusLabel",
component: StatusLabel,
};
const Template: ComponentStory<typeof StatusLabel> = (args) => (
<StatusLabel {...args} />
);
export const Deployed = Template.bind({});
Deployed.args = {
status: DeploymentStatus.DEPLOYED,
isRollback: false,
};
export const Failed = Template.bind({});
Failed.args = {
status: DeploymentStatus.FAILED,
isRollback: false,
};
export const Pending = Template.bind({});
Pending.args = {
status: DeploymentStatus.PENDING,
isRollback: false,
};
export const Superseded = Template.bind({});
Superseded.args = {
status: DeploymentStatus.SUPERSEDED,
isRollback: false,
};

View File

@@ -0,0 +1,42 @@
import { AiOutlineReload } from "react-icons/ai";
type StatusLabelProps = {
status: string;
isRollback?: boolean;
};
export enum DeploymentStatus {
DEPLOYED = "deployed",
FAILED = "failed",
PENDING = "pending-install",
SUPERSEDED = "superseded",
}
export function getStatusColor(status: DeploymentStatus) {
if (status === DeploymentStatus.DEPLOYED) return "text-deployed";
if (status === DeploymentStatus.FAILED) return "text-failed";
if (status === DeploymentStatus.PENDING) return "text-pending";
else return "text-superseded";
}
function StatusLabel({ status, isRollback }: StatusLabelProps) {
const statusColor = getStatusColor(status as DeploymentStatus);
return (
<div
style={{
minWidth: "100px",
display: "flex",
fontSize: "14px",
justifyContent: "space-between",
}}
>
<span className={`${statusColor} font-bold text-xs`}>
{status.toUpperCase()}
</span>
{isRollback && <AiOutlineReload size={14} />}
</div>
);
}
export default StatusLabel;

View File

@@ -0,0 +1,25 @@
// Modal.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import AddRepositoryModal from "./AddRepositoryModal";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "AddRepositoryModal",
component: AddRepositoryModal,
} as ComponentMeta<typeof AddRepositoryModal>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof AddRepositoryModal> = (args) => (
<AddRepositoryModal {...args} isOpen={true} />
);
export const Default = Template.bind({});
Default.args = {
isOpen: true,
};

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import Modal from "./Modal";
import Spinner from "../Spinner";
import useAlertError from "../../hooks/useAlertError";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import { useAppContext } from "../../context/AppContext";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import apiService from "../../API/apiService";
interface FormKeys {
name: string;
url: string;
username: string;
password: string;
}
type AddRepositoryModalProps = {
isOpen: boolean;
onClose: () => void;
};
function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
const [formData, setFormData] = useState<FormKeys>({} as FormKeys);
const [isLoading, setIsLoading] = useState(false);
const alertError = useAlertError();
const { searchParamsObject } = useCustomSearchParams();
const { repo_url, repo_name } = searchParamsObject;
const { setSelectedRepo } = useAppContext();
const { context } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
useEffect(() => {
if (!repo_url || !repo_name) return;
setFormData({ ...formData, name: repo_name, url: repo_url });
}, [repo_url, repo_name, formData]);
const addRepository = () => {
const body = new FormData();
body.append("name", formData.name ?? "");
body.append("url", formData.url ?? "");
body.append("username", formData.username ?? "");
body.append("password", formData.password ?? "");
setIsLoading(true);
apiService.fetchWithDefaults<void>("/api/helm/repositories", {
method: "POST",
body,
})
.then(() => {
setIsLoading(false);
onClose();
queryClient.invalidateQueries({
queryKey: ["helm", "repositories"],
});
setSelectedRepo(formData.name || "");
navigate(`/${context}/repository/${formData.name}`, {
replace: true,
});
})
.catch((error) => {
alertError.setShowErrorModal({
title: "Failed to add repo",
msg: error.message,
});
})
.finally(() => {
setIsLoading(false);
});
};
return (
<Modal
containerClassNames={"w-full max-w-5xl"}
title="Add Chart Repository"
isOpen={isOpen}
onClose={onClose}
bottomContent={
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<button
className="flex items-center text-white font-medium px-3 py-1.5 bg-primary hover:bg-add-repo focus:ring-4 focus:outline-none focus:ring-blue-300 disabled:bg-blue-300 rounded-lg text-base text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
onClick={addRepository}
disabled={isLoading}
>
{isLoading && <Spinner size={4} />}
Add Repository
</button>
</div>
}
>
<div className="flex gap-x-3">
<label className="flex-1" htmlFor="name">
<div className="mb-2 text-sm require">Name</div>
<input
value={formData.name}
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="name"
type="text"
placeholder="Komodorio"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
<label className="flex-1" htmlFor="url">
<div className="mb-2 text-sm require">URL</div>
<input
value={formData.url}
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="url"
type="text"
placeholder="https://helm-charts.komodor.io"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
</div>
<div className="flex gap-x-3">
<label className="flex-1 " htmlFor="username">
<div className="mb-2 text-sm">Username</div>
<input
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="username"
type="text"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
<label className="flex-1" htmlFor="password">
<div className="mb-2 text-sm">Password</div>
<input
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="password"
type="text"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
</div>
</Modal>
);
}
export default AddRepositoryModal;

View File

@@ -0,0 +1,31 @@
/* eslint-disable no-console */
// Modal.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ErrorModal from "./ErrorModal";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ErrorModal",
component: ErrorModal,
} as ComponentMeta<typeof ErrorModal>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ErrorModal> = (args) => (
<ErrorModal {...args} />
);
export const Default = Template.bind({});
Default.args = {
onClose: () => {
console.log("on Close clicked");
},
titleText: "Failed to get list of charts",
contentText:
"failed to get list of releases, cause: Kubernetes cluster unreachable: Get &#34;https://kubernetes.docker.internal:6443/version&#34;: dial tcp 127.0.0.1:6443: connectex: No connection could be made because the target machine actively refused it.",
};

View File

@@ -0,0 +1,63 @@
import Modal from "./Modal";
interface ErrorModalProps {
isOpen: boolean;
titleText: string;
contentText: string;
onClose: () => void;
}
export default function ErrorModal({
isOpen,
onClose,
titleText,
contentText,
}: ErrorModalProps) {
const ErrorTitle = (
<div className="font-medium text-2xl text-error-color">
<div className="flex gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
fill="currentColor"
className="bi bi-exclamation-triangle-fill mt-1"
viewBox="0 0 16 16"
>
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
{titleText}
</div>
<h4 className="alert-heading" />
</div>
);
const bottomContent = (
<div className="flex py-6 px-4 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<span className="text-sm text-muted fs-80 text-gray-500">
Hint: Komodor has the same HELM capabilities, with enterprise features
and support.{" "}
<a
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank" rel="noreferrer"
>
<span className="text-link-color underline">Sign up for free.</span>
</a>
</span>
</div>
);
return (
<Modal
containerClassNames={
"border-2 border-error-border-color bg-error-background w-2/3"
}
title={ErrorTitle}
isOpen={isOpen}
onClose={onClose}
bottomContent={bottomContent}
>
<p className="text-error-color border-green-400">{contentText}</p>
</Modal>
);
}

View File

@@ -0,0 +1,66 @@
import Modal from "./Modal";
interface ErrorModalProps {
isOpen: boolean;
titleText: string;
contentText: string;
onClose: () => void;
}
export default function GlobalErrorModal({
isOpen,
onClose,
titleText,
contentText,
}: ErrorModalProps) {
const ErrorTitle = (
<div className="font-medium text-2xl text-error-color">
<div className="flex gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
fill="currentColor"
className="bi bi-exclamation-triangle-fill mt-1"
viewBox="0 0 16 16"
>
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
{titleText}
</div>
<h4 className="alert-heading" />
</div>
);
return (
<Modal
containerClassNames={
"border-2 border-error-border-color bg-error-background w-3/5 "
}
title={ErrorTitle}
isOpen={isOpen}
onClose={onClose}
bottomContent={
<div className="text-xs">
Hint: Komodor has the same HELM capabilities, with enterprise features
and support.{" "}
<a
className="text-blue-500"
href="https://komodor.com/helm-dash/?utm_campaign=Helm+Dashboard+%7C+CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank"
rel="noopener noreferrer"
>
Sign up for free.
</a>
</div>
}
>
<p
style={{ minWidth: "500px" }}
className="text-error-color border-green-400 text-sm"
>
{contentText}
</p>
</Modal>
);
}

View File

@@ -0,0 +1,39 @@
import hljs from "highlight.js";
import Spinner from "../../Spinner";
export const ChartValues = ({
chartValues,
loading,
}: {
chartValues: string;
loading: boolean;
}) => {
return (
<div className="w-1/2">
<label
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
htmlFor="grid-user-defined-values"
>
Chart Value Reference:
</label>
<pre
className="text-base bg-chart-values p-2 rounded font-medium w-full max-h-[330px] block overflow-y-auto font-sf-mono"
dangerouslySetInnerHTML={
chartValues && !loading
? {
__html: hljs.highlight(chartValues, {
language: "yaml",
}).value,
}
: undefined
}
>
{loading ? (
<Spinner />
) : !chartValues && !loading ? (
"No original values information found"
) : null}
</pre>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { ChartValues } from "./ChartValues";
import { UserDefinedValues } from "./UserDefinedValues";
interface DefinedValuesProps {
initialValue: string;
onUserValuesChange: (values: string) => void;
chartValues: string;
loading: boolean;
}
export const DefinedValues = ({
initialValue,
chartValues,
onUserValuesChange,
loading,
}: DefinedValuesProps) => {
return (
<div className="flex w-full gap-6 mt-4">
<UserDefinedValues
initialValue={initialValue}
onValuesChange={onUserValuesChange}
/>
<ChartValues chartValues={chartValues} loading={loading} />
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import useDebounce from "../../../hooks/useDebounce";
export const GeneralDetails = ({
releaseName,
namespace = "",
disabled,
onNamespaceInput,
onReleaseNameInput,
}: {
releaseName: string;
namespace?: string;
disabled: boolean;
onNamespaceInput: (namespace: string) => void;
onReleaseNameInput: (chartName: string) => void;
}) => {
const [namespaceInputValue, setNamespaceInputValue] = useState(namespace);
const namespaceInputValueDebounced = useDebounce<string>(namespaceInputValue, 500);
useEffect(() => {
onNamespaceInput(namespaceInputValueDebounced);
}, [namespaceInputValueDebounced, onNamespaceInput]);
const { context } = useParams();
const inputClassName = ` text-lg py-1 px-2 border border-1 border-gray-300 ${
disabled ? "bg-gray-200" : "bg-white "
} rounded`;
return (
<div className="flex gap-8">
<div>
<h4 className="text-lg">Release name:</h4>
<input
className={inputClassName}
value={releaseName}
disabled={disabled}
onChange={(e) => onReleaseNameInput(e.target.value)}
></input>
</div>
<div>
<h4 className="text-lg">Namespace (optional):</h4>
<input
className={inputClassName}
value={namespaceInputValue}
disabled={disabled}
onChange={(e) => setNamespaceInputValue(e.target.value)}
></input>
</div>
{context ? (
<div className="flex">
<h4 className="text-lg">Cluster:</h4>
<p className="text-lg">{context}</p>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,260 @@
import { useParams } from "react-router-dom";
import useAlertError from "../../../hooks/useAlertError";
import { useMemo, useState } from "react";
import {
useChartReleaseValues,
useGetReleaseManifest,
useGetVersions,
useVersionData,
} from "../../../API/releases";
import Modal, { ModalButtonStyle } from "../Modal";
import { GeneralDetails } from "./GeneralDetails";
import { ManifestDiff } from "./ManifestDiff";
import { useMutation } from "@tanstack/react-query";
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
import { VersionToInstall } from "./VersionToInstall";
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
import useCustomSearchParams from "../../../hooks/useCustomSearchParams";
import { useChartRepoValues } from "../../../API/repositories";
import { useDiffData } from "../../../API/shared";
import { InstallChartModalProps } from "../../../data/types";
import { DefinedValues } from "./DefinedValues";
export const InstallReleaseChartModal = ({
isOpen,
onClose,
chartName,
currentlyInstalledChartVersion,
latestVersion,
isUpgrade = false,
latestRevision,
}: InstallChartModalProps) => {
const navigate = useNavigateWithSearchParams();
const { setShowErrorModal } = useAlertError();
const [userValues, setUserValues] = useState<string>();
const [installError, setInstallError] = useState("");
const {
namespace: queryNamespace,
chart: _releaseName,
context: selectedCluster,
} = useParams();
const { searchParamsObject } = useCustomSearchParams();
const { filteredNamespace } = searchParamsObject;
const [namespace, setNamespace] = useState(queryNamespace || "");
const [releaseName, setReleaseName] = useState(_releaseName || "");
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
select: (data) => {
return data?.sort((a, b) =>
isNewerVersion(a.version, b.version) ? 1 : -1
);
},
onSuccess: (data) => {
const empty = { version: "", repository: "", urls: [] };
return setSelectedVersionData(data[0] ?? empty);
},
});
const versions = _versions?.map((v) => ({
...v,
isChartVersion: v.version === currentlyInstalledChartVersion,
}));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
const [selectedVersionData, setSelectedVersionData] = useState<{
version: string;
repository?: string;
urls: string[];
}>();
const selectedVersion = useMemo(() => {
return selectedVersionData?.version;
}, [selectedVersionData]);
const selectedRepo = useMemo(() => {
return selectedVersionData?.repository || "";
}, [selectedVersionData]);
const chartAddress = useMemo(() => {
if (!selectedVersionData || !selectedVersionData.repository) return "";
return selectedVersionData.urls?.[0]?.startsWith("file://")
? selectedVersionData.urls[0]
: `${selectedVersionData.repository}/${chartName}`;
}, [selectedVersionData, chartName]);
// the original chart values
const { data: chartValues } = useChartRepoValues({
version: selectedVersion || "",
chart: chartAddress,
});
// The user defined values (if any we're set)
const { data: releaseValues, isLoading: loadingReleaseValues } =
useChartReleaseValues({
namespace,
release: String(releaseName),
revision: latestRevision ? latestRevision : undefined,
});
// This hold the selected version manifest, we use it for the diff
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
{
version: selectedVersion || "",
userValues: userValues || "",
chartAddress,
releaseValues,
namespace,
releaseName,
}
);
const { data: currentVerManifest, error: currentVerManifestError } =
useGetReleaseManifest({
namespace,
chartName: _releaseName || "",
});
const {
data: diffData,
isLoading: isLoadingDiff,
error: diffError,
} = useDiffData({
selectedRepo,
versionsError: versionsError as string,
currentVerManifest,
selectedVerData,
chart: chartAddress,
});
// Confirm method (install)
const setReleaseVersionMutation = useMutation(
[
"setVersion",
namespace,
releaseName,
selectedVersion,
selectedRepo,
selectedCluster,
chartAddress,
],
async () => {
setInstallError("");
const formData = new FormData();
formData.append("preview", "false");
if (chartAddress) {
formData.append("chart", chartAddress);
}
formData.append("version", selectedVersion || "");
formData.append("values", userValues || releaseValues || ""); // if userValues is empty, we use the release values
const res = await fetch(
// Todo: Change to BASE_URL from env
`/api/helm/releases/${
namespace ? namespace : "default"
}${`/${releaseName}`}`,
{
method: "post",
body: formData,
headers: {
"X-Kubecontext": selectedCluster as string,
},
}
);
if (!res.ok) {
setShowErrorModal({
title: "Failed to upgrade the chart",
msg: String(await res.text()),
});
}
return res.json();
},
{
onSuccess: async (response) => {
onClose();
setSelectedVersionData({ version: "", urls: [] }); //cleanup
navigate(
`/${selectedCluster}/${
namespace ? namespace : "default"
}/${releaseName}/installed/revision/${response.version}`
);
window.location.reload();
},
onError: (error) => {
setInstallError((error as Error)?.message || "Failed to update");
},
}
);
return (
<Modal
isOpen={isOpen}
onClose={() => {
setSelectedVersionData({ version: "", urls: [] });
setUserValues(releaseValues);
onClose();
}}
title={
<div className="font-bold">
{`${isUpgrade ? "Upgrade" : "Install"} `}
{(isUpgrade || releaseValues) && (
<span className="text-green-700 ">{chartName}</span>
)}
</div>
}
containerClassNames="w-full text-2xl h-2/3"
actions={[
{
id: "1",
callback: setReleaseVersionMutation.mutate,
variant: ModalButtonStyle.info,
isLoading: setReleaseVersionMutation.isLoading,
disabled:
loadingReleaseValues ||
isLoadingDiff ||
setReleaseVersionMutation.isLoading,
},
]}
>
{versions && isNoneEmptyArray(versions) && (
<VersionToInstall
versions={versions}
initialVersion={selectedVersionData}
onSelectVersion={setSelectedVersionData}
showCurrentVersion
/>
)}
<GeneralDetails
releaseName={releaseName}
disabled
namespace={namespace ? namespace : filteredNamespace}
onReleaseNameInput={setReleaseName}
onNamespaceInput={setNamespace}
/>
<DefinedValues
initialValue={releaseValues}
onUserValuesChange={(values: string) => setUserValues(values)}
chartValues={chartValues}
loading={loadingReleaseValues}
/>
<ManifestDiff
diff={diffData as string}
isLoading={isLoadingDiff}
error={
(currentVerManifestError as string) ||
(selectedVerDataError as string) ||
(diffError as string) ||
installError ||
(versionsError as string)
}
/>
</Modal>
);
};

View File

@@ -0,0 +1,229 @@
import { useParams } from "react-router-dom";
import useAlertError from "../../../hooks/useAlertError";
import { useMemo, useState } from "react";
import { useGetVersions, useVersionData } from "../../../API/releases";
import Modal, { ModalButtonStyle } from "../Modal";
import { GeneralDetails } from "./GeneralDetails";
import { ManifestDiff } from "./ManifestDiff";
import { useMutation } from "@tanstack/react-query";
import { useChartRepoValues } from "../../../API/repositories";
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
import { VersionToInstall } from "./VersionToInstall";
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
import { useDiffData } from "../../../API/shared";
import { InstallChartModalProps } from "../../../data/types";
import { DefinedValues } from "./DefinedValues";
export const InstallRepoChartModal = ({
isOpen,
onClose,
chartName,
currentlyInstalledChartVersion,
latestVersion,
}: InstallChartModalProps) => {
const navigate = useNavigateWithSearchParams();
const { setShowErrorModal } = useAlertError();
const [userValues, setUserValues] = useState("");
const [installError, setInstallError] = useState("");
const { context: selectedCluster, selectedRepo: currentRepoCtx } =
useParams();
const [namespace, setNamespace] = useState("");
const [releaseName, setReleaseName] = useState(chartName);
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
select: (data) => {
return data?.sort((a, b) =>
isNewerVersion(a.version, b.version) ? 1 : -1
);
},
onSuccess: (data) => {
const empty = { version: "", repository: "", urls: [] };
const versionsToRepo = data.filter(
(v) => v.repository === currentRepoCtx
);
return setSelectedVersionData(versionsToRepo[0] ?? empty);
},
});
const versions = _versions?.map((v) => ({
...v,
isChartVersion: v.version === currentlyInstalledChartVersion,
}));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
const [selectedVersionData, setSelectedVersionData] = useState<{
version: string;
repository?: string;
urls: string[];
}>();
const selectedVersion = useMemo(() => {
return selectedVersionData?.version;
}, [selectedVersionData]);
const selectedRepo = useMemo(() => {
return selectedVersionData?.repository;
}, [selectedVersionData]);
const chartAddress = useMemo(() => {
if (!selectedVersionData || !selectedVersionData?.repository) {
return "";
}
return selectedVersionData?.urls?.[0]?.startsWith("file://")
? selectedVersionData?.urls[0]
: `${selectedVersionData?.repository}/${chartName}`;
}, [selectedVersionData, chartName]);
const { data: chartValues, isLoading: loadingChartValues } =
useChartRepoValues({
version: selectedVersion || "",
chart: chartAddress,
});
// This hold the selected version manifest, we use it for the diff
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
{
version: selectedVersion || "",
userValues,
chartAddress,
releaseValues: userValues,
namespace,
releaseName,
isInstallRepoChart: true,
options: {
enabled: Boolean(chartAddress),
},
}
);
const {
data: diffData,
isLoading: isLoadingDiff,
error: diffError,
} = useDiffData({
selectedRepo: selectedRepo || "",
versionsError: versionsError as string,
currentVerManifest: "", // current version manifest should always be empty since its a fresh install
selectedVerData,
chart: chartAddress,
});
// Confirm method (install)
const setReleaseVersionMutation = useMutation(
[
"setVersion",
namespace,
releaseName,
selectedVersion,
selectedRepo,
selectedCluster,
chartAddress,
],
async () => {
setInstallError("");
const formData = new FormData();
formData.append("preview", "false");
formData.append("chart", chartAddress);
formData.append("version", selectedVersion || "");
formData.append("values", userValues);
formData.append("name", releaseName || "");
const res = await fetch(
// Todo: Change to BASE_URL from env
`/api/helm/releases/${namespace ? namespace : "default"}`,
{
method: "post",
body: formData,
headers: {
"X-Kubecontext": selectedCluster as string,
},
}
);
if (!res.ok) {
setShowErrorModal({
title: "Failed to install the chart",
msg: String(await res.text()),
});
}
return res.json();
},
{
onSuccess: async (response) => {
onClose();
navigate(
`/${selectedCluster}/${response.namespace}/${response.name}/installed/revision/1`
);
},
onError: (error) => {
setInstallError((error as Error)?.message || "Failed to update");
},
}
);
return (
<Modal
isOpen={isOpen}
onClose={() => {
setSelectedVersionData({ version: "", urls: [] });
onClose();
}}
title={
<div className="font-bold">
Install <span className="text-green-700 ">{chartName}</span>
</div>
}
containerClassNames="w-full text-2xl h-2/3"
actions={[
{
id: "1",
callback: setReleaseVersionMutation.mutate,
variant: ModalButtonStyle.info,
isLoading: setReleaseVersionMutation.isLoading,
disabled:
loadingChartValues ||
isLoadingDiff ||
setReleaseVersionMutation.isLoading,
},
]}
>
{versions && isNoneEmptyArray(versions) && (
<VersionToInstall
versions={versions}
initialVersion={selectedVersionData}
onSelectVersion={setSelectedVersionData}
showCurrentVersion={false}
/>
)}
<GeneralDetails
releaseName={releaseName ?? ""}
disabled={false}
namespace={namespace}
onReleaseNameInput={setReleaseName}
onNamespaceInput={setNamespace}
/>
<DefinedValues
initialValue={""}
onUserValuesChange={setUserValues}
chartValues={chartValues}
loading={loadingChartValues}
/>
<ManifestDiff
diff={diffData as string}
isLoading={isLoadingDiff}
error={
(selectedVerDataError as string) ||
(diffError as string) ||
installError ||
(versionsError as string)
}
/>
</Modal>
);
};

View File

@@ -0,0 +1,65 @@
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-base";
import hljs from "highlight.js";
import { useEffect, useRef } from "react";
import Spinner from "../../Spinner";
import { diffConfiguration } from "../../../utils";
interface ManifestDiffProps {
diff?: string;
isLoading: boolean;
error: string;
}
export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
const diffContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isLoading) {
// we're listening to isLoading to draw new diffs which are not
// always rerender, probably because of the use of ref
return;
}
if (diff && diffContainerRef.current) {
const diff2htmlUi = new Diff2HtmlUI(
diffContainerRef.current,
diff,
diffConfiguration,
hljs
);
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
}
}, [diff, isLoading]);
if (isLoading && !error) {
return (
<div className="flex text-lg items-end">
<Spinner />
Calculating diff...
</div>
);
}
return (
<div>
<h4 className="text-xl">Manifest changes:</h4>
{error ? (
<p className="text-red-600 text-lg">
Failed to get upgrade info: {error.toString()}
</p>
) : diff ? (
<div
ref={diffContainerRef}
className="relative overflow-y-auto leading-5"
></div>
) : (
<pre className="font-roboto text-base">
No changes will happen to the cluster
</pre>
)}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import useDebounce from "../../../hooks/useDebounce";
export const UserDefinedValues = ({
initialValue,
onValuesChange,
}: {
initialValue: string;
onValuesChange: (val: string) => void;
}) => {
const [userDefinedValues, setUserDefinedValues] = useState(initialValue);
const debouncedValue = useDebounce<string>(userDefinedValues, 500);
useEffect(() => {
if (!debouncedValue || debouncedValue === initialValue) {
return;
}
onValuesChange(debouncedValue);
}, [debouncedValue, onValuesChange, initialValue]);
return (
<div className="w-1/2 ">
<label
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
htmlFor="grid-user-defined-values"
>
User-Defined Values:
</label>
<textarea
value={userDefinedValues}
defaultValue={initialValue}
onChange={(e) => setUserDefinedValues(e.target.value)}
rows={14}
className="block p-2.5 w-full text-md text-gray-900 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 resize-none font-monospace"
></textarea>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { useMemo, useState } from "react";
import Select, { components } from "react-select";
import { BsCheck2 } from "react-icons/bs";
import { NonEmptyArray } from "../../../data/types";
interface Version {
repository: string;
version: string;
isChartVersion: boolean;
urls: string[];
}
export const VersionToInstall: React.FC<{
versions: NonEmptyArray<Version>;
initialVersion?: {
repository?: string;
version?: string;
};
onSelectVersion: (props: {
version: string;
repository: string;
urls: string[];
}) => void;
showCurrentVersion: boolean;
}> = ({ versions, onSelectVersion, showCurrentVersion, initialVersion }) => {
const chartVersion = useMemo(
() => versions.find(({ isChartVersion }) => isChartVersion)?.version,
[versions]
);
const currentVersion =
chartVersion && showCurrentVersion ? (
<p className="text-xl text-muted ml-2">
{"(current version is "}
<span className="text-green-700">{`${chartVersion}`}</span>
{")"}
</p>
) : null;
// Prepare your options for react-select
const options = useMemo(
() =>
versions.map(({ repository, version, urls }) => ({
value: { repository, version, urls },
label: `${repository} @ ${version}`,
check: chartVersion === version,
})) || [],
[chartVersion, versions]
);
const [selectedOption, setSelectedOption] =
useState<(typeof options)[number]>();
const initOpt = useMemo(
() =>
options.find(
({ value }) =>
value.version === initialVersion?.version &&
value.repository === initialVersion?.repository
),
[options, initialVersion]
);
return (
<div className="flex gap-2 text-xl items-center">
{versions?.length && (selectedOption || initOpt) ? (
<>
Version to install:{" "}
<Select
className="basic-single cursor-pointer min-w-[272px]"
classNamePrefix="select"
isClearable={false}
isSearchable={false}
name="version"
options={options}
onChange={(selectedOption) => {
if (selectedOption) {
setSelectedOption(selectedOption);
onSelectVersion(selectedOption.value);
}
}}
value={selectedOption ?? initOpt}
components={{
SingleValue: ({ children, ...props }) => (
<components.SingleValue {...props}>
<span className="text-green-700 font-bold">{children}</span>
{props.data.check && showCurrentVersion && (
<BsCheck2 className="inline-block ml-2 text-green-700 font-bold" />
)}
</components.SingleValue>
),
Option: ({ children, innerProps, data }) => (
<div
className={
"flex items-center py-2 pl-4 pr-2 text-green-700 hover:bg-blue-100"
}
{...innerProps}
>
<div className="width-auto">{children}</div>
{data.check && showCurrentVersion && (
<BsCheck2
fontWeight={"bold"}
className="inline-block ml-2 text-green-700 font-bold"
/>
)}
</div>
),
}} // Use the custom Option component
/>
</>
) : null}
{currentVersion}
</div>
);
};

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