├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── main.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── DCO ├── LICENSE ├── MAINTAINERS ├── Makefile ├── README.md ├── dev-server.js ├── go.mod ├── go.sum ├── main.go ├── package-lock.json ├── package.json ├── pkg ├── clustersserver │ ├── adapters.go │ ├── clustersserver.go │ ├── clustersserver_test.go │ └── suite_test.go ├── rpc │ └── clusters │ │ ├── clusters.pb.go │ │ ├── clusters.proto │ │ └── clusters.twirp.go └── util │ └── util.go ├── static └── img │ ├── flux-horizontal-black.png │ └── flux-horizontal-white.png ├── tools ├── crd │ ├── bucket.yaml │ ├── gitrepository.yaml │ ├── helmrelease.yaml │ ├── helmrepository.yaml │ └── kustomization.yaml ├── podinfo-helm.yaml ├── setup-dev-cluster.sh ├── tag-release.sh └── update-flux-deps.sh ├── tsconfig.json └── ui ├── App.tsx ├── components ├── AppStateProvider.tsx ├── CommandLineHint.tsx ├── ConditionsTable.tsx ├── DataTable.tsx ├── ErrorBoundary.tsx ├── Flex.tsx ├── Graph.tsx ├── KeyValueTable.tsx ├── LeftNav.tsx ├── Link.tsx ├── LoadingPage.tsx ├── Logo.tsx ├── Page.tsx ├── Panel.tsx ├── ReconciliationGraph.tsx ├── SuggestedAction.tsx ├── TopNav.tsx └── __tests__ │ ├── AppStateProvider.test.tsx │ ├── Flex.test.tsx │ ├── TopNav.test.tsx │ └── __snapshots__ │ ├── Flex.test.tsx.snap │ └── TopNav.test.tsx.snap ├── index.html ├── lib ├── fileMock.js ├── hooks │ ├── __tests__ │ │ └── hooks.test.tsx │ ├── app.ts │ ├── events.ts │ ├── helm_releases.ts │ ├── kustomizations.ts │ └── sources.ts ├── rpc │ ├── clusters.ts │ └── twirp.ts ├── test-utils.tsx ├── theme.ts ├── types.ts └── util.ts ├── main.js └── pages ├── Error.tsx ├── Events.tsx ├── HelmReleaseDetail.tsx ├── HelmReleases.tsx ├── KustomizationDetail.tsx ├── Kustomizations.tsx ├── Redirector.tsx ├── SourceDetail.tsx ├── Sources.tsx └── __tests__ ├── KustomizationDetail.test.tsx └── __snapshots__ └── KustomizationDetail.test.tsx.snap /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:import/errors", 5 | "plugin:import/warnings", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:import/typescript" 8 | ], 9 | "rules": { 10 | "import/default": 0, 11 | "import/no-named-as-default-member": 0, 12 | "import/named": 2, 13 | "import/order": 2, 14 | "@typescript-eslint/explicit-module-boundary-types": 0, 15 | "@typescript-eslint/no-explicit-any": 0, 16 | "@typescript-eslint/ban-ts-comment": 0 17 | }, 18 | "ignorePatterns": "rpc" 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | ci: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Restore Go cache 18 | uses: actions/cache@v1 19 | with: 20 | path: ~/go/pkg/mod 21 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 22 | restore-keys: | 23 | ${{ runner.os }}-go- 24 | - name: Cache node modules 25 | uses: actions/cache@v2 26 | env: 27 | cache-name: cache-node-modules 28 | with: 29 | # npm cache files are stored in `~/.npm` on Linux/macOS 30 | path: ~/.npm 31 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 32 | restore-keys: | 33 | ${{ runner.os }}-build-${{ env.cache-name }}- 34 | ${{ runner.os }}-build- 35 | ${{ runner.os }}- 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - name: Setup Go 41 | uses: actions/setup-go@v2 42 | with: 43 | go-version: 1.16.x 44 | - name: Set up kubebuilder 45 | uses: fluxcd/pkg/actions/kubebuilder@main 46 | # - run: ./tools/install-kube-builder.sh 47 | - run: npm ci 48 | - run: npm run build 49 | - run: npm run lint 50 | - run: npm test 51 | - run: go mod download 52 | - run: make test 53 | env: 54 | # GOPATH: /github/home/go 55 | KUBEBUILDER_ASSETS: ${{ github.workspace }}/kubebuilder/bin 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.16 19 | - name: Install NPM Modules 20 | run: npm ci 21 | - name: Build Assets 22 | run: make build 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v2 25 | with: 26 | version: latest 27 | args: release --rm-dist 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | bin 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | - go generate ./... 5 | dist: bin 6 | builds: 7 | - <<: &build_defaults 8 | binary: flux-webui 9 | main: main.go 10 | ldflags: 11 | - -s -w -X main.VERSION={{ .Version }} 12 | env: 13 | - CGO_ENABLED=0 14 | id: linux 15 | goos: 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm64 20 | - arm 21 | goarm: 22 | - 7 23 | - <<: *build_defaults 24 | id: darwin 25 | goos: 26 | - darwin 27 | goarch: 28 | - amd64 29 | - arm64 30 | - <<: *build_defaults 31 | id: windows 32 | goos: 33 | - windows 34 | archives: 35 | - name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 36 | id: nix 37 | builds: [linux, darwin] 38 | format: tar.gz 39 | files: 40 | - none* 41 | - name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 42 | id: windows 43 | builds: [windows] 44 | format: zip 45 | files: 46 | - none* 47 | checksum: 48 | name_template: "checksums.txt" 49 | snapshot: 50 | name_template: "{{ .Tag }}-next" 51 | changelog: 52 | sort: asc 53 | filters: 54 | exclude: 55 | - "^docs:" 56 | - "^test:" 57 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | The Flux project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 660 York Street, Suite 102, 6 | San Francisco, CA 94110 USA 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | 12 | Developer's Certificate of Origin 1.1 13 | 14 | By making a contribution to this project, I certify that: 15 | 16 | (a) The contribution was created in whole or in part by me and I 17 | have the right to submit it under the open source license 18 | indicated in the file; or 19 | 20 | (b) The contribution is based upon previous work that, to the best 21 | of my knowledge, is covered under an appropriate open source 22 | license and I have the right under that license to submit that 23 | work with modifications, whether created in whole or in part 24 | by me, under the same open source license (unless I am 25 | permitted to submit under a different license), as indicated 26 | in the file; or 27 | 28 | (c) The contribution was provided directly to me by some other 29 | person who certified (a), (b) or (c) and I have not modified 30 | it. 31 | 32 | (d) I understand and agree that this project and the contribution 33 | are public and that a record of the contribution (including all 34 | personal information I submit with it, including my sign-off) is 35 | maintained indefinitely and may be redistributed consistent with 36 | this project or the open source license(s) involved. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | The maintainers are generally available in Slack at 2 | https://cloud-native.slack.com in #flux (https://cloud-native.slack.com/messages/CLAJ40HV3) 3 | (obtain an invitation at https://slack.cncf.io/). 4 | 5 | In alphabetical order: 6 | 7 | Bianca Cheng Costanzo, Weaveworks (github: @bia, slack: bianca cheng costanzo) 8 | Jordan Pellizzari, Weaveworks (github: @jpellizzari, slack: Jordan Pellizzari) 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean all test assets dev proto download-crd-deps 2 | SOURCE_VERSION := v0.13.2 3 | KUSTOMIZE_VERSION := v0.12.2 4 | HELM_CRD_VERSION := v0.10.1 5 | 6 | all: test build 7 | 8 | dist/index.html: 9 | npm run build 10 | 11 | test: 12 | go test ./... 13 | 14 | build: dist/index.html 15 | CGO_ENABLED=0 go build -o ./bin/webui . 16 | 17 | dev: dist/index.html 18 | reflex -r '.go' -s -- sh -c 'go run main.go' 19 | 20 | proto: pkg/rpc/clusters/clusters.proto 21 | protoc pkg/rpc/clusters/clusters.proto --twirp_out=./ --go_out=. --twirp_typescript_out=./ui/lib/rpc 22 | 23 | download-crd-deps: 24 | curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VERSION}/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml > tools/crd/gitrepository.yaml 25 | curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VERSION}/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml > tools/crd/bucket.yaml 26 | curl -s https://raw.githubusercontent.com/fluxcd/kustomize-controller/${KUSTOMIZE_VERSION}/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml > tools/crd/kustomization.yaml 27 | curl -s https://raw.githubusercontent.com/fluxcd/helm-controller/${HELM_CRD_VERSION}/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml > tools/crd/helmrelease.yaml 28 | curl -s https://raw.githubusercontent.com/fluxcd/source-controller/${SOURCE_VERSION}/config/crd/bases/source.toolkit.fluxcd.io_helmrepositories.yaml > tools/crd/helmrepository.yaml 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flux Web UI 2 | 3 | ## Project Status 4 | 5 | :warning: This project has been archived and is no longer under development in the FluxCD organization. 6 | If you are looking for a Web UI for Flux, please see the options listed below. 7 | 8 | ## Flux Web UI by Weaveworks 9 | 10 | [Weaveworks](https://www.weave.works) offers a free and open source GUI for Flux under the 11 | [weave-gitops](https://github.com/weaveworks/weave-gitops) project. 12 | 13 | ![weave-gitops-flux-ui](https://github.com/fluxcd-community/microservices-demo/raw/v1.1.1/docs/img/weave-gitops-msdemo.png) 14 | 15 | You can install the Weave GitOps UI using a Flux `HelmRelease`, 16 | please see the [Weave GitOps documentation](https://docs.gitops.weave.works/docs/getting-started/) for more details. 17 | 18 | ## Flux Grafana Dashboards 19 | 20 | The Flux community maintains a series of Grafana dashboards for monitoring Flux. 21 | 22 | ![flux-grafana](https://github.com/fluxcd/website/raw/main/static/img/cluster-dashboard.png) 23 | 24 | See [the monitoring section of the Flux documentation](https://fluxcd.io/docs/guides/monitoring/) 25 | for how to install Flux's Grafana dashboards. 26 | -------------------------------------------------------------------------------- /dev-server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const Bundler = require("parcel-bundler"); 3 | const httpProxy = require("http-proxy"); 4 | 5 | const bundler = new Bundler("ui/index.html", { outDir: "./dist/dev" }); 6 | const server = httpProxy.createProxyServer({}); 7 | 8 | const app = express(); 9 | 10 | const API_BACKEND = "http://localhost:9000/api/"; 11 | 12 | const port = 1234; 13 | 14 | const proxy = (url) => { 15 | return (req, res) => { 16 | server.web( 17 | req, 18 | res, 19 | { 20 | target: url, 21 | ws: true, 22 | }, 23 | (e) => { 24 | console.error(e); 25 | res.status(500).json({ msg: e.message }); 26 | } 27 | ); 28 | }; 29 | }; 30 | 31 | app.use("/api", proxy(API_BACKEND)); 32 | 33 | app.use(bundler.middleware()); 34 | 35 | app.listen(port, () => console.log(`Dev server started on :${port}`)); 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fluxcd/webui 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/fluxcd/helm-controller/api v0.10.1 7 | github.com/fluxcd/kustomize-controller/api v0.12.2 8 | github.com/fluxcd/notification-controller/api v0.14.1 9 | github.com/fluxcd/pkg/apis/meta v0.9.0 10 | github.com/fluxcd/pkg/runtime v0.11.0 11 | github.com/fluxcd/source-controller/api v0.13.2 12 | github.com/go-logr/logr v0.4.0 13 | github.com/golang/protobuf v1.4.3 14 | github.com/imdario/mergo v0.3.12 // indirect 15 | github.com/kr/text v0.2.0 // indirect 16 | github.com/onsi/ginkgo v1.14.2 17 | github.com/onsi/gomega v1.10.2 18 | github.com/prometheus/client_golang v1.7.1 19 | github.com/stretchr/testify v1.7.0 // indirect 20 | github.com/twitchtv/twirp v7.1.0+incompatible 21 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect 22 | golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect 23 | golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79 // indirect 24 | golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3 // indirect 25 | google.golang.org/protobuf v1.25.0 26 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 27 | k8s.io/api v0.20.4 28 | k8s.io/apimachinery v0.20.4 29 | k8s.io/client-go v0.20.4 30 | sigs.k8s.io/controller-runtime v0.8.3 31 | sigs.k8s.io/kustomize/kstatus v0.0.2 32 | ) 33 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/fluxcd/pkg/runtime/logger" 11 | "github.com/fluxcd/webui/pkg/clustersserver" 12 | "github.com/go-logr/logr" 13 | "github.com/prometheus/client_golang/prometheus" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | _ "k8s.io/client-go/plugin/pkg/client/auth" 16 | "k8s.io/client-go/tools/clientcmd" 17 | ) 18 | 19 | func init() { 20 | var durations = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 21 | Name: "http_request_duration_seconds", 22 | Help: "HTTP request durations", 23 | Buckets: prometheus.DefBuckets, 24 | }, []string{"service", "method", "status"}) 25 | 26 | prometheus.MustRegister(durations) 27 | } 28 | 29 | func initialContexts() (contexts []string, currentCtx string, err error) { 30 | cfgLoadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 31 | 32 | rules, err := cfgLoadingRules.Load() 33 | 34 | if err != nil { 35 | return contexts, currentCtx, err 36 | } 37 | 38 | for contextName := range rules.Contexts { 39 | contexts = append(contexts, contextName) 40 | } 41 | 42 | return contexts, rules.CurrentContext, nil 43 | } 44 | 45 | func main() { 46 | log := logger.NewLogger(logger.Options{LogLevel: "debug"}) 47 | 48 | mux := http.NewServeMux() 49 | 50 | mux.Handle("/metrics/", promhttp.Handler()) 51 | 52 | mux.Handle("/health/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | w.WriteHeader(http.StatusOK) 54 | })) 55 | 56 | kubeContexts, currentKubeContext, err := initialContexts() 57 | 58 | if err != nil { 59 | log.Error(err, "could not get k8s contexts") 60 | os.Exit(1) 61 | } 62 | 63 | clusters := clustersserver.NewServer(kubeContexts, currentKubeContext) 64 | 65 | mux.Handle("/api/clusters/", http.StripPrefix("/api/clusters", clusters)) 66 | 67 | assetFS := getAssets() 68 | assetHandler := http.FileServer(http.FS(assetFS)) 69 | redirector := createRedirector(assetFS, log) 70 | 71 | mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 72 | extension := filepath.Ext(req.URL.Path) 73 | // We use the golang http.FileServer for static file requests. 74 | // This will return a 404 on normal page requests, ie /kustomizations and /sources. 75 | // Redirect all non-file requests to index.html, where the JS routing will take over. 76 | if extension == "" { 77 | redirector(w, req) 78 | return 79 | } 80 | assetHandler.ServeHTTP(w, req) 81 | })) 82 | 83 | log.Info("Serving on port 9000") 84 | 85 | if err := http.ListenAndServe(":9000", mux); err != nil { 86 | log.Error(err, "server exited") 87 | os.Exit(1) 88 | } 89 | } 90 | 91 | //go:embed dist/* 92 | var static embed.FS 93 | 94 | func getAssets() fs.FS { 95 | f, err := fs.Sub(static, "dist") 96 | 97 | if err != nil { 98 | panic(err) 99 | } 100 | return f 101 | } 102 | 103 | func createRedirector(fsys fs.FS, log logr.Logger) http.HandlerFunc { 104 | return func(w http.ResponseWriter, r *http.Request) { 105 | indexPage, err := fsys.Open("index.html") 106 | 107 | if err != nil { 108 | log.Error(err, "could not open index.html page") 109 | w.WriteHeader(http.StatusInternalServerError) 110 | return 111 | } 112 | stat, err := indexPage.Stat() 113 | if err != nil { 114 | log.Error(err, "could not get index.html stat") 115 | w.WriteHeader(http.StatusInternalServerError) 116 | return 117 | } 118 | 119 | bt := make([]byte, stat.Size()) 120 | _, err = indexPage.Read(bt) 121 | 122 | if err != nil { 123 | log.Error(err, "could not read index.html") 124 | w.WriteHeader(http.StatusInternalServerError) 125 | return 126 | } 127 | 128 | _, err = w.Write(bt) 129 | 130 | if err != nil { 131 | log.Error(err, "error writing index.html") 132 | w.WriteHeader(http.StatusInternalServerError) 133 | return 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webui", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "repository": "git@github.com:fluxcd/webui.git", 6 | "author": "Flux Maintainers", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "build": "parcel build --no-source-maps ui/index.html", 10 | "start": "nodemon dev-server.js --watch dev-server.js", 11 | "lint": "eslint ui", 12 | "test": "jest", 13 | "watch": "jest --runInBand --watch" 14 | }, 15 | "jest": { 16 | "preset": "ts-jest", 17 | "moduleNameMapper": { 18 | "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/ui/lib/fileMock.js", 19 | "\\.(css|less)$": "/ui/lib/fileMock.js" 20 | } 21 | }, 22 | "dependencies": { 23 | "@babel/preset-env": "^7.13.8", 24 | "@babel/preset-react": "^7.13.13", 25 | "@babel/preset-typescript": "^7.13.0", 26 | "@material-ui/core": "^4.11.4", 27 | "@material-ui/icons": "^4.11.2", 28 | "@material-ui/lab": "^4.0.0-alpha.58", 29 | "@testing-library/jest-dom": "^5.12.0", 30 | "@testing-library/react": "^11.2.7", 31 | "@testing-library/react-hooks": "^6.0.0", 32 | "@types/jest": "^26.0.23", 33 | "@types/lodash": "^4.14.165", 34 | "@types/react": "^17.0.6", 35 | "@types/react-dom": "^17.0.5", 36 | "@types/react-router": "^5.1.8", 37 | "@types/react-router-dom": "^5.1.6", 38 | "@types/styled-components": "^5.1.4", 39 | "babel-jest": "^26.6.3", 40 | "d3": "^6.6.0", 41 | "d3-selection": "^2.0.0", 42 | "dagre": "^0.8.5", 43 | "dagre-d3": "^0.6.4", 44 | "history": "^4.0.0", 45 | "jest": "^26.6.3", 46 | "jest-styled-components": "^7.0.4", 47 | "lodash": "^4.17.21", 48 | "query-string": "^6.13.7", 49 | "react": "^17.0.2", 50 | "react-dom": "^17.0.2", 51 | "react-router-dom": "^5.2.0", 52 | "react-test-renderer": "^17.0.2", 53 | "react-toastify": "^7.0.3", 54 | "styled-components": "^5.2.1", 55 | "ts-jest": "^26.5.6" 56 | }, 57 | "devDependencies": { 58 | "@typescript-eslint/eslint-plugin": "^4.16.1", 59 | "@typescript-eslint/parser": "^4.16.1", 60 | "eslint": "^7.21.0", 61 | "eslint-plugin-import": "^2.22.1", 62 | "express": "^4.17.1", 63 | "http-proxy": "^1.18.1", 64 | "morgan": "^1.10.0", 65 | "nodemon": "^2.0.6", 66 | "parcel": "1.12.3", 67 | "parcel-bundler": "1.12.3", 68 | "prettier": "^2.2.1", 69 | "typescript": "^4.1.2" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/clustersserver/adapters.go: -------------------------------------------------------------------------------- 1 | package clustersserver 2 | 3 | import ( 4 | helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" 5 | kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" 6 | sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | type reconcilable interface { 12 | client.Object 13 | GetAnnotations() map[string]string 14 | SetAnnotations(map[string]string) 15 | GetStatusConditions() *[]metav1.Condition 16 | GetLastHandledReconcileRequest() string 17 | asClientObject() client.Object 18 | } 19 | 20 | type apiType struct { 21 | kind, humanKind string 22 | } 23 | 24 | type reconcileWrapper struct { 25 | apiType 26 | object reconcilable 27 | } 28 | 29 | type gitRepositoryAdapter struct { 30 | *sourcev1.GitRepository 31 | } 32 | 33 | func (o gitRepositoryAdapter) GetLastHandledReconcileRequest() string { 34 | return o.Status.GetLastHandledReconcileRequest() 35 | } 36 | 37 | func (o gitRepositoryAdapter) asClientObject() client.Object { 38 | return o.GitRepository 39 | } 40 | 41 | type bucketAdapter struct { 42 | *sourcev1.Bucket 43 | } 44 | 45 | func (obj bucketAdapter) GetLastHandledReconcileRequest() string { 46 | return obj.Status.GetLastHandledReconcileRequest() 47 | } 48 | 49 | func (obj bucketAdapter) asClientObject() client.Object { 50 | return obj 51 | } 52 | 53 | type helmReleaseAdapter struct { 54 | *helmv2.HelmRelease 55 | } 56 | 57 | func (obj helmReleaseAdapter) GetLastHandledReconcileRequest() string { 58 | return obj.Status.GetLastHandledReconcileRequest() 59 | } 60 | 61 | func (obj helmReleaseAdapter) asClientObject() client.Object { 62 | return obj.HelmRelease 63 | } 64 | 65 | type helmChartAdapter struct { 66 | *sourcev1.HelmChart 67 | } 68 | 69 | func (obj helmChartAdapter) GetLastHandledReconcileRequest() string { 70 | return obj.Status.GetLastHandledReconcileRequest() 71 | } 72 | 73 | func (obj helmChartAdapter) asClientObject() client.Object { 74 | return obj.HelmChart 75 | } 76 | 77 | type kustomizationAdapter struct { 78 | *kustomizev1.Kustomization 79 | } 80 | 81 | func (o kustomizationAdapter) GetLastHandledReconcileRequest() string { 82 | return o.Status.GetLastHandledReconcileRequest() 83 | } 84 | 85 | func (o kustomizationAdapter) asClientObject() client.Object { 86 | return o.Kustomization 87 | } 88 | -------------------------------------------------------------------------------- /pkg/clustersserver/clustersserver.go: -------------------------------------------------------------------------------- 1 | package clustersserver 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | "time" 10 | 11 | helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" 12 | kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" 13 | "github.com/fluxcd/pkg/apis/meta" 14 | sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" 15 | pb "github.com/fluxcd/webui/pkg/rpc/clusters" 16 | "github.com/fluxcd/webui/pkg/util" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | "k8s.io/apimachinery/pkg/runtime/schema" 21 | "k8s.io/apimachinery/pkg/types" 22 | "k8s.io/apimachinery/pkg/util/wait" 23 | 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/kustomize/kstatus/status" 26 | 27 | corev1 "k8s.io/api/core/v1" 28 | apierrors "k8s.io/apimachinery/pkg/api/errors" 29 | ) 30 | 31 | type clientCache map[string]client.Client 32 | 33 | var k8sPollInterval = 2 * time.Second 34 | var k8sTimeout = 1 * time.Minute 35 | 36 | type Server struct { 37 | ClientCache clientCache 38 | AvailableContexts []string 39 | InitialContext string 40 | CreateClient func(string) (client.Client, error) 41 | mu sync.Mutex 42 | } 43 | 44 | func NewServer(kubeContexts []string, currentKubeContext string) http.Handler { 45 | clusters := Server{ 46 | AvailableContexts: kubeContexts, 47 | InitialContext: currentKubeContext, 48 | ClientCache: map[string]client.Client{}, 49 | CreateClient: defaultCreateClient, 50 | } 51 | 52 | clustersHandler := pb.NewClustersServer(&clusters, nil) 53 | 54 | return clustersHandler 55 | } 56 | 57 | func defaultCreateClient(kubeContext string) (client.Client, error) { 58 | return util.NewKubeClient(kubeContext) 59 | } 60 | 61 | func (s *Server) getClient(kubeContext string) (client.Client, error) { 62 | s.mu.Lock() 63 | defer s.mu.Unlock() 64 | 65 | if s.ClientCache[kubeContext] != nil { 66 | return s.ClientCache[kubeContext], nil 67 | } 68 | 69 | client, err := s.CreateClient(kubeContext) 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | s.ClientCache[kubeContext] = client 76 | 77 | return client, nil 78 | } 79 | 80 | func (s *Server) ListContexts(ctx context.Context, msg *pb.ListContextsReq) (*pb.ListContextsRes, error) { 81 | ctxs := []*pb.Context{} 82 | for _, c := range s.AvailableContexts { 83 | ctxs = append(ctxs, &pb.Context{Name: c}) 84 | } 85 | 86 | return &pb.ListContextsRes{Contexts: ctxs, CurrentContext: s.InitialContext}, nil 87 | } 88 | 89 | func (s *Server) ListNamespacesForContext(ctx context.Context, msg *pb.ListNamespacesForContextReq) (*pb.ListNamespacesForContextRes, error) { 90 | c, err := s.getClient(msg.ContextName) 91 | 92 | if err != nil { 93 | return nil, fmt.Errorf("could not create client: %w", err) 94 | } 95 | 96 | result := corev1.NamespaceList{} 97 | if err := c.List(ctx, &result); err != nil { 98 | return nil, fmt.Errorf("could not list namespaces: %w", err) 99 | } 100 | 101 | res := pb.ListNamespacesForContextRes{ 102 | Namespaces: []string{}, 103 | } 104 | 105 | for _, ns := range result.Items { 106 | 107 | res.Namespaces = append(res.Namespaces, ns.Name) 108 | } 109 | 110 | return &res, nil 111 | } 112 | 113 | func namespaceOpts(ns string) *client.ListOptions { 114 | opts := client.ListOptions{} 115 | if ns != "" { 116 | opts.Namespace = ns 117 | } 118 | 119 | return &opts 120 | } 121 | 122 | func getSourceTypeEnum(kind string) pb.Source_Type { 123 | switch kind { 124 | case sourcev1.GitRepositoryKind: 125 | return pb.Source_Git 126 | } 127 | 128 | return pb.Source_Git 129 | } 130 | 131 | func mapConditions(conditions []metav1.Condition) []*pb.Condition { 132 | out := []*pb.Condition{} 133 | 134 | for _, c := range conditions { 135 | out = append(out, &pb.Condition{ 136 | Type: c.Type, 137 | Status: string(c.Status), 138 | Reason: c.Reason, 139 | Message: c.Message, 140 | Timestamp: c.LastTransitionTime.String(), 141 | }) 142 | } 143 | 144 | return out 145 | } 146 | 147 | func doReconcileAnnotations(annotations map[string]string) { 148 | if annotations == nil { 149 | annotations = map[string]string{ 150 | meta.ReconcileAtAnnotation: time.Now().Format(time.RFC3339Nano), 151 | } 152 | } else { 153 | annotations[meta.ReconcileAtAnnotation] = time.Now().Format(time.RFC3339Nano) 154 | } 155 | } 156 | 157 | func addSnapshots(out []*pb.GroupVersionKind, collection []schema.GroupVersionKind) []*pb.GroupVersionKind { 158 | for _, gvk := range collection { 159 | out = append(out, &pb.GroupVersionKind{ 160 | Group: gvk.Group, 161 | Version: gvk.Version, 162 | Kind: gvk.Kind, 163 | }) 164 | } 165 | 166 | return out 167 | } 168 | 169 | func convertKustomization(kustomization kustomizev1.Kustomization, namespace string) (*pb.Kustomization, error) { 170 | reconcileRequestAt := kustomization.Annotations[meta.ReconcileRequestAnnotation] 171 | reconcileAt := kustomization.Annotations[meta.ReconcileAtAnnotation] 172 | 173 | k := &pb.Kustomization{ 174 | Name: kustomization.Name, 175 | Namespace: kustomization.Namespace, 176 | TargetNamespace: kustomization.Spec.TargetNamespace, 177 | Path: kustomization.Spec.Path, 178 | SourceRef: kustomization.Spec.SourceRef.Name, 179 | SourceRefKind: getSourceTypeEnum(kustomization.Spec.SourceRef.Kind), 180 | Conditions: mapConditions(kustomization.Status.Conditions), 181 | Interval: kustomization.Spec.Interval.Duration.String(), 182 | Prune: kustomization.Spec.Prune, 183 | ReconcileRequestAt: reconcileRequestAt, 184 | ReconcileAt: reconcileAt, 185 | Snapshots: []*pb.SnapshotEntry{}, 186 | LastAppliedRevision: kustomization.Status.LastAppliedRevision, 187 | LastAttemptedRevision: kustomization.Status.LastAttemptedRevision, 188 | } 189 | kinds := []*pb.GroupVersionKind{} 190 | 191 | // The current test environment does not append a Snapshot, 192 | // so check for it here. Should only be nil in tests. 193 | if kustomization.Status.Snapshot != nil { 194 | for ns, gvks := range kustomization.Status.Snapshot.NamespacedKinds() { 195 | kinds = addSnapshots(kinds, gvks) 196 | 197 | k.Snapshots = append(k.Snapshots, &pb.SnapshotEntry{ 198 | Namespace: ns, 199 | Kinds: kinds, 200 | }) 201 | } 202 | 203 | for _, gvk := range kustomization.Status.Snapshot.NonNamespacedKinds() { 204 | kinds = addSnapshots(kinds, []schema.GroupVersionKind{gvk}) 205 | 206 | k.Snapshots = append(k.Snapshots, &pb.SnapshotEntry{ 207 | Namespace: "", 208 | Kinds: kinds, 209 | }) 210 | } 211 | } 212 | 213 | return k, nil 214 | 215 | } 216 | 217 | func (s *Server) ListKustomizations(ctx context.Context, msg *pb.ListKustomizationsReq) (*pb.ListKustomizationsRes, error) { 218 | c, err := s.getClient(msg.ContextName) 219 | 220 | if err != nil { 221 | return nil, fmt.Errorf("could not create client: %w", err) 222 | } 223 | 224 | result := kustomizev1.KustomizationList{} 225 | 226 | if err := c.List(ctx, &result, namespaceOpts(msg.Namespace)); err != nil { 227 | return nil, fmt.Errorf("could not list kustomizations: %w", err) 228 | } 229 | 230 | k := []*pb.Kustomization{} 231 | for _, kustomization := range result.Items { 232 | m, err := convertKustomization(kustomization, msg.Namespace) 233 | if err != nil { 234 | return nil, err 235 | } 236 | 237 | k = append(k, m) 238 | } 239 | 240 | return &pb.ListKustomizationsRes{Kustomizations: k}, nil 241 | 242 | } 243 | 244 | const KustomizeNameKey string = "kustomize.toolkit.fluxcd.io/name" 245 | const KustomizeNamespaceKey string = "kustomize.toolkit.fluxcd.io/namespace" 246 | 247 | func (s *Server) GetReconciledObjects(ctx context.Context, msg *pb.GetReconciledObjectsReq) (*pb.GetReconciledObjectsRes, error) { 248 | c, err := s.getClient(msg.ContextName) 249 | 250 | if err != nil { 251 | return nil, fmt.Errorf("could not create client: %w", err) 252 | } 253 | 254 | result := []unstructured.Unstructured{} 255 | 256 | for _, gvk := range msg.Kinds { 257 | list := unstructured.UnstructuredList{} 258 | 259 | list.SetGroupVersionKind(schema.GroupVersionKind{ 260 | Group: gvk.Group, 261 | Kind: gvk.Kind, 262 | Version: gvk.Version, 263 | }) 264 | 265 | opts := client.MatchingLabels{ 266 | KustomizeNameKey: msg.KustomizationName, 267 | KustomizeNamespaceKey: msg.KustomizationNamespace, 268 | } 269 | 270 | if err := c.List(ctx, &list, opts); err != nil { 271 | return nil, fmt.Errorf("could not get unstructured list: %s\n", err) 272 | } 273 | 274 | result = append(result, list.Items...) 275 | 276 | } 277 | 278 | objects := []*pb.UnstructuredObject{} 279 | for _, obj := range result { 280 | res, err := status.Compute(&obj) 281 | 282 | if err != nil { 283 | return nil, fmt.Errorf("could not get status for %s: %w", obj.GetName(), err) 284 | } 285 | 286 | objects = append(objects, &pb.UnstructuredObject{ 287 | GroupVersionKind: &pb.GroupVersionKind{ 288 | Group: obj.GetObjectKind().GroupVersionKind().Group, 289 | Version: obj.GetObjectKind().GroupVersionKind().GroupVersion().Version, 290 | Kind: obj.GetKind(), 291 | }, 292 | Name: obj.GetName(), 293 | Namespace: obj.GetNamespace(), 294 | Status: res.Status.String(), 295 | Uid: string(obj.GetUID()), 296 | }) 297 | } 298 | return &pb.GetReconciledObjectsRes{Objects: objects}, nil 299 | } 300 | 301 | func (s *Server) GetChildObjects(ctx context.Context, msg *pb.GetChildObjectsReq) (*pb.GetChildObjectsRes, error) { 302 | c, err := s.getClient(msg.ContextName) 303 | 304 | if err != nil { 305 | return nil, fmt.Errorf("could not create client: %w", err) 306 | } 307 | 308 | list := unstructured.UnstructuredList{} 309 | 310 | list.SetGroupVersionKind(schema.GroupVersionKind{ 311 | Group: msg.GroupVersionKind.Group, 312 | Version: msg.GroupVersionKind.Version, 313 | Kind: msg.GroupVersionKind.Kind, 314 | }) 315 | 316 | if err := c.List(ctx, &list, namespaceOpts("default")); err != nil { 317 | return nil, fmt.Errorf("could not get unstructured object: %s\n", err) 318 | } 319 | 320 | objects := []*pb.UnstructuredObject{} 321 | 322 | Items: 323 | for _, obj := range list.Items { 324 | 325 | refs := obj.GetOwnerReferences() 326 | 327 | for _, ref := range refs { 328 | if ref.UID != types.UID(msg.ParentUid) { 329 | // This is not the child we are looking for. 330 | // Skip the rest of the operations in the outer loop 331 | continue Items 332 | } 333 | } 334 | 335 | res, err := status.Compute(&obj) 336 | 337 | if err != nil { 338 | return nil, fmt.Errorf("could not get status for %s: %w", obj.GetName(), err) 339 | } 340 | objects = append(objects, &pb.UnstructuredObject{ 341 | GroupVersionKind: &pb.GroupVersionKind{ 342 | Group: obj.GetObjectKind().GroupVersionKind().Group, 343 | Version: obj.GetObjectKind().GroupVersionKind().GroupVersion().Version, 344 | Kind: obj.GetKind(), 345 | }, 346 | Name: obj.GetName(), 347 | Namespace: obj.GetNamespace(), 348 | Status: res.Status.String(), 349 | Uid: string(obj.GetUID()), 350 | }) 351 | } 352 | 353 | return &pb.GetChildObjectsRes{Objects: objects}, nil 354 | } 355 | 356 | func kindToSourceType(kind string) pb.Source_Type { 357 | switch kind { 358 | case "Git": 359 | return pb.Source_Git 360 | case "Bucket": 361 | return pb.Source_Bucket 362 | 363 | case "HelmRelease": 364 | return pb.Source_Helm 365 | 366 | case "HelmChart": 367 | return pb.Source_Chart 368 | } 369 | 370 | return -1 371 | } 372 | 373 | func getSourceType(sourceType pb.Source_Type) (client.ObjectList, *reconcileWrapper, error) { 374 | switch sourceType { 375 | case pb.Source_Git: 376 | return &sourcev1.GitRepositoryList{}, &reconcileWrapper{object: gitRepositoryAdapter{&sourcev1.GitRepository{}}}, nil 377 | 378 | case pb.Source_Bucket: 379 | return &sourcev1.BucketList{}, &reconcileWrapper{object: bucketAdapter{&sourcev1.Bucket{}}}, nil 380 | 381 | case pb.Source_Helm: 382 | return &sourcev1.HelmRepositoryList{}, &reconcileWrapper{object: helmReleaseAdapter{&helmv2.HelmRelease{}}}, nil 383 | 384 | case pb.Source_Chart: 385 | return &sourcev1.HelmChartList{}, &reconcileWrapper{object: helmChartAdapter{&sourcev1.HelmChart{}}}, nil 386 | } 387 | 388 | return nil, nil, errors.New("could not find source type") 389 | } 390 | 391 | func appendSources(k8sObj runtime.Object, res *pb.ListSourcesRes) error { 392 | switch list := k8sObj.(type) { 393 | case *sourcev1.GitRepositoryList: 394 | for _, i := range list.Items { 395 | artifact := i.Status.Artifact 396 | ref := i.Spec.Reference 397 | 398 | src := pb.Source{ 399 | Name: i.Name, 400 | Namespace: i.Namespace, 401 | Type: pb.Source_Git, 402 | Url: i.Spec.URL, 403 | Artifact: &pb.Artifact{}, 404 | Reference: &pb.GitRepositoryRef{}, 405 | } 406 | if artifact != nil { 407 | src.Artifact = &pb.Artifact{ 408 | Checksum: artifact.Checksum, 409 | Path: artifact.Path, 410 | Revision: artifact.Revision, 411 | Url: artifact.URL, 412 | } 413 | } 414 | 415 | if ref != nil { 416 | src.Reference = &pb.GitRepositoryRef{ 417 | Branch: i.Spec.Reference.Branch, 418 | Tag: i.Spec.Reference.Tag, 419 | Semver: i.Spec.Reference.SemVer, 420 | Commit: i.Spec.Reference.Commit, 421 | } 422 | } 423 | 424 | res.Sources = append(res.Sources, &src) 425 | } 426 | 427 | case *sourcev1.BucketList: 428 | for _, i := range list.Items { 429 | res.Sources = append(res.Sources, &pb.Source{ 430 | Name: i.Name, 431 | Type: pb.Source_Bucket, 432 | }) 433 | } 434 | 435 | case *sourcev1.HelmRepositoryList: 436 | for _, i := range list.Items { 437 | src := &pb.Source{ 438 | Name: i.Name, 439 | Type: pb.Source_Helm, 440 | Url: i.Spec.URL, 441 | Timeout: i.Spec.Timeout.Duration.String(), 442 | Artifact: &pb.Artifact{}, 443 | Conditions: mapConditions(i.Status.Conditions), 444 | } 445 | 446 | if i.Status.Artifact != nil { 447 | src.Artifact = &pb.Artifact{ 448 | Checksum: i.Status.Artifact.Checksum, 449 | Path: i.Status.Artifact.Path, 450 | Revision: i.Status.Artifact.Revision, 451 | Url: i.Status.Artifact.URL, 452 | } 453 | } 454 | 455 | res.Sources = append(res.Sources, src) 456 | } 457 | case *sourcev1.HelmChartList: 458 | for _, i := range list.Items { 459 | res.Sources = append(res.Sources, &pb.Source{Name: i.Name}) 460 | } 461 | } 462 | 463 | return nil 464 | } 465 | 466 | func (s *Server) ListSources(ctx context.Context, msg *pb.ListSourcesReq) (*pb.ListSourcesRes, error) { 467 | client, err := s.getClient(msg.ContextName) 468 | 469 | if err != nil { 470 | return nil, fmt.Errorf("could not create client: %w", err) 471 | } 472 | 473 | res := &pb.ListSourcesRes{Sources: []*pb.Source{}} 474 | 475 | k8sList, _, err := getSourceType(msg.SourceType) 476 | 477 | if err != nil { 478 | return nil, fmt.Errorf("could not get source type: %w", err) 479 | } 480 | 481 | if err := client.List(ctx, k8sList, namespaceOpts(msg.Namespace)); err != nil { 482 | if apierrors.IsNotFound(err) { 483 | return res, nil 484 | } 485 | return nil, fmt.Errorf("could not list sources: %w", err) 486 | } 487 | 488 | if err := appendSources(k8sList, res); err != nil { 489 | return nil, fmt.Errorf("could not append source: %w", err) 490 | } 491 | 492 | return res, nil 493 | } 494 | 495 | func reconcileSource(ctx context.Context, c client.Client, sourceName, namespace string, obj reconcilable) error { 496 | name := types.NamespacedName{ 497 | Name: sourceName, 498 | Namespace: namespace, 499 | } 500 | 501 | if err := c.Get(ctx, name, obj.asClientObject()); err != nil { 502 | return err 503 | } 504 | annotations := obj.GetAnnotations() 505 | doReconcileAnnotations(annotations) 506 | 507 | obj.SetAnnotations(annotations) 508 | 509 | return c.Update(ctx, obj.asClientObject()) 510 | } 511 | 512 | func checkResourceSync(ctx context.Context, c client.Client, name types.NamespacedName, obj reconcilable, lastReconcile string) func() (bool, error) { 513 | return func() (bool, error) { 514 | err := c.Get(ctx, name, obj.asClientObject()) 515 | if err != nil { 516 | return false, err 517 | } 518 | 519 | return obj.GetLastHandledReconcileRequest() != lastReconcile, nil 520 | } 521 | } 522 | 523 | func (s *Server) SyncKustomization(ctx context.Context, msg *pb.SyncKustomizationReq) (*pb.SyncKustomizationRes, error) { 524 | client, err := s.getClient(msg.ContextName) 525 | 526 | if err != nil { 527 | return nil, fmt.Errorf("could not create client: %w", err) 528 | } 529 | 530 | name := types.NamespacedName{ 531 | Name: msg.KustomizationName, 532 | Namespace: msg.Namespace, 533 | } 534 | kustomization := kustomizev1.Kustomization{} 535 | 536 | if err := client.Get(ctx, name, &kustomization); err != nil { 537 | return nil, fmt.Errorf("could not list kustomizations: %w", err) 538 | } 539 | 540 | if msg.WithSource { 541 | sourceRef := kustomization.Spec.SourceRef 542 | 543 | _, sourceObj, err := getSourceType(kindToSourceType(sourceRef.Kind)) 544 | 545 | if err != nil { 546 | return nil, fmt.Errorf("could not get reconcileable source object: %w", err) 547 | } 548 | 549 | err = reconcileSource(ctx, client, sourceRef.Name, sourceRef.Namespace, sourceObj.object) 550 | 551 | if err != nil { 552 | return nil, fmt.Errorf("could not reconcile source: %w", err) 553 | } 554 | } 555 | 556 | doReconcileAnnotations(kustomization.Annotations) 557 | 558 | if err := client.Update(ctx, &kustomization); err != nil { 559 | return nil, fmt.Errorf("could not update kustomization: %w", err) 560 | } 561 | 562 | if err := wait.PollImmediate( 563 | k8sPollInterval, 564 | k8sTimeout, 565 | checkResourceSync(ctx, client, name, kustomizationAdapter{&kustomizev1.Kustomization{}}, kustomization.Status.LastHandledReconcileAt), 566 | ); err != nil { 567 | return nil, err 568 | } 569 | 570 | k, err := convertKustomization(kustomization, msg.Namespace) 571 | 572 | if err != nil { 573 | return nil, err 574 | } 575 | return &pb.SyncKustomizationRes{Kustomization: k}, nil 576 | 577 | } 578 | 579 | func (s *Server) SyncSource(ctx context.Context, msg *pb.SyncSourceReq) (*pb.SyncSourceRes, error) { 580 | c, err := s.getClient(msg.ContextName) 581 | 582 | if err != nil { 583 | return nil, fmt.Errorf("could not create client: %w", err) 584 | } 585 | 586 | _, sourceObj, err := getSourceType(msg.SourceType) 587 | 588 | if err != nil { 589 | return nil, fmt.Errorf("could not get source type: %w", err) 590 | } 591 | 592 | if err := reconcileSource(ctx, c, msg.SourceName, msg.Namespace, sourceObj.object); err != nil { 593 | return nil, fmt.Errorf("could not reconcile source: %w", err) 594 | } 595 | 596 | name := types.NamespacedName{ 597 | Name: msg.SourceName, 598 | Namespace: msg.Namespace, 599 | } 600 | 601 | if err := wait.PollImmediate( 602 | k8sPollInterval, 603 | k8sTimeout, 604 | checkResourceSync(ctx, c, name, sourceObj.object, sourceObj.object.GetLastHandledReconcileRequest()), 605 | ); err != nil { 606 | return nil, err 607 | } 608 | 609 | return &pb.SyncSourceRes{}, nil 610 | } 611 | 612 | func convertHelmRelease(hr helmv2.HelmRelease) *pb.HelmRelease { 613 | spec := hr.Spec 614 | chartSpec := hr.Spec.Chart.Spec 615 | return &pb.HelmRelease{ 616 | Name: hr.Name, 617 | Namespace: hr.Namespace, 618 | Interval: spec.Interval.Duration.String(), 619 | ChartName: chartSpec.Chart, 620 | Version: chartSpec.Version, 621 | SourceKind: chartSpec.SourceRef.Kind, 622 | SourceName: chartSpec.SourceRef.Name, 623 | SourceNamespace: chartSpec.SourceRef.Namespace, 624 | Conditions: mapConditions(hr.Status.Conditions), 625 | } 626 | } 627 | 628 | func (s *Server) ListHelmReleases(ctx context.Context, msg *pb.ListHelmReleasesReq) (*pb.ListHelmReleasesRes, error) { 629 | c, err := s.getClient(msg.ContextName) 630 | 631 | if err != nil { 632 | return nil, fmt.Errorf("could not create client: %w", err) 633 | } 634 | 635 | res := &pb.ListHelmReleasesRes{HelmReleases: []*pb.HelmRelease{}} 636 | 637 | list := helmv2.HelmReleaseList{} 638 | 639 | if err := c.List(ctx, &list, &client.ListOptions{Namespace: msg.Namespace}); err != nil { 640 | if apierrors.IsNotFound(err) { 641 | return res, nil 642 | } 643 | 644 | return nil, fmt.Errorf("could not list helm releases: %w", err) 645 | } 646 | 647 | for _, r := range list.Items { 648 | res.HelmReleases = append(res.HelmReleases, convertHelmRelease(r)) 649 | } 650 | 651 | return res, nil 652 | } 653 | 654 | func (s *Server) SyncHelmRelease(ctx context.Context, msg *pb.SyncHelmReleaseReq) (*pb.SyncHelmReleaseRes, error) { 655 | c, err := s.getClient(msg.ContextName) 656 | 657 | if err != nil { 658 | return nil, fmt.Errorf("could not create client: %w", err) 659 | } 660 | 661 | name := types.NamespacedName{ 662 | Name: msg.HelmReleaseName, 663 | Namespace: msg.Namespace, 664 | } 665 | hr := helmv2.HelmRelease{} 666 | 667 | if err := c.Get(ctx, name, &hr); err != nil { 668 | return nil, fmt.Errorf("could not get helm release: %w", err) 669 | } 670 | 671 | doReconcileAnnotations(hr.Annotations) 672 | 673 | if err := c.Update(ctx, &hr); err != nil { 674 | return nil, fmt.Errorf("could not update kustomization: %w", err) 675 | } 676 | 677 | if err := wait.PollImmediate( 678 | k8sPollInterval, 679 | k8sTimeout, 680 | checkResourceSync(ctx, c, name, helmReleaseAdapter{&helmv2.HelmRelease{}}, hr.Status.LastHandledReconcileAt), 681 | ); err != nil { 682 | return nil, err 683 | } 684 | 685 | return &pb.SyncHelmReleaseRes{Helmrelease: convertHelmRelease(hr)}, nil 686 | 687 | } 688 | 689 | const KustomizationNameLabelKey string = "kustomize.toolkit.fluxcd.io/name" 690 | const KustomizationNamespaceLabelKey string = "kustomize.toolkit.fluxcd.io/namespace" 691 | 692 | func (s *Server) ListEvents(ctx context.Context, msg *pb.ListEventsReq) (*pb.ListEventsRes, error) { 693 | c, err := s.getClient(msg.ContextName) 694 | 695 | if err != nil { 696 | return nil, fmt.Errorf("could not create client: %w", err) 697 | } 698 | 699 | list := corev1.EventList{} 700 | if err := c.List(ctx, &list, namespaceOpts(msg.Namespace)); err != nil { 701 | return nil, fmt.Errorf("could not get events: %w", err) 702 | } 703 | 704 | events := []*pb.Event{} 705 | 706 | for _, e := range list.Items { 707 | events = append(events, &pb.Event{ 708 | Type: e.Type, 709 | Source: fmt.Sprintf("%s/%s", e.Source.Component, e.ObjectMeta.Name), 710 | Reason: e.Reason, 711 | Message: e.Message, 712 | Timestamp: int32(e.LastTimestamp.Unix()), 713 | }) 714 | } 715 | 716 | return &pb.ListEventsRes{Events: events}, nil 717 | } 718 | -------------------------------------------------------------------------------- /pkg/clustersserver/clustersserver_test.go: -------------------------------------------------------------------------------- 1 | package clustersserver_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" 8 | kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" 9 | sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" 10 | pb "github.com/fluxcd/webui/pkg/rpc/clusters" 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/ginkgo/extensions/table" 13 | . "github.com/onsi/gomega" 14 | corev1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | var _ = Describe("clustersserver", func() { 20 | 21 | It("ListContexts", func() { 22 | ctxs, err := c.ListContexts(context.Background(), &pb.ListContextsReq{}) 23 | 24 | Expect(err).NotTo(HaveOccurred()) 25 | 26 | Expect(len(ctxs.Contexts)).To(Equal(1)) 27 | 28 | Expect(ctxs.Contexts[0].Name).To(Equal(testClustername)) 29 | 30 | }) 31 | 32 | It("ListKustomizations", func() { 33 | name := "my-kustomization" 34 | ks := &kustomizev1.Kustomization{ 35 | ObjectMeta: metav1.ObjectMeta{ 36 | Name: name, 37 | Namespace: "default", 38 | }, 39 | Spec: kustomizev1.KustomizationSpec{ 40 | SourceRef: kustomizev1.CrossNamespaceSourceReference{ 41 | Kind: "GitRepository", 42 | }, 43 | }, 44 | } 45 | 46 | err = testclient.Create(context.Background(), ks) 47 | 48 | Expect(err).NotTo(HaveOccurred()) 49 | 50 | k, err := c.ListKustomizations(context.Background(), &pb.ListKustomizationsReq{ContextName: testClustername}) 51 | 52 | Expect(err).NotTo(HaveOccurred()) 53 | 54 | Expect(k.Kustomizations[0].Name).To(Equal(name)) 55 | 56 | }) 57 | 58 | It("ListHelmReleases", func() { 59 | name := "my-helmrelease" 60 | 61 | hr := &helmv2.HelmRelease{ 62 | ObjectMeta: metav1.ObjectMeta{ 63 | Name: name, 64 | Namespace: "default", 65 | }, 66 | Spec: helmv2.HelmReleaseSpec{ 67 | Chart: helmv2.HelmChartTemplate{ 68 | Spec: helmv2.HelmChartTemplateSpec{ 69 | SourceRef: helmv2.CrossNamespaceObjectReference{ 70 | Name: name, 71 | }, 72 | }, 73 | }, 74 | }, 75 | } 76 | 77 | err = testclient.Create(context.Background(), hr) 78 | 79 | Expect(err).NotTo(HaveOccurred()) 80 | 81 | res, err := c.ListHelmReleases(context.Background(), &pb.ListHelmReleasesReq{}) 82 | 83 | Expect(err).NotTo(HaveOccurred()) 84 | 85 | Expect(len(res.HelmReleases)).To(Equal(1)) 86 | 87 | }) 88 | DescribeTable("ListSources", func(sourceType pb.Source_Type) { 89 | k8sObj, err := getPopulatedSourceType(sourceType) 90 | 91 | Expect(err).NotTo(HaveOccurred()) 92 | 93 | err = testclient.Create(context.Background(), k8sObj) 94 | 95 | Expect(err).NotTo(HaveOccurred()) 96 | 97 | res, err := c.ListSources(context.Background(), &pb.ListSourcesReq{ 98 | SourceType: sourceType, 99 | }) 100 | 101 | Expect(err).NotTo(HaveOccurred()) 102 | 103 | Expect(len(res.Sources)).To(Equal(1)) 104 | 105 | }, 106 | Entry("git repo", pb.Source_Git), 107 | Entry("bucket", pb.Source_Bucket), 108 | Entry("helm release", pb.Source_Helm), 109 | ) 110 | It("ListEvents", func() { 111 | e := &corev1.Event{ObjectMeta: metav1.ObjectMeta{ 112 | Name: "my-event", 113 | Namespace: "default", 114 | }} 115 | 116 | err = testclient.Create(context.Background(), e) 117 | Expect(err).NotTo(HaveOccurred()) 118 | 119 | res, err := c.ListEvents(context.Background(), &pb.ListEventsReq{}) 120 | 121 | Expect(err).NotTo(HaveOccurred()) 122 | 123 | Expect(len(res.Events)).To(Equal(1)) 124 | }) 125 | 126 | }) 127 | 128 | func getPopulatedSourceType(sourceType pb.Source_Type) (client.Object, error) { 129 | objMeta := metav1.ObjectMeta{Name: "somename", Namespace: "default"} 130 | 131 | switch sourceType { 132 | case pb.Source_Git: 133 | return &sourcev1.GitRepository{ 134 | ObjectMeta: objMeta, 135 | Spec: sourcev1.GitRepositorySpec{ 136 | URL: "ssh://git@github.com/someorg/myproj", 137 | Reference: &sourcev1.GitRepositoryRef{}, 138 | }, 139 | }, nil 140 | 141 | case pb.Source_Bucket: 142 | return &sourcev1.Bucket{ObjectMeta: objMeta}, nil 143 | 144 | case pb.Source_Helm: 145 | return &sourcev1.HelmRepository{ObjectMeta: objMeta}, nil 146 | 147 | case pb.Source_Chart: 148 | return &sourcev1.HelmChart{}, nil 149 | } 150 | 151 | return nil, errors.New("could not find source type") 152 | } 153 | -------------------------------------------------------------------------------- /pkg/clustersserver/suite_test.go: -------------------------------------------------------------------------------- 1 | package clustersserver_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/fluxcd/webui/pkg/clustersserver" 9 | "github.com/fluxcd/webui/pkg/rpc/clusters" 10 | pb "github.com/fluxcd/webui/pkg/rpc/clusters" 11 | "github.com/fluxcd/webui/pkg/util" 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/gomega" 14 | apiruntime "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/client-go/rest" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | "sigs.k8s.io/controller-runtime/pkg/envtest" 18 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 19 | ) 20 | 21 | var testClustername = "test-cluster" 22 | var testenv *envtest.Environment 23 | var cfg *rest.Config 24 | var testclient client.Client 25 | var err error 26 | var scheme *apiruntime.Scheme 27 | var server *httptest.Server 28 | var c clusters.Clusters 29 | 30 | func TestAPIs(t *testing.T) { 31 | RegisterFailHandler(Fail) 32 | 33 | RunSpecsWithDefaultAndCustomReporters(t, 34 | "clusterserver suite", 35 | []Reporter{printer.NewlineReporter{}}) 36 | } 37 | 38 | var _ = BeforeSuite(func() { 39 | testenv = &envtest.Environment{CRDDirectoryPaths: []string{"../../tools/crd"}} 40 | 41 | cfg, err = testenv.Start() 42 | 43 | Expect(err).NotTo(HaveOccurred()) 44 | 45 | scheme = util.CreateScheme() 46 | }) 47 | 48 | var _ = BeforeEach(func() { 49 | testclient, err = client.New(cfg, client.Options{Scheme: scheme}) 50 | Expect(err).NotTo(HaveOccurred()) 51 | 52 | clusters := clustersserver.Server{ 53 | AvailableContexts: []string{testClustername}, 54 | InitialContext: testClustername, 55 | ClientCache: map[string]client.Client{}, 56 | CreateClient: func(kubeContext string) (client.Client, error) { 57 | return testclient, nil 58 | }, 59 | } 60 | 61 | clustersHandler := pb.NewClustersServer(&clusters, nil) 62 | 63 | server = httptest.NewServer(clustersHandler) 64 | 65 | c = pb.NewClustersProtobufClient(server.URL, http.DefaultClient) 66 | 67 | }) 68 | 69 | var _ = AfterEach(func() { 70 | // err = testclient.Delete(context.Background(), "default") 71 | // Expect(err).NotTo(HaveOccurred(), "failed to delete test namespace") 72 | server.Close() 73 | }) 74 | 75 | var _ = AfterSuite(func() { 76 | err = testenv.Stop() 77 | Expect(err).NotTo(HaveOccurred(), "error stopping testenv") 78 | }) 79 | -------------------------------------------------------------------------------- /pkg/rpc/clusters/clusters.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package clusters; 3 | 4 | option go_package = "pkg/rpc/clusters"; 5 | 6 | service Clusters { 7 | rpc ListContexts (ListContextsReq) returns (ListContextsRes); 8 | rpc ListNamespacesForContext (ListNamespacesForContextReq) returns (ListNamespacesForContextRes); 9 | rpc ListKustomizations (ListKustomizationsReq) returns (ListKustomizationsRes); 10 | rpc ListSources (ListSourcesReq) returns (ListSourcesRes); 11 | rpc SyncKustomization (SyncKustomizationReq) returns (SyncKustomizationRes); 12 | rpc ListHelmReleases (ListHelmReleasesReq) returns (ListHelmReleasesRes); 13 | rpc ListEvents (ListEventsReq) returns (ListEventsRes); 14 | rpc SyncSource (SyncSourceReq) returns (SyncSourceRes); 15 | rpc SyncHelmRelease (SyncHelmReleaseReq) returns (SyncHelmReleaseRes); 16 | rpc GetReconciledObjects(GetReconciledObjectsReq) returns (GetReconciledObjectsRes); 17 | rpc GetChildObjects(GetChildObjectsReq) returns (GetChildObjectsRes); 18 | } 19 | 20 | message Context { 21 | string name = 1; 22 | } 23 | 24 | message ListContextsReq { 25 | 26 | } 27 | 28 | message ListContextsRes { 29 | string currentContext = 1; 30 | repeated Context contexts = 2; 31 | } 32 | 33 | message ListNamespacesForContextReq { 34 | string contextName = 1; 35 | } 36 | 37 | message ListNamespacesForContextRes { 38 | repeated string namespaces = 1; 39 | } 40 | 41 | message Condition { 42 | string type = 1; 43 | string status = 2; 44 | string reason = 3; 45 | string message = 4; 46 | string timestamp = 5; 47 | } 48 | 49 | message GroupVersionKind { 50 | string group = 1; 51 | string kind = 2; 52 | string version = 3; 53 | } 54 | 55 | message SnapshotEntry { 56 | string namespace = 1; 57 | repeated GroupVersionKind kinds =2; 58 | } 59 | 60 | message Kustomization { 61 | string name = 1; 62 | string namespace = 2; 63 | string targetNamespace = 3; 64 | string path = 4; 65 | string sourceRef = 5; 66 | repeated Condition conditions = 6; 67 | string interval = 7; 68 | bool prune = 8; 69 | string reconcileRequestAt = 9; 70 | string reconcileAt = 10; 71 | Source.Type sourceRefKind = 11; 72 | repeated SnapshotEntry snapshots = 12; 73 | string lastAppliedRevision = 13; 74 | string lastAttemptedRevision = 14; 75 | 76 | } 77 | 78 | message ListKustomizationsReq { 79 | string contextName = 1; 80 | string namespace = 2; 81 | } 82 | 83 | message ListKustomizationsRes { 84 | repeated Kustomization kustomizations = 1; 85 | } 86 | 87 | 88 | message GitRepositoryRef { 89 | string branch = 1; 90 | string tag = 2; 91 | string semver = 3; 92 | string commit = 4; 93 | } 94 | 95 | message Artifact { 96 | string checksum = 1; 97 | int32 lastupdateat = 2; 98 | string path = 3; 99 | string revision = 4; 100 | string url = 5; 101 | } 102 | 103 | message Source { 104 | string name = 1; 105 | string url = 2; 106 | GitRepositoryRef reference = 3; 107 | enum Type { 108 | Git = 0; 109 | Bucket = 1; 110 | Helm = 2; 111 | Chart = 3; 112 | }; 113 | Type type = 4; 114 | string provider = 5; 115 | string bucketname = 6; 116 | string region = 7; 117 | string namespace = 8; 118 | string gitimplementation = 9; 119 | string timeout = 10; 120 | string secretRefName = 11; 121 | repeated Condition conditions = 12; 122 | Artifact artifact = 13; 123 | } 124 | 125 | message ListSourcesReq { 126 | string contextName = 1; 127 | string namespace = 2; 128 | Source.Type sourceType = 3; 129 | } 130 | 131 | message ListSourcesRes { 132 | repeated Source sources = 1; 133 | } 134 | 135 | message SyncSourceReq { 136 | string contextName = 1; 137 | string namespace = 2; 138 | string sourceName = 3; 139 | Source.Type sourceType = 4; 140 | } 141 | 142 | message SyncSourceRes { 143 | Source source = 1; 144 | } 145 | 146 | message SyncKustomizationReq { 147 | string contextName = 1; 148 | string namespace = 2; 149 | string kustomizationName = 3; 150 | bool withSource = 4; 151 | } 152 | 153 | message SyncKustomizationRes { 154 | Kustomization kustomization = 1; 155 | } 156 | 157 | message HelmRelease { 158 | string name = 1; 159 | string namespace = 2; 160 | string interval = 3; 161 | string chartName = 5; 162 | string version = 6; 163 | string sourceKind = 7; 164 | string sourceName = 8; 165 | string sourceNamespace = 9; 166 | repeated Condition conditions = 12; 167 | } 168 | 169 | message ListHelmReleasesReq { 170 | string contextName = 1; 171 | string namespace = 2; 172 | } 173 | 174 | message ListHelmReleasesRes { 175 | repeated HelmRelease helm_releases = 1; 176 | } 177 | 178 | message SyncHelmReleaseReq { 179 | string contextName = 1; 180 | string namespace = 2; 181 | string helmReleaseName = 3; 182 | } 183 | 184 | message SyncHelmReleaseRes { 185 | HelmRelease helmrelease = 1; 186 | } 187 | 188 | message Container { 189 | string name = 1; 190 | string image = 2; 191 | } 192 | 193 | message PodTemplate { 194 | repeated Container containers = 1; 195 | } 196 | 197 | message Workload { 198 | string name = 1; 199 | string namespace = 2; 200 | string kustomizationRefName = 3; 201 | string kustomizationRefNamespace = 4; 202 | PodTemplate podTemplate = 5; 203 | } 204 | 205 | message ListWorkloadsReq { 206 | string contextName = 1; 207 | string namespace = 2; 208 | } 209 | 210 | message ListWorkloadsRes { 211 | repeated Workload workloads = 1; 212 | } 213 | 214 | message ListKustomizationChildrenReq { 215 | string contextName = 1; 216 | string kustomizationName = 2; 217 | string KustomizationNamespace = 3; 218 | } 219 | 220 | message ListKustomizationChildrenRes { 221 | repeated Workload workloads = 1; 222 | } 223 | 224 | message Event { 225 | string type = 1; 226 | string reason = 2; 227 | string message = 3; 228 | int32 timestamp = 4; 229 | string source = 5; 230 | } 231 | 232 | message ListEventsReq { 233 | string contextName = 1; 234 | string namespace = 2; 235 | } 236 | 237 | message ListEventsRes { 238 | repeated Event events = 1; 239 | } 240 | 241 | 242 | message GetReconciledObjectsReq { 243 | string contextName = 1; 244 | string kustomizationName = 2; 245 | string kustomizationNamespace = 3; 246 | repeated GroupVersionKind kinds = 4; 247 | } 248 | 249 | message UnstructuredObject { 250 | GroupVersionKind groupVersionKind = 2; 251 | string name = 3; 252 | string namespace = 4; 253 | string uid = 5; 254 | string status = 6; 255 | } 256 | 257 | message GetReconciledObjectsRes { 258 | repeated UnstructuredObject objects = 1; 259 | } 260 | 261 | message GetChildObjectsReq { 262 | string contextName = 1; 263 | GroupVersionKind groupVersionKind = 2; 264 | string parentUid = 3; 265 | } 266 | 267 | message GetChildObjectsRes { 268 | repeated UnstructuredObject objects = 1; 269 | } 270 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/client-go/tools/clientcmd" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | 9 | helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" 10 | kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" 11 | notificationv1 "github.com/fluxcd/notification-controller/api/v1beta1" 12 | sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" 13 | appsv1 "k8s.io/api/apps/v1" 14 | corev1 "k8s.io/api/core/v1" 15 | rbacv1 "k8s.io/api/rbac/v1" 16 | apiruntime "k8s.io/apimachinery/pkg/runtime" 17 | ) 18 | 19 | func CreateScheme() *apiruntime.Scheme { 20 | scheme := apiruntime.NewScheme() 21 | _ = appsv1.AddToScheme(scheme) 22 | _ = corev1.AddToScheme(scheme) 23 | _ = rbacv1.AddToScheme(scheme) 24 | _ = sourcev1.AddToScheme(scheme) 25 | _ = kustomizev1.AddToScheme(scheme) 26 | _ = helmv2.AddToScheme(scheme) 27 | _ = notificationv1.AddToScheme(scheme) 28 | 29 | return scheme 30 | } 31 | 32 | func NewKubeClient(kubeContext string) (client.Client, error) { 33 | cfgLoadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 34 | configOverrides := clientcmd.ConfigOverrides{CurrentContext: kubeContext} 35 | 36 | restCfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 37 | cfgLoadingRules, 38 | &configOverrides, 39 | ).ClientConfig() 40 | 41 | if err != nil { 42 | return nil, fmt.Errorf("could not create rest config: %w", err) 43 | } 44 | 45 | scheme := CreateScheme() 46 | 47 | kubeClient, err := client.New(restCfg, client.Options{ 48 | Scheme: scheme, 49 | }) 50 | 51 | if err != nil { 52 | return nil, fmt.Errorf("kubernetes client initialization failed: %w", err) 53 | } 54 | 55 | return kubeClient, nil 56 | } 57 | -------------------------------------------------------------------------------- /static/img/flux-horizontal-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluxcd/webui/bb0823ef4d7665bc8300a7f5a1cbb25c795ae829/static/img/flux-horizontal-black.png -------------------------------------------------------------------------------- /static/img/flux-horizontal-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fluxcd/webui/bb0823ef4d7665bc8300a7f5a1cbb25c795ae829/static/img/flux-horizontal-white.png -------------------------------------------------------------------------------- /tools/crd/bucket.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.1 8 | creationTimestamp: null 9 | name: buckets.source.toolkit.fluxcd.io 10 | spec: 11 | group: source.toolkit.fluxcd.io 12 | names: 13 | kind: Bucket 14 | listKind: BucketList 15 | plural: buckets 16 | singular: bucket 17 | scope: Namespaced 18 | versions: 19 | - additionalPrinterColumns: 20 | - jsonPath: .spec.url 21 | name: URL 22 | type: string 23 | - jsonPath: .status.conditions[?(@.type=="Ready")].status 24 | name: Ready 25 | type: string 26 | - jsonPath: .status.conditions[?(@.type=="Ready")].message 27 | name: Status 28 | type: string 29 | - jsonPath: .metadata.creationTimestamp 30 | name: Age 31 | type: date 32 | name: v1beta1 33 | schema: 34 | openAPIV3Schema: 35 | description: Bucket is the Schema for the buckets API 36 | properties: 37 | apiVersion: 38 | description: 'APIVersion defines the versioned schema of this representation 39 | of an object. Servers should convert recognized schemas to the latest 40 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 41 | type: string 42 | kind: 43 | description: 'Kind is a string value representing the REST resource this 44 | object represents. Servers may infer this from the endpoint the client 45 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 46 | type: string 47 | metadata: 48 | type: object 49 | spec: 50 | description: BucketSpec defines the desired state of an S3 compatible 51 | bucket 52 | properties: 53 | bucketName: 54 | description: The bucket name. 55 | type: string 56 | endpoint: 57 | description: The bucket endpoint address. 58 | type: string 59 | ignore: 60 | description: Ignore overrides the set of excluded patterns in the 61 | .sourceignore format (which is the same as .gitignore). If not provided, 62 | a default will be used, consult the documentation for your version 63 | to find out what those are. 64 | type: string 65 | insecure: 66 | description: Insecure allows connecting to a non-TLS S3 HTTP endpoint. 67 | type: boolean 68 | interval: 69 | description: The interval at which to check for bucket updates. 70 | type: string 71 | provider: 72 | default: generic 73 | description: The S3 compatible storage provider name, default ('generic'). 74 | enum: 75 | - generic 76 | - aws 77 | type: string 78 | region: 79 | description: The bucket region. 80 | type: string 81 | secretRef: 82 | description: The name of the secret containing authentication credentials 83 | for the Bucket. 84 | properties: 85 | name: 86 | description: Name of the referent 87 | type: string 88 | required: 89 | - name 90 | type: object 91 | suspend: 92 | description: This flag tells the controller to suspend the reconciliation 93 | of this source. 94 | type: boolean 95 | timeout: 96 | default: 20s 97 | description: The timeout for download operations, defaults to 20s. 98 | type: string 99 | required: 100 | - bucketName 101 | - endpoint 102 | - interval 103 | type: object 104 | status: 105 | description: BucketStatus defines the observed state of a bucket 106 | properties: 107 | artifact: 108 | description: Artifact represents the output of the last successful 109 | Bucket sync. 110 | properties: 111 | checksum: 112 | description: Checksum is the SHA1 checksum of the artifact. 113 | type: string 114 | lastUpdateTime: 115 | description: LastUpdateTime is the timestamp corresponding to 116 | the last update of this artifact. 117 | format: date-time 118 | type: string 119 | path: 120 | description: Path is the relative file path of this artifact. 121 | type: string 122 | revision: 123 | description: Revision is a human readable identifier traceable 124 | in the origin source system. It can be a Git commit SHA, Git 125 | tag, a Helm index timestamp, a Helm chart version, etc. 126 | type: string 127 | url: 128 | description: URL is the HTTP address of this artifact. 129 | type: string 130 | required: 131 | - path 132 | - url 133 | type: object 134 | conditions: 135 | description: Conditions holds the conditions for the Bucket. 136 | items: 137 | description: "Condition contains details for one aspect of the current 138 | state of this API Resource. --- This struct is intended for direct 139 | use as an array at the field path .status.conditions. For example, 140 | type FooStatus struct{ // Represents the observations of a 141 | foo's current state. // Known .status.conditions.type are: 142 | \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type 143 | \ // +patchStrategy=merge // +listType=map // +listMapKey=type 144 | \ Conditions []metav1.Condition `json:\"conditions,omitempty\" 145 | patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` 146 | \n // other fields }" 147 | properties: 148 | lastTransitionTime: 149 | description: lastTransitionTime is the last time the condition 150 | transitioned from one status to another. This should be when 151 | the underlying condition changed. If that is not known, then 152 | using the time when the API field changed is acceptable. 153 | format: date-time 154 | type: string 155 | message: 156 | description: message is a human readable message indicating 157 | details about the transition. This may be an empty string. 158 | maxLength: 32768 159 | type: string 160 | observedGeneration: 161 | description: observedGeneration represents the .metadata.generation 162 | that the condition was set based upon. For instance, if .metadata.generation 163 | is currently 12, but the .status.conditions[x].observedGeneration 164 | is 9, the condition is out of date with respect to the current 165 | state of the instance. 166 | format: int64 167 | minimum: 0 168 | type: integer 169 | reason: 170 | description: reason contains a programmatic identifier indicating 171 | the reason for the condition's last transition. Producers 172 | of specific condition types may define expected values and 173 | meanings for this field, and whether the values are considered 174 | a guaranteed API. The value should be a CamelCase string. 175 | This field may not be empty. 176 | maxLength: 1024 177 | minLength: 1 178 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 179 | type: string 180 | status: 181 | description: status of the condition, one of True, False, Unknown. 182 | enum: 183 | - "True" 184 | - "False" 185 | - Unknown 186 | type: string 187 | type: 188 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 189 | --- Many .condition.type values are consistent across resources 190 | like Available, but because arbitrary conditions can be useful 191 | (see .node.status.conditions), the ability to deconflict is 192 | important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 193 | maxLength: 316 194 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 195 | type: string 196 | required: 197 | - lastTransitionTime 198 | - message 199 | - reason 200 | - status 201 | - type 202 | type: object 203 | type: array 204 | lastHandledReconcileAt: 205 | description: LastHandledReconcileAt holds the value of the most recent 206 | reconcile request value, so a change can be detected. 207 | type: string 208 | observedGeneration: 209 | description: ObservedGeneration is the last observed generation. 210 | format: int64 211 | type: integer 212 | url: 213 | description: URL is the download link for the artifact output of the 214 | last Bucket sync. 215 | type: string 216 | type: object 217 | type: object 218 | served: true 219 | storage: true 220 | subresources: 221 | status: {} 222 | status: 223 | acceptedNames: 224 | kind: "" 225 | plural: "" 226 | conditions: [] 227 | storedVersions: [] 228 | -------------------------------------------------------------------------------- /tools/crd/gitrepository.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.1 8 | creationTimestamp: null 9 | name: gitrepositories.source.toolkit.fluxcd.io 10 | spec: 11 | group: source.toolkit.fluxcd.io 12 | names: 13 | kind: GitRepository 14 | listKind: GitRepositoryList 15 | plural: gitrepositories 16 | shortNames: 17 | - gitrepo 18 | singular: gitrepository 19 | scope: Namespaced 20 | versions: 21 | - additionalPrinterColumns: 22 | - jsonPath: .spec.url 23 | name: URL 24 | type: string 25 | - jsonPath: .status.conditions[?(@.type=="Ready")].status 26 | name: Ready 27 | type: string 28 | - jsonPath: .status.conditions[?(@.type=="Ready")].message 29 | name: Status 30 | type: string 31 | - jsonPath: .metadata.creationTimestamp 32 | name: Age 33 | type: date 34 | name: v1beta1 35 | schema: 36 | openAPIV3Schema: 37 | description: GitRepository is the Schema for the gitrepositories API 38 | properties: 39 | apiVersion: 40 | description: 'APIVersion defines the versioned schema of this representation 41 | of an object. Servers should convert recognized schemas to the latest 42 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 43 | type: string 44 | kind: 45 | description: 'Kind is a string value representing the REST resource this 46 | object represents. Servers may infer this from the endpoint the client 47 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 48 | type: string 49 | metadata: 50 | type: object 51 | spec: 52 | description: GitRepositorySpec defines the desired state of a Git repository. 53 | properties: 54 | gitImplementation: 55 | default: go-git 56 | description: Determines which git client library to use. Defaults 57 | to go-git, valid values are ('go-git', 'libgit2'). 58 | enum: 59 | - go-git 60 | - libgit2 61 | type: string 62 | ignore: 63 | description: Ignore overrides the set of excluded patterns in the 64 | .sourceignore format (which is the same as .gitignore). If not provided, 65 | a default will be used, consult the documentation for your version 66 | to find out what those are. 67 | type: string 68 | include: 69 | description: Extra git repositories to map into the repository 70 | items: 71 | description: GitRepositoryInclude defines a source with a from and 72 | to path. 73 | properties: 74 | fromPath: 75 | description: The path to copy contents from, defaults to the 76 | root directory. 77 | type: string 78 | repository: 79 | description: Reference to a GitRepository to include. 80 | properties: 81 | name: 82 | description: Name of the referent 83 | type: string 84 | required: 85 | - name 86 | type: object 87 | toPath: 88 | description: The path to copy contents to, defaults to the name 89 | of the source ref. 90 | type: string 91 | required: 92 | - repository 93 | type: object 94 | type: array 95 | interval: 96 | description: The interval at which to check for repository updates. 97 | type: string 98 | recurseSubmodules: 99 | description: When enabled, after the clone is created, initializes 100 | all submodules within, using their default settings. This option 101 | is available only when using the 'go-git' GitImplementation. 102 | type: boolean 103 | ref: 104 | description: The Git reference to checkout and monitor for changes, 105 | defaults to master branch. 106 | properties: 107 | branch: 108 | default: master 109 | description: The Git branch to checkout, defaults to master. 110 | type: string 111 | commit: 112 | description: The Git commit SHA to checkout, if specified Tag 113 | filters will be ignored. 114 | type: string 115 | semver: 116 | description: The Git tag semver expression, takes precedence over 117 | Tag. 118 | type: string 119 | tag: 120 | description: The Git tag to checkout, takes precedence over Branch. 121 | type: string 122 | type: object 123 | secretRef: 124 | description: The secret name containing the Git credentials. For HTTPS 125 | repositories the secret must contain username and password fields. 126 | For SSH repositories the secret must contain identity, identity.pub 127 | and known_hosts fields. 128 | properties: 129 | name: 130 | description: Name of the referent 131 | type: string 132 | required: 133 | - name 134 | type: object 135 | suspend: 136 | description: This flag tells the controller to suspend the reconciliation 137 | of this source. 138 | type: boolean 139 | timeout: 140 | default: 20s 141 | description: The timeout for remote Git operations like cloning, defaults 142 | to 20s. 143 | type: string 144 | url: 145 | description: The repository URL, can be a HTTP/S or SSH address. 146 | pattern: ^(http|https|ssh):// 147 | type: string 148 | verify: 149 | description: Verify OpenPGP signature for the Git commit HEAD points 150 | to. 151 | properties: 152 | mode: 153 | description: Mode describes what git object should be verified, 154 | currently ('head'). 155 | enum: 156 | - head 157 | type: string 158 | secretRef: 159 | description: The secret name containing the public keys of all 160 | trusted Git authors. 161 | properties: 162 | name: 163 | description: Name of the referent 164 | type: string 165 | required: 166 | - name 167 | type: object 168 | required: 169 | - mode 170 | type: object 171 | required: 172 | - interval 173 | - url 174 | type: object 175 | status: 176 | description: GitRepositoryStatus defines the observed state of a Git repository. 177 | properties: 178 | artifact: 179 | description: Artifact represents the output of the last successful 180 | repository sync. 181 | properties: 182 | checksum: 183 | description: Checksum is the SHA1 checksum of the artifact. 184 | type: string 185 | lastUpdateTime: 186 | description: LastUpdateTime is the timestamp corresponding to 187 | the last update of this artifact. 188 | format: date-time 189 | type: string 190 | path: 191 | description: Path is the relative file path of this artifact. 192 | type: string 193 | revision: 194 | description: Revision is a human readable identifier traceable 195 | in the origin source system. It can be a Git commit SHA, Git 196 | tag, a Helm index timestamp, a Helm chart version, etc. 197 | type: string 198 | url: 199 | description: URL is the HTTP address of this artifact. 200 | type: string 201 | required: 202 | - path 203 | - url 204 | type: object 205 | conditions: 206 | description: Conditions holds the conditions for the GitRepository. 207 | items: 208 | description: "Condition contains details for one aspect of the current 209 | state of this API Resource. --- This struct is intended for direct 210 | use as an array at the field path .status.conditions. For example, 211 | type FooStatus struct{ // Represents the observations of a 212 | foo's current state. // Known .status.conditions.type are: 213 | \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type 214 | \ // +patchStrategy=merge // +listType=map // +listMapKey=type 215 | \ Conditions []metav1.Condition `json:\"conditions,omitempty\" 216 | patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` 217 | \n // other fields }" 218 | properties: 219 | lastTransitionTime: 220 | description: lastTransitionTime is the last time the condition 221 | transitioned from one status to another. This should be when 222 | the underlying condition changed. If that is not known, then 223 | using the time when the API field changed is acceptable. 224 | format: date-time 225 | type: string 226 | message: 227 | description: message is a human readable message indicating 228 | details about the transition. This may be an empty string. 229 | maxLength: 32768 230 | type: string 231 | observedGeneration: 232 | description: observedGeneration represents the .metadata.generation 233 | that the condition was set based upon. For instance, if .metadata.generation 234 | is currently 12, but the .status.conditions[x].observedGeneration 235 | is 9, the condition is out of date with respect to the current 236 | state of the instance. 237 | format: int64 238 | minimum: 0 239 | type: integer 240 | reason: 241 | description: reason contains a programmatic identifier indicating 242 | the reason for the condition's last transition. Producers 243 | of specific condition types may define expected values and 244 | meanings for this field, and whether the values are considered 245 | a guaranteed API. The value should be a CamelCase string. 246 | This field may not be empty. 247 | maxLength: 1024 248 | minLength: 1 249 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 250 | type: string 251 | status: 252 | description: status of the condition, one of True, False, Unknown. 253 | enum: 254 | - "True" 255 | - "False" 256 | - Unknown 257 | type: string 258 | type: 259 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 260 | --- Many .condition.type values are consistent across resources 261 | like Available, but because arbitrary conditions can be useful 262 | (see .node.status.conditions), the ability to deconflict is 263 | important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 264 | maxLength: 316 265 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 266 | type: string 267 | required: 268 | - lastTransitionTime 269 | - message 270 | - reason 271 | - status 272 | - type 273 | type: object 274 | type: array 275 | includedArtifacts: 276 | description: IncludedArtifacts represents the included artifacts from 277 | the last successful repository sync. 278 | items: 279 | description: Artifact represents the output of a source synchronisation. 280 | properties: 281 | checksum: 282 | description: Checksum is the SHA1 checksum of the artifact. 283 | type: string 284 | lastUpdateTime: 285 | description: LastUpdateTime is the timestamp corresponding to 286 | the last update of this artifact. 287 | format: date-time 288 | type: string 289 | path: 290 | description: Path is the relative file path of this artifact. 291 | type: string 292 | revision: 293 | description: Revision is a human readable identifier traceable 294 | in the origin source system. It can be a Git commit SHA, Git 295 | tag, a Helm index timestamp, a Helm chart version, etc. 296 | type: string 297 | url: 298 | description: URL is the HTTP address of this artifact. 299 | type: string 300 | required: 301 | - path 302 | - url 303 | type: object 304 | type: array 305 | lastHandledReconcileAt: 306 | description: LastHandledReconcileAt holds the value of the most recent 307 | reconcile request value, so a change can be detected. 308 | type: string 309 | observedGeneration: 310 | description: ObservedGeneration is the last observed generation. 311 | format: int64 312 | type: integer 313 | url: 314 | description: URL is the download link for the artifact output of the 315 | last repository sync. 316 | type: string 317 | type: object 318 | type: object 319 | served: true 320 | storage: true 321 | subresources: 322 | status: {} 323 | status: 324 | acceptedNames: 325 | kind: "" 326 | plural: "" 327 | conditions: [] 328 | storedVersions: [] 329 | -------------------------------------------------------------------------------- /tools/crd/helmrepository.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.1 8 | creationTimestamp: null 9 | name: helmrepositories.source.toolkit.fluxcd.io 10 | spec: 11 | group: source.toolkit.fluxcd.io 12 | names: 13 | kind: HelmRepository 14 | listKind: HelmRepositoryList 15 | plural: helmrepositories 16 | shortNames: 17 | - helmrepo 18 | singular: helmrepository 19 | scope: Namespaced 20 | versions: 21 | - additionalPrinterColumns: 22 | - jsonPath: .spec.url 23 | name: URL 24 | type: string 25 | - jsonPath: .status.conditions[?(@.type=="Ready")].status 26 | name: Ready 27 | type: string 28 | - jsonPath: .status.conditions[?(@.type=="Ready")].message 29 | name: Status 30 | type: string 31 | - jsonPath: .metadata.creationTimestamp 32 | name: Age 33 | type: date 34 | name: v1beta1 35 | schema: 36 | openAPIV3Schema: 37 | description: HelmRepository is the Schema for the helmrepositories API 38 | properties: 39 | apiVersion: 40 | description: 'APIVersion defines the versioned schema of this representation 41 | of an object. Servers should convert recognized schemas to the latest 42 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 43 | type: string 44 | kind: 45 | description: 'Kind is a string value representing the REST resource this 46 | object represents. Servers may infer this from the endpoint the client 47 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 48 | type: string 49 | metadata: 50 | type: object 51 | spec: 52 | description: HelmRepositorySpec defines the reference to a Helm repository. 53 | properties: 54 | interval: 55 | description: The interval at which to check the upstream for updates. 56 | type: string 57 | secretRef: 58 | description: The name of the secret containing authentication credentials 59 | for the Helm repository. For HTTP/S basic auth the secret must contain 60 | username and password fields. For TLS the secret must contain a 61 | certFile and keyFile, and/or caCert fields. 62 | properties: 63 | name: 64 | description: Name of the referent 65 | type: string 66 | required: 67 | - name 68 | type: object 69 | suspend: 70 | description: This flag tells the controller to suspend the reconciliation 71 | of this source. 72 | type: boolean 73 | timeout: 74 | default: 60s 75 | description: The timeout of index downloading, defaults to 60s. 76 | type: string 77 | url: 78 | description: The Helm repository URL, a valid URL contains at least 79 | a protocol and host. 80 | type: string 81 | required: 82 | - interval 83 | - url 84 | type: object 85 | status: 86 | description: HelmRepositoryStatus defines the observed state of the HelmRepository. 87 | properties: 88 | artifact: 89 | description: Artifact represents the output of the last successful 90 | repository sync. 91 | properties: 92 | checksum: 93 | description: Checksum is the SHA1 checksum of the artifact. 94 | type: string 95 | lastUpdateTime: 96 | description: LastUpdateTime is the timestamp corresponding to 97 | the last update of this artifact. 98 | format: date-time 99 | type: string 100 | path: 101 | description: Path is the relative file path of this artifact. 102 | type: string 103 | revision: 104 | description: Revision is a human readable identifier traceable 105 | in the origin source system. It can be a Git commit SHA, Git 106 | tag, a Helm index timestamp, a Helm chart version, etc. 107 | type: string 108 | url: 109 | description: URL is the HTTP address of this artifact. 110 | type: string 111 | required: 112 | - path 113 | - url 114 | type: object 115 | conditions: 116 | description: Conditions holds the conditions for the HelmRepository. 117 | items: 118 | description: "Condition contains details for one aspect of the current 119 | state of this API Resource. --- This struct is intended for direct 120 | use as an array at the field path .status.conditions. For example, 121 | type FooStatus struct{ // Represents the observations of a 122 | foo's current state. // Known .status.conditions.type are: 123 | \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type 124 | \ // +patchStrategy=merge // +listType=map // +listMapKey=type 125 | \ Conditions []metav1.Condition `json:\"conditions,omitempty\" 126 | patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` 127 | \n // other fields }" 128 | properties: 129 | lastTransitionTime: 130 | description: lastTransitionTime is the last time the condition 131 | transitioned from one status to another. This should be when 132 | the underlying condition changed. If that is not known, then 133 | using the time when the API field changed is acceptable. 134 | format: date-time 135 | type: string 136 | message: 137 | description: message is a human readable message indicating 138 | details about the transition. This may be an empty string. 139 | maxLength: 32768 140 | type: string 141 | observedGeneration: 142 | description: observedGeneration represents the .metadata.generation 143 | that the condition was set based upon. For instance, if .metadata.generation 144 | is currently 12, but the .status.conditions[x].observedGeneration 145 | is 9, the condition is out of date with respect to the current 146 | state of the instance. 147 | format: int64 148 | minimum: 0 149 | type: integer 150 | reason: 151 | description: reason contains a programmatic identifier indicating 152 | the reason for the condition's last transition. Producers 153 | of specific condition types may define expected values and 154 | meanings for this field, and whether the values are considered 155 | a guaranteed API. The value should be a CamelCase string. 156 | This field may not be empty. 157 | maxLength: 1024 158 | minLength: 1 159 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 160 | type: string 161 | status: 162 | description: status of the condition, one of True, False, Unknown. 163 | enum: 164 | - "True" 165 | - "False" 166 | - Unknown 167 | type: string 168 | type: 169 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 170 | --- Many .condition.type values are consistent across resources 171 | like Available, but because arbitrary conditions can be useful 172 | (see .node.status.conditions), the ability to deconflict is 173 | important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 174 | maxLength: 316 175 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 176 | type: string 177 | required: 178 | - lastTransitionTime 179 | - message 180 | - reason 181 | - status 182 | - type 183 | type: object 184 | type: array 185 | lastHandledReconcileAt: 186 | description: LastHandledReconcileAt holds the value of the most recent 187 | reconcile request value, so a change can be detected. 188 | type: string 189 | observedGeneration: 190 | description: ObservedGeneration is the last observed generation. 191 | format: int64 192 | type: integer 193 | url: 194 | description: URL is the download link for the last index fetched. 195 | type: string 196 | type: object 197 | type: object 198 | served: true 199 | storage: true 200 | subresources: 201 | status: {} 202 | status: 203 | acceptedNames: 204 | kind: "" 205 | plural: "" 206 | conditions: [] 207 | storedVersions: [] 208 | -------------------------------------------------------------------------------- /tools/podinfo-helm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: helm.toolkit.fluxcd.io/v2beta1 2 | kind: HelmRelease 3 | metadata: 4 | name: podinfo 5 | namespace: default 6 | spec: 7 | interval: 5m 8 | chart: 9 | spec: 10 | chart: podinfo 11 | version: "4.0.x" 12 | sourceRef: 13 | kind: HelmRepository 14 | name: podinfo 15 | namespace: flux-system 16 | interval: 1m 17 | values: 18 | replicaCount: 2 19 | -------------------------------------------------------------------------------- /tools/setup-dev-cluster.sh: -------------------------------------------------------------------------------- 1 | flux bootstrap github \ 2 | --personal \ 3 | --private \ 4 | --owner=${GITHUB_USER} \ 5 | --repository=fleet-infra \ 6 | --branch=main \ 7 | --path=./clusters 8 | -------------------------------------------------------------------------------- /tools/tag-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Pulled and modified from this link: 4 | # https://gist.github.com/devster/b91b97ebbca4db4d02b84337b2a3d933 5 | 6 | # Script to simplify the release flow. 7 | # 1) Fetch the current release version 8 | # 2) Increase the version (major, minor, patch) 9 | # 3) Add a new git tag 10 | # 4) Push the tag 11 | 12 | # Parse command line options. 13 | while getopts ":Mmpd" Option 14 | do 15 | case $Option in 16 | M ) major=true;; 17 | m ) minor=true;; 18 | p ) patch=true;; 19 | d ) dry=true;; 20 | esac 21 | done 22 | 23 | shift $(($OPTIND - 1)) 24 | 25 | # Display usage 26 | if [ -z $major ] && [ -z $minor ] && [ -z $patch ]; 27 | then 28 | echo "usage: $(basename $0) [Mmp] [message]" 29 | echo "" 30 | echo " -d Dry run" 31 | echo " -M for a major release" 32 | echo " -m for a minor release" 33 | echo " -p for a patch release" 34 | echo "" 35 | echo " Example: release -p \"Some fix\"" 36 | echo " means create a patch release with the message \"Some fix\"" 37 | exit 1 38 | fi 39 | 40 | # 1) Fetch the current release version 41 | 42 | echo "Fetch tags" 43 | git fetch --prune --tags 44 | 45 | version=$(git describe --abbrev=0 --tags) 46 | version=${version:1} # Remove the v in the tag v0.37.10 for example 47 | 48 | echo "Current version: $version" 49 | 50 | # 2) Increase version number 51 | 52 | # Build array from version string. 53 | 54 | a=( ${version//./ } ) 55 | 56 | # Increment version numbers as requested. 57 | 58 | if [ ! -z $major ] 59 | then 60 | ((a[0]++)) 61 | a[1]=0 62 | a[2]=0 63 | fi 64 | 65 | if [ ! -z $minor ] 66 | then 67 | ((a[1]++)) 68 | a[2]=0 69 | fi 70 | 71 | if [ ! -z $patch ] 72 | then 73 | ((a[2]++)) 74 | fi 75 | 76 | next_version="${a[0]}.${a[1]}.${a[2]}" 77 | 78 | msg="$1" 79 | 80 | branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') 81 | 82 | # If its a dry run, just display the new release version number 83 | if [ ! -z $dry ] 84 | then 85 | echo "Tag message: $msg" 86 | echo "Next version: v$next_version" 87 | else 88 | # If a command fails, exit the script 89 | set -e 90 | 91 | # Push master 92 | git push origin $branch 93 | 94 | # If it's not a dry run, let's go! 95 | # 3) Add git tag 96 | echo "Add git tag v$next_version with message: $msg" 97 | git tag -s -a "v$next_version" -m "$msg" 98 | 99 | # 4) Push the new tag 100 | 101 | echo "Push the tag" 102 | git push --tags origin $branch 103 | 104 | echo -e "\e[32mRelease done: $next_version\e[0m" 105 | fi 106 | -------------------------------------------------------------------------------- /tools/update-flux-deps.sh: -------------------------------------------------------------------------------- 1 | RELEASE_VERSION=$(curl -s https://api.github.com/repos/fluxcd/flux2/releases | jq -r 'sort_by(.published_at) | .[-1] | .tag_name') 2 | go mod edit -require github.com/fluxcd/flux2@${RELEASE_VERSION} 3 | go mod tidy 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ui/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider as MuiThemeProvider } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; 4 | import { ToastContainer } from "react-toastify"; 5 | import "react-toastify/dist/ReactToastify.css"; 6 | import styled, { ThemeProvider } from "styled-components"; 7 | import AppStateProvider from "./components/AppStateProvider"; 8 | import ErrorBoundary from "./components/ErrorBoundary"; 9 | import Flex from "./components/Flex"; 10 | import LeftNav from "./components/LeftNav"; 11 | import TopNav from "./components/TopNav"; 12 | import theme, { GlobalStyle } from "./lib/theme"; 13 | import { clustersClient, PageRoute } from "./lib/util"; 14 | import Error from "./pages/Error"; 15 | import Events from "./pages/Events"; 16 | import HelmReleaseDetail from "./pages/HelmReleaseDetail"; 17 | import HelmReleases from "./pages/HelmReleases"; 18 | import KustomizationDetail from "./pages/KustomizationDetail"; 19 | import Kustomizations from "./pages/Kustomizations"; 20 | import Redirector from "./pages/Redirector"; 21 | import SourceDetail from "./pages/SourceDetail"; 22 | import Sources from "./pages/Sources"; 23 | 24 | const AppContainer = styled.div` 25 | width: 100%; 26 | height: 100%; 27 | margin: 0 auto; 28 | padding: 0; 29 | `; 30 | 31 | const NavContainer = styled.div` 32 | width: 240px; 33 | `; 34 | 35 | const ContentContainer = styled.div` 36 | width: 100%; 37 | max-width: 1024px; 38 | margin: 0 auto; 39 | `; 40 | 41 | const TopNavContainer = styled.div` 42 | width: 100%; 43 | `; 44 | 45 | export default function App() { 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 69 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 101 | 102 |

404

} /> 103 |
104 |
105 |
106 | 115 |
116 |
117 |
118 |
119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /ui/components/AppStateProvider.tsx: -------------------------------------------------------------------------------- 1 | import qs from "query-string"; 2 | import * as React from "react"; 3 | import { useLocation } from "react-router"; 4 | import { Clusters, Context } from "../lib/rpc/clusters"; 5 | import { AllNamespacesOption } from "../lib/types"; 6 | 7 | export type AppContextType = { 8 | contexts: Context[]; 9 | namespaces: { [context: string]: string[] }; 10 | currentContext: string; 11 | currentNamespace: string; 12 | appState: AppState; 13 | setContexts: (contexts: Context[]) => void; 14 | setNamespaces: (namespaces: string[]) => void; 15 | setCurrentNamespace: (namespace: string) => void; 16 | doAsyncError: (message: string, fatal?: boolean, detail?: Error) => void; 17 | clustersClient: Clusters; 18 | }; 19 | 20 | export const AppContext = React.createContext(null as any); 21 | 22 | type AppState = { 23 | error: null | { fatal: boolean; message: string; detail?: string }; 24 | loading: boolean; 25 | }; 26 | 27 | export default function AppStateProvider({ clustersClient, ...props }) { 28 | const location = useLocation(); 29 | const { context } = qs.parse(location.search); 30 | const [contexts, setContexts] = React.useState([]); 31 | const [namespaces, setNamespaces] = React.useState({}); 32 | const [currentNamespace, setCurrentNamespace] = React.useState( 33 | AllNamespacesOption 34 | ); 35 | const [appState, setAppState] = React.useState({ 36 | error: null, 37 | loading: false, 38 | }); 39 | 40 | const doAsyncError = (message: string, fatal: boolean, detail?: Error) => { 41 | console.error(message); 42 | setAppState({ 43 | ...(appState as AppState), 44 | error: { message, fatal, detail }, 45 | }); 46 | }; 47 | 48 | const query = qs.parse(location.search); 49 | 50 | const getNamespaces = (ctx) => { 51 | clustersClient.listNamespacesForContext({ contextname: ctx }).then( 52 | (nsRes) => { 53 | const nextNamespaces = nsRes.namespaces; 54 | 55 | nextNamespaces.unshift(AllNamespacesOption); 56 | 57 | setNamespaces({ 58 | ...namespaces, 59 | ...{ 60 | [ctx as string]: nextNamespaces, 61 | }, 62 | }); 63 | }, 64 | (err) => { 65 | doAsyncError( 66 | "There was an error fetching namespaces", 67 | true, 68 | err.message 69 | ); 70 | } 71 | ); 72 | }; 73 | 74 | React.useEffect(() => { 75 | // Runs once on app startup. 76 | clustersClient.listContexts({}).then( 77 | (res) => { 78 | setContexts(res.contexts); 79 | const ns = query.namespace || AllNamespacesOption; 80 | setCurrentNamespace(ns as string); 81 | }, 82 | (err) => { 83 | doAsyncError("Error getting contexts", true, err); 84 | } 85 | ); 86 | }, []); 87 | 88 | React.useEffect(() => { 89 | // Get namespaces whenever context changes 90 | getNamespaces(context); 91 | }, [context]); 92 | 93 | React.useEffect(() => { 94 | // clear the error state on navigation 95 | setAppState({ 96 | ...appState, 97 | error: null, 98 | }); 99 | }, [context, currentNamespace, location]); 100 | 101 | React.useEffect(() => { 102 | setCurrentNamespace(query.namespace as string); 103 | }, [location]); 104 | 105 | const value: AppContextType = { 106 | contexts, 107 | namespaces, 108 | currentContext: context as string, 109 | currentNamespace, 110 | appState, 111 | setContexts, 112 | setNamespaces, 113 | setCurrentNamespace, 114 | doAsyncError, 115 | clustersClient, 116 | }; 117 | 118 | return ; 119 | } 120 | -------------------------------------------------------------------------------- /ui/components/CommandLineHint.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | 5 | type Props = { 6 | className?: string; 7 | lines: string[]; 8 | }; 9 | 10 | const Styled = (c) => styled(c)` 11 | background-color: #2f2f2f; 12 | color: white; 13 | margin: 0; 14 | padding: 8px 16px; 15 | border-radius: 4px; 16 | font-size: 1.2em; 17 | `; 18 | 19 | function CommandLineHint({ className, lines }: Props) { 20 | return ( 21 |
22 |
{_.map(lines, (l) => `${l} \\\n`)}
23 |
24 | ); 25 | } 26 | 27 | export default Styled(CommandLineHint); 28 | -------------------------------------------------------------------------------- /ui/components/ConditionsTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | } from "@material-ui/core"; 9 | import _ from "lodash"; 10 | import * as React from "react"; 11 | import styled from "styled-components"; 12 | 13 | type Props = { 14 | className?: string; 15 | conditions: { 16 | type: string; 17 | reason: string; 18 | status: string; 19 | message: string; 20 | timestamp: string; 21 | }[]; 22 | }; 23 | const Styled = (c) => styled(c)``; 24 | 25 | function ConditionsTable({ className, conditions }: Props) { 26 | return ( 27 |
28 | 29 | 30 | 31 | 32 | Type 33 | Status 34 | Reason 35 | Message 36 | 37 | 38 | 39 | {_.map(conditions, (c) => ( 40 | 41 | {c.type} 42 | {c.status} 43 | {c.reason} 44 | {c.message} 45 | 46 | ))} 47 | 48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | export default Styled(ConditionsTable); 55 | -------------------------------------------------------------------------------- /ui/components/DataTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | } from "@material-ui/core"; 9 | import _ from "lodash"; 10 | import * as React from "react"; 11 | import styled from "styled-components"; 12 | 13 | type Props = { 14 | className?: string; 15 | fields: { label: string; value: string | ((k: any) => string) }[]; 16 | rows: any[]; 17 | sortFields: string[]; 18 | }; 19 | 20 | const EmptyRow = styled(TableRow)<{ colSpan: number }>` 21 | font-style: italic; 22 | 23 | td { 24 | text-align: center; 25 | } 26 | `; 27 | const Styled = (c) => styled(c)``; 28 | 29 | function DataTable({ className, fields, rows, sortFields }: Props) { 30 | const sorted = _.sortBy(rows, sortFields, "asc"); 31 | 32 | const r = _.map(sorted, (r, i) => ( 33 | 34 | {_.map(fields, (f) => ( 35 | 36 | {typeof f.value === "function" ? f.value(r) : r[f.value]} 37 | 38 | ))} 39 | 40 | )); 41 | 42 | return ( 43 |
44 | 45 | 46 | 47 | 48 | {_.map(fields, (f) => ( 49 | {f.label} 50 | ))} 51 | 52 | 53 | 54 | {r.length > 0 ? ( 55 | r 56 | ) : ( 57 | 58 | 59 | No rows 60 | 61 | 62 | )} 63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | 70 | export default Styled(DataTable); 71 | -------------------------------------------------------------------------------- /ui/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Page from "./Page"; 3 | 4 | export default class ErrorBoundary extends React.Component< 5 | any, 6 | { hasError: boolean; error: Error } 7 | > { 8 | constructor(props) { 9 | super(props); 10 | this.state = { hasError: false, error: null }; 11 | } 12 | 13 | static getDerivedStateFromError(error) { 14 | // Update state so the next render will show the fallback UI. 15 | return { hasError: true, error }; 16 | } 17 | 18 | componentDidCatch(error) { 19 | // You can also log the error to an error reporting service 20 | console.error(error); 21 | } 22 | 23 | render() { 24 | if (this.state.hasError) { 25 | // You can render any custom fallback UI 26 | return ( 27 | 28 |
Something went wrong.
29 |
{this.state.error.message}
30 |
{this.state.error.stack}
31 |
32 | ); 33 | } 34 | 35 | return this.props.children; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/components/Flex.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | type Props = { 5 | className?: string; 6 | column?: boolean; 7 | align?: boolean; 8 | between?: boolean; 9 | center?: boolean; 10 | wide?: boolean; 11 | wrap?: boolean; 12 | }; 13 | 14 | const Styled = (component) => styled(component)` 15 | display: flex; 16 | flex-direction: ${(props) => (props.column ? "column" : "row")}; 17 | align-items: ${(props) => (props.align ? "center" : "start")}; 18 | ${({ between }) => between && "justify-content: space-between"}; 19 | ${({ center }) => center && "justify-content: center"}; 20 | ${({ wide }) => wide && "width: 100%"}; 21 | ${({ wrap }) => wrap && "flex-wrap: wrap"}; 22 | ${({ end }) => end && "justify-content: flex-end"}; 23 | `; 24 | 25 | class Flex extends React.PureComponent { 26 | render() { 27 | const { className, children } = this.props; 28 | return
{children}
; 29 | } 30 | } 31 | 32 | export default Styled(Flex); 33 | -------------------------------------------------------------------------------- /ui/components/Graph.tsx: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import dagreD3 from "dagre-d3"; 3 | import _ from "lodash"; 4 | import * as React from "react"; 5 | import styled from "styled-components"; 6 | 7 | type Props = { 8 | className?: string; 9 | nodes: { id: any; data: N; label: (v: N) => string }[]; 10 | edges: { source: any; target: any }[]; 11 | scale?: number; 12 | }; 13 | 14 | const width = 960; 15 | const height = 960; 16 | // Stolen from here: 17 | // https://dagrejs.github.io/project/dagre-d3/latest/demo/sentence-tokenization.html 18 | function Graph({ className, nodes, edges, scale }: Props) { 19 | const svgRef = React.useRef(null); 20 | 21 | React.useEffect(() => { 22 | if (!svgRef.current) { 23 | return; 24 | } 25 | 26 | // https://github.com/jsdom/jsdom/issues/2531 27 | if (process.env.NODE_ENV === "test") { 28 | return; 29 | } 30 | 31 | const graph = new dagreD3.graphlib.Graph() 32 | .setGraph({ 33 | nodesep: 70, 34 | ranksep: 50, 35 | rankdir: "LR", 36 | marginx: 20, 37 | marginy: 20, 38 | }) 39 | .setDefaultEdgeLabel(() => { 40 | return {}; 41 | }); 42 | 43 | _.each(nodes, (n) => { 44 | graph.setNode(n.id, { 45 | label: n.label(n.data), 46 | labelType: "html", 47 | rx: 5, 48 | ry: 5, 49 | }); 50 | }); 51 | 52 | _.each(edges, (e) => { 53 | graph.setEdge(e.source, e.target); 54 | }); 55 | 56 | // Create the renderer 57 | const render = new dagreD3.render(); 58 | 59 | // Set up an SVG group so that we can translate the final graph. 60 | const svg = d3.select(svgRef.current); 61 | svg.append("g"); 62 | 63 | // Set up zoom support 64 | const zoom = d3.zoom().on("zoom", (e) => { 65 | svg.select("g").attr("transform", e.transform); 66 | }); 67 | 68 | svg.call(zoom).call(zoom.transform, d3.zoomIdentity.scale(scale)); 69 | 70 | // Run the renderer. This is what draws the final graph. 71 | render(d3.select("svg g"), graph); 72 | }, [svgRef.current, nodes, edges]); 73 | return ( 74 |
75 | 76 |
77 | ); 78 | } 79 | 80 | export default styled(Graph)` 81 | overflow: hidden; 82 | 83 | text { 84 | font-weight: 300; 85 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 86 | font-size: 12px; 87 | } 88 | 89 | .node rect { 90 | stroke: #999; 91 | fill: #fff; 92 | stroke-width: 1.5px; 93 | } 94 | 95 | .edgePath path { 96 | stroke: #333; 97 | stroke-width: 1.5px; 98 | } 99 | 100 | foreignObject { 101 | overflow: visible; 102 | } 103 | `; 104 | -------------------------------------------------------------------------------- /ui/components/KeyValueTable.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | 5 | type Props = { 6 | className?: string; 7 | pairs: { key: string; value: string }[]; 8 | columns: number; 9 | overrides?: { 10 | [keyName: string]: Array; 11 | }; 12 | }; 13 | 14 | const Key = styled.div` 15 | font-weight: bold; 16 | `; 17 | 18 | const Value = styled.div``; 19 | 20 | const Styled = (c) => styled(c)` 21 | table { 22 | width: 100%; 23 | } 24 | 25 | tr { 26 | height: 64px; 27 | } 28 | `; 29 | 30 | function KeyValueTable({ className, pairs, columns, overrides }: Props) { 31 | const arr = new Array(Math.ceil(pairs.length / columns)) 32 | .fill(null) 33 | .map(() => pairs.splice(0, columns)); 34 | 35 | return ( 36 |
37 | 38 | 39 | {_.map(arr, (a, i) => ( 40 | 41 | {_.map(a, ({ key, value }) => { 42 | let k = key; 43 | let v = value; 44 | const override = overrides ? overrides[key] : null; 45 | 46 | if (override) { 47 | v = override[0] as string; 48 | k = override[1] as string; 49 | } 50 | 51 | const label = _.capitalize(k); 52 | 53 | return ( 54 | 60 | ); 61 | })} 62 | 63 | ))} 64 | 65 |
55 | {label} 56 | 57 | {v || -} 58 | 59 |
66 |
67 | ); 68 | } 69 | 70 | export default Styled(KeyValueTable); 71 | -------------------------------------------------------------------------------- /ui/components/LeftNav.tsx: -------------------------------------------------------------------------------- 1 | import { Tab, Tabs } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import * as React from "react"; 4 | import styled from "styled-components"; 5 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 6 | import { formatURL, getNavValue, PageRoute } from "../lib/util"; 7 | import Link from "./Link"; 8 | 9 | type Props = { 10 | className?: string; 11 | }; 12 | 13 | const navItems = [ 14 | { value: PageRoute.Sources, label: "Sources" }, 15 | { value: PageRoute.Kustomizations, label: "Kustomizations" }, 16 | { value: PageRoute.HelmReleases, label: "Helm Releases" }, 17 | { value: PageRoute.Events, label: "Events" }, 18 | ]; 19 | 20 | const LinkTab = styled((props) => ( 21 | ( 23 | 24 | ))} 25 | {...props} 26 | /> 27 | ))` 28 | span { 29 | align-items: flex-start; 30 | } 31 | `; 32 | 33 | const Styled = (cmp) => styled(cmp)` 34 | #context-selector { 35 | min-width: 120px; 36 | } 37 | 38 | background-color: #f5f5f5; 39 | height: 100vh; 40 | padding-left: 8px; 41 | `; 42 | 43 | function LeftNav({ className }: Props) { 44 | const { currentContext, currentNamespace } = useKubernetesContexts(); 45 | const { currentPage } = useNavigation(); 46 | 47 | return ( 48 |
49 |
50 | 55 | {_.map(navItems, (n) => ( 56 | 62 | ))} 63 | 64 |
65 |
66 | ); 67 | } 68 | 69 | export default Styled(LeftNav); 70 | -------------------------------------------------------------------------------- /ui/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link as RouterLink } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | 5 | type Props = { 6 | className?: string; 7 | children: any; 8 | to?: string; 9 | }; 10 | 11 | const Styled = (c) => styled(c)``; 12 | 13 | function Link({ className, children, to, ...rest }: Props) { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | 21 | export default Styled(Link); 22 | -------------------------------------------------------------------------------- /ui/components/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import Flex from "./Flex"; 4 | 5 | type Props = { 6 | className?: string; 7 | }; 8 | 9 | export default function LoadingPage({ className }: Props) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /ui/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | // @ts-ignore 4 | import imgSrc from "../../static/img/flux-horizontal-white.png"; 5 | 6 | type Props = { 7 | className?: string; 8 | }; 9 | const Styled = (c) => styled(c)` 10 | padding: 8px; 11 | 12 | img { 13 | max-height: 40px; 14 | } 15 | `; 16 | 17 | function Logo({ className }: Props) { 18 | return ( 19 |
20 | 21 |
22 | ); 23 | } 24 | 25 | export default Styled(Logo); 26 | -------------------------------------------------------------------------------- /ui/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@material-ui/core"; 2 | import { AlertTitle } from "@material-ui/lab"; 3 | import Alert from "@material-ui/lab/Alert"; 4 | import * as React from "react"; 5 | import styled from "styled-components"; 6 | import { AppContext } from "./AppStateProvider"; 7 | import LoadingPage from "./LoadingPage"; 8 | 9 | type Props = { 10 | className?: string; 11 | children: any; 12 | loading: boolean; 13 | }; 14 | const Styled = (c) => styled(c)``; 15 | 16 | function Page({ className, children, loading }: Props) { 17 | const { appState } = React.useContext(AppContext); 18 | 19 | if (loading) { 20 | return ; 21 | } 22 | if (appState.error) { 23 | return ( 24 | 25 | 26 | 27 | {appState.error.message} 28 | {appState.error.detail} 29 | 30 | 31 | 32 |
{children}
33 |
34 | ); 35 | } 36 | return
{children}
; 37 | } 38 | 39 | export default Styled(Page); 40 | -------------------------------------------------------------------------------- /ui/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | 5 | type Props = { 6 | className?: string; 7 | title: string; 8 | children?: any; 9 | }; 10 | 11 | const Title = styled.div` 12 | background-color: #f5f5f5; 13 | 14 | h3 { 15 | margin: 0; 16 | padding: 16px; 17 | } 18 | `; 19 | 20 | const Styled = (c) => styled(c)``; 21 | 22 | function Panel({ className, children, title }: Props) { 23 | return ( 24 |
25 | 26 | 27 | <h3>{title}</h3> 28 | 29 | 30 |
{children}
31 |
32 |
33 |
34 | ); 35 | } 36 | 37 | export default Styled(Panel); 38 | -------------------------------------------------------------------------------- /ui/components/ReconciliationGraph.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from "@material-ui/core"; 2 | import CheckCircleIcon from "@material-ui/icons/CheckCircle"; 3 | import _ from "lodash"; 4 | import * as React from "react"; 5 | import { renderToString } from "react-dom/server"; 6 | import styled from "styled-components"; 7 | import { UnstructuredObjectWithParent } from "../lib/hooks/kustomizations"; 8 | import { UnstructuredObject } from "../lib/rpc/clusters"; 9 | import Graph from "./Graph"; 10 | 11 | export interface Props { 12 | objects: UnstructuredObjectWithParent[]; 13 | parentObject: any; 14 | parentObjectKind: string; 15 | className?: string; 16 | } 17 | 18 | function getStatusIcon(status: string) { 19 | switch (status) { 20 | case "Current": 21 | return ; 22 | 23 | case "InProgress": 24 | return ; 25 | 26 | default: 27 | return ""; 28 | } 29 | } 30 | 31 | const NodeHtml = ({ object }) => { 32 | return ( 33 |
34 |
35 |
{object.groupversionkind.kind}
36 |
{getStatusIcon(object.status)}
37 |
38 |
39 | {object.namespace} / {object.name} 40 |
41 |
42 | ); 43 | }; 44 | 45 | function ReconciliationGraph({ 46 | className, 47 | objects, 48 | parentObject, 49 | parentObjectKind, 50 | }: Props) { 51 | const edges = _.reduce( 52 | objects, 53 | (r, v) => { 54 | if (v.parentUid) { 55 | r.push({ source: v.parentUid, target: v.uid }); 56 | } else { 57 | r.push({ source: parentObject.name, target: v.uid }); 58 | } 59 | return r; 60 | }, 61 | [] 62 | ); 63 | 64 | const nodes = [ 65 | ..._.map(objects, (r) => ({ 66 | id: r.uid, 67 | data: r, 68 | label: (u: UnstructuredObject) => renderToString(), 69 | })), 70 | { 71 | id: parentObject.name, 72 | data: parentObject, 73 | label: (u: Props["parentObject"]) => 74 | renderToString( 75 | 78 | ), 79 | }, 80 | ]; 81 | return ( 82 |
83 | 84 |
85 | ); 86 | } 87 | 88 | export default styled(ReconciliationGraph)` 89 | ${Graph} { 90 | background-color: #f5f5f5; 91 | } 92 | 93 | .node { 94 | font-size: 14px; 95 | background-color: white; 96 | } 97 | 98 | rect { 99 | filter: drop-shadow(2px 2px 2px #7c7c7c); 100 | } 101 | 102 | .kind { 103 | display: flex; 104 | width: 100%; 105 | justify-content: space-between; 106 | color: black; 107 | } 108 | 109 | .status { 110 | height: 24px; 111 | width: 24px; 112 | font-size: 12px; 113 | } 114 | 115 | .Current { 116 | color: green; 117 | } 118 | 119 | .name { 120 | color: #3570e3; 121 | } 122 | 123 | .MuiSvgIcon-root { 124 | height: 16px; 125 | width: 16px; 126 | float: right; 127 | } 128 | `; 129 | -------------------------------------------------------------------------------- /ui/components/SuggestedAction.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | 5 | type Props = React.PropsWithChildren<{ 6 | className?: string; 7 | title?: string; 8 | }>; 9 | 10 | const Styled = (c) => styled(c)` 11 | background-color: #d6d6ff; 12 | border: 2px solid #9992ff; 13 | border-radius: 2px; 14 | 15 | h4 { 16 | margin: 8px 0; 17 | } 18 | `; 19 | 20 | function SuggestedAction({ className, children, title }: Props) { 21 | return ( 22 |
23 | 24 |

Suggested Action: {title}

25 | {children} 26 |
27 |
28 | ); 29 | } 30 | 31 | export default Styled(SuggestedAction); 32 | -------------------------------------------------------------------------------- /ui/components/TopNav.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, InputLabel, MenuItem, Select } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import * as React from "react"; 4 | import styled from "styled-components"; 5 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 6 | import { AllNamespacesOption } from "../lib/types"; 7 | import { formatURL, getNavValue, PageRoute } from "../lib/util"; 8 | import Flex from "./Flex"; 9 | import Link from "./Link"; 10 | import Logo from "./Logo"; 11 | 12 | const allNamespaces = "All Namespaces"; 13 | 14 | type Props = { 15 | className?: string; 16 | }; 17 | 18 | const NavWrapper = styled(Flex)` 19 | height: 60px; 20 | align-items: flex-end; 21 | `; 22 | 23 | const Styled = (c) => styled(c)` 24 | padding: 8px 0; 25 | background-color: #3570e3; 26 | width: 100%; 27 | 28 | .MuiSelect-outlined { 29 | border-color: white !important; 30 | 31 | input { 32 | border-color: white !important; 33 | } 34 | } 35 | 36 | .MuiFormControl-root { 37 | border-color: white !important; 38 | margin-right: 16px; 39 | } 40 | 41 | .MuiSelect-outlined, 42 | label { 43 | color: white !important; 44 | } 45 | 46 | fieldset { 47 | &, 48 | &:hover { 49 | border-color: #ffffff !important; 50 | } 51 | } 52 | 53 | svg { 54 | color: white; 55 | } 56 | 57 | .MuiSelect-select { 58 | min-width: 120px; 59 | } 60 | 61 | label { 62 | height: 42px !important; 63 | transform: translate(14px, 14px) scale(1); 64 | } 65 | 66 | .MuiOutlinedInput-root { 67 | height: 40px; 68 | } 69 | `; 70 | 71 | function TopNav({ className }: Props) { 72 | const { 73 | contexts, 74 | namespaces, 75 | currentContext, 76 | currentNamespace, 77 | } = useKubernetesContexts(); 78 | const { navigate, currentPage } = useNavigation(); 79 | 80 | return ( 81 |
82 | 83 |
84 | 87 | 88 | 89 |
90 | 91 | 92 | 93 | Context 94 | {currentContext && contexts.length > 0 && ( 95 | 115 | )} 116 | 117 | 118 | Namespace 119 | {currentNamespace && namespaces && namespaces.length > 0 && ( 120 | 150 | )} 151 | 152 | 153 | 154 |
155 |
156 | ); 157 | } 158 | 159 | export default Styled(TopNav); 160 | -------------------------------------------------------------------------------- /ui/components/__tests__/AppStateProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import "jest-styled-components"; 4 | import * as React from "react"; 5 | import { Router } from "react-router"; 6 | import { useKubernetesContexts } from "../../lib/hooks/app"; 7 | import { 8 | createMockClient, 9 | stubbedClustersResponses, 10 | } from "../../lib/test-utils"; 11 | import AppStateProvider from "../AppStateProvider"; 12 | 13 | describe("AppStateProvider", () => { 14 | let container; 15 | beforeEach(() => { 16 | container = document.createElement("div"); 17 | document.body.appendChild(container); 18 | }); 19 | afterEach(() => { 20 | document.body.removeChild(container); 21 | container = null; 22 | }); 23 | 24 | it("sets the namespace from the url", async () => { 25 | const ns = "my-ns"; 26 | const otherNs = "other-ns"; 27 | const url = `/?context=my-context&namespace=${ns}`; 28 | 29 | const client = createMockClient({ 30 | ...stubbedClustersResponses, 31 | listNamespacesForContext: { namespaces: [ns, otherNs] }, 32 | }); 33 | 34 | const TestComponent = () => { 35 | const { currentNamespace } = useKubernetesContexts(); 36 | 37 | return
{currentNamespace}
; 38 | }; 39 | 40 | const history = createMemoryHistory(); 41 | history.push(url); 42 | 43 | render( 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | expect((await screen.findByTestId("ns")).textContent).toEqual(ns); 51 | 52 | // simulate navigation to another namespace 53 | history.push(`/?context=my-context&namespace=${otherNs}`); 54 | 55 | expect((await screen.findByTestId("ns")).textContent).toEqual(otherNs); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /ui/components/__tests__/Flex.test.tsx: -------------------------------------------------------------------------------- 1 | import "jest-styled-components"; 2 | import React from "react"; 3 | import renderer from "react-test-renderer"; 4 | import Flex from "../Flex"; 5 | 6 | it("renders correctly", () => { 7 | const tree = renderer 8 | .create( 9 | 10 | Aligned and Centered! 11 | 12 | ) 13 | .toJSON(); 14 | expect(tree).toMatchSnapshot(); 15 | }); 16 | -------------------------------------------------------------------------------- /ui/components/__tests__/TopNav.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import "jest-styled-components"; 3 | import { stubbedClustersResponses, withContext } from "../../lib/test-utils"; 4 | import TopNav from "../TopNav"; 5 | 6 | describe("TopNav", () => { 7 | let container; 8 | beforeEach(() => { 9 | container = document.createElement("div"); 10 | document.body.appendChild(container); 11 | }); 12 | afterEach(() => { 13 | document.body.removeChild(container); 14 | container = null; 15 | }); 16 | 17 | describe("snapshots", () => { 18 | it("renders", async () => { 19 | const url = "/?context=my-context&namespace=default"; 20 | 21 | const tree = render(withContext(TopNav, url, stubbedClustersResponses)); 22 | expect((await screen.findByText("my-context")).textContent).toBeTruthy(); 23 | expect(tree).toMatchSnapshot(); 24 | }); 25 | }); 26 | it("sets the current context", async () => { 27 | const url = "/?context=my-context&namespace=default"; 28 | 29 | render(withContext(TopNav, url, stubbedClustersResponses)); 30 | 31 | const input = await screen.findByDisplayValue("my-context"); 32 | 33 | expect(input).toBeTruthy(); 34 | }); 35 | it("sets the current namespace", async () => { 36 | const ns = "some-ns"; 37 | const url = `/?context=my-context&namespace=${ns}`; 38 | 39 | render( 40 | withContext(TopNav, url, { 41 | ...stubbedClustersResponses, 42 | listNamespacesForContext: { namespaces: [ns] }, 43 | }) 44 | ); 45 | 46 | const input = await screen.findByDisplayValue(ns); 47 | expect(input).toBeTruthy(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /ui/components/__tests__/__snapshots__/Flex.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | .c0 { 5 | display: -webkit-box; 6 | display: -webkit-flex; 7 | display: -ms-flexbox; 8 | display: flex; 9 | -webkit-flex-direction: row; 10 | -ms-flex-direction: row; 11 | flex-direction: row; 12 | -webkit-align-items: center; 13 | -webkit-box-align: center; 14 | -ms-flex-align: center; 15 | align-items: center; 16 | -webkit-box-pack: center; 17 | -webkit-justify-content: center; 18 | -ms-flex-pack: center; 19 | justify-content: center; 20 | width: 100%; 21 | } 22 | 23 |
26 | Aligned and Centered! 27 |
28 | `; 29 | -------------------------------------------------------------------------------- /ui/components/__tests__/__snapshots__/TopNav.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TopNav snapshots renders 1`] = ` 4 | Object { 5 | "asFragment": [Function], 6 | "baseElement": .c1 { 7 | display: -webkit-box; 8 | display: -webkit-flex; 9 | display: -ms-flexbox; 10 | display: flex; 11 | -webkit-flex-direction: row; 12 | -ms-flex-direction: row; 13 | flex-direction: row; 14 | -webkit-align-items: start; 15 | -webkit-box-align: start; 16 | -ms-flex-align: start; 17 | align-items: start; 18 | } 19 | 20 | .c3 { 21 | display: -webkit-box; 22 | display: -webkit-flex; 23 | display: -ms-flexbox; 24 | display: flex; 25 | -webkit-flex-direction: column; 26 | -ms-flex-direction: column; 27 | flex-direction: column; 28 | -webkit-align-items: start; 29 | -webkit-box-align: start; 30 | -ms-flex-align: start; 31 | align-items: start; 32 | -webkit-box-pack: center; 33 | -webkit-justify-content: center; 34 | -ms-flex-pack: center; 35 | justify-content: center; 36 | width: 100%; 37 | } 38 | 39 | .c5 { 40 | display: -webkit-box; 41 | display: -webkit-flex; 42 | display: -ms-flexbox; 43 | display: flex; 44 | -webkit-flex-direction: row; 45 | -ms-flex-direction: row; 46 | flex-direction: row; 47 | -webkit-align-items: start; 48 | -webkit-box-align: start; 49 | -ms-flex-align: start; 50 | align-items: start; 51 | -webkit-box-pack: center; 52 | -webkit-justify-content: center; 53 | -ms-flex-pack: center; 54 | justify-content: center; 55 | } 56 | 57 | .c2 { 58 | padding: 8px; 59 | } 60 | 61 | .c2 img { 62 | max-height: 40px; 63 | } 64 | 65 | .c4 { 66 | height: 60px; 67 | -webkit-align-items: flex-end; 68 | -webkit-box-align: flex-end; 69 | -ms-flex-align: flex-end; 70 | align-items: flex-end; 71 | } 72 | 73 | .c0 { 74 | padding: 8px 0; 75 | background-color: #3570e3; 76 | width: 100%; 77 | } 78 | 79 | .c0 .MuiSelect-outlined { 80 | border-color: white !important; 81 | } 82 | 83 | .c0 .MuiSelect-outlined input { 84 | border-color: white !important; 85 | } 86 | 87 | .c0 .MuiFormControl-root { 88 | border-color: white !important; 89 | margin-right: 16px; 90 | } 91 | 92 | .c0 .MuiSelect-outlined, 93 | .c0 label { 94 | color: white !important; 95 | } 96 | 97 | .c0 fieldset, 98 | .c0 fieldset:hover { 99 | border-color: #ffffff !important; 100 | } 101 | 102 | .c0 svg { 103 | color: white; 104 | } 105 | 106 | .c0 .MuiSelect-select { 107 | min-width: 120px; 108 | } 109 | 110 | .c0 label { 111 | height: 42px !important; 112 | -webkit-transform: translate(14px,14px) scale(1); 113 | -ms-transform: translate(14px,14px) scale(1); 114 | transform: translate(14px,14px) scale(1); 115 | } 116 | 117 | .c0 .MuiOutlinedInput-root { 118 | height: 40px; 119 | } 120 | 121 | 122 |
123 |
124 |
127 |
130 | 144 |
147 |
150 |
153 | 159 |
162 |
170 | my-context 171 |
172 | 178 | 188 | 202 |
203 |
204 |
207 | 214 |
217 |
225 | default 226 |
227 | 233 | 243 | 255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 | , 263 | "container":
264 |
267 |
270 | 284 |
287 |
290 |
293 | 299 |
302 |
310 | my-context 311 |
312 | 318 | 328 | 342 |
343 |
344 |
347 | 354 |
357 |
365 | default 366 |
367 | 373 | 383 | 395 |
396 |
397 |
398 |
399 |
400 |
401 |
, 402 | "debug": [Function], 403 | "findAllByAltText": [Function], 404 | "findAllByDisplayValue": [Function], 405 | "findAllByLabelText": [Function], 406 | "findAllByPlaceholderText": [Function], 407 | "findAllByRole": [Function], 408 | "findAllByTestId": [Function], 409 | "findAllByText": [Function], 410 | "findAllByTitle": [Function], 411 | "findByAltText": [Function], 412 | "findByDisplayValue": [Function], 413 | "findByLabelText": [Function], 414 | "findByPlaceholderText": [Function], 415 | "findByRole": [Function], 416 | "findByTestId": [Function], 417 | "findByText": [Function], 418 | "findByTitle": [Function], 419 | "getAllByAltText": [Function], 420 | "getAllByDisplayValue": [Function], 421 | "getAllByLabelText": [Function], 422 | "getAllByPlaceholderText": [Function], 423 | "getAllByRole": [Function], 424 | "getAllByTestId": [Function], 425 | "getAllByText": [Function], 426 | "getAllByTitle": [Function], 427 | "getByAltText": [Function], 428 | "getByDisplayValue": [Function], 429 | "getByLabelText": [Function], 430 | "getByPlaceholderText": [Function], 431 | "getByRole": [Function], 432 | "getByTestId": [Function], 433 | "getByText": [Function], 434 | "getByTitle": [Function], 435 | "queryAllByAltText": [Function], 436 | "queryAllByDisplayValue": [Function], 437 | "queryAllByLabelText": [Function], 438 | "queryAllByPlaceholderText": [Function], 439 | "queryAllByRole": [Function], 440 | "queryAllByTestId": [Function], 441 | "queryAllByText": [Function], 442 | "queryAllByTitle": [Function], 443 | "queryByAltText": [Function], 444 | "queryByDisplayValue": [Function], 445 | "queryByLabelText": [Function], 446 | "queryByPlaceholderText": [Function], 447 | "queryByRole": [Function], 448 | "queryByTestId": [Function], 449 | "queryByText": [Function], 450 | "queryByTitle": [Function], 451 | "rerender": [Function], 452 | "unmount": [Function], 453 | } 454 | `; 455 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Flux Web UI 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /ui/lib/fileMock.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | module.exports = ""; 3 | -------------------------------------------------------------------------------- /ui/lib/hooks/__tests__/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { withContext } from "../../test-utils"; 4 | import { useKubernetesContexts } from "../app"; 5 | 6 | describe("app hooks", () => { 7 | describe("useKubernetesContexts", () => { 8 | let container; 9 | beforeEach(() => { 10 | container = document.createElement("div"); 11 | document.body.appendChild(container); 12 | }); 13 | afterEach(() => { 14 | document.body.removeChild(container); 15 | container = null; 16 | }); 17 | it("lists contexts", async () => { 18 | const TestComponent = () => { 19 | const { contexts, currentContext } = useKubernetesContexts(); 20 | 21 | return ( 22 |
    23 |

    {currentContext}

    24 | {(contexts || []).map((c) => ( 25 |
  • {c.name}
  • 26 | ))} 27 |
28 | ); 29 | }; 30 | 31 | render( 32 | withContext(TestComponent, "/?context=other-context", { 33 | listContexts: { 34 | contexts: [{ name: "my-context" }, { name: "other-context" }], 35 | currentcontext: "other-context", 36 | }, 37 | listNamespacesForContext: { namespaces: ["default"] }, 38 | }), 39 | container 40 | ); 41 | 42 | expect((await screen.findByText("my-context")).textContent).toBeTruthy(); 43 | expect((await screen.findByTestId("current")).textContent).toEqual( 44 | "other-context" 45 | ); 46 | }); 47 | it("lists namespaces", async () => { 48 | const TestComponent = () => { 49 | const { namespaces } = useKubernetesContexts(); 50 | 51 | return ( 52 |
    53 | {(namespaces || []).map((c) => ( 54 |
  • {c}
  • 55 | ))} 56 |
57 | ); 58 | }; 59 | 60 | render( 61 | withContext(TestComponent, "/?context=my-context", { 62 | listContexts: { contexts: [{ name: "my-context" }] }, 63 | listNamespacesForContext: { namespaces: ["default"] }, 64 | }), 65 | container 66 | ); 67 | 68 | expect((await screen.findByText("default")).textContent).toBeTruthy(); 69 | }); 70 | it("sets the current namespace", async () => { 71 | const ns = "my-ns"; 72 | const TestComponent = () => { 73 | const { currentNamespace } = useKubernetesContexts(); 74 | 75 | return

{currentNamespace}

; 76 | }; 77 | 78 | render( 79 | withContext(TestComponent, `/?context=my-context&namespace=${ns}`, { 80 | listContexts: { contexts: [{ name: "my-context" }] }, 81 | listNamespacesForContext: { namespaces: [ns] }, 82 | }), 83 | container 84 | ); 85 | 86 | expect((await screen.findByText(ns)).textContent).toBeTruthy(); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /ui/lib/hooks/app.ts: -------------------------------------------------------------------------------- 1 | import qs from "query-string"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { useHistory, useLocation } from "react-router-dom"; 4 | import { AppContext } from "../../components/AppStateProvider"; 5 | import { Context } from "../rpc/clusters"; 6 | import { AllNamespacesOption } from "../types"; 7 | import { formatURL, normalizePath, PageRoute } from "../util"; 8 | // The backend doesn't like the word "all". Instead, it wants an empty string. 9 | // Navigation might get weird if we use an empty string on the front-end. 10 | // There may also be a naming collision with a namespace named "all". 11 | export const formatAPINamespace = (ns: string) => 12 | ns === AllNamespacesOption ? "" : ns; 13 | 14 | export function useKubernetesContexts(): { 15 | contexts: Context[]; 16 | namespaces: string[]; 17 | currentContext: string; 18 | currentNamespace: string; 19 | } { 20 | const { navigate } = useNavigation(); 21 | 22 | const { contexts, namespaces, currentContext, currentNamespace } = useContext( 23 | AppContext 24 | ); 25 | 26 | useEffect(() => { 27 | if (!currentContext) { 28 | navigate(PageRoute.Redirector, null, null); 29 | } 30 | }, []); 31 | 32 | return { 33 | contexts, 34 | namespaces: namespaces[currentContext] || [], 35 | currentContext: currentContext, 36 | currentNamespace: currentNamespace, 37 | }; 38 | } 39 | 40 | export function useAppState() { 41 | const { appState } = useContext(AppContext); 42 | return appState; 43 | } 44 | 45 | export function useNavigation() { 46 | const history = useHistory(); 47 | const location = useLocation(); 48 | const [currentPage, setCurrentPage] = useState(""); 49 | 50 | useEffect(() => { 51 | if (!location.pathname) { 52 | console.log(location); 53 | } 54 | const [pageName] = normalizePath(location.pathname); 55 | setCurrentPage(pageName as string); 56 | }, [location]); 57 | 58 | return { 59 | currentPage, 60 | query: qs.parse(location.search), 61 | navigate: ( 62 | page: PageRoute | null, 63 | context: string, 64 | namespace: string, 65 | query: any = {} 66 | ) => { 67 | let nextPage = page || currentPage; 68 | 69 | if (nextPage == "error") { 70 | nextPage = PageRoute.Home; 71 | } 72 | 73 | history.push(formatURL(nextPage as string, context, namespace, query)); 74 | }, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /ui/lib/hooks/events.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { AppContext } from "../../components/AppStateProvider"; 3 | import { clustersClient } from "../util"; 4 | 5 | export default function useEvents(currentContext) { 6 | const [events, setEvents] = useState([]); 7 | const [loading, setLoading] = useState(true); 8 | const { doAsyncError } = useContext(AppContext); 9 | 10 | useEffect(() => { 11 | setLoading(true); 12 | setEvents([]); 13 | clustersClient 14 | .listEvents({ 15 | contextname: currentContext, 16 | namespace: "flux-system", 17 | }) 18 | .then((res) => { 19 | setEvents(res.events); 20 | }) 21 | .catch((err) => { 22 | doAsyncError("There was an error fetching events", true, err.message); 23 | }) 24 | .finally(() => setLoading(false)); 25 | }, [currentContext]); 26 | 27 | return { events, loading }; 28 | } 29 | -------------------------------------------------------------------------------- /ui/lib/hooks/helm_releases.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { AppContext } from "../../components/AppStateProvider"; 4 | import { HelmRelease } from "../rpc/clusters"; 5 | import { notifyError, notifySuccess } from "../util"; 6 | import { formatAPINamespace } from "./app"; 7 | 8 | export function useHelmReleases( 9 | currentContext: string, 10 | currentNamespace: string 11 | ) { 12 | const [helmReleases, setHelmReleases] = useState<{ 13 | [name: string]: HelmRelease; 14 | }>({}); 15 | const { doAsyncError, clustersClient } = useContext(AppContext); 16 | 17 | useEffect(() => { 18 | if (!currentContext) { 19 | return; 20 | } 21 | 22 | setHelmReleases({}); 23 | 24 | clustersClient 25 | .listHelmReleases({ 26 | contextname: currentContext, 27 | namespace: formatAPINamespace(currentNamespace), 28 | }) 29 | .then((res) => { 30 | const releases = _.keyBy(res.helmReleases, "name"); 31 | setHelmReleases(releases); 32 | }) 33 | .catch((err) => { 34 | doAsyncError( 35 | "There was an error fetching helm releases", 36 | true, 37 | err.message 38 | ); 39 | }); 40 | }, [currentContext, currentNamespace]); 41 | 42 | const syncHelmRelease = (hr: HelmRelease) => 43 | clustersClient 44 | .syncHelmRelease({ 45 | contextname: currentContext, 46 | namespace: hr.namespace, 47 | helmreleasename: hr.name, 48 | }) 49 | .then(() => { 50 | setHelmReleases({ 51 | ...helmReleases, 52 | [hr.name]: hr, 53 | }); 54 | notifySuccess("Sync successful"); 55 | }) 56 | .catch((err) => notifyError(err.message)); 57 | 58 | return { helmReleases, syncHelmRelease }; 59 | } 60 | -------------------------------------------------------------------------------- /ui/lib/hooks/kustomizations.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { AppContext } from "../../components/AppStateProvider"; 4 | import { 5 | GroupVersionKind, 6 | Kustomization, 7 | SyncKustomizationRes, 8 | UnstructuredObject, 9 | } from "../rpc/clusters"; 10 | import { notifyError, notifySuccess } from "../util"; 11 | import { formatAPINamespace } from "./app"; 12 | 13 | type KustomizationList = { [name: string]: Kustomization }; 14 | 15 | // Kubernetes does not allow us to query children by parents. 16 | // We keep a list of common parent-child relationships 17 | // to look up children recursively. 18 | export const PARENT_CHILD_LOOKUP = { 19 | Deployment: { 20 | group: "apps", 21 | version: "v1", 22 | kind: "Deployment", 23 | children: [ 24 | { 25 | group: "apps", 26 | version: "v1", 27 | kind: "ReplicaSet", 28 | children: [{ version: "v1", kind: "Pod" }], 29 | }, 30 | ], 31 | }, 32 | }; 33 | 34 | export type UnstructuredObjectWithParent = UnstructuredObject & { 35 | parentUid?: string; 36 | }; 37 | 38 | export function useKustomizations( 39 | currentContext: string, 40 | currentNamespace: string 41 | ) { 42 | const [loading, setLoading] = useState(true); 43 | const { doAsyncError, clustersClient } = useContext(AppContext); 44 | const [kustomizations, setKustomizations] = useState({} as KustomizationList); 45 | 46 | useEffect(() => { 47 | if (!currentContext) { 48 | return; 49 | } 50 | 51 | setLoading(true); 52 | setKustomizations({}); 53 | 54 | clustersClient 55 | .listKustomizations({ 56 | contextname: currentContext, 57 | namespace: formatAPINamespace(currentNamespace), 58 | }) 59 | .then((res) => { 60 | const r = _.keyBy(res.kustomizations, "name"); 61 | setKustomizations(r); 62 | }) 63 | .catch((err) => { 64 | doAsyncError( 65 | "There was an error fetching kustomizations", 66 | true, 67 | err.message 68 | ); 69 | }) 70 | .finally(() => setLoading(false)); 71 | }, [currentContext, currentNamespace]); 72 | 73 | const syncKustomization = (k: Kustomization) => 74 | clustersClient 75 | .syncKustomization({ 76 | contextname: currentContext, 77 | namespace: k.namespace, 78 | withsource: false, 79 | kustomizationname: k.name, 80 | }) 81 | .then((res: SyncKustomizationRes) => { 82 | setKustomizations({ 83 | ...kustomizations, 84 | [k.name]: res.kustomization, 85 | }); 86 | notifySuccess("Sync successful"); 87 | }) 88 | .catch((err) => notifyError(err.message)); 89 | 90 | const getReconciledObjects = ( 91 | kustomizationname: string, 92 | kustomizationnamespace: string, 93 | kinds: GroupVersionKind[] 94 | ) => { 95 | return clustersClient.getReconciledObjects({ 96 | contextname: currentContext, 97 | kustomizationname, 98 | kustomizationnamespace, 99 | kinds, 100 | }); 101 | }; 102 | 103 | const getChildObjects = ( 104 | parentuid: string, 105 | groupversionkind: GroupVersionKind 106 | ) => 107 | clustersClient.getChildObjects({ 108 | parentuid, 109 | groupversionkind, 110 | }); 111 | 112 | // Kubernetes does not let us query by parent-child relationship. 113 | // We need to get parent IDs and recursively pass them to children 114 | // in order to build the whole reconciliation "tree". 115 | const getChildrenRecursive = async ( 116 | result: any, 117 | object: UnstructuredObjectWithParent, 118 | lookup: any 119 | ) => { 120 | result.push(object); 121 | 122 | const k = lookup[object.groupversionkind.kind]; 123 | 124 | if (k && k.children) { 125 | for (let i = 0; i < k.children.length; i++) { 126 | const child: GroupVersionKind = k.children[i]; 127 | 128 | const res = await getChildObjects(object.uid, child); 129 | 130 | for (let q = 0; q < res.objects.length; q++) { 131 | const c = res.objects[q]; 132 | 133 | // Dive down one level and update the lookup accordingly. 134 | await getChildrenRecursive( 135 | result, 136 | { ...c, parentUid: object.uid }, 137 | { 138 | [child.kind]: child, 139 | } 140 | ); 141 | } 142 | } 143 | } 144 | }; 145 | 146 | return { 147 | kustomizations, 148 | syncKustomization, 149 | loading, 150 | getReconciledObjects, 151 | getChildObjects, 152 | getChildrenRecursive, 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /ui/lib/hooks/sources.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { AppContext } from "../../components/AppStateProvider"; 4 | import { Source } from "../rpc/clusters"; 5 | import { notifySuccess } from "../util"; 6 | import { formatAPINamespace } from "./app"; 7 | 8 | export enum SourceType { 9 | Git = "git", 10 | Bucket = "bucket", 11 | Helm = "helm", 12 | Chart = "chart", 13 | } 14 | 15 | function convertSourceTypeToInt(s: SourceType) { 16 | switch (s) { 17 | case SourceType.Git: 18 | return 0; 19 | case SourceType.Bucket: 20 | return 1; 21 | case SourceType.Helm: 22 | return 2; 23 | case SourceType.Chart: 24 | return 3; 25 | } 26 | } 27 | 28 | type SourceData = { 29 | [SourceType.Git]: Source[]; 30 | [SourceType.Bucket]: Source[]; 31 | [SourceType.Helm]: Source[]; 32 | }; 33 | 34 | const initialState = { 35 | [SourceType.Git]: [], 36 | [SourceType.Bucket]: [], 37 | [SourceType.Helm]: [], 38 | }; 39 | 40 | type SourceHook = { 41 | loading: boolean; 42 | sources: SourceData; 43 | syncSource: (Source) => Promise; 44 | }; 45 | 46 | export function useSources( 47 | currentContext: string, 48 | currentNamespace: string 49 | ): SourceHook { 50 | const { doAsyncError, clustersClient } = useContext(AppContext); 51 | const [sources, setSources] = useState(initialState); 52 | const [loading, setLoading] = useState(true); 53 | 54 | useEffect(() => { 55 | if (!currentContext) { 56 | return; 57 | } 58 | 59 | setSources(initialState); 60 | 61 | const p = _.map(SourceType, (s) => 62 | clustersClient.listSources({ 63 | contextname: currentContext, 64 | namespace: formatAPINamespace(currentNamespace), 65 | // @ts-ignore 66 | sourcetype: convertSourceTypeToInt(s), 67 | }) 68 | ); 69 | 70 | Promise.all(p) 71 | .then((arr) => { 72 | const d = {}; 73 | _.each(arr, (a) => { 74 | _.each(a.sources, (src) => { 75 | const t = _.lowerCase(src.type); 76 | if (!d[t]) { 77 | d[t] = []; 78 | } 79 | 80 | d[t].push(src); 81 | }); 82 | }); 83 | setSources(d as SourceData); 84 | }) 85 | .catch((err) => { 86 | doAsyncError("There was an error fetching sources", true, err.message); 87 | }) 88 | .finally(() => setLoading(false)); 89 | }, [currentContext, currentNamespace]); 90 | 91 | const syncSource = (s: Source) => 92 | clustersClient 93 | .syncSource({ 94 | contextname: currentContext, 95 | namespace: s.namespace, 96 | sourcename: s.name, 97 | }) 98 | .then(() => { 99 | setSources({ 100 | ...sources, 101 | [s.name]: s, 102 | }); 103 | notifySuccess("Sync successful"); 104 | }) 105 | .catch((err) => { 106 | doAsyncError(err); 107 | }); 108 | 109 | return { loading, sources, syncSource }; 110 | } 111 | -------------------------------------------------------------------------------- /ui/lib/rpc/twirp.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface TwirpErrorJSON { 3 | code: string; 4 | msg: string; 5 | meta: {[index:string]: string}; 6 | } 7 | 8 | export class TwirpError extends Error { 9 | code: string; 10 | meta: {[index:string]: string}; 11 | 12 | constructor(te: TwirpErrorJSON) { 13 | super(te.msg); 14 | 15 | this.code = te.code; 16 | this.meta = te.meta; 17 | } 18 | } 19 | 20 | export const throwTwirpError = (resp: Response) => { 21 | return resp.json().then((err: TwirpErrorJSON) => { throw new TwirpError(err); }) 22 | }; 23 | 24 | export const createTwirpRequest = (url: string, body: object, headersOverride: HeadersInit = {}): Request => { 25 | const headers = { 26 | ...{ 27 | "Content-Type": "application/json" 28 | }, 29 | ...headersOverride 30 | }; 31 | return new Request(url, { 32 | method: "POST", 33 | headers, 34 | body: JSON.stringify(body) 35 | }); 36 | }; 37 | 38 | export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise; 39 | -------------------------------------------------------------------------------- /ui/lib/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import { MuiThemeProvider } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import * as React from "react"; 4 | import { MemoryRouter } from "react-router"; 5 | import { ThemeProvider } from "styled-components"; 6 | import AppStateProvider from "../components/AppStateProvider"; 7 | import { 8 | GetChildObjectsRes, 9 | GetReconciledObjectsRes, 10 | Kustomization, 11 | ListContextsRes, 12 | ListKustomizationsRes, 13 | ListNamespacesForContextRes, 14 | } from "./rpc/clusters"; 15 | import theme from "./theme"; 16 | 17 | type ClientOverrides = { 18 | listContexts: ListContextsRes; 19 | listNamespacesForContext: ListNamespacesForContextRes; 20 | listKustomizations?: ListKustomizationsRes; 21 | getReconciledObjects?: GetReconciledObjectsRes; 22 | getChildObjects?: GetChildObjectsRes; 23 | }; 24 | 25 | export const createMockClient = (ovr: ClientOverrides) => { 26 | // Don't make the user wire up all the promise stuff to be interface-compliant 27 | const promisified = _.reduce( 28 | ovr, 29 | (result, desiredResponse, method) => { 30 | result[method] = () => 31 | new Promise((accept) => accept(desiredResponse as any)); 32 | 33 | return result; 34 | }, 35 | {} 36 | ); 37 | 38 | return promisified; 39 | }; 40 | 41 | export function withContext( 42 | TestComponent, 43 | simulatedUrl: string, 44 | clientOverrides?: ClientOverrides 45 | ) { 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | const k: Kustomization = { 60 | name: "my-kustomization", 61 | namespace: "default", 62 | path: "/k8s", 63 | sourceref: "my-source", 64 | conditions: [], 65 | interval: "1m", 66 | prune: false, 67 | reconcilerequestat: "", 68 | reconcileat: "", 69 | sourcerefkind: "Git", 70 | }; 71 | 72 | export const stubbedClustersResponses = { 73 | listContexts: { 74 | contexts: [{ name: "my-context" }, { name: "other-context" }], 75 | currentcontext: "other-context", 76 | }, 77 | listNamespacesForContext: { namespaces: ["default"] }, 78 | listKustomizations: { 79 | kustomizations: [k], 80 | }, 81 | getReconciledObjects: { 82 | objects: [ 83 | { 84 | groupversionkind: { 85 | group: "apps", 86 | version: "v1", 87 | kind: "Deployment", 88 | }, 89 | name: "reconciled-deployment", 90 | namespace: "default", 91 | status: "Current", 92 | }, 93 | ], 94 | }, 95 | getChildObjects: { 96 | objects: [ 97 | { 98 | groupversionkind: { 99 | group: "", 100 | version: "v1", 101 | kind: "Pod", 102 | }, 103 | name: "reconciled-deployment-abc", 104 | namespace: "default", 105 | status: "Current", 106 | }, 107 | ], 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /ui/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import green from "@material-ui/core/colors/green"; 2 | import purple from "@material-ui/core/colors/purple"; 3 | import { createMuiTheme } from "@material-ui/core/styles"; 4 | import { createGlobalStyle } from "styled-components"; 5 | 6 | const theme = createMuiTheme({ 7 | typography: { 8 | fontFamily: "Montserrat", 9 | }, 10 | palette: { 11 | primary: { 12 | main: purple[500], 13 | }, 14 | secondary: { 15 | main: green[500], 16 | }, 17 | }, 18 | }); 19 | 20 | export default theme; 21 | 22 | export const GlobalStyle = createGlobalStyle` 23 | body { 24 | font-family: ${(props: { theme: typeof theme }) => 25 | props.theme.typography.fontFamily}, sans-serif; 26 | padding: 0; 27 | margin: 0; 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /ui/lib/types.ts: -------------------------------------------------------------------------------- 1 | export const AllNamespacesOption = "all"; 2 | 3 | export type NamespaceLabel = { value: string; label: string }; 4 | -------------------------------------------------------------------------------- /ui/lib/util.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import qs from "query-string"; 3 | import { toast } from "react-toastify"; 4 | import { DefaultClusters } from "./rpc/clusters"; 5 | 6 | export const wrappedFetch = (url, opts: RequestInit = {}) => { 7 | return fetch(url, { 8 | ...opts, 9 | credentials: "same-origin", 10 | headers: { 11 | "content-type": "application/json", 12 | ...(opts.headers || {}), 13 | }, 14 | }); 15 | }; 16 | 17 | export const normalizePath = (pathname) => { 18 | return _.tail(pathname.split("/")); 19 | }; 20 | 21 | export const prefixRoute = (route: string, ...idParams: string[]) => 22 | `/:context/:namespace/${route}${ 23 | idParams ? _.map(idParams, (p) => "/:" + p).join("") : "" 24 | }`; 25 | 26 | export const toRoute = (route: PageRoute, params: string[]) => { 27 | const path = `/${_.map(params, (p) => `${p}/`).join("")}`; 28 | 29 | if (route === PageRoute.Home) { 30 | return route; 31 | } 32 | 33 | return `/${route}${params ? path : ""}`; 34 | }; 35 | 36 | export const formatURL = ( 37 | page: string, 38 | context: string, 39 | namespace: string, 40 | query: any = {} 41 | ) => { 42 | return `${page}?${qs.stringify({ context, namespace, ...query })}`; 43 | }; 44 | 45 | export enum PageRoute { 46 | Redirector = "/", 47 | Home = "/sources", 48 | Sources = "/sources", 49 | SourceDetail = "/sources_detail", 50 | Kustomizations = "/kustomizations", 51 | KustomizationDetail = "/kustomizations_detail", 52 | HelmReleases = "/helmreleases", 53 | HelmReleaseDetail = "/helmreleases_detail", 54 | Events = "/events", 55 | Error = "/error", 56 | } 57 | 58 | export const getNavValue = (currentPage: any): PageRoute | boolean => { 59 | switch (currentPage) { 60 | case "kustomizations": 61 | case "kustomizations_detail": 62 | return PageRoute.Kustomizations; 63 | case "sources": 64 | case "sources_detail": 65 | return PageRoute.Sources; 66 | 67 | case "helmreleases": 68 | case "helmreleases_detail": 69 | return PageRoute.HelmReleases; 70 | 71 | case "events": 72 | return PageRoute.Events; 73 | 74 | default: 75 | return false; 76 | } 77 | }; 78 | 79 | export const clustersClient = new DefaultClusters( 80 | "/api/clusters", 81 | wrappedFetch 82 | ); 83 | 84 | export function notifySuccess(message: string) { 85 | toast["success"](message); 86 | } 87 | 88 | export function notifyError(message: string) { 89 | toast["error"](`Error: ${message}`); 90 | } 91 | -------------------------------------------------------------------------------- /ui/main.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import App from "./App.tsx"; 5 | 6 | const history = createBrowserHistory(); 7 | 8 | // eslint-disable-next-line 9 | ReactDOM.render(, document.getElementById("app")); 10 | // eslint-disable-next-line 11 | if (module.hot) { 12 | // eslint-disable-next-line 13 | module.hot.accept(); 14 | } 15 | -------------------------------------------------------------------------------- /ui/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useHistory } from "react-router"; 3 | import styled from "styled-components"; 4 | import Flex from "../components/Flex"; 5 | import Page from "../components/Page"; 6 | import { useAppState } from "../lib/hooks/app"; 7 | 8 | type Props = { 9 | className?: string; 10 | }; 11 | const Styled = (c) => styled(c)``; 12 | 13 | function Error({ className }: Props) { 14 | const { error } = useAppState(); 15 | 16 | const history = useHistory(); 17 | 18 | React.useEffect(() => { 19 | if (!error) { 20 | history.push("/"); 21 | } 22 | }, [error]); 23 | 24 | return ( 25 |
26 | 27 | 28 |

Error

29 |
30 | 31 |
{error && error.message}
32 |
33 | 34 |
35 |
{error && error.detail.toString()}
36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | export default Styled(Error); 44 | -------------------------------------------------------------------------------- /ui/pages/Events.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | import DataTable from "../components/DataTable"; 5 | import Flex from "../components/Flex"; 6 | import Page from "../components/Page"; 7 | import { useKubernetesContexts } from "../lib/hooks/app"; 8 | import useEvents from "../lib/hooks/events"; 9 | import { Event } from "../lib/rpc/clusters"; 10 | 11 | type Props = { 12 | className?: string; 13 | }; 14 | const Styled = (c) => styled(c)``; 15 | 16 | function Events({ className }: Props) { 17 | const { currentContext } = useKubernetesContexts(); 18 | const { events, loading } = useEvents(currentContext); 19 | 20 | return ( 21 | 22 | 23 |

Events

24 |
25 | { 33 | // timestamps come back in seconds, JS likes milliseconds 34 | const t = new Date(e.timestamp * 1000); 35 | const ts = `${t.toLocaleTimeString()} ${t.toLocaleDateString()}`; 36 | 37 | return ts; 38 | }, 39 | }, 40 | ]} 41 | rows={_.sortBy(events, "timestamp").reverse()} 42 | /> 43 |
44 | ); 45 | } 46 | 47 | export default Styled(Events); 48 | -------------------------------------------------------------------------------- /ui/pages/HelmReleaseDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Breadcrumbs, Button, CircularProgress } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | import ConditionsTable from "../components/ConditionsTable"; 5 | import Flex from "../components/Flex"; 6 | import KeyValueTable from "../components/KeyValueTable"; 7 | import Link from "../components/Link"; 8 | import Page from "../components/Page"; 9 | import Panel from "../components/Panel"; 10 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 11 | import { useHelmReleases } from "../lib/hooks/helm_releases"; 12 | import { SourceType } from "../lib/hooks/sources"; 13 | import { formatURL, PageRoute } from "../lib/util"; 14 | 15 | type Props = { 16 | className?: string; 17 | }; 18 | const Styled = (c) => styled(c)` 19 | ${Panel} { 20 | width: 100%; 21 | &:first-child { 22 | margin-right: 16px; 23 | } 24 | } 25 | 26 | .MuiBox-root { 27 | margin-left: 0 !important; 28 | } 29 | `; 30 | 31 | function HelmReleaseDetail({ className }: Props) { 32 | const [syncing, setSyncing] = React.useState(false); 33 | const { query } = useNavigation(); 34 | const { currentContext, currentNamespace } = useKubernetesContexts(); 35 | const { helmReleases, syncHelmRelease } = useHelmReleases( 36 | currentContext, 37 | currentNamespace 38 | ); 39 | const helmRelease = helmReleases[query.helmReleaseId as string]; 40 | 41 | const handleSyncClicked = () => { 42 | setSyncing(true); 43 | 44 | syncHelmRelease(helmRelease).then(() => { 45 | setSyncing(false); 46 | }); 47 | }; 48 | 49 | if (!helmRelease) { 50 | return null; 51 | } 52 | 53 | return ( 54 | 55 | 56 | 57 | 64 |

Helm Releases

65 | 66 | 67 |

{helmRelease.name}

68 |
69 |
70 | 78 |
79 | 80 | 81 | 82 | 83 | 91 | 92 | 93 | 108 | {helmRelease.sourcename} 109 | , 110 | "Source", 111 | ], 112 | }} 113 | pairs={[ 114 | { key: "Name", value: helmRelease.sourcename }, 115 | { key: "Kind", value: helmRelease.sourcekind }, 116 | { key: "Namespace", value: helmRelease.sourcenamespace }, 117 | ]} 118 | /> 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
128 | ); 129 | } 130 | 131 | export default Styled(HelmReleaseDetail); 132 | -------------------------------------------------------------------------------- /ui/pages/HelmReleases.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | } from "@material-ui/core"; 9 | import _ from "lodash"; 10 | import * as React from "react"; 11 | import styled from "styled-components"; 12 | import Link from "../components/Link"; 13 | import Page from "../components/Page"; 14 | import { useKubernetesContexts } from "../lib/hooks/app"; 15 | import { useHelmReleases } from "../lib/hooks/helm_releases"; 16 | import { HelmRelease } from "../lib/rpc/clusters"; 17 | import { formatURL, PageRoute } from "../lib/util"; 18 | 19 | type Props = { 20 | className?: string; 21 | }; 22 | const Styled = (c) => styled(c)``; 23 | 24 | function HelmRelease({ className }: Props) { 25 | const { currentContext, currentNamespace } = useKubernetesContexts(); 26 | const { helmReleases } = useHelmReleases(currentContext, currentNamespace); 27 | 28 | const fields: { 29 | value: string | ((w: any) => JSX.Element); 30 | label: string; 31 | }[] = [ 32 | { 33 | value: (h: HelmRelease) => ( 34 | 42 | {h.name} 43 | 44 | ), 45 | label: "Name", 46 | }, 47 | { 48 | value: "chartname", 49 | label: "Chart", 50 | }, 51 | { 52 | value: "version", 53 | label: "Version", 54 | }, 55 | ]; 56 | 57 | const rows = _.map(helmReleases, (k) => ( 58 | 59 | {_.map(fields, (f) => ( 60 | 61 | {typeof f.value === "function" ? f.value(k) : k[f.value]} 62 | 63 | ))} 64 | 65 | )); 66 | 67 | return ( 68 | 69 |

Helm Releases

70 | 71 | 72 | 73 | 74 | {_.map(fields, (f) => ( 75 | {f.label} 76 | ))} 77 | 78 | 79 | 80 | {rows.length > 0 ? ( 81 | rows 82 | ) : ( 83 | 84 | 85 | No rows 86 | 87 | 88 | )} 89 | 90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | export default Styled(HelmRelease); 97 | -------------------------------------------------------------------------------- /ui/pages/KustomizationDetail.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Breadcrumbs, 4 | Button, 5 | CircularProgress, 6 | Tab, 7 | Table, 8 | TableBody, 9 | TableCell, 10 | TableContainer, 11 | TableHead, 12 | TableRow, 13 | } from "@material-ui/core"; 14 | import { TabContext, TabList, TabPanel } from "@material-ui/lab"; 15 | import _ from "lodash"; 16 | import * as React from "react"; 17 | import styled from "styled-components"; 18 | import DataTable from "../components/DataTable"; 19 | import Flex from "../components/Flex"; 20 | import KeyValueTable from "../components/KeyValueTable"; 21 | import Link from "../components/Link"; 22 | import Page from "../components/Page"; 23 | import Panel from "../components/Panel"; 24 | import ReconciliationGraph from "../components/ReconciliationGraph"; 25 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 26 | import { 27 | PARENT_CHILD_LOOKUP, 28 | UnstructuredObjectWithParent, 29 | useKustomizations, 30 | } from "../lib/hooks/kustomizations"; 31 | import { Kustomization } from "../lib/rpc/clusters"; 32 | import { formatURL, PageRoute } from "../lib/util"; 33 | 34 | type Props = { 35 | className?: string; 36 | }; 37 | 38 | const infoFields = [ 39 | "sourceref", 40 | "namespace", 41 | "path", 42 | "interval", 43 | "prune", 44 | "lastappliedrevision", 45 | ]; 46 | 47 | const formatInfo = (detail: Kustomization) => 48 | _.map(_.pick(detail, infoFields), (v, k) => ({ 49 | key: k, 50 | value: typeof v === "string" ? v : v.toString(), 51 | })); 52 | 53 | function KustomizationDetail({ className }: Props) { 54 | const [syncing, setSyncing] = React.useState(false); 55 | const [selectedTab, setSelectedTab] = React.useState("graph"); 56 | const [reconciledObjects, setReconciledObjects] = React.useState< 57 | UnstructuredObjectWithParent[] 58 | >([]); 59 | const { query } = useNavigation(); 60 | const { currentContext, currentNamespace } = useKubernetesContexts(); 61 | 62 | const { 63 | kustomizations, 64 | syncKustomization, 65 | getReconciledObjects, 66 | getChildrenRecursive, 67 | } = useKustomizations(currentContext, currentNamespace); 68 | const kustomizationDetail = kustomizations[query.kustomizationId as string]; 69 | 70 | const handleSyncClicked = () => { 71 | setSyncing(true); 72 | 73 | syncKustomization(kustomizationDetail).then(() => { 74 | setSyncing(false); 75 | }); 76 | }; 77 | 78 | React.useEffect(() => { 79 | if (!kustomizationDetail) { 80 | return; 81 | } 82 | 83 | const { snapshots, name, namespace } = kustomizationDetail; 84 | 85 | const kinds = _.reduce( 86 | snapshots, 87 | (r, e) => { 88 | r = [...r, ...e.kinds]; 89 | return r; 90 | }, 91 | [] 92 | ); 93 | 94 | const uniq = _.uniqBy(kinds, "kind"); 95 | 96 | const getChildren = async () => { 97 | const { objects } = await getReconciledObjects(name, namespace, uniq); 98 | 99 | const result = []; 100 | for (let o = 0; o < objects.length; o++) { 101 | const obj = objects[o]; 102 | 103 | await getChildrenRecursive(result, obj, PARENT_CHILD_LOOKUP); 104 | } 105 | 106 | setReconciledObjects(_.flatten(result)); 107 | }; 108 | 109 | let timeout; 110 | // function poll() { 111 | // timeout = setTimeout(async () => { 112 | // // Polling will stop if this errors. 113 | // // Also prevents sending a request before the previous request finishes. 114 | // await getChildren(); 115 | // poll(); 116 | // }, 5000); 117 | // } 118 | 119 | // Get children now, to avoid waiting for the first poll() setTimeout. 120 | getChildren(); 121 | // Turn off polling for now. 122 | // The idea was to "live-update" the visualization, 123 | // but there are issues with the zoom causing weirdness with the 124 | // foreignObject html scaling when the graph re-renders. 125 | // poll(); 126 | 127 | // Stop polling when the component unmounts 128 | return () => clearTimeout(timeout); 129 | }, [kustomizationDetail]); 130 | 131 | if (!kustomizationDetail) { 132 | return null; 133 | } 134 | 135 | const overrides = { 136 | sourceref: [ 137 | 148 | {kustomizationDetail.sourceref} 149 | , 150 | "Source", 151 | ], 152 | lastappliedrevision: [ 153 | kustomizationDetail.lastappliedrevision, 154 | "Applied Revision", 155 | ], 156 | }; 157 | 158 | return ( 159 | 160 | 161 | 162 | 169 |

Kustomizations

170 | 171 | 172 |

{kustomizationDetail.name}

173 |
174 |
175 | 183 |
184 | 185 | 186 | 187 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | Type 202 | Status 203 | Timestamp 204 | Reason 205 | Message 206 | 207 | 208 | 209 | {_.map(kustomizationDetail.conditions, (c, i) => ( 210 | 211 | {c.type} 212 | {c.status} 213 | 214 | {new Date(c.timestamp).toLocaleDateString()} 215 | 216 | {c.reason} 217 | {c.message} 218 | 219 | ))} 220 | 221 |
222 |
223 |
224 |
225 | 226 | 227 | 228 | setSelectedTab(i)}> 229 | 230 | 231 | 232 | 233 | 238 | 239 | 240 | v.groupversionkind.kind }, 244 | { label: "Name", value: "name" }, 245 | { label: "Namespace", value: "namespace" }, 246 | { label: "Status", value: "status" }, 247 | ]} 248 | rows={reconciledObjects} 249 | /> 250 | 251 | 252 | 253 | 254 |
255 | ); 256 | } 257 | 258 | export default styled(KustomizationDetail)` 259 | .MuiBreadcrumbs-root { 260 | width: 100%; 261 | } 262 | `; 263 | -------------------------------------------------------------------------------- /ui/pages/Kustomizations.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import * as React from "react"; 3 | import styled from "styled-components"; 4 | import DataTable from "../components/DataTable"; 5 | import Link from "../components/Link"; 6 | import Page from "../components/Page"; 7 | import { useKubernetesContexts } from "../lib/hooks/app"; 8 | import { useKustomizations } from "../lib/hooks/kustomizations"; 9 | import { Kustomization } from "../lib/rpc/clusters"; 10 | import { formatURL, PageRoute } from "../lib/util"; 11 | 12 | type Props = { 13 | className?: string; 14 | }; 15 | const Styled = (c) => styled(c)``; 16 | 17 | function Kustomizations({ className }: Props) { 18 | const { currentContext, currentNamespace } = useKubernetesContexts(); 19 | const { kustomizations, loading } = useKustomizations( 20 | currentContext, 21 | currentNamespace 22 | ); 23 | 24 | const fields: { 25 | value: string | ((v: any) => JSX.Element | string); 26 | label: string; 27 | }[] = [ 28 | { 29 | value: (k: Kustomization) => ( 30 | 38 | {k.name} 39 | 40 | ), 41 | label: "Name", 42 | }, 43 | { 44 | label: "Ready", 45 | value: (k: Kustomization) => { 46 | const readyCondition = _.find(k.conditions, (c) => c.type === "Ready"); 47 | if (readyCondition) { 48 | return readyCondition.status; 49 | } 50 | }, 51 | }, 52 | { 53 | label: "Message", 54 | value: (k: Kustomization) => { 55 | const readyCondition = _.find(k.conditions, (c) => c.type === "Ready"); 56 | 57 | if (readyCondition && readyCondition.status === "False") { 58 | return readyCondition.message; 59 | } 60 | }, 61 | }, 62 | ]; 63 | 64 | return ( 65 | 66 |

Kustomizations

67 | 68 |
69 | ); 70 | } 71 | 72 | export default Styled(Kustomizations); 73 | -------------------------------------------------------------------------------- /ui/pages/Redirector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { AppContext } from "../components/AppStateProvider"; 3 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 4 | import { AllNamespacesOption } from "../lib/types"; 5 | import { clustersClient, PageRoute } from "../lib/util"; 6 | 7 | export default function Redirector() { 8 | const { doAsyncError, setContexts } = React.useContext(AppContext); 9 | const { currentContext, currentNamespace } = useKubernetesContexts(); 10 | const { navigate } = useNavigation(); 11 | 12 | React.useEffect(() => { 13 | if (currentContext) { 14 | navigate(PageRoute.Home, currentContext, currentNamespace); 15 | return; 16 | } 17 | 18 | // Runs once on app startup. 19 | clustersClient.listContexts({}).then( 20 | (res) => { 21 | setContexts(res.contexts); 22 | 23 | navigate( 24 | PageRoute.Home, 25 | res.currentcontext, 26 | currentNamespace || AllNamespacesOption 27 | ); 28 | }, 29 | (err) => { 30 | doAsyncError("Error getting contexts", true, err); 31 | } 32 | ); 33 | }, []); 34 | 35 | return null; 36 | } 37 | -------------------------------------------------------------------------------- /ui/pages/SourceDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Breadcrumbs, Button, CircularProgress } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import * as React from "react"; 4 | import styled from "styled-components"; 5 | import ConditionsTable from "../components/ConditionsTable"; 6 | import Flex from "../components/Flex"; 7 | import KeyValueTable from "../components/KeyValueTable"; 8 | import Link from "../components/Link"; 9 | import Page from "../components/Page"; 10 | import Panel from "../components/Panel"; 11 | import { useKubernetesContexts, useNavigation } from "../lib/hooks/app"; 12 | import { useSources } from "../lib/hooks/sources"; 13 | import { Source } from "../lib/rpc/clusters"; 14 | import { formatURL, PageRoute } from "../lib/util"; 15 | 16 | type Props = { 17 | className?: string; 18 | }; 19 | 20 | function isHTTP(uri) { 21 | return uri.includes("http") || uri.includes("https"); 22 | } 23 | 24 | function convertRefURLToGitProvider(uri: string) { 25 | if (isHTTP(uri)) { 26 | return uri; 27 | } 28 | 29 | const [, provider, org, repo] = uri.match(/git@(.*)\/(.*)\/(.*)/); 30 | 31 | return `https://${provider}/${org}/${repo}`; 32 | } 33 | 34 | const LayoutBox = styled(Box)` 35 | width: 100%; 36 | 37 | /* Override more specific MUI rules */ 38 | margin-right: 0 !important; 39 | margin-left: 0 !important; 40 | `; 41 | 42 | const Styled = (c) => styled(c)``; 43 | 44 | function SourceDetail({ className }: Props) { 45 | const [syncing, setSyncing] = React.useState(false); 46 | const { query } = useNavigation(); 47 | const { sourceType, sourceId } = query; 48 | const { currentContext, currentNamespace } = useKubernetesContexts(); 49 | const { sources, syncSource } = useSources(currentContext, currentNamespace); 50 | 51 | const sourceDetail: Source = _.find(sources[sourceType as string], { 52 | name: sourceId, 53 | }); 54 | 55 | if (!sourceDetail) { 56 | return null; 57 | } 58 | 59 | const providerUrl = sourceDetail.reference ? ( 60 | 61 | {sourceDetail.url} 62 | 63 | ) : ( 64 | sourceDetail.url 65 | ); 66 | 67 | const handleSyncClicked = () => { 68 | setSyncing(true); 69 | 70 | syncSource(sourceDetail).then(() => { 71 | setSyncing(false); 72 | }); 73 | }; 74 | 75 | return ( 76 | 77 | 78 | 79 | 82 |

Sources

83 | 84 | 85 |

{sourceDetail.name}

86 |
87 |
88 | 96 |
97 | 98 | 99 | 100 | 111 | 112 | 113 | 114 | {sourceDetail.reference && ( 115 | 116 | 117 | 138 | 139 | 140 | )} 141 | 142 | 143 | 144 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 |
166 | ); 167 | } 168 | 169 | export default Styled(SourceDetail); 170 | -------------------------------------------------------------------------------- /ui/pages/Sources.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@material-ui/core"; 2 | import _ from "lodash"; 3 | import * as React from "react"; 4 | import styled from "styled-components"; 5 | import Link from "../components/Link"; 6 | import LoadingPage from "../components/LoadingPage"; 7 | import Page from "../components/Page"; 8 | import Panel from "../components/Panel"; 9 | import { useKubernetesContexts } from "../lib/hooks/app"; 10 | import { SourceType, useSources } from "../lib/hooks/sources"; 11 | import { formatURL, PageRoute } from "../lib/util"; 12 | 13 | type Props = { 14 | className?: string; 15 | }; 16 | const Styled = (c) => styled(c)` 17 | ul { 18 | list-style: none; 19 | padding-left: 0; 20 | } 21 | 22 | .MuiBox-root { 23 | margin-left: 0; 24 | } 25 | 26 | .MuiCardContent-root { 27 | padding-bottom: 16px !important; 28 | } 29 | `; 30 | 31 | const sections = [ 32 | { value: SourceType.Git, label: "Git Repos" }, 33 | { value: SourceType.Bucket, label: "Buckets" }, 34 | { value: SourceType.Helm, label: "Helm Repos" }, 35 | ]; 36 | 37 | function Sources({ className }: Props) { 38 | const { currentContext, currentNamespace } = useKubernetesContexts(); 39 | const { sources, loading } = useSources(currentContext, currentNamespace); 40 | 41 | if (loading) { 42 | return ; 43 | } 44 | 45 | return ( 46 | 47 |

Sources

48 |
49 | {_.map(sections, (t) => ( 50 | 51 | 52 |
    53 | {_.map(sources[t.value], (s) => ( 54 |
  • 55 | 64 | {s.name} 65 | 66 |
  • 67 | ))} 68 |
69 |
70 |
71 | ))} 72 |
73 |
74 | ); 75 | } 76 | 77 | export default Styled(Sources); 78 | -------------------------------------------------------------------------------- /ui/pages/__tests__/KustomizationDetail.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { stubbedClustersResponses, withContext } from "../../lib/test-utils"; 3 | import KustomizationDetail from "../KustomizationDetail"; 4 | 5 | // https://github.com/mui-org/material-ui/issues/21293 6 | // MUI relies on Math.random() to generate IDs. 7 | // Mock it to create deterministic tests. 8 | const mockMath = Object.create(global.Math); 9 | mockMath.random = () => 0.5; 10 | global.Math = mockMath; 11 | 12 | describe("KustomizationDetail", () => { 13 | let container; 14 | beforeEach(() => { 15 | container = document.createElement("div"); 16 | document.body.appendChild(container); 17 | }); 18 | afterEach(() => { 19 | document.body.removeChild(container); 20 | container = null; 21 | }); 22 | it("renders", async () => { 23 | const url = 24 | "/?context=my-context&namespace=default&kustomizationId=my-kustomization"; 25 | const tree = render( 26 | withContext(KustomizationDetail, url, stubbedClustersResponses), 27 | container 28 | ); 29 | 30 | expect( 31 | (await screen.findByText("my-kustomization")).textContent 32 | ).toBeTruthy(); 33 | expect(tree).toMatchSnapshot(); 34 | }); 35 | }); 36 | --------------------------------------------------------------------------------