mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-28 07:18:03 +00:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ba15e1bd | ||
|
|
a6b8beb25c | ||
|
|
2b6964dcd5 | ||
|
|
e44556d100 | ||
|
|
cfc28cf3a0 | ||
|
|
443207191d | ||
|
|
c5ae60a779 | ||
|
|
4fb2eb099a | ||
|
|
62cf1dfc3e | ||
|
|
f7deda06f5 | ||
|
|
123f674e2f | ||
|
|
5d2a61c2ff | ||
|
|
f857f8dfdc | ||
|
|
6b07fbe242 | ||
|
|
2d1fa25e7e | ||
|
|
331925900a | ||
|
|
f91daafd4a | ||
|
|
a15e375105 | ||
|
|
2b7df9cfa3 | ||
|
|
b457be85c1 | ||
|
|
c9b8fb7809 | ||
|
|
939dd8ac0c | ||
|
|
ae598bec68 | ||
|
|
f647a3db03 | ||
|
|
ea7f8722ac | ||
|
|
4714d76784 | ||
|
|
5d0bdb40c1 | ||
|
|
e816f5881f | ||
|
|
2dfc25c038 | ||
|
|
aa2cc04084 | ||
|
|
65a250e2a4 | ||
|
|
323a60fe31 | ||
|
|
37af7dfbec | ||
|
|
05c7c0b5c4 | ||
|
|
9b3fd77105 | ||
|
|
9f07cea128 | ||
|
|
9d28119bc6 | ||
|
|
4c0821307d | ||
|
|
077582e795 | ||
|
|
651397e2d2 | ||
|
|
f660411722 | ||
|
|
f2eb91bc02 | ||
|
|
362f881b47 | ||
|
|
f10cc6d8a5 | ||
|
|
73f74d77bb | ||
|
|
7572f00f7c | ||
|
|
1129651e6c | ||
|
|
3f623458b3 | ||
|
|
f01c19f330 | ||
|
|
e50ae801a7 | ||
|
|
51df16e83e | ||
|
|
210a371d06 | ||
|
|
40161aee12 | ||
|
|
71d0a4d849 | ||
|
|
1d8151d41d | ||
|
|
b23310cb2d | ||
|
|
b5750ca40b | ||
|
|
63d55c1c25 | ||
|
|
756706dcd4 | ||
|
|
b76c4e077d | ||
|
|
5e24721801 | ||
|
|
7c8f3c29e0 | ||
|
|
219e6b7392 | ||
|
|
3ffdbba19b | ||
|
|
f749db9c4d | ||
|
|
996f637a9d | ||
|
|
2da8f23285 | ||
|
|
f22c84c288 | ||
|
|
285cc1fe1e | ||
|
|
96e103ff84 | ||
|
|
2717734406 | ||
|
|
9648f5ccce | ||
|
|
e1e176a22b | ||
|
|
930eefae5d | ||
|
|
aee1ac59ae | ||
|
|
cda3dd0c51 | ||
|
|
2439515055 | ||
|
|
6995fe957a | ||
|
|
5737e8495c | ||
|
|
6517c47754 | ||
|
|
aab9411ed2 | ||
|
|
63eb98e309 | ||
|
|
5b2f1e2818 | ||
|
|
945e68590b | ||
|
|
ee8bb96912 | ||
|
|
b0fb0e062b | ||
|
|
dc6d781374 | ||
|
|
d36bd6d09a | ||
|
|
21209945f2 | ||
|
|
dabf99ec1f | ||
|
|
13ac6385da | ||
|
|
2884712255 | ||
|
|
751746f0d2 | ||
|
|
e5e15f922c | ||
|
|
1a39abbdb5 | ||
|
|
69fe906c7d | ||
|
|
3b0b44f392 | ||
|
|
922bb1c7c2 | ||
|
|
f85343a173 | ||
|
|
14fa9b8894 | ||
|
|
0436eabb51 | ||
|
|
fb39d7e324 | ||
|
|
f1747b41d7 | ||
|
|
c4d4db9e68 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Node part
|
# Node part
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v2.5.2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.24"
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: |
|
run: |
|
||||||
go test -v -race ./... -covermode=atomic -coverprofile=coverage.out # Run all the tests with the race detector enabled
|
go test -v -race ./... -covermode=atomic -coverprofile=coverage.out # Run all the tests with the race detector enabled
|
||||||
@@ -53,10 +53,10 @@ jobs:
|
|||||||
uses: goreleaser/goreleaser-action@v2
|
uses: goreleaser/goreleaser-action@v2
|
||||||
with:
|
with:
|
||||||
version: "1.18.2"
|
version: "1.18.2"
|
||||||
args: release --snapshot --rm-dist
|
args: release --snapshot --clean
|
||||||
- name: Test if the Binary is Runnable
|
- name: Test if the Binary is Runnable
|
||||||
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
|
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: binaries
|
name: binaries
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v3
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.24"
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v4
|
uses: golangci/golangci-lint-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
args: --timeout=5m
|
args: --timeout=5m
|
||||||
|
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: ./frontend/package-lock.json
|
cache-dependency-path: ./frontend/package-lock.json
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
- name: Helm Template Check For Sanity
|
- name: Helm Template Check For Sanity
|
||||||
uses: igabaydulin/helm-check-action@0.1.4
|
uses: igabaydulin/helm-check-action@0.2.1
|
||||||
env:
|
env:
|
||||||
CHART_LOCATION: ./charts/helm-dashboard
|
CHART_LOCATION: ./charts/helm-dashboard
|
||||||
CHART_VALUES: ./charts/helm-dashboard/values.yaml
|
CHART_VALUES: ./charts/helm-dashboard/values.yaml
|
||||||
|
|||||||
2
.github/workflows/publish-chart.yaml
vendored
2
.github/workflows/publish-chart.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Bump versions
|
- name: Bump versions
|
||||||
|
|||||||
31
.github/workflows/release.yaml
vendored
31
.github/workflows/release.yaml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get tag name
|
- name: Get tag name
|
||||||
@@ -29,20 +29,33 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Node part
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: NPM install
|
||||||
|
run: npm i
|
||||||
|
working-directory: ./frontend
|
||||||
|
- name: NPM build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
# Golang part
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.22"
|
go-version: "1.24"
|
||||||
- name: git cleanup
|
- name: git cleanup
|
||||||
run: git clean -f
|
run: git clean -f && git checkout frontend/yarn.lock
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v2
|
uses: goreleaser/goreleaser-action@v2
|
||||||
with:
|
with:
|
||||||
version: "1.18.2"
|
version: "1.18.2"
|
||||||
args: release --rm-dist
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Test Binary Versions
|
- name: Test Binary Versions
|
||||||
@@ -54,7 +67,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v3
|
||||||
@@ -91,7 +104,7 @@ jobs:
|
|||||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Bump versions
|
- name: Bump versions
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ go.work
|
|||||||
.vscode/
|
.vscode/
|
||||||
/pkg/dashboard/objects/testdata/hello-world-0.1.0.tgz
|
/pkg/dashboard/objects/testdata/hello-world-0.1.0.tgz
|
||||||
/pkg/frontend/dist/*
|
/pkg/frontend/dist/*
|
||||||
|
/dist/
|
||||||
|
|||||||
2
.husky/pre-commit
Executable file
2
.husky/pre-commit
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
cd frontend || exit 1
|
||||||
|
npm run pre:commit
|
||||||
@@ -84,7 +84,7 @@ If your port 8080 is busy, you can specify a different port to use via `--port <
|
|||||||
|
|
||||||
If you need to limit the operations to a specific namespace, please use `--namespace=...` in your command-line. You can specify multiple namespaces, separated by commas.
|
If you need to limit the operations to a specific namespace, please use `--namespace=...` in your command-line. You can specify multiple namespaces, separated by commas.
|
||||||
|
|
||||||
If you don't want the browser tab to automatically open, add `--no-browser` flag in your command line.
|
If you don't want the browser tab to automatically open, add `--no-browser` flag in your command-line.
|
||||||
|
|
||||||
If you want to increase the logging verbosity and see all the debug info, use the `--verbose` flag.
|
If you want to increase the logging verbosity and see all the debug info, use the `--verbose` flag.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: application
|
|||||||
|
|
||||||
name: helm-dashboard
|
name: helm-dashboard
|
||||||
description: A GUI Dashboard for Helm by Komodor
|
description: A GUI Dashboard for Helm by Komodor
|
||||||
icon: "https://raw.githubusercontent.com/komodorio/helm-dashboard/main/images/logo.svg"
|
icon: "https://raw.githubusercontent.com/komodorio/helm-dashboard/refs/heads/main/images/logo.svg"
|
||||||
|
|
||||||
version: 2.0.1
|
version: 2.0.4
|
||||||
appVersion: "1.3.3"
|
appVersion: "2.0.4"
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ The following table lists the configurable parameters of the chart and their def
|
|||||||
| `dashboard.persistence.accessModes` | Persistent Volume access modes | `["ReadWriteOnce"]` |
|
| `dashboard.persistence.accessModes` | Persistent Volume access modes | `["ReadWriteOnce"]` |
|
||||||
| `dashboard.persistence.storageClass` | Persistent Volume storage class | `""` |
|
| `dashboard.persistence.storageClass` | Persistent Volume storage class | `""` |
|
||||||
| `dashboard.persistence.size` | Persistent Volume size | `100M` |
|
| `dashboard.persistence.size` | Persistent Volume size | `100M` |
|
||||||
| `dashboard.persistence.hostPath` | Set path in case you want to use local host path volumes (not recommended in production) | `""` |
|
| `dashboard.persistence.finalizers` | Finalizers for the Persistent Volume Claim | `[kubernetes.io/pvc-protection]` |
|
||||||
|
| `dashboard.persistence.lookupVolumeName` | Lookup volume name for the Persistent Volume Claim | `true` |
|
||||||
| `updateStrategy.type` | Set up update strategy for helm-dashboard installation. | `RollingUpdate` |
|
| `updateStrategy.type` | Set up update strategy for helm-dashboard installation. | `RollingUpdate` |
|
||||||
| `extraArgs` | Set the arguments to be supplied to the helm-dashboard binary | `[--no-browser, --bind=0.0.0.0]` |
|
| `extraArgs` | Set the arguments to be supplied to the helm-dashboard binary | `[--no-browser, --bind=0.0.0.0]` |
|
||||||
| `testImage.repository` | Test image registry/name | `busybox` |
|
| `testImage.repository` | Test image registry/name | `busybox` |
|
||||||
|
|||||||
@@ -11,16 +11,9 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this
|
|||||||
If release name contains chart name it will be used as a full name.
|
If release name contains chart name it will be used as a full name.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "helm-dashboard.fullname" -}}
|
{{- define "helm-dashboard.fullname" -}}
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
{{- if contains $name .Release.Name }}
|
{{- $fullname := default (ternary .Release.Name (printf "%s-%s" .Release.Name $name) (contains $name .Release.Name)) .Values.fullnameOverride }}
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
{{- $fullname | trunc 63 | trimSuffix "-" }}
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
@@ -54,11 +47,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||||||
Create the name of the service account to use
|
Create the name of the service account to use
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "helm-dashboard.serviceAccountName" -}}
|
{{- define "helm-dashboard.serviceAccountName" -}}
|
||||||
{{- if .Values.serviceAccount.create }}
|
{{- default (.Values.serviceAccount.create | ternary (include "helm-dashboard.fullname" .) "default") .Values.serviceAccount.name }}
|
||||||
{{- default (include "helm-dashboard.fullname" .) .Values.serviceAccount.name }}
|
|
||||||
{{- else }}
|
|
||||||
{{- default "default" .Values.serviceAccount.name }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
@@ -74,10 +63,7 @@ Return the proper image name
|
|||||||
*/}}
|
*/}}
|
||||||
{{- define "helm-dashboard.image" -}}
|
{{- define "helm-dashboard.image" -}}
|
||||||
{{- $image := .Values.image -}}
|
{{- $image := .Values.image -}}
|
||||||
{{- $tag := .Chart.AppVersion -}}
|
{{- $tag := default .Chart.AppVersion $image.tag -}}
|
||||||
{{- if $image.tag -}}
|
|
||||||
{{- $tag = $image.tag -}}
|
|
||||||
{{- end -}}
|
|
||||||
{{- $_ := set $image "tag" $tag -}}
|
{{- $_ := set $image "tag" $tag -}}
|
||||||
{{ include "common.images.image" (dict "imageRoot" $_ "global" .Values.global) }}
|
{{ include "common.images.image" (dict "imageRoot" $_ "global" .Values.global) }}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|||||||
@@ -10,47 +10,22 @@ metadata:
|
|||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
{{- with .Values.dashboard.persistence.finalizers }}
|
||||||
{{- if .Values.dashboard.persistence.hostPath }}
|
finalizers:
|
||||||
storageClassName: ""
|
|
||||||
{{- else }}
|
|
||||||
{{- if kindIs "string" .Values.dashboard.persistence.storageClass }}
|
|
||||||
storageClassName: "{{ .Values.dashboard.persistence.storageClass }}"
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
accessModes:
|
|
||||||
{{- if not (empty .Values.dashboard.persistence.accessModes) }}
|
|
||||||
{{- range .Values.dashboard.persistence.accessModes }}
|
|
||||||
- {{ . | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.dashboard.persistence.size | quote }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
---
|
|
||||||
{{- if and .Values.dashboard.persistence.enabled .Values.dashboard.persistence.hostPath -}}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolume
|
|
||||||
metadata:
|
|
||||||
name: {{ include "helm-dashboard.fullname" . }}
|
|
||||||
namespace: {{ .Release.Namespace | quote }}
|
|
||||||
labels:
|
|
||||||
{{- include "helm-dashboard.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.dashboard.persistence.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
{{- if not (empty .Values.dashboard.persistence.accessModes) }}
|
|
||||||
{{- range .Values.dashboard.persistence.accessModes }}
|
{{- range .Values.dashboard.persistence.accessModes }}
|
||||||
- {{ . | quote }}
|
- {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.dashboard.persistence.size | quote }}
|
||||||
|
{{- if and (.Values.dashboard.persistence.lookupVolumeName) (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (include "helm-dashboard.fullname" .)) }}
|
||||||
|
volumeName: {{ (lookup "v1" "PersistentVolumeClaim" .Release.Namespace (include "helm-dashboard.fullname" .)).spec.volumeName }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
capacity:
|
{{- with .Values.dashboard.persistence.storageClassName }}
|
||||||
storage: {{ .Values.dashboard.persistence.size | quote }}
|
storageClassName: {{ . }}
|
||||||
hostPath:
|
{{- end }}
|
||||||
path: {{ .Values.dashboard.persistence.hostPath | quote }}
|
{{- end }}
|
||||||
{{- end -}}
|
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ spec:
|
|||||||
name: http
|
name: http
|
||||||
selector:
|
selector:
|
||||||
{{- include "helm-dashboard.selectorLabels" . | nindent 4 }}
|
{{- include "helm-dashboard.selectorLabels" . | nindent 4 }}
|
||||||
|
{{- if .Values.service.loadBalancerIP }}
|
||||||
|
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ image:
|
|||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
# Overrides the image tag whose default is the chart appVersion.
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
tag: ""
|
tag: ""
|
||||||
|
# Specifies the exact image digest to pull.
|
||||||
|
digest: ""
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
|
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
@@ -47,12 +49,11 @@ dashboard:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
## If defined, storageClassName: <storageClass>
|
## If defined, storageClassName: <storageClass>
|
||||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
|
||||||
## If undefined (the default) or set to null, no storageClassName spec is
|
## If undefined (the default) or set to null, no storageClassName spec is
|
||||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||||
## GKE, AWS & OpenStack)
|
## GKE, AWS & OpenStack)
|
||||||
##
|
##
|
||||||
storageClass: null
|
# storageClassName: default
|
||||||
|
|
||||||
## Helm Dashboard Persistent Volume access modes
|
## Helm Dashboard Persistent Volume access modes
|
||||||
## Must match those of existing PV or dynamic provisioner
|
## Must match those of existing PV or dynamic provisioner
|
||||||
@@ -69,14 +70,19 @@ dashboard:
|
|||||||
##
|
##
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
|
||||||
## Set path in case you want to use local host path volumes (not recommended in production)
|
## Finalizer to ensure PVC is not deleted until the pod is terminated
|
||||||
##
|
##
|
||||||
hostPath: ""
|
finalizers:
|
||||||
|
- kubernetes.io/pvc-protection
|
||||||
|
|
||||||
## Helm Dashboard data Persistent Volume size
|
## Helm Dashboard data Persistent Volume size
|
||||||
##
|
##
|
||||||
size: 100M
|
size: 100M
|
||||||
|
|
||||||
|
## If 'lookupVolumeName' is set to true, Helm will attempt to retrieve
|
||||||
|
## the current value of 'spec.volumeName' and incorporate it into the template.
|
||||||
|
lookupVolumeName: true
|
||||||
|
|
||||||
## @param.updateStrategy.type Set up update strategy for helm-dashboard installation.
|
## @param.updateStrategy.type Set up update strategy for helm-dashboard installation.
|
||||||
## Set to Recreate if you use persistent volume that cannot be mounted by more than one pods to make sure the pods is destroyed first.
|
## Set to Recreate if you use persistent volume that cannot be mounted by more than one pods to make sure the pods is destroyed first.
|
||||||
## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
|
## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
|
||||||
@@ -100,6 +106,7 @@ securityContext: {}
|
|||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8080
|
port: 8080
|
||||||
|
loadBalancerIP: null
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ WORKING_DIRECTORY="$PWD"
|
|||||||
APP_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'appVersion:' | awk -F'"' '{print $2}')
|
APP_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'appVersion:' | awk -F'"' '{print $2}')
|
||||||
}
|
}
|
||||||
|
|
||||||
sed -i -e "s/appVersion.*/appVersion: \"${APP_VERSION}\" /g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
sed -i -e "s/appVersion.*/appVersion: \"${APP_VERSION}\"/g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
||||||
sed -i -e "s/version.*/version: \"${APP_VERSION}\" /g" plugin.yaml
|
sed -i -e "s/version.*/version: \"${APP_VERSION}\"/g" plugin.yaml
|
||||||
CURRENT_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'version:' | awk '{print $2}')
|
CURRENT_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'version:' | awk '{print $2}')
|
||||||
NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g')
|
NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g')
|
||||||
sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
2
frontend/.flowbite-react/.gitignore
vendored
Normal file
2
frontend/.flowbite-react/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
class-list.json
|
||||||
|
pid
|
||||||
10
frontend/.flowbite-react/config.json
Normal file
10
frontend/.flowbite-react/config.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/flowbite-react/schema.json",
|
||||||
|
"components": [],
|
||||||
|
"dark": false,
|
||||||
|
"path": "src/components",
|
||||||
|
"prefix": "",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"version": 4
|
||||||
|
}
|
||||||
22
frontend/.flowbite-react/init.tsx
Normal file
22
frontend/.flowbite-react/init.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// biome-ignore-all lint: auto-generated file
|
||||||
|
|
||||||
|
// This file is auto-generated by the flowbite-react CLI.
|
||||||
|
// Do not edit this file directly.
|
||||||
|
// Instead, edit the .flowbite-react/config.json file.
|
||||||
|
|
||||||
|
import { StoreInit } from "flowbite-react/store/init";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const CONFIG = {
|
||||||
|
dark: false,
|
||||||
|
prefix: "",
|
||||||
|
version: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeInit() {
|
||||||
|
return <StoreInit {...CONFIG} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeInit.displayName = "ThemeInit";
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
# Ignore artifacts:
|
# Ignore artifacts:
|
||||||
build
|
build
|
||||||
coverage
|
coverage
|
||||||
|
.env
|
||||||
|
.gitignore
|
||||||
|
.npmrc
|
||||||
|
.prettierignore
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
.flowbite-react/*
|
||||||
@@ -2,3 +2,7 @@ trailingComma: "es5"
|
|||||||
tabWidth: 2
|
tabWidth: 2
|
||||||
semi: true
|
semi: true
|
||||||
singleQuote: false
|
singleQuote: false
|
||||||
|
bracketSpacing: true
|
||||||
|
plugins:
|
||||||
|
- "prettier-plugin-tailwindcss" # should be last https://github.com/tailwindlabs/prettier-plugin-tailwindcss?tab=readme-ov-file#compatibility-with-other-prettier-plugins
|
||||||
|
tailwindStylesheet: "./src/index.css"
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
// .storybook/main.ts
|
|
||||||
|
|
||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
|
||||||
addons: [
|
|
||||||
"@storybook/addon-actions",
|
addons: ["@storybook/addon-links", "@storybook/addon-docs"],
|
||||||
"@storybook/addon-links",
|
|
||||||
"@storybook/addon-essentials",
|
|
||||||
"@storybook/addon-styling",
|
|
||||||
{
|
|
||||||
name: "@storybook/addon-styling",
|
|
||||||
},
|
|
||||||
"@storybook/addon-mdx-gfm",
|
|
||||||
],
|
|
||||||
core: {},
|
core: {},
|
||||||
|
|
||||||
framework: {
|
framework: {
|
||||||
name: "@storybook/react-vite",
|
name: "@storybook/react-vite",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
docs: {
|
|
||||||
autodocs: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import "../src/index.css";
|
|
||||||
import "tailwindcss/tailwind.css";
|
|
||||||
|
|
||||||
export const parameters = {
|
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import "tailwindcss/tailwind.css";
|
|
||||||
import "../src/index.css";
|
|
||||||
|
|
||||||
export const parameters = {
|
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
33
frontend/.storybook/preview.tsx
Normal file
33
frontend/.storybook/preview.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import "../src/index.css";
|
||||||
|
|
||||||
|
import { BrowserRouter } from "react-router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Preview, StoryFn } from "@storybook/react";
|
||||||
|
|
||||||
|
import { AppContextProvider } from "../src/context/AppContext";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decorators: Preview["decorators"] = [
|
||||||
|
(Story: StoryFn) => (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContextProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Story />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AppContextProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tags = ["autodocs"];
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Welcome to the frontend of the helm dashboard.
|
Welcome to the frontend of the helm dashboard.
|
||||||
We care most about keeping the project:
|
We care most about keeping the project:
|
||||||
|
|
||||||
1. Maintainable
|
1. Maintainable
|
||||||
2. Extendable
|
2. Extendable
|
||||||
3. Contributor friendly
|
3. Contributor friendly
|
||||||
@@ -28,12 +29,11 @@ Please follow through the file structure to understand how things are structured
|
|||||||
|
|
||||||
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).
|
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. run the backend server. This is also explained in the above link.
|
2. run the backend server. This is also explained in the above link.
|
||||||
2. go to `frontend` in your local project.
|
3. go to `frontend` in your local project.
|
||||||
3. in order to install dependencies and start the development server
|
4. in order to install dependencies and start the development server
|
||||||
- `npm i`
|
- `npm i`
|
||||||
- `npm run dev`
|
- `npm run dev`
|
||||||
4. with the default integration the dashboard should run on http://localhost:5173/
|
5. with the default integration the dashboard should run on http://localhost:5173/
|
||||||
|
|
||||||
|
|
||||||
# Component library
|
# Component library
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from "cypress";
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
allowCypressEnv: false,
|
||||||
component: {
|
component: {
|
||||||
devServer: {
|
devServer: {
|
||||||
framework: "react",
|
framework: "react",
|
||||||
@@ -9,8 +10,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
e2e: {
|
e2e: {
|
||||||
setupNodeEvents(on, config) {
|
baseUrl: "http://localhost:5173",
|
||||||
// implement node event listeners here
|
// setupNodeEvents(on, config) {
|
||||||
},
|
// // implement node event listeners here
|
||||||
|
// },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ describe("Adding repository flow", () => {
|
|||||||
const addChartRepositoryButton = "[data-cy='add-chart-repository-button']";
|
const addChartRepositoryButton = "[data-cy='add-chart-repository-button']";
|
||||||
|
|
||||||
it("Adding new chart repository", () => {
|
it("Adding new chart repository", () => {
|
||||||
cy.intercept("GET", "http://localhost:5173/status", {
|
cy.intercept("GET", "/status", {
|
||||||
fixture: "status.json",
|
fixture: "status.json",
|
||||||
}).as("status");
|
}).as("status");
|
||||||
|
|
||||||
cy.intercept("GET", "http://localhost:5173/api/helm/releases", {
|
cy.intercept("GET", "/api/helm/releases", {
|
||||||
fixture: "releases.json",
|
fixture: "releases.json",
|
||||||
}).as("releases");
|
}).as("releases");
|
||||||
|
|
||||||
cy.visit(
|
cy.visit("/#/minikube/installed?filteredNamespace=default");
|
||||||
"http://localhost:5173/#/minikube/installed?filteredNamespace=default"
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get("[data-cy='navigation-link']").contains("Repository").click();
|
cy.get("[data-cy='navigation-link']").contains("Repository").click();
|
||||||
cy.get("[data-cy='install-repository-button']").click();
|
cy.get("[data-cy='install-repository-button']").click();
|
||||||
@@ -22,11 +20,12 @@ describe("Adding repository flow", () => {
|
|||||||
cy.get(addChartNameInput).type("Komodorio");
|
cy.get(addChartNameInput).type("Komodorio");
|
||||||
cy.get(addChartUrlInput).type("https://helm-charts.komodor.io");
|
cy.get(addChartUrlInput).type("https://helm-charts.komodor.io");
|
||||||
|
|
||||||
cy.intercept("GET", "http://localhost:5173/api/helm/repositories", {
|
cy.intercept("GET", "/api/helm/repositories", {
|
||||||
fixture: "repositories.json",
|
fixture: "repositories.json",
|
||||||
}).as("repositories");
|
}).as("repositories");
|
||||||
|
|
||||||
cy.get(addChartRepositoryButton).click();
|
cy.get(addChartRepositoryButton).click();
|
||||||
|
cy.wait("@repositories");
|
||||||
|
|
||||||
cy.contains("https://helm-charts.komodor.io");
|
cy.contains("https://helm-charts.komodor.io");
|
||||||
|
|
||||||
@@ -36,15 +35,13 @@ describe("Adding repository flow", () => {
|
|||||||
.contains("Install")
|
.contains("Install")
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.intercept("POST", "http://localhost:5173/api/helm/releases/default", {
|
cy.intercept("POST", "/api/helm/releases/default", {
|
||||||
fixture: "defaultReleases.json",
|
fixture: "defaultReleases.json",
|
||||||
}).as("defaultReleases");
|
}).as("defaultReleases");
|
||||||
|
|
||||||
cy.intercept(
|
cy.intercept("GET", "/api/helm/releases/default/helm-dashboard/history", {
|
||||||
"GET",
|
fixture: "history.json",
|
||||||
"http://localhost:5173/api/helm/releases/default/helm-dashboard/history",
|
}).as("history");
|
||||||
{ fixture: "history.json" }
|
|
||||||
).as("history");
|
|
||||||
|
|
||||||
cy.contains("Confirm").click();
|
cy.contains("Confirm").click();
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,14 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"enabled":true,
|
"enabled":true,
|
||||||
"hostPath":"",
|
|
||||||
"labels":{
|
"labels":{
|
||||||
|
|
||||||
},
|
},
|
||||||
"size":"100M",
|
"size":"100M",
|
||||||
"storageClass":null
|
"finalizers":[
|
||||||
|
"kubernetes.io/pvc-protection"
|
||||||
|
],
|
||||||
|
"lookupVolumeName": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"debug":false,
|
"debug":false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./commands";
|
import "./commands";
|
||||||
import { mount } from "cypress/react18";
|
import { mount } from "cypress/react";
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
declare global {
|
declare global {
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
@@ -8,5 +9,5 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-namespace */
|
||||||
Cypress.Commands.add("mount", mount);
|
Cypress.Commands.add("mount", mount);
|
||||||
|
|||||||
119
frontend/eslint.config.js
Normal file
119
frontend/eslint.config.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import { configs as tseslintConf } from "typescript-eslint";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import { flatConfigs as importXFlatConf } from "eslint-plugin-import-x";
|
||||||
|
import prettierRecommended from "eslint-plugin-prettier/recommended";
|
||||||
|
// import tscPlugin from "eslint-plugin-tsc";
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
{ ignores: ["dist", "node_modules"] },
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslintConf.recommendedTypeChecked,
|
||||||
|
// tsEslint.configs.strictTypeChecked, // The project is not ready yet
|
||||||
|
// tsEslint.configs.stylisticTypeChecked, // Added for better 2026 coding standards, however the project is not ready yet
|
||||||
|
importXFlatConf.recommended,
|
||||||
|
importXFlatConf.typescript,
|
||||||
|
react.configs.flat.recommended,
|
||||||
|
react.configs.flat["jsx-runtime"],
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
prettierRecommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
projectService: {
|
||||||
|
allowDefaultProject: ["eslint.config.js"],
|
||||||
|
},
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: { version: "detect" },
|
||||||
|
"import-x/resolver": {
|
||||||
|
node: true,
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// plugins: {
|
||||||
|
// tsc: tscPlugin,
|
||||||
|
// },
|
||||||
|
rules: {
|
||||||
|
/* ───────── Base Overrides ───────── */
|
||||||
|
"no-console": ["error", { allow: ["error", "warn"] }],
|
||||||
|
"no-debugger": "error",
|
||||||
|
quotes: ["error", "double"],
|
||||||
|
semi: ["error", "always"],
|
||||||
|
|
||||||
|
/* ───────── Import Precision ───────── */
|
||||||
|
"import-x/no-duplicates": ["error", { "prefer-inline": true }],
|
||||||
|
|
||||||
|
/* ───────── React Precision ───────── */
|
||||||
|
"no-restricted-properties": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
object: "React",
|
||||||
|
message:
|
||||||
|
"Use named imports instead (e.g. import { useState } from 'react')",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
name: "react",
|
||||||
|
importNames: ["default"],
|
||||||
|
message: "Default React imports are prohibited. Use named imports.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ───────── TypeScript & Verbatim Syntax ───────── */
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
prefer: "type-imports",
|
||||||
|
fixStyle: "inline-type-imports",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
|
||||||
|
"@typescript-eslint/no-restricted-types": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
types: {
|
||||||
|
"React.FC": "Use 'import type { FC }' instead.",
|
||||||
|
"React.ReactNode": "Use 'import type { ReactNode }' instead.",
|
||||||
|
// FC: "Avoid FC (Functional Component) type; prefer explicit return types.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs}"],
|
||||||
|
...tseslintConf.disableTypeChecked,
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["eslint.config.js"],
|
||||||
|
rules: { "import-x/no-unresolved": "off" },
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Helm Dashboard</title>
|
<title>Helm Dashboard</title>
|
||||||
<script src="/assets/analytics.js"></script>
|
<script type="module" src="/analytics.js"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/github.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/github.min.css"
|
||||||
|
|||||||
34149
frontend/package-lock.json
generated
34149
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,84 +1,101 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard",
|
"name": "dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"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.46",
|
|
||||||
"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.5.0",
|
|
||||||
"@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",
|
|
||||||
"cypress": "^13.3.0",
|
|
||||||
"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.5.0",
|
|
||||||
"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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"prepare": "cd .. && husky",
|
||||||
|
"pre:commit": "lint-staged",
|
||||||
|
"test": "echo \"Error: no test specified. Please use 'cypress:run' or 'cypress:open' commands\" && exit 1",
|
||||||
|
"tsc": "tsc",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"storybook:build": "storybook build",
|
||||||
"lint": "npx eslint src/",
|
"lint": "npx eslint src/",
|
||||||
"lint:fix": "npm run lint -- --fix",
|
"lint:fix": "npm run lint -- --fix --max-warnings=0",
|
||||||
"prettier": "npx prettier src/ --check",
|
"prettier": "npx prettier src/ --check",
|
||||||
"prettier:fix": "npm run prettier -- --write",
|
"prettier:fix": "npm run prettier -- --write",
|
||||||
"cypress:open": "cypress open",
|
"cypress:open": "cypress open",
|
||||||
"cypress:run": "cypress run"
|
"cypress:run": "cypress run",
|
||||||
|
"cypress:component": "cypress run --component",
|
||||||
|
"cypress:component:open": "cypress open --component"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
|
"compare-versions": "^6.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"diff2html": "^3.4.52",
|
||||||
|
"flowbite-react": "^0.12.17",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"html-react-parser": "^5.2.17",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-error-boundary": "^6.1.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-intersection-observer": "^10.0.3",
|
||||||
|
"react-modern-drawer": "^1.4.0",
|
||||||
|
"react-router": "^7.13.0",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
|
"swagger-ui-react": "^5.31.2",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@storybook/addon-docs": "^10.2.10",
|
||||||
|
"@storybook/addon-links": "^10.2.10",
|
||||||
|
"@storybook/mdx2-csf": "^1.1.0",
|
||||||
|
"@storybook/react-vite": "^10.2.10",
|
||||||
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||||
|
"@typescript-eslint/parser": "^8.56.0",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"cypress": "15.10.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-import-x": "^4.16.1",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-storybook": "^10.2.10",
|
||||||
|
"eslint-plugin-tsc": "^2.0.0",
|
||||||
|
"flowbite": "^4.0.1",
|
||||||
|
"globals": "^17.3.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"rollup-plugin-visualizer": "^7.0.0",
|
||||||
|
"storybook": "^10.2.10",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.56.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-static-copy": "^3.2.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,jsx,ts,tsx}": "npm run lint:fix",
|
||||||
|
"*.{js,jsx,ts,tsx,json,css,md,mdx}": "npm run prettier:fix"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"dompurify": "^3.3.2"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -13,8 +13,24 @@ const BASE_ANALYTIC_MSG = {
|
|||||||
referrerPolicy: "no-referrer"
|
referrerPolicy: "no-referrer"
|
||||||
};
|
};
|
||||||
xhr.onload = function() {
|
xhr.onload = function() {
|
||||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
const status = JSON.parse(xhr.responseText);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTxt = xhr.responseText?.trim();
|
||||||
|
if (!responseTxt) {
|
||||||
|
console.warn("Analytics response is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
status = JSON.parse(responseTxt);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse JSON: ", xhr.responseText, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const version = status.CurVer;
|
const version = status.CurVer;
|
||||||
if (status.Analytics) {
|
if (status.Analytics) {
|
||||||
enableDD(version);
|
enableDD(version);
|
||||||
@@ -23,7 +39,6 @@ xhr.onload = function() {
|
|||||||
} else {
|
} else {
|
||||||
console.log("Analytics is disabled in this session");
|
console.log("Analytics is disabled in this session");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
xhr.open("GET", "/status", true);
|
xhr.open("GET", "/status", true);
|
||||||
xhr.send(null);
|
xhr.send(null);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import { type QueryFunctionContext } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type {
|
||||||
Chart,
|
Chart,
|
||||||
ChartVersion,
|
ChartVersion,
|
||||||
Release,
|
Release,
|
||||||
ReleaseHealthStatus,
|
ReleaseHealthStatus,
|
||||||
ReleaseRevision,
|
ReleaseRevision,
|
||||||
Repository,
|
|
||||||
} from "../data/types";
|
} from "../data/types";
|
||||||
import { type QueryFunctionContext } from "@tanstack/react-query";
|
|
||||||
interface ClustersResponse {
|
interface ClustersResponse {
|
||||||
AuthInfo: string;
|
AuthInfo: string;
|
||||||
Cluster: string;
|
Cluster: string;
|
||||||
@@ -25,7 +26,7 @@ class ApiService {
|
|||||||
public async fetchWithDefaults<T>(
|
public async fetchWithDefaults<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T | string> {
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
if (this.currentCluster) {
|
if (this.currentCluster) {
|
||||||
@@ -43,59 +44,80 @@ class ApiService {
|
|||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data;
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
if (!response.headers.get("Content-Type")) {
|
if (!contentType) {
|
||||||
return {} as T;
|
return {} as unknown as T;
|
||||||
} else if (response.headers.get("Content-Type")?.includes("text/plain")) {
|
} else if (contentType.includes("text/plain")) {
|
||||||
data = await response.text();
|
return await response.text();
|
||||||
} else {
|
} else {
|
||||||
data = await response.json();
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchWithSafeDefaults<T>({
|
||||||
|
url,
|
||||||
|
options,
|
||||||
|
fallback,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
options?: RequestInit;
|
||||||
|
fallback: T;
|
||||||
|
}): Promise<T> {
|
||||||
|
const data = await this.fetchWithDefaults<T>(url, options);
|
||||||
|
if (!data) {
|
||||||
|
console.error(url, " response is empty");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === "string") {
|
||||||
|
console.error(url, " response is string");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
getToolVersion = async () => {
|
getToolVersion = async () => {
|
||||||
const response = await fetch("/status");
|
return await this.fetchWithDefaults("/status");
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getRepositoryLatestVersion = async (repositoryName: string) => {
|
getRepositoryLatestVersion = async (repositoryName: string) => {
|
||||||
const data = await this.fetchWithDefaults(
|
return await this.fetchWithDefaults(
|
||||||
`/api/helm/repositories/latestver?name=${repositoryName}`
|
`/api/helm/repositories/latestver?name=${repositoryName}`
|
||||||
);
|
);
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getInstalledReleases = async () => {
|
getInstalledReleases = async () => {
|
||||||
const data = await this.fetchWithDefaults("/api/helm/releases");
|
return await this.fetchWithDefaults("/api/helm/releases");
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getClusters = async () => {
|
getClusters = async (): Promise<ClustersResponse[]> => {
|
||||||
const response = await fetch("/api/k8s/contexts");
|
return await this.fetchWithSafeDefaults<ClustersResponse[]>({
|
||||||
const data = (await response.json()) as ClustersResponse[];
|
url: "/api/k8s/contexts",
|
||||||
return data;
|
fallback: [],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
getNamespaces = async () => {
|
getNamespaces = async () => {
|
||||||
const data = await this.fetchWithDefaults("/api/k8s/namespaces/list");
|
return await this.fetchWithDefaults("/api/k8s/namespaces/list");
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getRepositories = async () => {
|
getRepositories = async () => {
|
||||||
const data = await this.fetchWithDefaults("/api/helm/repositories");
|
return await this.fetchWithDefaults("/api/helm/repositories");
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getRepositoryCharts = async ({
|
getRepositoryCharts = async ({
|
||||||
queryKey,
|
queryKey,
|
||||||
}: QueryFunctionContext<Chart[], Repository>) => {
|
}: {
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
}): Promise<Chart[]> => {
|
||||||
const [, repository] = queryKey;
|
const [, repository] = queryKey;
|
||||||
const data = await this.fetchWithDefaults(
|
if (!repository || typeof repository !== "string") {
|
||||||
`/api/helm/repositories/${repository}`
|
return [];
|
||||||
);
|
}
|
||||||
return data;
|
|
||||||
|
const url = `/api/helm/repositories/${repository}`;
|
||||||
|
return await this.fetchWithSafeDefaults<Chart[]>({ url, fallback: [] });
|
||||||
};
|
};
|
||||||
|
|
||||||
getChartVersions = async ({
|
getChartVersions = async ({
|
||||||
@@ -103,39 +125,37 @@ class ApiService {
|
|||||||
}: QueryFunctionContext<ChartVersion[], Chart>) => {
|
}: QueryFunctionContext<ChartVersion[], Chart>) => {
|
||||||
const [, chart] = queryKey;
|
const [, chart] = queryKey;
|
||||||
|
|
||||||
const data = await this.fetchWithDefaults(
|
return await this.fetchWithDefaults(
|
||||||
`/api/helm/repositories/versions?name=${chart.name}`
|
`/api/helm/repositories/versions?name=${chart.name}`
|
||||||
);
|
);
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getResourceStatus = async ({
|
getResourceStatus = async ({
|
||||||
release,
|
release,
|
||||||
}: {
|
}: {
|
||||||
release: Release;
|
release: Release;
|
||||||
}): Promise<ReleaseHealthStatus[] | null> => {
|
}): Promise<ReleaseHealthStatus[]> => {
|
||||||
if (!release) return null;
|
if (!release) return [];
|
||||||
|
|
||||||
const data = await this.fetchWithDefaults<
|
return await this.fetchWithSafeDefaults<ReleaseHealthStatus[]>({
|
||||||
Promise<ReleaseHealthStatus[] | null>
|
url: `/api/helm/releases/${release.namespace}/${release.name}/resources?health=true`,
|
||||||
>(
|
fallback: [],
|
||||||
`/api/helm/releases/${release.namespace}/${release.name}/resources?health=true`
|
});
|
||||||
);
|
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getReleasesHistory = async ({
|
getReleasesHistory = async ({
|
||||||
queryKey,
|
queryKey,
|
||||||
}: QueryFunctionContext<Release[], Release>): Promise<ReleaseRevision[]> => {
|
}: {
|
||||||
|
queryKey: readonly [string, Record<string, string | undefined>];
|
||||||
|
}): Promise<ReleaseRevision[]> => {
|
||||||
const [, params] = queryKey;
|
const [, params] = queryKey;
|
||||||
|
|
||||||
if (!params.namespace || !params.chart) return [];
|
if (!params.namespace || !params.chart) return [];
|
||||||
|
|
||||||
const data = await this.fetchWithDefaults<ReleaseRevision[]>(
|
return await this.fetchWithSafeDefaults<ReleaseRevision[]>({
|
||||||
`/api/helm/releases/${params.namespace}/${params.chart}/history`
|
url: `/api/helm/releases/${params.namespace}/${params.chart}/history`,
|
||||||
);
|
fallback: [],
|
||||||
|
});
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getValues = async ({
|
getValues = async ({
|
||||||
@@ -143,7 +163,7 @@ class ApiService {
|
|||||||
}: {
|
}: {
|
||||||
queryKey: [
|
queryKey: [
|
||||||
string,
|
string,
|
||||||
{ namespace: string; chart: { name: string }; version: number }
|
{ namespace: string; chart: { name: string }; version: number },
|
||||||
];
|
];
|
||||||
}) => {
|
}) => {
|
||||||
const [, params] = queryKey;
|
const [, params] = queryKey;
|
||||||
@@ -153,9 +173,7 @@ class ApiService {
|
|||||||
return Promise.reject(new Error("missing parameters"));
|
return Promise.reject(new Error("missing parameters"));
|
||||||
|
|
||||||
const url = `/api/helm/repositories/values?chart=${namespace}/${chart.name}&version=${version}`;
|
const url = `/api/helm/repositories/values?chart=${namespace}/${chart.name}&version=${version}`;
|
||||||
const data = await this.fetchWithDefaults(url);
|
return await this.fetchWithDefaults(url);
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface ApplicationStatus {
|
|||||||
ClusterMode: boolean;
|
ClusterMode: boolean;
|
||||||
CurVer: string;
|
CurVer: string;
|
||||||
LatestVer: string;
|
LatestVer: string;
|
||||||
|
NoHealth: boolean;
|
||||||
|
NoLatest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KubectlContexts {
|
export interface KubectlContexts {
|
||||||
|
|||||||
@@ -1,57 +1,74 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
||||||
import { K8sResource, K8sResourceList, KubectlContexts } from "./interfaces";
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
import type {
|
||||||
|
K8sResource,
|
||||||
|
K8sResourceList,
|
||||||
|
KubectlContexts,
|
||||||
|
} from "./interfaces";
|
||||||
|
|
||||||
// Get list of kubectl contexts configured locally
|
// Get list of kubectl contexts configured locally
|
||||||
|
// @ts-expect-error unused
|
||||||
function useGetKubectlContexts(options?: UseQueryOptions<KubectlContexts>) {
|
function useGetKubectlContexts(options?: UseQueryOptions<KubectlContexts>) {
|
||||||
return useQuery<KubectlContexts>(
|
return useQuery<KubectlContexts>({
|
||||||
["k8s", "contexts"],
|
queryKey: ["k8s", "contexts"],
|
||||||
() => apiService.fetchWithDefaults<KubectlContexts>("/api/k8s/contexts"),
|
queryFn: () =>
|
||||||
options
|
apiService.fetchWithSafeDefaults<KubectlContexts>({
|
||||||
);
|
url: "/api/k8s/contexts",
|
||||||
|
fallback: { contexts: [] },
|
||||||
|
}),
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get resources information
|
// Get resources information
|
||||||
|
// @ts-expect-error unused
|
||||||
function useGetK8sResource(
|
function useGetK8sResource(
|
||||||
kind: string,
|
kind: string,
|
||||||
name: string,
|
name: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
options?: UseQueryOptions<K8sResource>
|
options?: UseQueryOptions<K8sResource>
|
||||||
) {
|
) {
|
||||||
return useQuery<K8sResource>(
|
return useQuery<K8sResource>({
|
||||||
["k8s", kind, "get", name, namespace],
|
queryKey: ["k8s", kind, "get", name, namespace],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<K8sResource>(
|
apiService.fetchWithSafeDefaults<K8sResource>({
|
||||||
`/api/k8s/${kind}/get?name=${name}&namespace=${namespace}`
|
url: `/api/k8s/${kind}/get?name=${name}&namespace=${namespace}`,
|
||||||
),
|
fallback: { kind: "", name: "", namespace: "" },
|
||||||
options
|
}),
|
||||||
);
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get list of resources
|
// Get list of resources
|
||||||
|
// @ts-expect-error unused
|
||||||
function useGetK8sResourceList(
|
function useGetK8sResourceList(
|
||||||
kind: string,
|
kind: string,
|
||||||
options?: UseQueryOptions<K8sResourceList>
|
options?: UseQueryOptions<K8sResourceList>
|
||||||
) {
|
) {
|
||||||
return useQuery<K8sResourceList>(
|
return useQuery<K8sResourceList>({
|
||||||
["k8s", kind, "list"],
|
queryKey: ["k8s", kind, "list"],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<K8sResourceList>(`/api/k8s/${kind}/list`),
|
apiService.fetchWithSafeDefaults<K8sResourceList>({
|
||||||
options
|
url: `/api/k8s/${kind}/list`,
|
||||||
);
|
fallback: { items: [] },
|
||||||
|
}),
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get describe text for kubernetes resource
|
// Get describe text for kubernetes resource
|
||||||
|
// @ts-expect-error unused
|
||||||
function useGetK8sResourceDescribe(
|
function useGetK8sResourceDescribe(
|
||||||
kind: string,
|
kind: string,
|
||||||
name: string,
|
name: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
options?: UseQueryOptions<string>
|
options?: UseQueryOptions<string>
|
||||||
) {
|
) {
|
||||||
return useQuery<string>(
|
return useQuery<string>({
|
||||||
["k8s", kind, "describe", name, namespace],
|
queryKey: ["k8s", kind, "describe", name, namespace],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<string>(
|
apiService.fetchWithDefaults<string>(
|
||||||
`/api/k8s/${kind}/describe?name=${name}&namespace=${namespace}`,
|
`/api/k8s/${kind}/describe?name=${name}&namespace=${namespace}`,
|
||||||
{
|
{
|
||||||
@@ -60,6 +77,6 @@ function useGetK8sResourceDescribe(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,35 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { ApplicationStatus } from "./interfaces";
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
import type { ApplicationStatus } from "./interfaces";
|
||||||
|
|
||||||
// Shuts down the Helm Dashboard application
|
// Shuts down the Helm Dashboard application
|
||||||
export function useShutdownHelmDashboard(
|
export function useShutdownHelmDashboard(
|
||||||
options?: UseMutationOptions<void, Error>
|
options?: UseMutationOptions<string, Error>
|
||||||
) {
|
) {
|
||||||
return useMutation<void, Error>(
|
return useMutation<string, Error>({
|
||||||
() =>
|
mutationFn: () =>
|
||||||
apiService.fetchWithDefaults("/", {
|
apiService.fetchWithDefaults("/", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets application status
|
// Gets application status
|
||||||
export function useGetApplicationStatus(
|
export function useGetApplicationStatus(
|
||||||
options?: UseQueryOptions<ApplicationStatus>
|
options?: UseQueryOptions<ApplicationStatus | null>
|
||||||
) {
|
) {
|
||||||
return useQuery<ApplicationStatus>(
|
return useQuery<ApplicationStatus | null>({
|
||||||
["status"],
|
queryKey: ["status"],
|
||||||
() => apiService.fetchWithDefaults<ApplicationStatus>("/status"),
|
queryFn: async () =>
|
||||||
{
|
await apiService.fetchWithSafeDefaults<ApplicationStatus | null>({
|
||||||
...options,
|
url: "/status",
|
||||||
}
|
fallback: null,
|
||||||
);
|
}),
|
||||||
|
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
useQuery,
|
|
||||||
type UseQueryOptions,
|
|
||||||
useMutation,
|
useMutation,
|
||||||
type UseMutationOptions,
|
type UseMutationOptions,
|
||||||
|
useQuery,
|
||||||
|
type UseQueryOptions,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { ChartVersion, Release } from "../data/types";
|
|
||||||
import { LatestChartVersion } from "./interfaces";
|
import type { ChartVersion, Release } from "../data/types";
|
||||||
|
import { isNewerVersion } from "../utils";
|
||||||
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
import type { LatestChartVersion } from "./interfaces";
|
||||||
import { getVersionManifestFormData } from "./shared";
|
import { getVersionManifestFormData } from "./shared";
|
||||||
|
|
||||||
export const HD_RESOURCE_CONDITION_TYPE = "hdHealth"; // it's our custom condition type, only one exists
|
export const HD_RESOURCE_CONDITION_TYPE = "hdHealth"; // it's our custom condition type, only one exists
|
||||||
|
|
||||||
export function useGetInstalledReleases(
|
export function useGetInstalledReleases(context: string) {
|
||||||
context: string,
|
return useQuery<Release[]>({
|
||||||
options?: UseQueryOptions<Release[]>
|
queryKey: ["installedReleases", context],
|
||||||
) {
|
queryFn: () =>
|
||||||
return useQuery<Release[]>(
|
apiService.fetchWithSafeDefaults<Release[]>({
|
||||||
["installedReleases", context],
|
url: "/api/helm/releases",
|
||||||
() => apiService.fetchWithDefaults<Release[]>("/api/helm/releases"),
|
fallback: [],
|
||||||
options
|
}),
|
||||||
);
|
retry: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseManifest {
|
export interface ReleaseManifest {
|
||||||
@@ -62,96 +67,154 @@ export function useGetReleaseManifest({
|
|||||||
chartName: string;
|
chartName: string;
|
||||||
options?: UseQueryOptions<ReleaseManifest[]>;
|
options?: UseQueryOptions<ReleaseManifest[]>;
|
||||||
}) {
|
}) {
|
||||||
return useQuery<ReleaseManifest[]>(
|
return useQuery<ReleaseManifest[]>({
|
||||||
["manifest", namespace, chartName],
|
queryKey: ["manifest", namespace, chartName],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<ReleaseManifest[]>(
|
apiService.fetchWithSafeDefaults<ReleaseManifest[]>({
|
||||||
`/api/helm/releases/${namespace}/${chartName}/manifests`
|
url: `/api/helm/releases/${namespace}/${chartName}/manifests`,
|
||||||
),
|
fallback: [],
|
||||||
options
|
}),
|
||||||
);
|
...(options ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerImage {
|
||||||
|
resource: string;
|
||||||
|
kind: string;
|
||||||
|
container: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetImages(ns: string, name: string) {
|
||||||
|
return useQuery<ContainerImage[]>({
|
||||||
|
queryKey: ["images", ns, name],
|
||||||
|
queryFn: () =>
|
||||||
|
apiService.fetchWithSafeDefaults<ContainerImage[]>({
|
||||||
|
url: `/api/helm/releases/${ns}/${name}/images`,
|
||||||
|
fallback: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationNode {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
inRelease: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationGraph {
|
||||||
|
nodes: RelationNode[];
|
||||||
|
edges: RelationEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetRelations(ns: string, name: string) {
|
||||||
|
return useQuery<RelationGraph>({
|
||||||
|
queryKey: ["relations", ns, name],
|
||||||
|
queryFn: () =>
|
||||||
|
apiService.fetchWithSafeDefaults<RelationGraph>({
|
||||||
|
url: `/api/helm/releases/${ns}/${name}/relations`,
|
||||||
|
fallback: { nodes: [], edges: [] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of installed k8s resources for this release
|
// List of installed k8s resources for this release
|
||||||
export function useGetResources(
|
export function useGetResources(ns: string, name: string, enabled?: boolean) {
|
||||||
ns: string,
|
return useQuery<StructuredResources[]>({
|
||||||
name: string,
|
queryKey: ["resources", ns, name],
|
||||||
options?: UseQueryOptions<StructuredResources[]>
|
queryFn: () =>
|
||||||
) {
|
apiService.fetchWithSafeDefaults<StructuredResources[]>({
|
||||||
const { data, ...rest } = useQuery<StructuredResources[]>(
|
url: `/api/helm/releases/${ns}/${name}/resources?health=true`,
|
||||||
["resources", ns, name],
|
fallback: [],
|
||||||
() =>
|
|
||||||
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,
|
select: (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())
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetResourceDescription(
|
export function useGetResourceDescription(
|
||||||
type: string,
|
type: string,
|
||||||
ns: string,
|
ns: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
apiVersion?: string,
|
||||||
options?: UseQueryOptions<string>
|
options?: UseQueryOptions<string>
|
||||||
) {
|
) {
|
||||||
return useQuery<string>(
|
const params = new URLSearchParams({ name, namespace: ns });
|
||||||
["describe", type, ns, name],
|
if (apiVersion) {
|
||||||
() =>
|
params.set("apiVersion", apiVersion);
|
||||||
|
}
|
||||||
|
return useQuery<string>({
|
||||||
|
queryKey: ["describe", type, ns, name, apiVersion],
|
||||||
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<string>(
|
apiService.fetchWithDefaults<string>(
|
||||||
`/api/k8s/${type}/describe?name=${name}&namespace=${ns}`,
|
`/api/k8s/${type}/describe?${params.toString()}`,
|
||||||
{
|
{
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
export function useGetLatestVersion(
|
export function useGetLatestVersion(
|
||||||
chartName: string,
|
chartName: string,
|
||||||
options?: UseQueryOptions<ChartVersion[]>
|
options?: UseQueryOptions<ChartVersion[]>
|
||||||
) {
|
) {
|
||||||
return useQuery<ChartVersion[]>(
|
return useQuery<ChartVersion[]>({
|
||||||
["latestver", chartName],
|
queryKey: ["latestver", chartName],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<ChartVersion[]>(
|
apiService.fetchWithSafeDefaults<ChartVersion[]>({
|
||||||
`/api/helm/repositories/latestver?name=${chartName}`
|
url: `/api/helm/repositories/latestver?name=${chartName}`,
|
||||||
),
|
fallback: [],
|
||||||
options
|
}),
|
||||||
);
|
gcTime: 0,
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
export function useGetVersions(
|
export function useGetVersions(
|
||||||
chartName: string,
|
chartName: string,
|
||||||
options?: UseQueryOptions<LatestChartVersion[]>
|
options?: UseQueryOptions<LatestChartVersion[]>
|
||||||
) {
|
) {
|
||||||
return useQuery<LatestChartVersion[]>(
|
return useQuery<LatestChartVersion[]>({
|
||||||
["versions", chartName],
|
queryKey: ["versions", chartName],
|
||||||
() =>
|
queryFn: async () => {
|
||||||
apiService.fetchWithDefaults<LatestChartVersion[]>(
|
const url = `/api/helm/repositories/versions?name=${chartName}`;
|
||||||
`/api/helm/repositories/versions?name=${chartName}`
|
return await apiService.fetchWithSafeDefaults<LatestChartVersion[]>({
|
||||||
),
|
url,
|
||||||
options
|
fallback: [],
|
||||||
);
|
});
|
||||||
|
},
|
||||||
|
select: (data) =>
|
||||||
|
data?.sort((a, b) => (isNewerVersion(a.version, b.version) ? 1 : -1)),
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetReleaseInfoByType(
|
export function useGetReleaseInfoByType(
|
||||||
@@ -160,77 +223,80 @@ export function useGetReleaseInfoByType(
|
|||||||
options?: UseQueryOptions<string>
|
options?: UseQueryOptions<string>
|
||||||
) {
|
) {
|
||||||
const { chart, namespace, tab, revision } = params;
|
const { chart, namespace, tab, revision } = params;
|
||||||
return useQuery<string>(
|
return useQuery<string>({
|
||||||
[tab, namespace, chart, revision, additionalParams],
|
queryKey: [tab, namespace, chart, revision, additionalParams],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<string>(
|
apiService.fetchWithDefaults<string>(
|
||||||
`/api/helm/releases/${namespace}/${chart}/${tab}?revision=${revision}${additionalParams}`,
|
`/api/helm/releases/${namespace}/${chart}/${tab}?revision=${revision}${additionalParams}`,
|
||||||
{
|
{
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetDiff(
|
export function useGetDiff(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
options?: UseQueryOptions<string>
|
options?: UseQueryOptions<string>
|
||||||
) {
|
) {
|
||||||
return useQuery<string>(
|
return useQuery<string>({
|
||||||
["diff", formData],
|
queryKey: ["diff", formData],
|
||||||
() => {
|
queryFn: () => {
|
||||||
return apiService.fetchWithDefaults<string>("/diff", {
|
return apiService.fetchWithDefaults<string>("/diff", {
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rollback the release to a previous revision
|
// Rollback the release to a previous revision
|
||||||
export function useRollbackRelease(
|
export function useRollbackRelease(
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
void,
|
string,
|
||||||
unknown,
|
Error,
|
||||||
{ ns: string; name: string; revision: number }
|
{ ns: string; name: string; revision: number }
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
return useMutation<
|
return useMutation<
|
||||||
void,
|
string,
|
||||||
unknown,
|
Error,
|
||||||
{ ns: string; name: string; revision: number }
|
{ ns: string; name: string; revision: number }
|
||||||
>(({ ns, name, revision }) => {
|
>({
|
||||||
const formData = new FormData();
|
mutationFn: ({ ns, name, revision }) => {
|
||||||
formData.append("revision", revision.toString());
|
const formData = new FormData();
|
||||||
|
formData.append("revision", revision.toString());
|
||||||
|
|
||||||
return apiService.fetchWithDefaults<void>(
|
return apiService.fetchWithDefaults<string>(
|
||||||
`/api/helm/releases/${ns}/${name}/rollback`,
|
`/api/helm/releases/${ns}/${name}/rollback`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, options);
|
},
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the tests on a release
|
// Run the tests on a release
|
||||||
export function useTestRelease(
|
export function useTestRelease(
|
||||||
options?: UseMutationOptions<void, unknown, { ns: string; name: string }>
|
options?: UseMutationOptions<string, Error, { ns: string; name: string }>
|
||||||
) {
|
) {
|
||||||
return useMutation<void, unknown, { ns: string; name: string }>(
|
return useMutation<string, Error, { ns: string; name: string }>({
|
||||||
({ ns, name }) => {
|
mutationFn: ({ ns, name }) => {
|
||||||
return apiService.fetchWithDefaults<void>(
|
return apiService.fetchWithDefaults<string>(
|
||||||
`/api/helm/releases/${ns}/${name}/test`,
|
`/api/helm/releases/${ns}/${name}/test`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChartReleaseValues({
|
export function useChartReleaseValues({
|
||||||
@@ -246,12 +312,12 @@ export function useChartReleaseValues({
|
|||||||
userDefinedValue?: string;
|
userDefinedValue?: string;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
options?: UseQueryOptions<unknown>;
|
options?: UseQueryOptions<string>;
|
||||||
}) {
|
}) {
|
||||||
return useQuery<unknown>(
|
return useQuery<string>({
|
||||||
["values", namespace, release, userDefinedValue, version],
|
queryKey: ["values", namespace, release, userDefinedValue, version],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<unknown>(
|
apiService.fetchWithDefaults(
|
||||||
`/api/helm/releases/${namespace}/${release}/values?${"userDefined=true"}${
|
`/api/helm/releases/${namespace}/${release}/values?${"userDefined=true"}${
|
||||||
revision ? `&revision=${revision}` : ""
|
revision ? `&revision=${revision}` : ""
|
||||||
}`,
|
}`,
|
||||||
@@ -259,10 +325,16 @@ export function useChartReleaseValues({
|
|||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VersionData = {
|
||||||
|
version: string;
|
||||||
|
repository?: string;
|
||||||
|
urls: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const useVersionData = ({
|
export const useVersionData = ({
|
||||||
version,
|
version,
|
||||||
userValues,
|
userValues,
|
||||||
@@ -271,7 +343,7 @@ export const useVersionData = ({
|
|||||||
namespace,
|
namespace,
|
||||||
releaseName,
|
releaseName,
|
||||||
isInstallRepoChart = false,
|
isInstallRepoChart = false,
|
||||||
options,
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
version: string;
|
version: string;
|
||||||
userValues: string;
|
userValues: string;
|
||||||
@@ -280,10 +352,10 @@ export const useVersionData = ({
|
|||||||
namespace: string;
|
namespace: string;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
isInstallRepoChart?: boolean;
|
isInstallRepoChart?: boolean;
|
||||||
options?: UseQueryOptions;
|
enabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return useQuery(
|
return useQuery<{ [key: string]: string }>({
|
||||||
[
|
queryKey: [
|
||||||
version,
|
version,
|
||||||
userValues,
|
userValues,
|
||||||
chartAddress,
|
chartAddress,
|
||||||
@@ -292,7 +364,7 @@ export const useVersionData = ({
|
|||||||
releaseName,
|
releaseName,
|
||||||
isInstallRepoChart,
|
isInstallRepoChart,
|
||||||
],
|
],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
const formData = getVersionManifestFormData({
|
const formData = getVersionManifestFormData({
|
||||||
version,
|
version,
|
||||||
userValues,
|
userValues,
|
||||||
@@ -301,22 +373,26 @@ export const useVersionData = ({
|
|||||||
releaseName,
|
releaseName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchUrl = isInstallRepoChart
|
const url = isInstallRepoChart
|
||||||
? `/api/helm/releases/${namespace || "default"}`
|
? `/api/helm/releases/${namespace || "default"}`
|
||||||
: `/api/helm/releases/${
|
: `/api/helm/releases/${
|
||||||
namespace ? namespace : "[empty]"
|
namespace ? namespace : "[empty]"
|
||||||
}${`/${releaseName}`}`;
|
}${`/${releaseName}`}`;
|
||||||
|
|
||||||
const data = await apiService.fetchWithDefaults(fetchUrl, {
|
return await apiService.fetchWithSafeDefaults<{
|
||||||
method: "post",
|
[key: string]: string;
|
||||||
body: formData,
|
}>({
|
||||||
|
url,
|
||||||
|
options: {
|
||||||
|
method: "post",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
fallback: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
|
||||||
options
|
enabled,
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Request objects
|
// Request objects
|
||||||
|
|||||||
@@ -4,49 +4,60 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { HelmRepositories } from "./interfaces";
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
import type { HelmRepositories } from "./interfaces";
|
||||||
|
|
||||||
// Get list of Helm repositories
|
// Get list of Helm repositories
|
||||||
export function useGetRepositories(
|
export function useGetRepositories(
|
||||||
options?: UseQueryOptions<HelmRepositories>
|
options?: UseQueryOptions<HelmRepositories>
|
||||||
) {
|
) {
|
||||||
return useQuery<HelmRepositories>(
|
return useQuery<HelmRepositories>({
|
||||||
["helm", "repositories"],
|
queryKey: ["helm", "repositories"],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<HelmRepositories>("/api/helm/repositories"),
|
apiService.fetchWithSafeDefaults<HelmRepositories>({
|
||||||
options
|
url: "/api/helm/repositories",
|
||||||
);
|
fallback: [],
|
||||||
|
}),
|
||||||
|
select: (data) => data?.sort((a, b) => a?.name?.localeCompare(b?.name)),
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update repository from remote
|
// Update repository from remote
|
||||||
export function useUpdateRepo(
|
export function useUpdateRepo(
|
||||||
repo: string,
|
repo: string,
|
||||||
options?: UseMutationOptions<void, unknown, void>
|
options?: UseMutationOptions<string, Error>
|
||||||
) {
|
) {
|
||||||
return useMutation<void, unknown, void>(() => {
|
return useMutation<string, Error>({
|
||||||
return apiService.fetchWithDefaults<void>(
|
mutationFn: () => {
|
||||||
`/api/helm/repositories/${repo}`,
|
return apiService.fetchWithDefaults<string>(
|
||||||
{
|
`/api/helm/repositories/${repo}`,
|
||||||
method: "POST",
|
{
|
||||||
}
|
method: "POST",
|
||||||
);
|
}
|
||||||
}, options);
|
);
|
||||||
|
},
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove repository
|
// Remove repository
|
||||||
export function useDeleteRepo(
|
export function useDeleteRepo(
|
||||||
repo: string,
|
repo: string,
|
||||||
options?: UseMutationOptions<void, unknown, void>
|
options?: UseMutationOptions<string, Error>
|
||||||
) {
|
) {
|
||||||
return useMutation<void, unknown, void>(() => {
|
return useMutation<string, Error>({
|
||||||
return apiService.fetchWithDefaults<void>(
|
mutationFn: () => {
|
||||||
`/api/helm/repositories/${repo}`,
|
return apiService.fetchWithDefaults<string>(
|
||||||
{
|
`/api/helm/repositories/${repo}`,
|
||||||
method: "DELETE",
|
{
|
||||||
}
|
method: "DELETE",
|
||||||
);
|
}
|
||||||
}, options);
|
);
|
||||||
|
},
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChartRepoValues({
|
export function useChartRepoValues({
|
||||||
@@ -56,17 +67,15 @@ export function useChartRepoValues({
|
|||||||
version: string;
|
version: string;
|
||||||
chart: string;
|
chart: string;
|
||||||
}) {
|
}) {
|
||||||
return useQuery<string>(
|
return useQuery<string>({
|
||||||
["helm", "repositories", "values", chart, version],
|
queryKey: ["helm", "repositories", "values", chart, version],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<string>(
|
apiService.fetchWithDefaults<string>(
|
||||||
`/api/helm/repositories/values?chart=${chart}&version=${version}`,
|
`/api/helm/repositories/values?chart=${chart}&version=${version}`,
|
||||||
{
|
{
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{
|
enabled: Boolean(version) && Boolean(chart),
|
||||||
enabled: Boolean(version) && Boolean(chart),
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,48 +7,65 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { ScanResult, ScanResults, ScannersList } from "./interfaces";
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
import {
|
||||||
|
type ScanResult,
|
||||||
|
type ScanResults,
|
||||||
|
type ScannersList,
|
||||||
|
} from "./interfaces";
|
||||||
|
|
||||||
// Get list of discovered scanners
|
// Get list of discovered scanners
|
||||||
|
// @ts-expect-error unused
|
||||||
function useGetDiscoveredScanners(options?: UseQueryOptions<ScannersList>) {
|
function useGetDiscoveredScanners(options?: UseQueryOptions<ScannersList>) {
|
||||||
return useQuery<ScannersList>(
|
return useQuery<ScannersList>({
|
||||||
["scanners"],
|
queryKey: ["scanners"],
|
||||||
() => apiService.fetchWithDefaults<ScannersList>("/api/scanners"),
|
queryFn: () =>
|
||||||
options
|
apiService.fetchWithSafeDefaults<ScannersList>({
|
||||||
);
|
url: "/api/scanners",
|
||||||
|
fallback: { scanners: [] },
|
||||||
|
}),
|
||||||
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan manifests using all applicable scanners
|
// Scan manifests using all applicable scanners
|
||||||
|
// @ts-expect-error unused
|
||||||
function useScanManifests(
|
function useScanManifests(
|
||||||
manifest: string,
|
manifest: string,
|
||||||
options?: UseMutationOptions<ScanResults, Error, string>
|
options?: UseMutationOptions<ScanResults, Error, string>
|
||||||
) {
|
) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("manifest", manifest);
|
formData.append("manifest", manifest);
|
||||||
return useMutation<ScanResults, Error, string>(
|
return useMutation<ScanResults, Error, string>({
|
||||||
() =>
|
mutationFn: () =>
|
||||||
apiService.fetchWithDefaults<ScanResults>("/api/scanners/manifests", {
|
apiService.fetchWithSafeDefaults<ScanResults>({
|
||||||
method: "POST",
|
url: "/api/scanners/manifests",
|
||||||
body: formData,
|
options: {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
fallback: {},
|
||||||
}),
|
}),
|
||||||
options
|
...(options ?? {}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan specified k8s resource in cluster
|
// Scan specified k8s resource in cluster
|
||||||
|
// @ts-expect-error unused
|
||||||
function useScanK8sResource(
|
function useScanK8sResource(
|
||||||
kind: string,
|
kind: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
name: string,
|
name: string,
|
||||||
options?: UseQueryOptions<ScanResults>
|
options?: UseQueryOptions<ScanResults>
|
||||||
) {
|
) {
|
||||||
return useQuery<ScanResults>(
|
return useQuery<ScanResults>({
|
||||||
["scanners", "resource", kind, namespace, name],
|
queryKey: ["scanners", "resource", kind, namespace, name],
|
||||||
() =>
|
queryFn: () =>
|
||||||
apiService.fetchWithDefaults<ScanResults>(
|
apiService.fetchWithSafeDefaults<ScanResults>({
|
||||||
`/api/scanners/resource/${kind}?namespace=${namespace}&name=${name}`
|
url: `/api/scanners/resource/${kind}?namespace=${namespace}&name=${name}`,
|
||||||
),
|
fallback: {},
|
||||||
options
|
}),
|
||||||
);
|
...(options ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import apiService from "./apiService";
|
import apiService from "./apiService";
|
||||||
|
|
||||||
export const getVersionManifestFormData = ({
|
export const getVersionManifestFormData = ({
|
||||||
@@ -43,9 +44,15 @@ export const useDiffData = ({
|
|||||||
selectedVerData: { [key: string]: string };
|
selectedVerData: { [key: string]: string };
|
||||||
chart: string;
|
chart: string;
|
||||||
}) => {
|
}) => {
|
||||||
return useQuery(
|
return useQuery({
|
||||||
[selectedRepo, versionsError, chart, currentVerManifest, selectedVerData],
|
queryKey: [
|
||||||
async () => {
|
selectedRepo,
|
||||||
|
versionsError,
|
||||||
|
chart,
|
||||||
|
currentVerManifest,
|
||||||
|
selectedVerData,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("a", currentVerManifest);
|
formData.append("a", currentVerManifest);
|
||||||
formData.append("b", selectedVerData.manifest);
|
formData.append("b", selectedVerData.manifest);
|
||||||
@@ -57,8 +64,6 @@ export const useDiffData = ({
|
|||||||
|
|
||||||
return diff;
|
return diff;
|
||||||
},
|
},
|
||||||
{
|
enabled: Boolean(selectedVerData),
|
||||||
enabled: Boolean(selectedVerData),
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { type FC, useState, lazy } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { HashRouter, Outlet, Route, Routes, useParams } from "react-router";
|
||||||
|
|
||||||
|
import apiService from "./API/apiService";
|
||||||
|
import ErrorFallback from "./components/ErrorFallback";
|
||||||
|
import GlobalErrorModal from "./components/modal/GlobalErrorModal";
|
||||||
|
import { AppContextProvider } from "./context/AppContext";
|
||||||
|
import {
|
||||||
|
ErrorModalContext,
|
||||||
|
type ErrorAlert,
|
||||||
|
} from "./context/ErrorModalContext";
|
||||||
import Header from "./layout/Header";
|
import Header from "./layout/Header";
|
||||||
import { HashRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
|
|
||||||
import "./index.css";
|
|
||||||
import Installed from "./pages/Installed";
|
import Installed from "./pages/Installed";
|
||||||
import RepositoryPage from "./pages/Repository";
|
import RepositoryPage from "./pages/Repository";
|
||||||
import Revision from "./pages/Revision";
|
import Revision from "./pages/Revision";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
const DocsPage = lazy(() => import("./pages/DocsPage"));
|
||||||
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -23,19 +29,19 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
const PageLayout = () => {
|
const PageLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex h-screen flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="bg-body-background bg-no-repeat bg-[url('./assets/body-background.svg')] flex-1">
|
<div className="flex-1 bg-body-background bg-[url('./assets/body-background.svg')] bg-no-repeat">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SyncContext: React.FC = () => {
|
const SyncContext: FC = () => {
|
||||||
const { context } = useParams();
|
const { context } = useParams();
|
||||||
if (context) {
|
if (context) {
|
||||||
apiService.setCluster(context);
|
apiService.setCluster(decodeURIComponent(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
@@ -51,33 +57,33 @@ export default function App() {
|
|||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<ErrorModalContext.Provider value={value}>
|
<ErrorModalContext.Provider value={value}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HashRouter>
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<Routes>
|
<HashRouter>
|
||||||
<Route path="docs/" element={<DocsPage />} />
|
<Routes>
|
||||||
<Route path="*" element={<PageLayout />}>
|
<Route path="docs/*" element={<DocsPage />} />
|
||||||
<Route path=":context?/*" element={<SyncContext />}>
|
<Route path="*" element={<PageLayout />}>
|
||||||
<Route path="installed/?" element={<Installed />} />
|
<Route path=":context?/*" element={<SyncContext />}>
|
||||||
<Route
|
<Route
|
||||||
path=":namespace/:chart/installed/revision/:revision"
|
path="repository/:selectedRepo?/*"
|
||||||
element={<Revision />}
|
element={<RepositoryPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="repository/" element={<RepositoryPage />} />
|
<Route path="installed/?" element={<Installed />} />
|
||||||
<Route
|
<Route
|
||||||
path="repository/:selectedRepo?"
|
path=":namespace/:chart/installed/revision/:revision"
|
||||||
element={<RepositoryPage />}
|
element={<Revision />}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Installed />} />
|
<Route path="*" element={<Installed />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Installed />} />
|
</Routes>
|
||||||
</Route>
|
</HashRouter>
|
||||||
</Routes>
|
</ErrorBoundary>
|
||||||
<GlobalErrorModal
|
<GlobalErrorModal
|
||||||
isOpen={!!shouldShowErrorModal}
|
isOpen={!!shouldShowErrorModal}
|
||||||
onClose={() => setShowErrorModal(undefined)}
|
onClose={() => setShowErrorModal(undefined)}
|
||||||
titleText={shouldShowErrorModal?.title || ""}
|
titleText={shouldShowErrorModal?.title || ""}
|
||||||
contentText={shouldShowErrorModal?.msg || ""}
|
contentText={shouldShowErrorModal?.msg || ""}
|
||||||
/>
|
/>
|
||||||
</HashRouter>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorModalContext.Provider>
|
</ErrorModalContext.Provider>
|
||||||
</AppContextProvider>
|
</AppContextProvider>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
* @see https://storybook.js.org/docs/react/writing-stories/introduction
|
* @see https://storybook.js.org/docs/react/writing-stories/introduction
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import Badge from "./Badge";
|
import Badge from "./Badge";
|
||||||
|
|
||||||
// We set the metadata for the story.
|
// We set the metadata for the story.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
import type { JSX, ReactNode } from "react";
|
||||||
|
|
||||||
export type BadgeCode = "success" | "warning" | "error" | "unknown";
|
export type BadgeCode = "success" | "warning" | "error" | "unknown";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export const BadgeCodes = Object.freeze({
|
|||||||
|
|
||||||
export interface BadgeProps {
|
export interface BadgeProps {
|
||||||
type: BadgeCode;
|
type: BadgeCode;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
additionalClassNames?: string;
|
additionalClassNames?: string;
|
||||||
}
|
}
|
||||||
export default function Badge(props: BadgeProps): JSX.Element {
|
export default function Badge(props: BadgeProps): JSX.Element {
|
||||||
@@ -41,7 +42,7 @@ export default function Badge(props: BadgeProps): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const badgeBase =
|
const badgeBase =
|
||||||
"inline-flex items-center px-1 py-1 rounded text-xs font-light";
|
"inline-flex items-center px-1 py-1 rounded-sm text-xs font-light";
|
||||||
|
|
||||||
const badgeElem = (
|
const badgeElem = (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { mount } from "cypress/react18";
|
import { mount } from "cypress/react";
|
||||||
|
|
||||||
import { Button } from "./common/Button/Button";
|
import { Button } from "./common/Button/Button";
|
||||||
|
|
||||||
describe("Button component tests", () => {
|
describe("Button component tests", () => {
|
||||||
const buttonText = "buttonText";
|
const buttonText = "buttonText";
|
||||||
|
|
||||||
it("renders", () => {
|
it("renders", () => {
|
||||||
mount(<Button onClick={() => {}}></Button>);
|
mount(<Button onClick={() => {}} label=""></Button>);
|
||||||
cy.get("button").should("exist");
|
cy.get("button").should("exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -17,14 +18,14 @@ describe("Button component tests", () => {
|
|||||||
it("calls onClick when clicked", () => {
|
it("calls onClick when clicked", () => {
|
||||||
const onClickStub = cy.stub().as("onClick");
|
const onClickStub = cy.stub().as("onClick");
|
||||||
|
|
||||||
mount(<Button onClick={onClickStub}></Button>);
|
mount(<Button onClick={onClickStub} label={""}></Button>);
|
||||||
|
|
||||||
cy.get("button").click();
|
cy.get("button").click();
|
||||||
cy.get("@onClick").should("have.been.calledOnce");
|
cy.get("@onClick").should("have.been.calledOnce");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be disabled", () => {
|
it("should be disabled", () => {
|
||||||
mount(<Button onClick={() => {}} disabled></Button>);
|
mount(<Button onClick={() => {}} disabled label={""}></Button>);
|
||||||
|
|
||||||
cy.get("button").should("be.disabled");
|
cy.get("button").should("be.disabled");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
import type { HTMLAttributes, JSX, ReactNode } from "react";
|
||||||
|
|
||||||
// this is a type declaration for the action prop.
|
// this is a type declaration for the action prop.
|
||||||
// it is a function that takes a string as an argument and returns void.
|
// it is a function that takes a string as an argument and returns void.
|
||||||
export interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -26,7 +27,7 @@ export default function Button(props: ButtonProps): JSX.Element {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
className={`${props.className} bg-white border border-gray-300 hover:bg-gray-50 text-black py-1 px-4 rounded `}
|
className={`${props.className} rounded-sm border border-gray-300 bg-white px-4 py-1 text-black hover:bg-gray-50`}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { AppContextProvider } from "../context/AppContext";
|
|
||||||
import ClustersList from "./ClustersList";
|
|
||||||
import { BrowserRouter } from "react-router-dom";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Release } from "../data/types";
|
import { BrowserRouter } from "react-router";
|
||||||
|
|
||||||
|
import { AppContextProvider } from "../context/AppContext";
|
||||||
|
import type { Release } from "../data/types";
|
||||||
|
|
||||||
|
import ClustersList from "./ClustersList";
|
||||||
|
import { DeploymentStatus } from "./common/StatusLabel";
|
||||||
|
|
||||||
type ClustersListProps = {
|
type ClustersListProps = {
|
||||||
onClusterChange: (clusterName: string) => void;
|
onClusterChange: (clusterName: string) => void;
|
||||||
@@ -17,7 +20,7 @@ const generateTestReleaseData = (): Release => ({
|
|||||||
namespace: "default",
|
namespace: "default",
|
||||||
revision: 1,
|
revision: 1,
|
||||||
updated: "2024-01-23T15:37:35.0992836+02:00",
|
updated: "2024-01-23T15:37:35.0992836+02:00",
|
||||||
status: "deployed",
|
status: DeploymentStatus.DEPLOYED,
|
||||||
chart: "helm-dashboard-0.1.10",
|
chart: "helm-dashboard-0.1.10",
|
||||||
chart_name: "helm-dashboard",
|
chart_name: "helm-dashboard",
|
||||||
chart_ver: "0.1.10",
|
chart_ver: "0.1.10",
|
||||||
@@ -44,6 +47,14 @@ const renderClustersList = (props: ClustersListProps) => {
|
|||||||
|
|
||||||
describe("ClustersList", () => {
|
describe("ClustersList", () => {
|
||||||
it("Got one cluster information", () => {
|
it("Got one cluster information", () => {
|
||||||
|
cy.intercept("GET", "/api/k8s/contexts", [
|
||||||
|
{
|
||||||
|
Name: "minikube",
|
||||||
|
Namespace: "default",
|
||||||
|
IsCurrent: true,
|
||||||
|
},
|
||||||
|
]).as("getClusters");
|
||||||
|
|
||||||
renderClustersList({
|
renderClustersList({
|
||||||
selectedCluster: "minikube",
|
selectedCluster: "minikube",
|
||||||
filteredNamespaces: ["default"],
|
filteredNamespaces: ["default"],
|
||||||
@@ -51,12 +62,21 @@ describe("ClustersList", () => {
|
|||||||
installedReleases: [generateTestReleaseData()],
|
installedReleases: [generateTestReleaseData()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cy.wait("@getClusters");
|
||||||
cy.get(".data-cy-clusterName").contains("minikube");
|
cy.get(".data-cy-clusterName").contains("minikube");
|
||||||
cy.get(".data-cy-clusterList-namespace").contains("default");
|
cy.get(".data-cy-clusterList-namespace").contains("default");
|
||||||
cy.get(".data-cy-clustersInput").should("be.checked");
|
cy.get(".data-cy-clustersInput").should("be.checked");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Dont have a cluster chekced", () => {
|
it("Dont have a cluster chekced", () => {
|
||||||
|
cy.intercept("GET", "/api/k8s/contexts", [
|
||||||
|
{
|
||||||
|
Name: "minikube",
|
||||||
|
Namespace: "default",
|
||||||
|
IsCurrent: true,
|
||||||
|
},
|
||||||
|
]).as("getClusters");
|
||||||
|
|
||||||
renderClustersList({
|
renderClustersList({
|
||||||
selectedCluster: "",
|
selectedCluster: "",
|
||||||
filteredNamespaces: [""],
|
filteredNamespaces: [""],
|
||||||
@@ -64,6 +84,7 @@ describe("ClustersList", () => {
|
|||||||
installedReleases: [generateTestReleaseData()],
|
installedReleases: [generateTestReleaseData()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cy.wait("@getClusters");
|
||||||
cy.get(".data-cy-clustersInput").should("not.be.checked");
|
cy.get(".data-cy-clustersInput").should("not.be.checked");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
import ClustersList from "./ClustersList";
|
import ClustersList from "./ClustersList";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Cluster, Release } from "../data/types";
|
|
||||||
import apiService from "../API/apiService";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import useCustomSearchParams from "../hooks/useCustomSearchParams";
|
import { useEffect, useEffectEvent, useMemo } from "react";
|
||||||
import { useAppContext } from "../context/AppContext";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import apiService from "../API/apiService";
|
||||||
|
import { useAppContext } from "../context/AppContext";
|
||||||
|
import type { Cluster, Release } from "../data/types";
|
||||||
|
import useCustomSearchParams from "../hooks/useCustomSearchParams";
|
||||||
|
|
||||||
type ClustersListProps = {
|
type ClustersListProps = {
|
||||||
onClusterChange: (clusterName: string) => void;
|
onClusterChange: (clusterName: string) => void;
|
||||||
selectedCluster: string;
|
selectedCluster: string;
|
||||||
@@ -44,29 +45,36 @@ function ClustersList({
|
|||||||
const { upsertSearchParams, removeSearchParam } = useCustomSearchParams();
|
const { upsertSearchParams, removeSearchParam } = useCustomSearchParams();
|
||||||
const { clusterMode } = useAppContext();
|
const { clusterMode } = useAppContext();
|
||||||
|
|
||||||
const { data: clusters } = useQuery<Cluster[]>({
|
const { data: clusters = [], isSuccess } = useQuery<Cluster[]>({
|
||||||
queryKey: ["clusters", selectedCluster],
|
queryKey: ["clusters", selectedCluster],
|
||||||
queryFn: apiService.getClusters,
|
queryFn: apiService.getClusters,
|
||||||
onSuccess(data) {
|
select: (data) =>
|
||||||
const sortedData = data?.sort((a, b) =>
|
data?.sort((a, b) =>
|
||||||
getCleanClusterName(a.Name).localeCompare(getCleanClusterName(b.Name))
|
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 onSuccess = useEffectEvent((clusters: Cluster[]) => {
|
||||||
|
if (clusters && clusters.length && !selectedCluster) {
|
||||||
|
onClusterChange(clusters[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCluster) {
|
||||||
|
const cluster = clusters.find(
|
||||||
|
(cluster) => getCleanClusterName(cluster.Name) === selectedCluster
|
||||||
|
);
|
||||||
|
if (!filteredNamespaces && cluster?.Namespace) {
|
||||||
|
upsertSearchParams("filteredNamespace", cluster.Namespace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clusters && isSuccess) {
|
||||||
|
onSuccess(clusters);
|
||||||
|
}
|
||||||
|
}, [clusters, isSuccess]);
|
||||||
|
|
||||||
const namespaces = useMemo(() => {
|
const namespaces = useMemo(() => {
|
||||||
const mapNamespaces = new Map<string, number>();
|
const mapNamespaces = new Map<string, number>();
|
||||||
|
|
||||||
@@ -98,47 +106,41 @@ function ClustersList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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-">
|
<div className="custom- custom-shadow m-5 flex h-fit w-48 flex-col rounded-sm bg-white p-2 pb-4 text-cluster-list">
|
||||||
{!clusterMode ? (
|
{!clusterMode ? (
|
||||||
<>
|
<>
|
||||||
<label className="font-bold">Clusters</label>
|
<label className="font-bold">Clusters</label>
|
||||||
{clusters
|
{clusters?.map((cluster) => {
|
||||||
?.sort((a, b) =>
|
return (
|
||||||
getCleanClusterName(a.Name).localeCompare(
|
<span
|
||||||
getCleanClusterName(b.Name)
|
key={cluster.Name + cluster.Namespace}
|
||||||
)
|
className="data-cy-clusterName mt-2 flex items-center text-xs"
|
||||||
)
|
>
|
||||||
?.map((cluster) => {
|
<input
|
||||||
return (
|
className="data-cy-clustersInput cursor-pointer"
|
||||||
<span
|
onChange={(e) => {
|
||||||
key={cluster.Name}
|
onClusterChange(e.target.value);
|
||||||
className="data-cy-clusterName flex items-center mt-2 text-xs"
|
}}
|
||||||
>
|
type="radio"
|
||||||
<input
|
id={cluster.Name}
|
||||||
className="cursor-pointer data-cy-clustersInput"
|
value={cluster.Name}
|
||||||
onChange={(e) => {
|
checked={cluster.Name === selectedCluster}
|
||||||
onClusterChange(e.target.value);
|
name="clusters"
|
||||||
}}
|
/>
|
||||||
type="radio"
|
<label htmlFor={cluster.Name} className="ml-1">
|
||||||
id={cluster.Name}
|
{getCleanClusterName(cluster.Name)}
|
||||||
value={cluster.Name}
|
</label>
|
||||||
checked={cluster.Name === selectedCluster}
|
</span>
|
||||||
name="clusters"
|
);
|
||||||
/>
|
})}
|
||||||
<label htmlFor={cluster.Name} className="ml-1 ">
|
|
||||||
{getCleanClusterName(cluster.Name)}
|
|
||||||
</label>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<label className="font-bold mt-4">Namespaces</label>
|
<label className="mt-4 font-bold">Namespaces</label>
|
||||||
{namespaces
|
{namespaces
|
||||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
?.map((namespace) => (
|
?.map((namespace) => (
|
||||||
<span key={namespace.name} className="flex items-center mt-2 text-xs">
|
<span key={namespace.name} className="mt-2 flex items-center text-xs">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id={namespace.name}
|
id={namespace.name}
|
||||||
|
|||||||
114
frontend/src/components/ErrorFallback/ErrorFallback.cy.tsx
Normal file
114
frontend/src/components/ErrorFallback/ErrorFallback.cy.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { mount } from "cypress/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import ErrorFallback from "./ErrorFallback";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component tests for ErrorFallback
|
||||||
|
* Tests the error fallback UI and reset functionality
|
||||||
|
*/
|
||||||
|
describe("ErrorFallback", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Ensure portal root exists for createPortal
|
||||||
|
if (!document.getElementById("portal")) {
|
||||||
|
const portalDiv = document.createElement("div");
|
||||||
|
portalDiv.id = "portal";
|
||||||
|
document.body.appendChild(portalDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error modal with error message and hint", () => {
|
||||||
|
const mockError = new Error("Test error message");
|
||||||
|
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||||
|
|
||||||
|
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||||
|
|
||||||
|
// Verify modal is open (checking document directly because of portal)
|
||||||
|
cy.get("#portal").should("be.visible");
|
||||||
|
cy.get("#portal").should("contain", "Application Error");
|
||||||
|
cy.get("#portal").should("contain", "Test error message");
|
||||||
|
|
||||||
|
// Verify Komodor hint is present (from GlobalErrorModal)
|
||||||
|
cy.get("#portal").should("contain", "Sign up for free.");
|
||||||
|
cy.get("#portal a")
|
||||||
|
.should("have.attr", "href")
|
||||||
|
.and("include", "komodor.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call resetErrorBoundary when modal is closed", () => {
|
||||||
|
const mockError = new Error("Test error");
|
||||||
|
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||||
|
|
||||||
|
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||||
|
|
||||||
|
// Find and click close button (using the selector from Modal.tsx)
|
||||||
|
cy.get("[data-modal-hide='staticModal']").click();
|
||||||
|
|
||||||
|
// Verify reset was called
|
||||||
|
cy.get("@resetErrorBoundary").should("have.been.calledOnce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-Error objects gracefully", () => {
|
||||||
|
const mockError = "String error" as unknown as Error;
|
||||||
|
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||||
|
|
||||||
|
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||||
|
|
||||||
|
// Should show fallback message
|
||||||
|
cy.get("#portal").should(
|
||||||
|
"contain",
|
||||||
|
"An unexpected error occurred. Please try again."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log error in development mode", () => {
|
||||||
|
const mockError = new Error("Test error for logging");
|
||||||
|
const mockReset = cy.stub();
|
||||||
|
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.spy(win.console, "error").as("consoleError");
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||||
|
|
||||||
|
// In dev mode, error should be logged
|
||||||
|
cy.get("@consoleError").should("have.been.called");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should catch errors from a real component and recover after reset (Integration)", () => {
|
||||||
|
const BuggyComponent = ({ shouldCrash }: { shouldCrash: boolean }) => {
|
||||||
|
if (shouldCrash) {
|
||||||
|
throw new Error("Integrated crash");
|
||||||
|
}
|
||||||
|
return <div data-cy="recovered">Recovered successfully!</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TestWrapper = () => {
|
||||||
|
const [shouldCrash, setShouldCrash] = useState(true);
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={ErrorFallback}
|
||||||
|
onReset={() => setShouldCrash(false)}
|
||||||
|
>
|
||||||
|
<BuggyComponent shouldCrash={shouldCrash} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
mount(<TestWrapper />);
|
||||||
|
|
||||||
|
// Verify modal caught the real throw
|
||||||
|
cy.get("#portal").should("be.visible").and("not.be.empty");
|
||||||
|
cy.get("#portal").should("contain", "Integrated crash");
|
||||||
|
|
||||||
|
// Click close to reset
|
||||||
|
cy.get("[data-modal-hide='staticModal']").click();
|
||||||
|
|
||||||
|
// Verify modal is gone (portal should be empty) and component recovered
|
||||||
|
cy.get("#portal").should("be.empty");
|
||||||
|
cy.get("[data-cy='recovered']")
|
||||||
|
.should("be.visible")
|
||||||
|
.and("contain", "Recovered successfully!");
|
||||||
|
});
|
||||||
|
});
|
||||||
118
frontend/src/components/ErrorFallback/ErrorFallback.stories.tsx
Normal file
118
frontend/src/components/ErrorFallback/ErrorFallback.stories.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
|
import ErrorFallback from "./ErrorFallback";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/ErrorFallback",
|
||||||
|
component: ErrorFallback,
|
||||||
|
parameters: {
|
||||||
|
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
|
||||||
|
layout: "centered",
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {
|
||||||
|
resetErrorBoundary: { action: "reset" },
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ErrorFallback>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default error fallback with a standard error message
|
||||||
|
*/
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
error: new Error("Something went wrong in the application"),
|
||||||
|
resetErrorBoundary: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InteractiveIntegration: Story = {
|
||||||
|
args: {
|
||||||
|
error: new Error("Interactive Demo Error"),
|
||||||
|
resetErrorBoundary: () => {},
|
||||||
|
},
|
||||||
|
render: (args) => {
|
||||||
|
const BuggyComponent = () => {
|
||||||
|
const [shouldError, setShouldError] = useState(false);
|
||||||
|
|
||||||
|
if (shouldError) {
|
||||||
|
throw new Error(
|
||||||
|
"This is a real runtime error caught by the ErrorBoundary!"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-96 rounded border bg-white p-8 text-center shadow-md">
|
||||||
|
<h3 className="mb-4 text-lg font-bold">Interactive Demo</h3>
|
||||||
|
<p className="mb-6 text-sm text-gray-600">
|
||||||
|
Clicking the button below will cause this component to throw a
|
||||||
|
runtime error during render.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShouldError(true)}>
|
||||||
|
Trigger Runtime Error
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={(props) => (
|
||||||
|
<ErrorFallback
|
||||||
|
{...props}
|
||||||
|
resetErrorBoundary={() => {
|
||||||
|
props.resetErrorBoundary();
|
||||||
|
args.resetErrorBoundary();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BuggyComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long error message to test text wrapping
|
||||||
|
*/
|
||||||
|
export const LongErrorMessage: Story = {
|
||||||
|
args: {
|
||||||
|
error: new Error(
|
||||||
|
"This is a very long error message that should demonstrate how the error modal handles text wrapping and displays lengthy error descriptions to the user. The error boundary should gracefully handle this scenario."
|
||||||
|
),
|
||||||
|
resetErrorBoundary: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-Error object to test fallback behavior
|
||||||
|
*/
|
||||||
|
export const NonErrorObject: Story = {
|
||||||
|
args: {
|
||||||
|
error: "String error message" as unknown as Error,
|
||||||
|
resetErrorBoundary: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error with stack trace (useful for development)
|
||||||
|
*/
|
||||||
|
export const WithStackTrace: Story = {
|
||||||
|
args: {
|
||||||
|
error: (() => {
|
||||||
|
try {
|
||||||
|
throw new Error("Error with detailed stack trace");
|
||||||
|
} catch (e) {
|
||||||
|
return e as Error;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
resetErrorBoundary: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
34
frontend/src/components/ErrorFallback/ErrorFallback.tsx
Normal file
34
frontend/src/components/ErrorFallback/ErrorFallback.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
|
import { useDevLogger } from "../../hooks/useDevLogger";
|
||||||
|
import GlobalErrorModal from "../modal/GlobalErrorModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error fallback component for React Error Boundary
|
||||||
|
* Uses the existing GlobalErrorModal for consistent error display
|
||||||
|
* @param error - The error that was caught
|
||||||
|
* @param resetErrorBoundary - Function to reset the error boundary state
|
||||||
|
*/
|
||||||
|
const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||||
|
useDevLogger(error);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// Reset the error boundary to allow the component tree to re-render
|
||||||
|
resetErrorBoundary();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalErrorModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
titleText="Application Error"
|
||||||
|
contentText={
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected error occurred. Please try again."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorFallback;
|
||||||
1
frontend/src/components/ErrorFallback/index.ts
Normal file
1
frontend/src/components/ErrorFallback/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ErrorFallback";
|
||||||
@@ -1,40 +1,44 @@
|
|||||||
import { HD_RESOURCE_CONDITION_TYPE } from "../../API/releases";
|
|
||||||
import { Tooltip } from "flowbite-react";
|
import { Tooltip } from "flowbite-react";
|
||||||
import { ReleaseHealthStatus } from "../../data/types";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { HD_RESOURCE_CONDITION_TYPE } from "../../API/releases";
|
||||||
|
import type { ReleaseHealthStatus } from "../../data/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
statusData: ReleaseHealthStatus[];
|
statusData: ReleaseHealthStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const HealthStatus = ({ statusData }: Props) => {
|
const HealthStatus = ({ statusData }: Props) => {
|
||||||
const statuses = statusData.map((item) => {
|
const statuses = statusData.flatMap((item) => {
|
||||||
for (let i = 0; i < item.status.conditions.length; i++) {
|
return item.status?.conditions
|
||||||
const cond = item.status.conditions[i];
|
?.filter((cond) => cond.type === HD_RESOURCE_CONDITION_TYPE)
|
||||||
|
.map((cond) => {
|
||||||
|
const stableKey = item.metadata?.uid
|
||||||
|
? `${item.metadata.uid}-${item.metadata.namespace ?? "default"}`
|
||||||
|
: `${item.kind}-${item.metadata?.namespace ?? "default"}-${item.metadata?.name}`;
|
||||||
|
|
||||||
if (cond.type !== HD_RESOURCE_CONDITION_TYPE) {
|
return (
|
||||||
continue;
|
<Tooltip
|
||||||
}
|
key={stableKey}
|
||||||
|
content={`${cond.status} ${item.kind} ${item.metadata?.name}`}
|
||||||
return (
|
>
|
||||||
<Tooltip
|
<span
|
||||||
key={uuidv4()} // this is not a good practice, we need to fetch some unique id from the backend
|
className={`inline-block ${
|
||||||
content={`${cond.status} ${item.kind} ${item.metadata.name}`}
|
cond.status === "Healthy"
|
||||||
>
|
? "bg-success"
|
||||||
<span
|
: cond.status === "Progressing"
|
||||||
className={`inline-block ${
|
? "bg-warning"
|
||||||
cond.status === "Healthy"
|
: "bg-danger"
|
||||||
? "bg-success"
|
} h-2.5 w-2.5 rounded-xs`}
|
||||||
: cond.status === "Progressing"
|
></span>
|
||||||
? "bg-warning"
|
</Tooltip>
|
||||||
: "bg-danger"
|
);
|
||||||
} w-2.5 h-2.5 rounded-sm`}
|
});
|
||||||
></span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return <div>No health statuses available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="flex flex-wrap gap-1">{statuses}</div>;
|
return <div className="flex flex-wrap gap-1">{statuses}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import InstalledPackageCard from "./InstalledPackageCard";
|
import InstalledPackageCard from "./InstalledPackageCard";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Release } from "../../data/types";
|
|
||||||
import { BsArrowUpCircleFill, BsPlusCircleFill } from "react-icons/bs";
|
import { BsArrowUpCircleFill, BsPlusCircleFill } from "react-icons/bs";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
|
import apiService from "../../API/apiService";
|
||||||
|
import type { LatestChartVersion } from "../../API/interfaces";
|
||||||
|
import { useGetApplicationStatus } from "../../API/other";
|
||||||
|
import { useGetLatestVersion } from "../../API/releases";
|
||||||
|
import HelmGrayIcon from "../../assets/helm-gray-50.svg";
|
||||||
|
import type { Release, ReleaseHealthStatus } from "../../data/types";
|
||||||
|
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
|
||||||
import { getAge } from "../../timeUtils";
|
import { getAge } from "../../timeUtils";
|
||||||
|
import { isNewerVersion } from "../../utils";
|
||||||
import StatusLabel, {
|
import StatusLabel, {
|
||||||
DeploymentStatus,
|
DeploymentStatus,
|
||||||
getStatusColor,
|
getStatusColor,
|
||||||
} from "../common/StatusLabel";
|
} 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 Spinner from "../Spinner";
|
||||||
import { useGetLatestVersion } from "../../API/releases";
|
|
||||||
import { isNewerVersion } from "../../utils";
|
import HealthStatus from "./HealthStatus";
|
||||||
import { LatestChartVersion } from "../../API/interfaces";
|
|
||||||
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
|
|
||||||
|
|
||||||
type InstalledPackageCardProps = {
|
type InstalledPackageCardProps = {
|
||||||
release: Release;
|
release: Release;
|
||||||
@@ -26,15 +30,21 @@ export default function InstalledPackageCard({
|
|||||||
const navigate = useNavigateWithSearchParams();
|
const navigate = useNavigateWithSearchParams();
|
||||||
|
|
||||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
const [isMouseOver, setIsMouseOver] = useState(false);
|
||||||
|
const { ref, inView } = useInView({
|
||||||
|
threshold: 0.3,
|
||||||
|
triggerOnce: true,
|
||||||
|
});
|
||||||
|
const { data: status } = useGetApplicationStatus();
|
||||||
|
|
||||||
const { data: latestVersionResult } = useGetLatestVersion(release.chartName, {
|
const { data: latestVersionResult } = useGetLatestVersion(release.chartName, {
|
||||||
queryKey: ["chartName", release.chartName],
|
queryKey: ["chartName", release.chartName],
|
||||||
cacheTime: 0,
|
enabled: !status?.NoLatest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: statusData } = useQuery<unknown>({
|
const { data: statusData = [], isLoading } = useQuery<ReleaseHealthStatus[]>({
|
||||||
queryKey: ["resourceStatus", release],
|
queryKey: ["resourceStatus", release],
|
||||||
queryFn: () => apiService.getResourceStatus({ release }),
|
queryFn: () => apiService.getResourceStatus({ release }),
|
||||||
|
enabled: inView && !status?.NoHealth,
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestVersionData: LatestChartVersion | undefined =
|
const latestVersionData: LatestChartVersion | undefined =
|
||||||
@@ -57,14 +67,21 @@ export default function InstalledPackageCard({
|
|||||||
setIsMouseOver(false);
|
setIsMouseOver(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnClick = () => {
|
const onClick = async () => {
|
||||||
const { name, namespace } = release;
|
const { name, namespace } = release;
|
||||||
navigate(`/${namespace}/${name}/installed/revision/${release.revision}`, {
|
await navigate(
|
||||||
state: release,
|
`/${namespace}/${name}/installed/revision/${release.revision}`,
|
||||||
});
|
{
|
||||||
|
state: release,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColor = getStatusColor(release.status as DeploymentStatus);
|
const handleClick = () => {
|
||||||
|
void onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = getStatusColor(release.status);
|
||||||
const borderLeftColor: { [key: string]: string } = {
|
const borderLeftColor: { [key: string]: string } = {
|
||||||
[DeploymentStatus.DEPLOYED]: "border-l-border-deployed",
|
[DeploymentStatus.DEPLOYED]: "border-l-border-deployed",
|
||||||
[DeploymentStatus.FAILED]: "border-l-text-danger",
|
[DeploymentStatus.FAILED]: "border-l-text-danger",
|
||||||
@@ -73,57 +90,58 @@ export default function InstalledPackageCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
className={`${
|
className={`${
|
||||||
borderLeftColor[release.status]
|
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 ${
|
} custom-shadow my-2 grid grid-cols-12 items-center rounded-md border-l-4 bg-white p-2 py-6 text-xs border-l-[${statusColor}] cursor-pointer ${
|
||||||
isMouseOver && "custom-shadow-lg"
|
isMouseOver && "custom-shadow-lg"
|
||||||
}`}
|
}`}
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
onClick={handleOnClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={release.icon || HelmGrayIcon}
|
src={release.icon || HelmGrayIcon}
|
||||||
alt="helm release icon"
|
alt="helm release icon"
|
||||||
className="w-[45px] mx-4 col-span-1 min-w-[45px]"
|
className="col-span-1 mx-4 w-[45px] min-w-[45px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="col-span-11 -mb-5">
|
<div className="col-span-11 -mb-5">
|
||||||
<div className="grid grid-cols-11">
|
<div className="grid grid-cols-11">
|
||||||
<div className="col-span-3 font-bold text-xl mr-0.5 font-roboto-slab">
|
<div className="col-span-3 mr-0.5 font-roboto-slab text-xl font-bold">
|
||||||
{release.name}
|
{release.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<StatusLabel status={release.status} />
|
<StatusLabel status={release.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 font-bold">{release.chart}</div>
|
<div className="col-span-2 font-bold">{release.chart}</div>
|
||||||
<div className="col-span-1 font-bold text-xs">
|
<div className="col-span-1 text-xs font-bold">
|
||||||
#{release.revision}
|
#{release.revision}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 font-bold text-xs">
|
<div className="col-span-1 text-xs font-bold">
|
||||||
{release.namespace}
|
{release.namespace}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 font-bold text-xs">{getAge(release)}</div>
|
<div className="col-span-1 text-xs font-bold">{getAge(release)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-11 text-xs mt-3"
|
className="mt-3 grid grid-cols-11 text-xs"
|
||||||
style={{ marginBottom: "12px" }}
|
style={{ marginBottom: "12px" }}
|
||||||
>
|
>
|
||||||
<div className="col-span-3 h-12 line-clamp-3 mr-1">
|
<div className="col-span-3 mr-1 line-clamp-3 h-12">
|
||||||
{release.description}
|
{release.description}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 mr-2">
|
<div className="col-span-3 mr-2">
|
||||||
{statusData ? (
|
{isLoading ? (
|
||||||
<HealthStatus statusData={statusData} />
|
|
||||||
) : (
|
|
||||||
<Spinner size={4} />
|
<Spinner size={4} />
|
||||||
|
) : (
|
||||||
|
<HealthStatus statusData={statusData} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-muted flex flex-col items">
|
<div className="items col-span-2 flex flex-col text-muted">
|
||||||
<span>CHART VERSION</span>
|
<span>CHART VERSION</span>
|
||||||
{(canUpgrade || installRepoSuggestion) && (
|
{(canUpgrade || installRepoSuggestion) && (
|
||||||
<div
|
<div
|
||||||
className="text-upgradable flex flex-row items-center gap-1 font-bold"
|
className="flex flex-row items-center gap-1 font-bold text-upgradable"
|
||||||
title={`upgrade available: ${latestVersionData?.version} from ${latestVersionData?.repository}`}
|
title={`upgrade available: ${latestVersionData?.version} from ${latestVersionData?.repository}`}
|
||||||
>
|
>
|
||||||
{canUpgrade && !installRepoSuggestion ? (
|
{canUpgrade && !installRepoSuggestion ? (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import InstalledPackagesHeader from "./InstalledPackagesHeader";
|
import InstalledPackagesHeader from "./InstalledPackagesHeader";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
import HeaderLogo from "../../assets/packges-header.svg";
|
import HeaderLogo from "../../assets/packges-header.svg";
|
||||||
import { Release } from "../../data/types";
|
import type { Release } from "../../data/types";
|
||||||
|
|
||||||
type InstalledPackagesHeaderProps = {
|
type InstalledPackagesHeaderProps = {
|
||||||
filteredReleases?: Release[];
|
filteredReleases?: Release[];
|
||||||
setFilterKey: React.Dispatch<React.SetStateAction<string>>;
|
setFilterKey: Dispatch<SetStateAction<string>>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,22 +19,22 @@ export default function InstalledPackagesHeader({
|
|||||||
!isLoading && (numOfPackages === undefined || numOfPackages === 0)
|
!isLoading && (numOfPackages === undefined || numOfPackages === 0)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="custom-shadow rounded-t-md ">
|
<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 justify-between rounded-t-md bg-white px-2 py-0.5 font-inter">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<img
|
||||||
src={HeaderLogo}
|
src={HeaderLogo}
|
||||||
alt="Helm-DashBoard"
|
alt="Helm-DashBoard"
|
||||||
className="display-inline h-12 ml-3 mr-3 w-[28px] "
|
className="display-inline mr-3 ml-3 h-12 w-[28px]"
|
||||||
/>
|
/>
|
||||||
<h2 className="display-inline font-bold text-base ">{`Installed Charts (${
|
<h2 className="display-inline text-base font-bold">{`Installed Charts (${
|
||||||
numOfPackages || "0"
|
numOfPackages || "0"
|
||||||
})`}</h2>
|
})`}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-1/3">
|
<div className="w-1/3">
|
||||||
<input
|
<input
|
||||||
className="border-installed-charts-filter rounded p-1 text-sm w-11/12"
|
className="w-11/12 rounded-sm border border-installed-charts-filter p-1 text-sm"
|
||||||
placeholder="Filter..."
|
placeholder="Filter..."
|
||||||
type="text"
|
type="text"
|
||||||
onChange={(ev) => setFilterKey(ev.target.value)}
|
onChange={(ev) => setFilterKey(ev.target.value)}
|
||||||
@@ -41,7 +43,7 @@ export default function InstalledPackagesHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNoPackageAlert && (
|
{showNoPackageAlert && (
|
||||||
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
|
<div className="display-none no-charts mt-3 rounded-sm bg-white p-4 text-sm shadow-sm">
|
||||||
Looks like you don't have any charts installed.
|
Looks like you don't have any charts installed.
|
||||||
"Repository" section may be a good place to start.
|
"Repository" section may be a good place to start.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import InstalledPackagesList from "./InstalledPackagesList";
|
import InstalledPackagesList from "./InstalledPackagesList";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { Release } from "../../data/types";
|
||||||
|
|
||||||
import InstalledPackageCard from "./InstalledPackageCard";
|
import InstalledPackageCard from "./InstalledPackageCard";
|
||||||
import { Release } from "../../data/types";
|
|
||||||
|
|
||||||
type InstalledPackagesListProps = {
|
type InstalledPackagesListProps = {
|
||||||
filteredReleases: Release[];
|
filteredReleases: Release[];
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NavLink, useLocation, useParams } from "react-router-dom";
|
import { type ReactNode } from "react";
|
||||||
|
import { NavLink, useLocation, useParams } from "react-router";
|
||||||
|
|
||||||
import { useAppContext } from "../context/AppContext";
|
import { useAppContext } from "../context/AppContext";
|
||||||
|
|
||||||
const LinkWithSearchParams = ({
|
const LinkWithSearchParams = ({
|
||||||
@@ -9,10 +11,10 @@ const LinkWithSearchParams = ({
|
|||||||
end?: boolean;
|
end?: boolean;
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const { context } = useParams();
|
const { context = "" } = useParams();
|
||||||
const { clusterMode } = useAppContext();
|
const { clusterMode } = useAppContext();
|
||||||
|
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
@@ -23,17 +25,15 @@ const LinkWithSearchParams = ({
|
|||||||
|
|
||||||
let prefixedUrl = to;
|
let prefixedUrl = to;
|
||||||
|
|
||||||
if (!clusterMode) {
|
if (!clusterMode && context) {
|
||||||
prefixedUrl = `/${context}${to}`;
|
prefixedUrl = `/${encodeURIComponent(context)}${to}`;
|
||||||
|
} else {
|
||||||
|
prefixedUrl = to;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const url = `${prefixedUrl}/?${params.toString()}`;
|
||||||
<NavLink
|
|
||||||
data-cy="navigation-link"
|
return <NavLink data-cy="navigation-link" to={url} {...props} />;
|
||||||
to={`${prefixedUrl}/?${params.toString()}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LinkWithSearchParams;
|
export default LinkWithSearchParams;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* The default story renders the component with the default props.
|
* The default story renders the component with the default props.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { action } from "@storybook/addon-actions";
|
import { action } from "storybook/actions";
|
||||||
|
|
||||||
import SelectMenu, { SelectMenuItem } from "./SelectMenu";
|
import SelectMenu, { SelectMenuItem } from "./SelectMenu";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
import type { JSX, ReactNode } from "react";
|
||||||
|
|
||||||
// define the SelectMenuItem type:
|
// define the SelectMenuItem type:
|
||||||
// This is an object with a label and id.
|
// This is an object with a label and id.
|
||||||
@@ -38,7 +39,7 @@ export interface SelectMenuItemProps {
|
|||||||
|
|
||||||
export interface SelectMenuProps {
|
export interface SelectMenuProps {
|
||||||
header: string;
|
header: string;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
selected: number;
|
selected: number;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@ export function SelectMenuItem({
|
|||||||
export default function SelectMenu(props: SelectMenuProps): JSX.Element {
|
export default function SelectMenu(props: SelectMenuProps): JSX.Element {
|
||||||
const { header, children } = props;
|
const { header, children } = props;
|
||||||
return (
|
return (
|
||||||
<div className="card flex flex-col">
|
<div className="flex flex-col card">
|
||||||
<h2 className="text-xl font-bold">{header}</h2>
|
<h2 className="text-xl font-bold">{header}</h2>
|
||||||
<div className="flex flex-col">{children}</div>
|
<div className="flex flex-col">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StoryFn, Meta } from "@storybook/react";
|
import type { StoryFn, Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import ShutDownButton from "./ShutDownButton";
|
import ShutDownButton from "./ShutDownButton";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { BsPower } from "react-icons/bs";
|
import { BsPower } from "react-icons/bs";
|
||||||
|
|
||||||
import Modal from "./modal/Modal";
|
|
||||||
import { useShutdownHelmDashboard } from "../API/other";
|
import { useShutdownHelmDashboard } from "../API/other";
|
||||||
|
|
||||||
|
import Modal from "./modal/Modal";
|
||||||
|
|
||||||
function ShutDownButton() {
|
function ShutDownButton() {
|
||||||
const { mutate: signOut, status } = useShutdownHelmDashboard();
|
const { mutate: signOut, status } = useShutdownHelmDashboard();
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = () => {
|
||||||
signOut();
|
signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function ShutDownButton() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title="Shut down the Helm Dashboard application"
|
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"
|
className="mr-5 flex w-full justify-center rounded-sm border border-transparent py-3 hover:rounded-lg hover:border hover:border-gray-500"
|
||||||
>
|
>
|
||||||
<BsPower className="w-6" />
|
<BsPower className="w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export default function Spinner({ size = 8 }: { size?: number }) {
|
|||||||
<div role="status">
|
<div role="status">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`w-${size} h-${size} mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600`}
|
className={`w-${size} h-${size} mr-2 animate-spin fill-blue-600 text-gray-200 dark:text-gray-600`}
|
||||||
viewBox="0 0 100 101"
|
viewBox="0 0 100 101"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import useCustomSearchParams from "../hooks/useCustomSearchParams";
|
import useCustomSearchParams from "../hooks/useCustomSearchParams";
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
@@ -18,19 +19,16 @@ export default function Tabs({ tabs, selectedTab }: TabsProps) {
|
|||||||
const moveTab = (tab: Tab) => {
|
const moveTab = (tab: Tab) => {
|
||||||
upsertSearchParams("tab", tab.value);
|
upsertSearchParams("tab", tab.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex pb-2">
|
<div className="flex pb-2">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
className={`cursor-pointer px-4 py-2 text-sm font-normal text-tab-color focus:outline-none"
|
className={`focus:outline-hidden" cursor-pointer px-4 py-2 text-sm font-normal text-tab-color ${
|
||||||
${
|
selectedTab.value === tab.value &&
|
||||||
selectedTab.value === tab.value &&
|
"border-b-[3px] border-tab-color"
|
||||||
"border-b-[3px] border-tab-color"
|
} `}
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => moveTab(tab)}
|
onClick={() => moveTab(tab)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import TabsBar from "./TabsBar";
|
import TabsBar from "./TabsBar";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -17,15 +18,15 @@ export const Default = {
|
|||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
name: "tab1",
|
name: "tab1",
|
||||||
component: <div className="w-250 h-250 bg-green-400">tab1</div>,
|
component: <div className="h-250 w-250 bg-green-400">tab1</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tab2",
|
name: "tab2",
|
||||||
component: <div className="w-250 h-250 bg-red-400">tab2</div>,
|
component: <div className="h-250 w-250 bg-red-400">tab2</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tab3",
|
name: "tab3",
|
||||||
component: <div className="w-250 h-250 bg-blue-400">tab3</div>,
|
component: <div className="h-250 w-250 bg-blue-400">tab3</div>,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
activeTab: "tab1",
|
activeTab: "tab1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*
|
*
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
interface TabsBarProps {
|
interface TabsBarProps {
|
||||||
tabs: Array<{ name: string; component: JSX.Element }>;
|
tabs: Array<{ name: string; component: JSX.Element }>;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* the first story simply renders the component with the default props.
|
* the first story simply renders the component with the default props.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import TextInput from "./TextInput";
|
import TextInput from "./TextInput";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -12,18 +12,19 @@
|
|||||||
* @return JSX.Element
|
* @return JSX.Element
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
import type { ChangeEvent, JSX } from "react";
|
||||||
|
|
||||||
export interface TextInputProps {
|
export interface TextInputProps {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
isMandatory?: boolean;
|
isMandatory?: boolean;
|
||||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextInput(props: TextInputProps): JSX.Element {
|
export default function TextInput(props: TextInputProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block ml-1 mb-1 text-sm font-medium text-gray-900dark:text-white">
|
<label className="text-gray-900dark:text-white mb-1 ml-1 block text-sm font-medium">
|
||||||
{props.label}
|
{props.label}
|
||||||
{/* if prop.isMandatory is true, add a whitespace and a red star to signify it*/}
|
{/* if prop.isMandatory is true, add a whitespace and a red star to signify it*/}
|
||||||
{props.isMandatory ? <span className="text-red-500"> *</span> : ""}
|
{props.isMandatory ? <span className="text-red-500"> *</span> : ""}
|
||||||
@@ -31,7 +32,7 @@ export default function TextInput(props: TextInputProps): JSX.Element {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={props.placeholder}
|
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 "
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactElement, cloneElement } from "react";
|
import { cloneElement, type HTMLAttributes, type ReactElement } from "react";
|
||||||
|
|
||||||
export default function Tooltip({
|
export default function Tooltip({
|
||||||
id,
|
id,
|
||||||
@@ -11,11 +11,16 @@ export default function Tooltip({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cloneElement(element, { "data-tooltip-target": id })}
|
{cloneElement(
|
||||||
|
element as ReactElement<HTMLAttributes<HTMLElement>>,
|
||||||
|
{
|
||||||
|
"data-tooltip-target": id,
|
||||||
|
} as unknown as HTMLAttributes<HTMLElement>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
role="tooltip"
|
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"
|
className="tooltip invisible absolute z-10 inline-block rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white opacity-0 shadow-xs transition-opacity duration-300 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
<div className="tooltip-arrow" data-popper-arrow></div>
|
<div className="tooltip-arrow" data-popper-arrow></div>
|
||||||
@@ -24,14 +29,14 @@ export default function Tooltip({
|
|||||||
<button
|
<button
|
||||||
data-tooltip-target="tooltip-default"
|
data-tooltip-target="tooltip-default"
|
||||||
type="button"
|
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"
|
className="rounded-lg bg-blue-700 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 focus:outline-hidden dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
>
|
>
|
||||||
Default tooltip
|
Default tooltip
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id="tooltip-default"
|
id="tooltip-default"
|
||||||
role="tooltip"
|
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"
|
className="tooltip invisible absolute z-10 inline-block rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white opacity-0 shadow-xs transition-opacity duration-300 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
Tooltip content
|
Tooltip content
|
||||||
<div className="tooltip-arrow" data-popper-arrow></div>
|
<div className="tooltip-arrow" data-popper-arrow></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta, StoryFn } from "@storybook/react";
|
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||||
|
|
||||||
import { Troubleshoot } from "./Troubleshoot";
|
import { Troubleshoot } from "./Troubleshoot";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Troubleshoot = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<button className="bg-primary text-white p-2 flex items-center rounded text-sm font-medium font-roboto">
|
<button className="flex items-center rounded-sm bg-primary p-2 font-roboto text-sm font-medium text-white">
|
||||||
Troubleshoot in Komodor
|
Troubleshoot in Komodor
|
||||||
<RiExternalLinkLine className="ml-2 text-lg" />
|
<RiExternalLinkLine className="ml-2 text-lg" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface ButtonProps {
|
|||||||
* Optional click handler
|
* Optional click handler
|
||||||
*/
|
*/
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
import { action } from "@storybook/addon-actions";
|
|
||||||
import DropDown from "./DropDown";
|
|
||||||
import { BsSlack, BsGithub } from "react-icons/bs";
|
import { BsSlack, BsGithub } from "react-icons/bs";
|
||||||
|
import { action } from "storybook/actions";
|
||||||
|
|
||||||
|
import DropDown from "./DropDown";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
/* 👇 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",
|
title: "DropDown",
|
||||||
component: DropDown,
|
component: DropDown,
|
||||||
} as Meta<typeof DropDown>;
|
} satisfies Meta<typeof DropDown>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, Fragment, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import ArrowDownIcon from "../../assets/arrow-down-icon.svg";
|
import ArrowDownIcon from "../../assets/arrow-down-icon.svg";
|
||||||
|
|
||||||
export type DropDownItem = {
|
export type DropDownItem = {
|
||||||
@@ -29,6 +30,15 @@ function DropDown({ items }: DropDownProps) {
|
|||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||||
|
setPopupState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isOpen: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (popupState.isOpen) {
|
if (popupState.isOpen) {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
@@ -41,15 +51,6 @@ function DropDown({ items }: DropDownProps) {
|
|||||||
};
|
};
|
||||||
}, [popupState.isOpen]);
|
}, [popupState.isOpen]);
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
|
||||||
setPopupState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isOpen: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
@@ -62,21 +63,21 @@ function DropDown({ items }: DropDownProps) {
|
|||||||
Y: e.pageY,
|
Y: e.pageY,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between"
|
className="flex cursor-pointer items-center justify-between"
|
||||||
>
|
>
|
||||||
Help
|
Help
|
||||||
<img src={ArrowDownIcon} className="ml-2 w-[10px] h-[10px]" />
|
<img src={ArrowDownIcon} className="ml-2 h-[10px] w-[10px]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{popupState.isOpen && (
|
{popupState.isOpen && (
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
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`}
|
className={`absolute z-10 mt-3 flex flex-col gap-1 rounded-sm border bg-white py-1 top-[${popupState.Y}] left-[${popupState.X}] border-gray-200`}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<>
|
<Fragment key={item.id}>
|
||||||
{item.isSeparator ? (
|
{item.isSeparator ? (
|
||||||
<div className="bg-gray-300 h-[1px]" />
|
<div className="h-[1px] bg-gray-300" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -86,9 +87,9 @@ function DropDown({ items }: DropDownProps) {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
className={`cursor-pointer font-normal flex items-center gap-2 py-1 pl-3 pr-7 hover:bg-dropdown ${
|
className={`flex cursor-pointer items-center gap-2 py-1 pr-7 pl-3 font-normal hover:bg-dropdown ${
|
||||||
item.isDisabled
|
item.isDisabled
|
||||||
? "cursor-default hover:bg-transparent text-gray-400"
|
? "cursor-default text-gray-400 hover:bg-transparent"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -96,7 +97,7 @@ function DropDown({ items }: DropDownProps) {
|
|||||||
<span>{item.text}</span>
|
<span>{item.text}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Meta, StoryObj } from "@storybook/react";
|
|
||||||
import { within, userEvent } from "@storybook/testing-library";
|
|
||||||
import { Page } from "./Page";
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: "Example/Page",
|
|
||||||
component: Page,
|
|
||||||
parameters: {
|
|
||||||
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
|
|
||||||
layout: "fullscreen",
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof Page>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
export const LoggedOut = {};
|
|
||||||
|
|
||||||
export const LoggedIn: StoryObj<typeof Page> = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
const loginButton = await canvas.getByRole("button", { name: /Log in/i });
|
|
||||||
await userEvent.click(loginButton);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Header } from "../Header/Header";
|
import { Header } from "../Header/Header";
|
||||||
import "./page.css";
|
import "./page.css";
|
||||||
@@ -7,8 +7,8 @@ type User = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Page: React.VFC = () => {
|
export const Page = () => {
|
||||||
const [user, setUser] = React.useState<User>();
|
const [user, setUser] = useState<User>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Meta } from "@storybook/react";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import StatusLabel, { DeploymentStatus } from "./StatusLabel";
|
import StatusLabel, { DeploymentStatus } from "./StatusLabel";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { AiOutlineReload } from "react-icons/ai";
|
import { AiOutlineReload } from "react-icons/ai";
|
||||||
|
|
||||||
type StatusLabelProps = {
|
|
||||||
status: string;
|
|
||||||
isRollback?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum DeploymentStatus {
|
export enum DeploymentStatus {
|
||||||
DEPLOYED = "deployed",
|
DEPLOYED = "deployed",
|
||||||
FAILED = "failed",
|
FAILED = "failed",
|
||||||
@@ -12,6 +7,11 @@ export enum DeploymentStatus {
|
|||||||
SUPERSEDED = "superseded",
|
SUPERSEDED = "superseded",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusLabelProps = {
|
||||||
|
status: DeploymentStatus;
|
||||||
|
isRollback?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function getStatusColor(status: DeploymentStatus) {
|
export function getStatusColor(status: DeploymentStatus) {
|
||||||
if (status === DeploymentStatus.DEPLOYED) return "text-deployed";
|
if (status === DeploymentStatus.DEPLOYED) return "text-deployed";
|
||||||
if (status === DeploymentStatus.FAILED) return "text-failed";
|
if (status === DeploymentStatus.FAILED) return "text-failed";
|
||||||
@@ -20,7 +20,7 @@ export function getStatusColor(status: DeploymentStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusLabel({ status, isRollback }: StatusLabelProps) {
|
function StatusLabel({ status, isRollback }: StatusLabelProps) {
|
||||||
const statusColor = getStatusColor(status as DeploymentStatus);
|
const statusColor = getStatusColor(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,7 +31,7 @@ function StatusLabel({ status, isRollback }: StatusLabelProps) {
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={`${statusColor} font-bold text-xs`}>
|
<span className={`${statusColor} text-xs font-bold`}>
|
||||||
● {status.toUpperCase()}
|
● {status.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{isRollback && <AiOutlineReload size={14} />}
|
{isRollback && <AiOutlineReload size={14} />}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StoryFn, Meta } from "@storybook/react";
|
import type { StoryFn, Meta } from "@storybook/react-vite";
|
||||||
|
|
||||||
import AddRepositoryModal from "./AddRepositoryModal";
|
import AddRepositoryModal from "./AddRepositoryModal";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal from "./Modal";
|
import { useState } from "react";
|
||||||
import Spinner from "../Spinner";
|
|
||||||
|
import apiService from "../../API/apiService";
|
||||||
|
import { useAppContext } from "../../context/AppContext";
|
||||||
import useAlertError from "../../hooks/useAlertError";
|
import useAlertError from "../../hooks/useAlertError";
|
||||||
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
|
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
|
||||||
import { useAppContext } from "../../context/AppContext";
|
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import Spinner from "../Spinner";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import apiService from "../../API/apiService";
|
import Modal from "./Modal";
|
||||||
|
|
||||||
interface FormKeys {
|
interface FormKeys {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,21 +23,22 @@ type AddRepositoryModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
||||||
const [formData, setFormData] = useState<FormKeys>({} as FormKeys);
|
const {
|
||||||
|
searchParamsObject: { repo_url, repo_name },
|
||||||
|
} = useCustomSearchParams();
|
||||||
|
const [formData, setFormData] = useState<FormKeys>({
|
||||||
|
name: repo_name ?? "",
|
||||||
|
url: repo_url ?? "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const alertError = useAlertError();
|
const alertError = useAlertError();
|
||||||
const { searchParamsObject } = useCustomSearchParams();
|
|
||||||
const { repo_url, repo_name } = searchParamsObject;
|
|
||||||
const { setSelectedRepo } = useAppContext();
|
const { setSelectedRepo } = useAppContext();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigateWithSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
const addRepository = async () => {
|
||||||
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();
|
const body = new FormData();
|
||||||
body.append("name", formData.name ?? "");
|
body.append("name", formData.name ?? "");
|
||||||
body.append("url", formData.url ?? "");
|
body.append("url", formData.url ?? "");
|
||||||
@@ -44,32 +47,42 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
apiService
|
try {
|
||||||
.fetchWithDefaults<void>("/api/helm/repositories", {
|
await apiService.fetchWithDefaults<void>("/api/helm/repositories", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
onClose();
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["helm", "repositories"],
|
|
||||||
});
|
|
||||||
setSelectedRepo(formData.name || "");
|
|
||||||
navigate(`/repository/${formData.name}`, {
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
alertError.setShowErrorModal({
|
|
||||||
title: "Failed to add repo",
|
|
||||||
msg: error.message,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["helm", "repositories"],
|
||||||
|
});
|
||||||
|
setSelectedRepo(formData.name || "");
|
||||||
|
const path = `/repository/${formData.name}`;
|
||||||
|
await navigate(path, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alertError.setShowErrorModal({
|
||||||
|
title: "Failed to add repo",
|
||||||
|
msg: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepository = () => {
|
||||||
|
void addRepository();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,11 +92,11 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
bottomContent={
|
bottomContent={
|
||||||
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
|
<div className="flex justify-end gap-2 rounded-b border-t border-gray-200 p-6">
|
||||||
<button
|
<button
|
||||||
data-cy="add-chart-repository-button"
|
data-cy="add-chart-repository-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"
|
className="flex cursor-pointer items-center rounded-lg bg-primary px-3 py-1.5 text-center text-base font-medium text-white hover:bg-add-repo focus:ring-4 focus:ring-blue-300 focus:outline-hidden disabled:bg-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
onClick={addRepository}
|
onClick={handleAddRepository}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading && <Spinner size={4} />}
|
{isLoading && <Spinner size={4} />}
|
||||||
@@ -94,7 +107,7 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex gap-x-3">
|
<div className="flex gap-x-3">
|
||||||
<label className="flex-1" htmlFor="name">
|
<label className="flex-1" htmlFor="name">
|
||||||
<div className="mb-2 text-sm require">Name</div>
|
<div className="require mb-2 text-sm">Name</div>
|
||||||
<input
|
<input
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -108,11 +121,11 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
data-cy="add-chart-name"
|
data-cy="add-chart-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Komodorio"
|
placeholder="Komodorio"
|
||||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
className="input-box-shadow w-full rounded-lg border border-gray-300 p-2 focus:border-sky-500 focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex-1" htmlFor="url">
|
<label className="flex-1" htmlFor="url">
|
||||||
<div className="mb-2 text-sm require">URL</div>
|
<div className="require mb-2 text-sm">URL</div>
|
||||||
<input
|
<input
|
||||||
value={formData.url}
|
value={formData.url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -126,12 +139,12 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
data-cy="add-chart-url"
|
data-cy="add-chart-url"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="https://helm-charts.komodor.io"
|
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"
|
className="input-box-shadow w-full rounded-lg border border-gray-300 p-2 focus:border-sky-500 focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-x-3">
|
<div className="mt-6 flex gap-x-3">
|
||||||
<label className="flex-1 " htmlFor="username">
|
<label className="flex-1" htmlFor="username">
|
||||||
<div className="mb-2 text-sm">Username</div>
|
<div className="mb-2 text-sm">Username</div>
|
||||||
<input
|
<input
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -143,7 +156,7 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
required
|
required
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
className="input-box-shadow w-full rounded-lg border border-gray-300 p-2 focus:border-sky-500 focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex-1" htmlFor="password">
|
<label className="flex-1" htmlFor="password">
|
||||||
@@ -158,7 +171,7 @@ function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
|
|||||||
required
|
required
|
||||||
id="password"
|
id="password"
|
||||||
type="text"
|
type="text"
|
||||||
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
|
className="input-box-shadow w-full rounded-lg border border-gray-300 p-2 focus:border-sky-500 focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { action } from "@storybook/addon-actions";
|
import type { Meta } from "@storybook/react-vite";
|
||||||
import { Meta } from "@storybook/react";
|
import { action } from "storybook/actions";
|
||||||
|
|
||||||
import ErrorModal from "./ErrorModal";
|
import ErrorModal from "./ErrorModal";
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function ErrorModal({
|
|||||||
contentText,
|
contentText,
|
||||||
}: ErrorModalProps) {
|
}: ErrorModalProps) {
|
||||||
const ErrorTitle = (
|
const ErrorTitle = (
|
||||||
<div className="font-medium text-2xl text-error-color">
|
<div className="text-2xl font-medium text-error-color">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -33,13 +33,14 @@ export default function ErrorModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const bottomContent = (
|
const bottomContent = (
|
||||||
<div className="flex py-6 px-4 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
|
<div className="flex gap-2 rounded-b border-t border-gray-200 px-4 py-6 dark:border-gray-600">
|
||||||
<span className="text-sm text-muted fs-80 text-gray-500">
|
<span className="fs-80 text-sm text-gray-500 text-muted">
|
||||||
Hint: Komodor has the same HELM capabilities, with enterprise features
|
Hint: Komodor has the same HELM capabilities, with enterprise features
|
||||||
and support.{" "}
|
and support.{" "}
|
||||||
<a
|
<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"
|
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"
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<span className="text-link-color underline">Sign up for free.</span>
|
<span className="text-link-color underline">Sign up for free.</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -49,15 +50,13 @@ export default function ErrorModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
containerClassNames={
|
containerClassNames={"error-dialog w-2/3"}
|
||||||
"border-2 border-error-border-color bg-error-background w-2/3"
|
|
||||||
}
|
|
||||||
title={ErrorTitle}
|
title={ErrorTitle}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
bottomContent={bottomContent}
|
bottomContent={bottomContent}
|
||||||
>
|
>
|
||||||
<p className="text-error-color border-green-400">{contentText}</p>
|
<p className="border-green-400 text-error-color">{contentText}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function GlobalErrorModal({
|
|||||||
contentText,
|
contentText,
|
||||||
}: ErrorModalProps) {
|
}: ErrorModalProps) {
|
||||||
const ErrorTitle = (
|
const ErrorTitle = (
|
||||||
<div className="font-medium text-2xl text-error-color">
|
<div className="text-2xl font-medium text-error-color">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -34,9 +34,7 @@ export default function GlobalErrorModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
containerClassNames={
|
containerClassNames={"error-dialog w-3/5"}
|
||||||
"border-2 border-error-border-color bg-error-background w-3/5 "
|
|
||||||
}
|
|
||||||
title={ErrorTitle}
|
title={ErrorTitle}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -57,7 +55,7 @@ export default function GlobalErrorModal({
|
|||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
style={{ minWidth: "500px" }}
|
style={{ minWidth: "500px" }}
|
||||||
className="text-error-color border-green-400 text-sm"
|
className="border-green-400 text-sm text-error-color"
|
||||||
>
|
>
|
||||||
{contentText}
|
{contentText}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import yaml from "highlight.js/lib/languages/yaml";
|
||||||
|
|
||||||
import Spinner from "../../Spinner";
|
import Spinner from "../../Spinner";
|
||||||
|
|
||||||
|
hljs.registerLanguage("yaml", yaml);
|
||||||
|
|
||||||
export const ChartValues = ({
|
export const ChartValues = ({
|
||||||
chartValues,
|
chartValues,
|
||||||
loading,
|
loading,
|
||||||
@@ -11,13 +15,13 @@ export const ChartValues = ({
|
|||||||
return (
|
return (
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
<label
|
<label
|
||||||
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
|
className="mb-2 block text-xl font-medium tracking-wide text-gray-700"
|
||||||
htmlFor="grid-user-defined-values"
|
htmlFor="grid-user-defined-values"
|
||||||
>
|
>
|
||||||
Chart Value Reference:
|
Chart Value Reference:
|
||||||
</label>
|
</label>
|
||||||
<pre
|
<pre
|
||||||
className="text-base bg-chart-values p-2 rounded font-medium w-full max-h-[330px] block overflow-y-auto font-sf-mono"
|
className="block max-h-[330px] w-full overflow-y-auto rounded-sm bg-chart-values p-2 font-sf-mono text-base font-medium"
|
||||||
dangerouslySetInnerHTML={
|
dangerouslySetInnerHTML={
|
||||||
chartValues && !loading
|
chartValues && !loading
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ interface DefinedValuesProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefinedValues = ({
|
const DefinedValues = ({
|
||||||
initialValue,
|
initialValue,
|
||||||
chartValues,
|
chartValues,
|
||||||
onUserValuesChange,
|
onUserValuesChange,
|
||||||
loading,
|
loading,
|
||||||
}: DefinedValuesProps) => {
|
}: DefinedValuesProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full gap-6 mt-4">
|
<div className="mt-4 flex w-full gap-6">
|
||||||
<UserDefinedValues
|
<UserDefinedValues
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
onValuesChange={onUserValuesChange}
|
onValuesChange={onUserValuesChange}
|
||||||
@@ -24,3 +24,5 @@ export const DefinedValues = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default DefinedValues;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
import useDebounce from "../../../hooks/useDebounce";
|
import useDebounce from "../../../hooks/useDebounce";
|
||||||
|
|
||||||
export const GeneralDetails = ({
|
export const GeneralDetails = ({
|
||||||
@@ -17,14 +18,17 @@ export const GeneralDetails = ({
|
|||||||
onReleaseNameInput: (chartName: string) => void;
|
onReleaseNameInput: (chartName: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [namespaceInputValue, setNamespaceInputValue] = useState(namespace);
|
const [namespaceInputValue, setNamespaceInputValue] = useState(namespace);
|
||||||
const namespaceInputValueDebounced = useDebounce<string>(namespaceInputValue, 500);
|
const namespaceInputValueDebounced = useDebounce<string>(
|
||||||
|
namespaceInputValue,
|
||||||
|
500
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onNamespaceInput(namespaceInputValueDebounced);
|
onNamespaceInput(namespaceInputValueDebounced);
|
||||||
}, [namespaceInputValueDebounced, onNamespaceInput]);
|
}, [namespaceInputValueDebounced, onNamespaceInput]);
|
||||||
const { context } = useParams();
|
const { context } = useParams();
|
||||||
const inputClassName = ` text-lg py-1 px-2 border border-1 border-gray-300 ${
|
const inputClassName = ` text-lg py-1 px-2 border border-1 border-gray-300 ${
|
||||||
disabled ? "bg-gray-200" : "bg-white "
|
disabled ? "bg-gray-200" : "bg-white "
|
||||||
} rounded`;
|
} rounded-sm`;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,38 +1,53 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
useEffect,
|
||||||
|
useEffectEvent,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
} from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
|
import { BsPencil, BsX } from "react-icons/bs";
|
||||||
|
|
||||||
|
import apiService from "../../../API/apiService";
|
||||||
|
import type { LatestChartVersion } from "../../../API/interfaces";
|
||||||
|
import {
|
||||||
|
type VersionData,
|
||||||
useChartReleaseValues,
|
useChartReleaseValues,
|
||||||
useGetReleaseManifest,
|
useGetReleaseManifest,
|
||||||
useGetVersions,
|
useGetVersions,
|
||||||
useVersionData,
|
useVersionData,
|
||||||
} from "../../../API/releases";
|
} 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 { useChartRepoValues } from "../../../API/repositories";
|
||||||
import { useDiffData } from "../../../API/shared";
|
import { useDiffData } from "../../../API/shared";
|
||||||
import { InstallChartModalProps } from "../../../data/types";
|
import type { InstallChartModalProps } from "../../../data/types";
|
||||||
import { DefinedValues } from "./DefinedValues";
|
import useCustomSearchParams from "../../../hooks/useCustomSearchParams";
|
||||||
import apiService from "../../../API/apiService";
|
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
|
||||||
|
import { isNoneEmptyArray } from "../../../utils";
|
||||||
|
import Spinner from "../../Spinner";
|
||||||
|
import Modal, { ModalButtonStyle } from "../Modal";
|
||||||
|
import { GeneralDetails } from "./GeneralDetails";
|
||||||
|
import { VersionToInstall } from "./VersionToInstall";
|
||||||
|
|
||||||
import { InstallUpgradeTitle } from "./InstallUpgradeTitle";
|
import { InstallUpgradeTitle } from "./InstallUpgradeTitle";
|
||||||
|
|
||||||
|
const DefinedValues = lazy(() => import("./DefinedValues"));
|
||||||
|
const ManifestDiff = lazy(() => import("./ManifestDiff"));
|
||||||
|
|
||||||
export const InstallReleaseChartModal = ({
|
export const InstallReleaseChartModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
chartName,
|
chartName,
|
||||||
currentlyInstalledChartVersion,
|
currentlyInstalledChartVersion,
|
||||||
latestVersion,
|
|
||||||
isUpgrade = false,
|
isUpgrade = false,
|
||||||
latestRevision,
|
latestRevision,
|
||||||
}: InstallChartModalProps) => {
|
}: InstallChartModalProps) => {
|
||||||
const navigate = useNavigateWithSearchParams();
|
const navigate = useNavigateWithSearchParams();
|
||||||
const [userValues, setUserValues] = useState<string>();
|
const [userValues, setUserValues] = useState<string>("");
|
||||||
const [installError, setInstallError] = useState("");
|
const [installError, setInstallError] = useState("");
|
||||||
|
const [forceUpgrade, setForceUpgrade] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
namespace: queryNamespace,
|
namespace: queryNamespace,
|
||||||
@@ -44,40 +59,43 @@ export const InstallReleaseChartModal = ({
|
|||||||
const [namespace, setNamespace] = useState(queryNamespace || "");
|
const [namespace, setNamespace] = useState(queryNamespace || "");
|
||||||
const [releaseName, setReleaseName] = useState(_releaseName || "");
|
const [releaseName, setReleaseName] = useState(_releaseName || "");
|
||||||
|
|
||||||
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
|
const {
|
||||||
select: (data) => {
|
error: versionsError,
|
||||||
return data?.sort((a, b) =>
|
data: _versions = [],
|
||||||
isNewerVersion(a.version, b.version) ? 1 : -1
|
isSuccess,
|
||||||
);
|
isLoading: isLoadingVersions,
|
||||||
},
|
} = useGetVersions(chartName);
|
||||||
onSuccess: (data) => {
|
|
||||||
const empty = { version: "", repository: "", urls: [] };
|
const [selectedVersionData, setSelectedVersionData] = useState<VersionData>();
|
||||||
return setSelectedVersionData(data[0] ?? empty);
|
|
||||||
},
|
const [versions, setVersions] = useState<
|
||||||
|
Array<LatestChartVersion & { isChartVersion: boolean }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const onSuccess = useEffectEvent(() => {
|
||||||
|
const empty = { version: "", repository: "", urls: [] };
|
||||||
|
setSelectedVersionData(_versions[0] ?? empty);
|
||||||
|
setVersions(
|
||||||
|
_versions?.map((v) => ({
|
||||||
|
...v,
|
||||||
|
isChartVersion: v.version === currentlyInstalledChartVersion,
|
||||||
|
}))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const versions = _versions?.map((v) => ({
|
useEffect(() => {
|
||||||
...v,
|
if (isSuccess && _versions.length) {
|
||||||
isChartVersion: v.version === currentlyInstalledChartVersion,
|
onSuccess();
|
||||||
}));
|
}
|
||||||
|
}, [isSuccess, _versions]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
const selectedVersion = selectedVersionData?.version || "";
|
||||||
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
|
const selectedRepo = selectedVersionData?.repository || "";
|
||||||
const [selectedVersionData, setSelectedVersionData] = useState<{
|
|
||||||
version: string;
|
|
||||||
repository?: string;
|
|
||||||
urls: string[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const selectedVersion = useMemo(() => {
|
const [chartURL, setChartURL] = useState("");
|
||||||
return selectedVersionData?.version;
|
const [useURLMode, setUseURLMode] = useState(false);
|
||||||
}, [selectedVersionData]);
|
|
||||||
|
|
||||||
const selectedRepo = useMemo(() => {
|
const repoChartAddress = useMemo(() => {
|
||||||
return selectedVersionData?.repository || "";
|
|
||||||
}, [selectedVersionData]);
|
|
||||||
|
|
||||||
const chartAddress = useMemo(() => {
|
|
||||||
if (!selectedVersionData || !selectedVersionData.repository) return "";
|
if (!selectedVersionData || !selectedVersionData.repository) return "";
|
||||||
|
|
||||||
return selectedVersionData.urls?.[0]?.startsWith("file://")
|
return selectedVersionData.urls?.[0]?.startsWith("file://")
|
||||||
@@ -85,14 +103,16 @@ export const InstallReleaseChartModal = ({
|
|||||||
: `${selectedVersionData.repository}/${chartName}`;
|
: `${selectedVersionData.repository}/${chartName}`;
|
||||||
}, [selectedVersionData, chartName]);
|
}, [selectedVersionData, chartName]);
|
||||||
|
|
||||||
|
const chartAddress = useURLMode ? chartURL : repoChartAddress || chartURL;
|
||||||
|
|
||||||
// the original chart values
|
// the original chart values
|
||||||
const { data: chartValues } = useChartRepoValues({
|
const { data: chartValues = "" } = useChartRepoValues({
|
||||||
version: selectedVersion || "",
|
version: selectedVersion,
|
||||||
chart: chartAddress,
|
chart: chartAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The user defined values (if any we're set)
|
// The user defined values (if any we're set)
|
||||||
const { data: releaseValues, isLoading: loadingReleaseValues } =
|
const { data: releaseValues = "", isLoading: loadingReleaseValues } =
|
||||||
useChartReleaseValues({
|
useChartReleaseValues({
|
||||||
namespace,
|
namespace,
|
||||||
release: String(releaseName),
|
release: String(releaseName),
|
||||||
@@ -100,16 +120,15 @@ export const InstallReleaseChartModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// This hold the selected version manifest, we use it for the diff
|
// This hold the selected version manifest, we use it for the diff
|
||||||
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
|
const { data: selectedVerData = {}, error: selectedVerDataError } =
|
||||||
{
|
useVersionData({
|
||||||
version: selectedVersion || "",
|
version: selectedVersion,
|
||||||
userValues: userValues || "",
|
userValues,
|
||||||
chartAddress,
|
chartAddress,
|
||||||
releaseValues,
|
releaseValues,
|
||||||
namespace,
|
namespace,
|
||||||
releaseName,
|
releaseName,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const { data: currentVerManifest, error: currentVerManifestError } =
|
const { data: currentVerManifest, error: currentVerManifestError } =
|
||||||
useGetReleaseManifest({
|
useGetReleaseManifest({
|
||||||
@@ -123,15 +142,15 @@ export const InstallReleaseChartModal = ({
|
|||||||
error: diffError,
|
error: diffError,
|
||||||
} = useDiffData({
|
} = useDiffData({
|
||||||
selectedRepo,
|
selectedRepo,
|
||||||
versionsError: versionsError as string,
|
versionsError: versionsError as unknown as string, // TODO fix it
|
||||||
currentVerManifest,
|
currentVerManifest: currentVerManifest as unknown as string, // TODO fix it
|
||||||
selectedVerData,
|
selectedVerData,
|
||||||
chart: chartAddress,
|
chart: chartAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm method (install)
|
// Confirm method (install)
|
||||||
const setReleaseVersionMutation = useMutation(
|
const setReleaseVersionMutation = useMutation<VersionData, Error>({
|
||||||
[
|
mutationKey: [
|
||||||
"setVersion",
|
"setVersion",
|
||||||
namespace,
|
namespace,
|
||||||
releaseName,
|
releaseName,
|
||||||
@@ -140,7 +159,7 @@ export const InstallReleaseChartModal = ({
|
|||||||
selectedCluster,
|
selectedCluster,
|
||||||
chartAddress,
|
chartAddress,
|
||||||
],
|
],
|
||||||
async () => {
|
mutationFn: async () => {
|
||||||
setInstallError("");
|
setInstallError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("preview", "false");
|
formData.append("preview", "false");
|
||||||
@@ -149,34 +168,36 @@ export const InstallReleaseChartModal = ({
|
|||||||
}
|
}
|
||||||
formData.append("version", selectedVersion || "");
|
formData.append("version", selectedVersion || "");
|
||||||
formData.append("values", userValues || releaseValues || ""); // if userValues is empty, we use the release values
|
formData.append("values", userValues || releaseValues || ""); // if userValues is empty, we use the release values
|
||||||
|
if (forceUpgrade) {
|
||||||
|
formData.append("force", "true");
|
||||||
|
}
|
||||||
|
const url = `/api/helm/releases/${
|
||||||
|
namespace ? namespace : "default"
|
||||||
|
}/${releaseName}`;
|
||||||
|
|
||||||
const data = await apiService.fetchWithDefaults(
|
return await apiService.fetchWithSafeDefaults<VersionData>({
|
||||||
`/api/helm/releases/${
|
url,
|
||||||
namespace ? namespace : "default"
|
options: {
|
||||||
}${`/${releaseName}`}`,
|
|
||||||
{
|
|
||||||
method: "post",
|
method: "post",
|
||||||
body: formData,
|
body: formData,
|
||||||
}
|
},
|
||||||
);
|
fallback: { version: "", urls: [""] },
|
||||||
return data;
|
});
|
||||||
},
|
},
|
||||||
{
|
onSuccess: async (response) => {
|
||||||
onSuccess: async (response) => {
|
onClose();
|
||||||
onClose();
|
setSelectedVersionData({ version: "", urls: [] }); //cleanup
|
||||||
setSelectedVersionData({ version: "", urls: [] }); //cleanup
|
await navigate(
|
||||||
navigate(
|
`/${
|
||||||
`/${
|
namespace ? namespace : "default"
|
||||||
namespace ? namespace : "default"
|
}/${releaseName}/installed/revision/${response.version}`
|
||||||
}/${releaseName}/installed/revision/${response.version}`
|
);
|
||||||
);
|
window.location.reload();
|
||||||
window.location.reload();
|
},
|
||||||
},
|
onError: (error) => {
|
||||||
onError: (error) => {
|
setInstallError(error?.message || "Failed to update");
|
||||||
setInstallError((error as Error)?.message || "Failed to update");
|
},
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -189,31 +210,80 @@ export const InstallReleaseChartModal = ({
|
|||||||
title={
|
title={
|
||||||
<InstallUpgradeTitle
|
<InstallUpgradeTitle
|
||||||
isUpgrade={isUpgrade}
|
isUpgrade={isUpgrade}
|
||||||
releaseValues={isUpgrade || releaseValues}
|
releaseValues={isUpgrade || !!releaseValues}
|
||||||
chartName={chartName}
|
chartName={chartName}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
containerClassNames="w-full text-2xl h-2/3"
|
containerClassNames="w-full text-2xl h-2/3"
|
||||||
|
bottomContent={
|
||||||
|
isUpgrade ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceUpgrade}
|
||||||
|
onChange={(e) => setForceUpgrade(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Force upgrade
|
||||||
|
</label>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
callback: setReleaseVersionMutation.mutate,
|
callback: setReleaseVersionMutation.mutate,
|
||||||
variant: ModalButtonStyle.info,
|
variant: ModalButtonStyle.info,
|
||||||
isLoading: setReleaseVersionMutation.isLoading,
|
isLoading: setReleaseVersionMutation.isPending,
|
||||||
disabled:
|
disabled:
|
||||||
loadingReleaseValues ||
|
loadingReleaseValues ||
|
||||||
isLoadingDiff ||
|
isLoadingDiff ||
|
||||||
setReleaseVersionMutation.isLoading,
|
setReleaseVersionMutation.isPending,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{versions && isNoneEmptyArray(versions) && (
|
{isLoadingVersions ? (
|
||||||
<VersionToInstall
|
<Spinner />
|
||||||
versions={versions}
|
) : !useURLMode && versions && isNoneEmptyArray(versions) ? (
|
||||||
initialVersion={selectedVersionData}
|
<div className="flex items-center gap-2">
|
||||||
onSelectVersion={setSelectedVersionData}
|
<VersionToInstall
|
||||||
showCurrentVersion
|
versions={versions}
|
||||||
/>
|
initialVersion={selectedVersionData}
|
||||||
|
onSelectVersion={setSelectedVersionData}
|
||||||
|
showCurrentVersion
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Switch to URL"
|
||||||
|
onClick={() => setUseURLMode(true)}
|
||||||
|
>
|
||||||
|
<BsPencil className="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-lg">Chart URL:</h4>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-sm border border-1 border-gray-300 bg-white px-2 py-1 text-lg"
|
||||||
|
value={chartURL}
|
||||||
|
onChange={(e) => setChartURL(e.target.value)}
|
||||||
|
placeholder="oci://registry-1.docker.io/example/chart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{versions && isNoneEmptyArray(versions) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Switch to repository"
|
||||||
|
onClick={() => {
|
||||||
|
setUseURLMode(false);
|
||||||
|
setChartURL("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BsX className="text-2xl" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<GeneralDetails
|
<GeneralDetails
|
||||||
@@ -224,24 +294,28 @@ export const InstallReleaseChartModal = ({
|
|||||||
onNamespaceInput={setNamespace}
|
onNamespaceInput={setNamespace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DefinedValues
|
<Suspense fallback={<Spinner />}>
|
||||||
initialValue={releaseValues}
|
<DefinedValues
|
||||||
onUserValuesChange={(values: string) => setUserValues(values)}
|
initialValue={releaseValues}
|
||||||
chartValues={chartValues}
|
onUserValuesChange={(values: string) => setUserValues(values)}
|
||||||
loading={loadingReleaseValues}
|
chartValues={chartValues}
|
||||||
/>
|
loading={loadingReleaseValues}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<ManifestDiff
|
<Suspense fallback={<Spinner />}>
|
||||||
diff={diffData as string}
|
<ManifestDiff
|
||||||
isLoading={isLoadingDiff}
|
diff={diffData as string}
|
||||||
error={
|
isLoading={isLoadingDiff}
|
||||||
(currentVerManifestError as string) ||
|
error={
|
||||||
(selectedVerDataError as string) ||
|
(currentVerManifestError as unknown as string) || // TODO fix it
|
||||||
(diffError as string) ||
|
(selectedVerDataError as unknown as string) ||
|
||||||
installError ||
|
(diffError as unknown as string) ||
|
||||||
(versionsError as string)
|
installError ||
|
||||||
}
|
(versionsError as unknown as string)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
import { useParams } from "react-router-dom";
|
|
||||||
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 { useMutation } from "@tanstack/react-query";
|
||||||
import { useChartRepoValues } from "../../../API/repositories";
|
import {
|
||||||
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
|
lazy,
|
||||||
import { VersionToInstall } from "./VersionToInstall";
|
Suspense,
|
||||||
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
|
useEffect,
|
||||||
import { useDiffData } from "../../../API/shared";
|
useEffectEvent,
|
||||||
import { InstallChartModalProps } from "../../../data/types";
|
useMemo,
|
||||||
import { DefinedValues } from "./DefinedValues";
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
|
import { BsPencil, BsX } from "react-icons/bs";
|
||||||
|
|
||||||
import apiService from "../../../API/apiService";
|
import apiService from "../../../API/apiService";
|
||||||
|
import type { LatestChartVersion } from "../../../API/interfaces";
|
||||||
|
import { useGetVersions, useVersionData } from "../../../API/releases";
|
||||||
|
import { useChartRepoValues } from "../../../API/repositories";
|
||||||
|
import { useDiffData } from "../../../API/shared";
|
||||||
|
import type { InstallChartModalProps } from "../../../data/types";
|
||||||
|
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
|
||||||
|
import { isNoneEmptyArray } from "../../../utils";
|
||||||
|
import Spinner from "../../Spinner";
|
||||||
|
import Modal, { ModalButtonStyle } from "../Modal";
|
||||||
|
|
||||||
|
import { GeneralDetails } from "./GeneralDetails";
|
||||||
import { InstallUpgradeTitle } from "./InstallUpgradeTitle";
|
import { InstallUpgradeTitle } from "./InstallUpgradeTitle";
|
||||||
|
import { VersionToInstall } from "./VersionToInstall";
|
||||||
|
|
||||||
|
const DefinedValues = lazy(() => import("./DefinedValues"));
|
||||||
|
const ManifestDiff = lazy(() => import("./ManifestDiff"));
|
||||||
|
|
||||||
export const InstallRepoChartModal = ({
|
export const InstallRepoChartModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
chartName,
|
chartName,
|
||||||
currentlyInstalledChartVersion,
|
currentlyInstalledChartVersion,
|
||||||
latestVersion,
|
urlMode: initialURLMode = false,
|
||||||
}: InstallChartModalProps) => {
|
}: InstallChartModalProps & { urlMode?: boolean }) => {
|
||||||
const navigate = useNavigateWithSearchParams();
|
const navigate = useNavigateWithSearchParams();
|
||||||
const [userValues, setUserValues] = useState("");
|
const [userValues, setUserValues] = useState("");
|
||||||
const [installError, setInstallError] = useState("");
|
const [installError, setInstallError] = useState("");
|
||||||
@@ -31,44 +45,51 @@ export const InstallRepoChartModal = ({
|
|||||||
const [namespace, setNamespace] = useState("");
|
const [namespace, setNamespace] = useState("");
|
||||||
const [releaseName, setReleaseName] = useState(chartName);
|
const [releaseName, setReleaseName] = useState(chartName);
|
||||||
|
|
||||||
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
|
const {
|
||||||
select: (data) => {
|
error: versionsError,
|
||||||
return data?.sort((a, b) =>
|
data: _versions = [],
|
||||||
isNewerVersion(a.version, b.version) ? 1 : -1
|
isSuccess,
|
||||||
);
|
} = useGetVersions(chartName);
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
const empty = { version: "", repository: "", urls: [] };
|
|
||||||
const versionsToRepo = data.filter(
|
|
||||||
(v) => v.repository === currentRepoCtx
|
|
||||||
);
|
|
||||||
|
|
||||||
return setSelectedVersionData(versionsToRepo[0] ?? empty);
|
const [versions, setVersions] = useState<
|
||||||
},
|
Array<LatestChartVersion & { isChartVersion: boolean }>
|
||||||
});
|
>([]);
|
||||||
|
|
||||||
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<{
|
const [selectedVersionData, setSelectedVersionData] = useState<{
|
||||||
version: string;
|
version: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
urls: string[];
|
urls: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedVersion = useMemo(() => {
|
const onSuccess = useEffectEvent(() => {
|
||||||
return selectedVersionData?.version;
|
const empty = { version: "", repository: "", urls: [] };
|
||||||
}, [selectedVersionData]);
|
const versionsToRepo = _versions.filter(
|
||||||
|
(v) => v.repository === currentRepoCtx
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRepo = useMemo(() => {
|
setSelectedVersionData(versionsToRepo[0] ?? empty);
|
||||||
return selectedVersionData?.repository;
|
setVersions(
|
||||||
}, [selectedVersionData]);
|
_versions?.map((v) => ({
|
||||||
|
...v,
|
||||||
|
isChartVersion: v.version === currentlyInstalledChartVersion,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const chartAddress = useMemo(() => {
|
useEffect(() => {
|
||||||
|
if (isSuccess && _versions.length) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}, [isSuccess, _versions]);
|
||||||
|
|
||||||
|
const selectedVersion = selectedVersionData?.version;
|
||||||
|
|
||||||
|
const selectedRepo = selectedVersionData?.repository;
|
||||||
|
|
||||||
|
const [chartURL, setChartURL] = useState("");
|
||||||
|
const [useURLMode, setUseURLMode] = useState(initialURLMode);
|
||||||
|
|
||||||
|
const repoChartAddress = useMemo(() => {
|
||||||
if (!selectedVersionData || !selectedVersionData?.repository) {
|
if (!selectedVersionData || !selectedVersionData?.repository) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -77,15 +98,17 @@ export const InstallRepoChartModal = ({
|
|||||||
: `${selectedVersionData?.repository}/${chartName}`;
|
: `${selectedVersionData?.repository}/${chartName}`;
|
||||||
}, [selectedVersionData, chartName]);
|
}, [selectedVersionData, chartName]);
|
||||||
|
|
||||||
const { data: chartValues, isLoading: loadingChartValues } =
|
const chartAddress = useURLMode ? chartURL : repoChartAddress || chartURL;
|
||||||
|
|
||||||
|
const { data: chartValues = "", isLoading: loadingChartValues } =
|
||||||
useChartRepoValues({
|
useChartRepoValues({
|
||||||
version: selectedVersion || "",
|
version: selectedVersion || "",
|
||||||
chart: chartAddress,
|
chart: chartAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
// This hold the selected version manifest, we use it for the diff
|
// This hold the selected version manifest, we use it for the diff
|
||||||
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
|
const { data: selectedVerData = {}, error: selectedVerDataError } =
|
||||||
{
|
useVersionData({
|
||||||
version: selectedVersion || "",
|
version: selectedVersion || "",
|
||||||
userValues,
|
userValues,
|
||||||
chartAddress,
|
chartAddress,
|
||||||
@@ -93,11 +116,8 @@ export const InstallRepoChartModal = ({
|
|||||||
namespace,
|
namespace,
|
||||||
releaseName,
|
releaseName,
|
||||||
isInstallRepoChart: true,
|
isInstallRepoChart: true,
|
||||||
options: {
|
enabled: Boolean(chartAddress),
|
||||||
enabled: Boolean(chartAddress),
|
});
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: diffData,
|
data: diffData,
|
||||||
@@ -105,15 +125,18 @@ export const InstallRepoChartModal = ({
|
|||||||
error: diffError,
|
error: diffError,
|
||||||
} = useDiffData({
|
} = useDiffData({
|
||||||
selectedRepo: selectedRepo || "",
|
selectedRepo: selectedRepo || "",
|
||||||
versionsError: versionsError as string,
|
versionsError: versionsError as unknown as string, // TODO fix it
|
||||||
currentVerManifest: "", // current version manifest should always be empty since its a fresh install
|
currentVerManifest: "", // current version manifest should always be empty since it's a fresh install
|
||||||
selectedVerData,
|
selectedVerData,
|
||||||
chart: chartAddress,
|
chart: chartAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm method (install)
|
// Confirm method (install)
|
||||||
const setReleaseVersionMutation = useMutation(
|
const setReleaseVersionMutation = useMutation<{
|
||||||
[
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
mutationKey: [
|
||||||
"setVersion",
|
"setVersion",
|
||||||
namespace,
|
namespace,
|
||||||
releaseName,
|
releaseName,
|
||||||
@@ -122,7 +145,7 @@ export const InstallRepoChartModal = ({
|
|||||||
selectedCluster,
|
selectedCluster,
|
||||||
chartAddress,
|
chartAddress,
|
||||||
],
|
],
|
||||||
async () => {
|
mutationFn: async () => {
|
||||||
setInstallError("");
|
setInstallError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("preview", "false");
|
formData.append("preview", "false");
|
||||||
@@ -130,27 +153,27 @@ export const InstallRepoChartModal = ({
|
|||||||
formData.append("version", selectedVersion || "");
|
formData.append("version", selectedVersion || "");
|
||||||
formData.append("values", userValues);
|
formData.append("values", userValues);
|
||||||
formData.append("name", releaseName || "");
|
formData.append("name", releaseName || "");
|
||||||
const data = await apiService.fetchWithDefaults(
|
|
||||||
`/api/helm/releases/${namespace ? namespace : "default"}`,
|
return await apiService.fetchWithSafeDefaults({
|
||||||
{
|
url: `/api/helm/releases/${namespace ? namespace : "default"}`,
|
||||||
|
options: {
|
||||||
method: "post",
|
method: "post",
|
||||||
body: formData,
|
body: formData,
|
||||||
}
|
},
|
||||||
);
|
fallback: { namespace: "", name: "" },
|
||||||
return data;
|
});
|
||||||
},
|
},
|
||||||
{
|
|
||||||
onSuccess: async (response) => {
|
onSuccess: async (response: { namespace: string; name: string }) => {
|
||||||
onClose();
|
onClose();
|
||||||
navigate(
|
await navigate(
|
||||||
`/${response.namespace}/${response.name}/installed/revision/1`
|
`/${response.namespace}/${response.name}/installed/revision/1`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setInstallError((error as Error)?.message || "Failed to update");
|
setInstallError(error?.message || "Failed to update");
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -160,11 +183,15 @@ export const InstallRepoChartModal = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
<InstallUpgradeTitle
|
initialURLMode ? (
|
||||||
isUpgrade={false}
|
<div className="font-bold">Install from URL</div>
|
||||||
releaseValues={false}
|
) : (
|
||||||
chartName={chartName}
|
<InstallUpgradeTitle
|
||||||
/>
|
isUpgrade={false}
|
||||||
|
releaseValues={false}
|
||||||
|
chartName={chartName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
containerClassNames="w-full text-2xl h-2/3"
|
containerClassNames="w-full text-2xl h-2/3"
|
||||||
actions={[
|
actions={[
|
||||||
@@ -172,21 +199,56 @@ export const InstallRepoChartModal = ({
|
|||||||
id: "1",
|
id: "1",
|
||||||
callback: setReleaseVersionMutation.mutate,
|
callback: setReleaseVersionMutation.mutate,
|
||||||
variant: ModalButtonStyle.info,
|
variant: ModalButtonStyle.info,
|
||||||
isLoading: setReleaseVersionMutation.isLoading,
|
isLoading: setReleaseVersionMutation.isPending,
|
||||||
disabled:
|
disabled:
|
||||||
loadingChartValues ||
|
loadingChartValues ||
|
||||||
isLoadingDiff ||
|
isLoadingDiff ||
|
||||||
setReleaseVersionMutation.isLoading,
|
setReleaseVersionMutation.isPending,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{versions && isNoneEmptyArray(versions) && (
|
{!useURLMode && versions && isNoneEmptyArray(versions) ? (
|
||||||
<VersionToInstall
|
<div className="flex items-center gap-2">
|
||||||
versions={versions}
|
<VersionToInstall
|
||||||
initialVersion={selectedVersionData}
|
versions={versions}
|
||||||
onSelectVersion={setSelectedVersionData}
|
initialVersion={selectedVersionData}
|
||||||
showCurrentVersion={false}
|
onSelectVersion={setSelectedVersionData}
|
||||||
/>
|
showCurrentVersion={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Switch to URL"
|
||||||
|
onClick={() => setUseURLMode(true)}
|
||||||
|
>
|
||||||
|
<BsPencil className="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-lg">Chart URL:</h4>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-sm border border-1 border-gray-300 bg-white px-2 py-1 text-lg"
|
||||||
|
value={chartURL}
|
||||||
|
onChange={(e) => setChartURL(e.target.value)}
|
||||||
|
placeholder="oci://registry-1.docker.io/example/chart:1.0.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{versions && isNoneEmptyArray(versions) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Switch to repository"
|
||||||
|
onClick={() => {
|
||||||
|
setUseURLMode(false);
|
||||||
|
setChartURL("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BsX className="text-2xl" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<GeneralDetails
|
<GeneralDetails
|
||||||
@@ -204,16 +266,18 @@ export const InstallRepoChartModal = ({
|
|||||||
loading={loadingChartValues}
|
loading={loadingChartValues}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ManifestDiff
|
<Suspense fallback={<Spinner />}>
|
||||||
diff={diffData as string}
|
<ManifestDiff
|
||||||
isLoading={isLoadingDiff}
|
diff={diffData as string}
|
||||||
error={
|
isLoading={isLoadingDiff}
|
||||||
(selectedVerDataError as string) ||
|
error={
|
||||||
(diffError as string) ||
|
(selectedVerDataError as unknown as string) || // TODO fix it
|
||||||
installError ||
|
(diffError as unknown as string) ||
|
||||||
(versionsError as string)
|
installError ||
|
||||||
}
|
(versionsError as unknown as string)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
interface InstallUpgradeProps {
|
interface InstallUpgradeProps {
|
||||||
isUpgrade: boolean;
|
isUpgrade: boolean;
|
||||||
@@ -17,7 +17,7 @@ export const InstallUpgradeTitle: FC<InstallUpgradeProps> = ({
|
|||||||
<div className="font-bold">
|
<div className="font-bold">
|
||||||
{`${text}`}
|
{`${text}`}
|
||||||
{(isUpgrade || releaseValues) && (
|
{(isUpgrade || releaseValues) && (
|
||||||
<span className="text-green-700">{chartName}</span>
|
<span className="ml-1 text-green-700">{chartName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-base";
|
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-base";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import yaml from "highlight.js/lib/languages/yaml";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import Spinner from "../../Spinner";
|
|
||||||
import { diffConfiguration } from "../../../utils";
|
import { diffConfiguration } from "../../../utils";
|
||||||
|
import Spinner from "../../Spinner";
|
||||||
|
|
||||||
|
hljs.registerLanguage("yaml", yaml);
|
||||||
|
|
||||||
interface ManifestDiffProps {
|
interface ManifestDiffProps {
|
||||||
diff?: string;
|
diff?: string;
|
||||||
@@ -11,7 +14,7 @@ interface ManifestDiffProps {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
||||||
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,7 +38,7 @@ export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
|||||||
|
|
||||||
if (isLoading && !error) {
|
if (isLoading && !error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex text-lg items-end">
|
<div className="flex items-end text-lg">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Calculating diff...
|
Calculating diff...
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +50,7 @@ export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
|||||||
<h4 className="text-xl">Manifest changes:</h4>
|
<h4 className="text-xl">Manifest changes:</h4>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-red-600 text-lg">
|
<p className="text-lg text-red-600">
|
||||||
Failed to get upgrade info: {error.toString()}
|
Failed to get upgrade info: {error.toString()}
|
||||||
</p>
|
</p>
|
||||||
) : diff ? (
|
) : diff ? (
|
||||||
@@ -63,3 +66,5 @@ export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ManifestDiff;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import useDebounce from "../../../hooks/useDebounce";
|
import useDebounce from "../../../hooks/useDebounce";
|
||||||
|
|
||||||
export const UserDefinedValues = ({
|
export const UserDefinedValues = ({
|
||||||
@@ -20,9 +21,9 @@ export const UserDefinedValues = ({
|
|||||||
}, [debouncedValue, onValuesChange, initialValue]);
|
}, [debouncedValue, onValuesChange, initialValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-1/2 ">
|
<div className="w-1/2">
|
||||||
<label
|
<label
|
||||||
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
|
className="mb-2 block text-xl font-medium tracking-wide text-gray-700"
|
||||||
htmlFor="grid-user-defined-values"
|
htmlFor="grid-user-defined-values"
|
||||||
>
|
>
|
||||||
User-Defined Values:
|
User-Defined Values:
|
||||||
@@ -32,7 +33,7 @@ export const UserDefinedValues = ({
|
|||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
onChange={(e) => setUserDefinedValues(e.target.value)}
|
onChange={(e) => setUserDefinedValues(e.target.value)}
|
||||||
rows={14}
|
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"
|
className="text-md font-monospace block w-full resize-none rounded-lg border border-gray-300 p-2.5 text-gray-900 focus:border-blue-500 focus:ring-blue-500"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user