├── ui ├── .prettierignore ├── __mocks__ │ ├── styleMock.js │ └── fileMock.js ├── .storybook │ ├── tsconfig.json │ ├── preview.ts │ ├── main.ts │ └── webpack.config.js ├── .prettierrc ├── print-version.js ├── src │ ├── favicon.png │ ├── app │ │ ├── images │ │ │ └── altinity_horizontal_logo.png │ │ ├── __snapshots__ │ │ │ └── app.test.tsx.snap │ │ ├── utils │ │ │ ├── utils.ts │ │ │ ├── alertContext.tsx │ │ │ ├── useDocumentTitle.tsx │ │ │ ├── humanFileSize.tsx │ │ │ └── fetchWithErrorHandling.tsx │ │ ├── Devel │ │ │ └── Devel.tsx │ │ ├── app.css │ │ ├── CHIs │ │ │ ├── model.tsx │ │ │ ├── CHIModal.tsx │ │ │ └── CHIs.tsx │ │ ├── NotFound │ │ │ └── NotFound.tsx │ │ ├── Namespaces │ │ │ └── NamespaceSelector.tsx │ │ ├── Components │ │ │ ├── Loading.tsx │ │ │ ├── SimpleModal.tsx │ │ │ ├── ToggleModal.tsx │ │ │ ├── ListSelector.tsx │ │ │ ├── StringHasher.tsx │ │ │ └── ExpandableTable.tsx │ │ ├── index.tsx │ │ ├── app.test.tsx │ │ ├── AppLayout │ │ │ └── AppLayout.tsx │ │ ├── Operators │ │ │ ├── NewOperatorModal.tsx │ │ │ └── Operators.tsx │ │ ├── routes.tsx │ │ └── Dashboard │ │ │ └── Dashboard.tsx │ ├── typings.d.ts │ ├── index.tsx │ └── index.html ├── .gitignore ├── test-setup.js ├── .husky │ └── pre-commit ├── .editorconfig ├── stylePaths.js ├── webpack.dev.js ├── tsconfig.json ├── LICENSE-PatternFly ├── webpack.prod.js ├── jest.config.js ├── .eslintrc ├── package.json └── webpack.common.js ├── .gitignore ├── .dockerignore ├── .gitmodules ├── internal ├── utils │ ├── embed.go │ ├── yaml.go │ ├── bind_addr.go │ └── k8s.go ├── api │ ├── api.go │ ├── dashboard.go │ ├── namespace.go │ ├── api_model.go │ ├── api_k8s.go │ ├── operator.go │ └── chi.go ├── server │ ├── auth_handler.go │ └── server.go └── certs │ └── certgen.go ├── .github ├── dependabot.yml └── workflows │ ├── dependabot.yml │ ├── ci.yml │ └── release.yml ├── Dockerfile ├── adash-incluster.yaml ├── .golangci.yml ├── Makefile ├── go.mod ├── adash.go ├── README.md └── LICENSE /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /ui/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /adash 3 | /bin/* 4 | /embed/* 5 | -------------------------------------------------------------------------------- /ui/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /ui/.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /ui/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import '@patternfly/react-core/dist/styles/base.css'; 2 | -------------------------------------------------------------------------------- /ui/print-version.js: -------------------------------------------------------------------------------- 1 | var pjson = require('./package.json'); 2 | console.log(pjson.version); 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # Allow needed files 5 | !Dockerfile 6 | !adash 7 | -------------------------------------------------------------------------------- /ui/src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Altinity/altinity-dashboard/HEAD/ui/src/favicon.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "clickhouse-operator"] 2 | path = clickhouse-operator 3 | url = https://github.com/altinity/clickhouse-operator 4 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | dist 3 | yarn-error.log 4 | stats.json 5 | coverage 6 | storybook-static 7 | .DS_Store 8 | .idea 9 | .env 10 | -------------------------------------------------------------------------------- /ui/src/app/images/altinity_horizontal_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Altinity/altinity-dashboard/HEAD/ui/src/app/images/altinity_horizontal_logo.png -------------------------------------------------------------------------------- /ui/test-setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import ReactSeventeenAdapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | 4 | configure({ adapter: new ReactSeventeenAdapter() }); 5 | -------------------------------------------------------------------------------- /ui/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # Run Go linter 5 | golangci-lint run 6 | 7 | # Run Javascript/Typescript linter 8 | cd ui && npx eslint --max-warnings=0 src/ 9 | -------------------------------------------------------------------------------- /ui/src/app/__snapshots__/app.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App tests should render default App component 1`] = ` 4 | 5 | 6 | 7 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /ui/src/app/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function accessibleRouteChangeHandler(): number { 2 | return window.setTimeout(() => { 3 | const mainContainer = document.getElementById('primary-app-container'); 4 | if (mainContainer) { 5 | mainContainer.focus(); 6 | } 7 | }, 50); 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.jpg'; 3 | declare module '*.jpeg'; 4 | declare module '*.gif'; 5 | declare module '*.svg'; 6 | declare module '*.css'; 7 | declare module '*.wav'; 8 | declare module '*.mp3'; 9 | declare module '*.m4a'; 10 | declare module '*.rdf'; 11 | declare module '*.ttl'; 12 | declare module '*.pdf'; 13 | -------------------------------------------------------------------------------- /ui/src/app/utils/alertContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AlertVariant } from '@patternfly/react-core'; 3 | 4 | export type AddAlertType = (title: string, variant: AlertVariant) => void 5 | export const AddAlertContext = React.createContext(() => {return undefined}) 6 | export const AddAlertContextProvider = AddAlertContext.Provider 7 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.snap] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /ui/src/app/utils/useDocumentTitle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // a custom hook for setting the page title 4 | export function useDocumentTitle(title: string): void { 5 | React.useEffect(() => { 6 | const originalTitle = document.title; 7 | document.title = title; 8 | 9 | return () => { 10 | document.title = originalTitle; 11 | }; 12 | }, [title]); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/app/Devel/Devel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PageSection, Title } from '@patternfly/react-core'; 3 | import SwaggerUI from "swagger-ui-react" 4 | import "swagger-ui-react/swagger-ui.css"; 5 | 6 | export const Devel: React.FunctionComponent = () => ( 7 | 8 | Developer Tools 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /ui/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | // https://storybook.js.org/blog/storybook-for-webpack-5/ 2 | module.exports = { 3 | // https://gist.github.com/shilman/8856ea1786dcd247139b47b270912324#upgrade 4 | core: { 5 | builder: "webpack5", 6 | }, 7 | stories: ['../stories/*.stories.tsx'], 8 | addons: [ 9 | '@storybook/addon-knobs', 10 | ], 11 | typescript: { 12 | check: false, 13 | checkOptions: {}, 14 | reactDocgen: 'react-docgen-typescript' 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /internal/utils/embed.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type FileToString struct { 10 | Filename string 11 | Dest *string 12 | } 13 | 14 | func ReadFilesToStrings(fs *embed.FS, reqs []FileToString) error { 15 | for _, fts := range reqs { 16 | fileData, err := fs.ReadFile(fts.Filename) 17 | if err != nil { 18 | return fmt.Errorf("error reading %s: %w", fts.Filename, err) 19 | } 20 | *fts.Dest = strings.TrimSpace(string(fileData)) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from '@app/index'; 4 | 5 | if (process.env.NODE_ENV !== "production") { 6 | const config = { 7 | rules: [ 8 | { 9 | id: 'color-contrast', 10 | enabled: false 11 | } 12 | ] 13 | }; 14 | // eslint-disable-next-line @typescript-eslint/no-var-requires, no-undef 15 | const axe = require("react-axe"); 16 | axe(React, ReactDOM, 1000, config).then(()=>null) 17 | } 18 | 19 | ReactDOM.render(, document.getElementById("root") as HTMLElement); 20 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "embed" 5 | "github.com/emicklei/go-restful/v3" 6 | "log" 7 | ) 8 | 9 | type WebServiceInfo struct { 10 | Version string 11 | ChopRelease string 12 | Embed *embed.FS 13 | } 14 | 15 | type WebService interface { 16 | Name() string 17 | WebService(*WebServiceInfo) (*restful.WebService, error) 18 | } 19 | 20 | var ErrorsToConsole bool 21 | 22 | func webError(response *restful.Response, status int, err error) { 23 | if ErrorsToConsole { 24 | log.Printf("%s\n", err) 25 | } 26 | _ = response.WriteError(status, err) 27 | } 28 | -------------------------------------------------------------------------------- /internal/utils/yaml.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | utilyaml "k8s.io/apimachinery/pkg/util/yaml" 8 | "strings" 9 | ) 10 | 11 | func SplitYAMLDocs(yaml string) ([]string, error) { 12 | multiDocReader := utilyaml.NewYAMLReader(bufio.NewReader(strings.NewReader(yaml))) 13 | yamlDocs := make([]string, 0) 14 | for { 15 | yd, err := multiDocReader.Read() 16 | if err != nil { 17 | if errors.Is(err, io.EOF) { 18 | break 19 | } else { 20 | return nil, err 21 | } 22 | } 23 | yamlDocs = append(yamlDocs, string(yd)) 24 | } 25 | return yamlDocs, nil 26 | } 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | ignore: 8 | - dependency-name: "clickhouse-operator" 9 | - dependency-name: "github.com/emicklei/go-restful/v3" 10 | versions: ["3.10.0"] 11 | - package-ecosystem: "npm" 12 | directory: "/ui" 13 | schedule: 14 | interval: "daily" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | # - package-ecosystem: "gitsubmodule" 20 | # directory: "/" 21 | # schedule: 22 | # interval: "daily" 23 | -------------------------------------------------------------------------------- /ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Altinity Dashboard 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /ui/src/app/utils/humanFileSize.tsx: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string/10420404 2 | 3 | export function humanFileSize(bytes, si=false, dp=1) { 4 | const thresh = si ? 1000 : 1024; 5 | 6 | if (Math.abs(bytes) < thresh) { 7 | return bytes + ' B'; 8 | } 9 | 10 | const units = si 11 | ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 12 | : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; 13 | let u = -1; 14 | const r = 10**dp; 15 | 16 | do { 17 | bytes /= thresh; 18 | ++u; 19 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); 20 | 21 | return bytes.toFixed(dp) + ' ' + units[u]; 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/app/app.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | } 4 | .wide-context-selector .pf-c-context-selector { 5 | --pf-c-context-selector--Width: 30rem 6 | } 7 | .table-no-extra-padding { 8 | --pf-c-table--nested--first-last-child--PaddingRight: var(--pf-global--spacer--md); 9 | --pf-c-table--nested--first-last-child--PaddingLeft: var(--pf-global--spacer--md); 10 | } 11 | .table-row-with-warning { 12 | --pf-c-table--border-width--base: 0; 13 | } 14 | .pf-c-table tr > .table-row-with-warning { 15 | padding-bottom: 0; 16 | } 17 | .pf-c-table tr > .table-warning-row { 18 | padding-top: 0; 19 | /*padding: 0 var(--pf-c-table--cell--PaddingRight) var(--pf-c-table--cell--PaddingBottom) var(--pf-c-table--cell--PaddingLeft);*/ 20 | } 21 | .padded-bullseye { 22 | --pf-l-bullseye--Padding: 1rem; 23 | } 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.access.redhat.com/ubi8/ubi-minimal:latest 2 | 3 | COPY adash /bin/adash 4 | 5 | EXPOSE 8080/tcp 6 | 7 | # Override a bunch of Red Hat labels set by ubi8 base image 8 | LABEL summary="Altinity Dashboard helps you manage ClickHouse installations controlled by clickhouse-operator." 9 | LABEL name="altinity-dashboard" 10 | LABEL url="https://github.com/altinity/altinity-dashboard" 11 | LABEL maintainer="Altinity, Inc." 12 | LABEL vendor="Altinity, Inc." 13 | LABEL version="" 14 | LABEL description="Altinity Dashboard helps you manage ClickHouse installations controlled by clickhouse-operator." 15 | LABEL io.k8s.display-name="Altinity Dashboard" 16 | LABEL io.k8s.description="Altinity Dashboard helps you manage ClickHouse installations controlled by clickhouse-operator." 17 | 18 | CMD ["/bin/adash"] 19 | -------------------------------------------------------------------------------- /ui/stylePaths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | stylePaths: [ 4 | path.resolve(__dirname, 'src'), 5 | path.resolve(__dirname, 'node_modules/patternfly'), 6 | path.resolve(__dirname, 'node_modules/@patternfly/patternfly'), 7 | path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css'), 8 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/base.css'), 9 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/esm/@patternfly/patternfly'), 10 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css'), 11 | path.resolve(__dirname, 'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css'), 12 | path.resolve(__dirname, 'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css') 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /ui/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require("webpack-merge"); 3 | const common = require("./webpack.common.js"); 4 | const { stylePaths } = require("./stylePaths"); 5 | const HOST = process.env.HOST || "localhost"; 6 | const PORT = process.env.PORT || "9090"; 7 | 8 | module.exports = merge(common('development'), { 9 | mode: "development", 10 | devtool: "eval-source-map", 11 | devServer: { 12 | host: HOST, 13 | port: PORT, 14 | compress: true, 15 | historyApiFallback: true, 16 | open: true, 17 | proxy: { 18 | '/api': 'http://localhost:8080', 19 | '/chi-examples': 'http://localhost:8080', 20 | }, 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.css$/, 26 | // include: [ 27 | // ...stylePaths 28 | // ], 29 | use: ["style-loader", "css-loader"] 30 | } 31 | ] 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "rootDir": ".", 5 | "outDir": "dist", 6 | "module": "esnext", 7 | "target": "es5", 8 | "lib": ["es6", "dom"], 9 | "sourceMap": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": false, 16 | "allowJs": true, 17 | "esModuleInterop": true, 18 | "allowSyntheticDefaultImports": true, 19 | "strict": true, 20 | "paths": { 21 | "@app/*": ["src/app/*"], 22 | "@assets/*": ["node_modules/@patternfly/react-core/dist/styles/assets/*"] 23 | }, 24 | "importHelpers": true, 25 | "skipLibCheck": true 26 | }, 27 | "include": [ 28 | "**/*.ts", 29 | "**/*.tsx", 30 | "**/*.jsx", 31 | "**/*.js" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/app/CHIs/model.tsx: -------------------------------------------------------------------------------- 1 | export interface Container { 2 | name: string 3 | state: string 4 | image: string 5 | } 6 | 7 | export interface PersistentVolume { 8 | name: string 9 | phase: string 10 | storage_class: string 11 | capacity: number 12 | reclaim_policy: string 13 | } 14 | 15 | export interface PersistentVolumeClaim { 16 | name: string 17 | namespace: string 18 | phase: string 19 | storage_class: string 20 | capacity: number 21 | bound_pv: PersistentVolume|undefined 22 | } 23 | 24 | export interface CHClusterPod { 25 | cluster_name: string 26 | name: string 27 | status: string 28 | containers: Array 29 | pvcs: Array 30 | } 31 | 32 | export interface CHI { 33 | name: string 34 | namespace: string 35 | status: string 36 | clusters: bigint 37 | hosts: bigint 38 | external_url: string 39 | resource_yaml: string 40 | ch_cluster_pods: Array 41 | } 42 | -------------------------------------------------------------------------------- /adash-incluster.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: Deployment 3 | apiVersion: apps/v1 4 | metadata: 5 | name: altinity-dashboard 6 | labels: 7 | app: altinity-dashboard 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: altinity-dashboard 13 | template: 14 | metadata: 15 | labels: 16 | app: altinity-dashboard 17 | spec: 18 | containers: 19 | - name: altinity-dashboard 20 | image: ghcr.io/altinity/altinity-dashboard:main 21 | imagePullPolicy: Always 22 | args: ["adash", "--notoken", "--debug", "--bindhost", "0.0.0.0"] 23 | ports: 24 | - containerPort: 8080 25 | --- 26 | kind: Service 27 | apiVersion: v1 28 | metadata: 29 | name: altinity-dashboard 30 | labels: 31 | app: altinity-dashboard 32 | spec: 33 | type: NodePort 34 | ports: 35 | - port: 8080 36 | name: altinity-dashboard 37 | protocol: TCP 38 | selector: 39 | app: altinity-dashboard 40 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | 3 | on: 4 | pull_request_target: 5 | 6 | permissions: 7 | pull-requests: write 8 | contents: write 9 | 10 | jobs: 11 | dependabot-auto-merge: 12 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Dependabot metadata 17 | id: dependabot-metadata 18 | uses: dependabot/fetch-metadata@v2.2.0 19 | 20 | - name: Enable auto-merge for Dependabot PRs 21 | run: gh pr merge --auto --rebase "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | 26 | - name: Approve Dependabot PRs 27 | run: gh pr review $PR_URL --approve -b "Auto-approval of Dependabot PRs" 28 | env: 29 | PR_URL: ${{github.event.pull_request.html_url}} 30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 31 | 32 | -------------------------------------------------------------------------------- /ui/.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 3 | const appConfig = require('../webpack.common'); 4 | const { stylePaths } = require("../stylePaths"); 5 | 6 | module.exports = ({ config, mode }) => { 7 | config.module.rules = []; 8 | config.module.rules.push(...appConfig(mode).module.rules); 9 | config.module.rules.push({ 10 | test: /\.css$/, 11 | include: [ 12 | path.resolve(__dirname, '../node_modules/@storybook'), 13 | ...stylePaths 14 | ], 15 | use: ["style-loader", "css-loader"] 16 | }); 17 | config.module.rules.push({ 18 | test: /\.tsx?$/, 19 | include: path.resolve(__dirname, '../src'), 20 | use: [ 21 | require.resolve('react-docgen-typescript-loader'), 22 | ], 23 | }) 24 | config.resolve.plugins = [ 25 | new TsconfigPathsPlugin({ 26 | configFile: path.resolve(__dirname, "../tsconfig.json") 27 | }) 28 | ]; 29 | config.resolve.extensions.push('.ts', '.tsx'); 30 | return config; 31 | }; 32 | -------------------------------------------------------------------------------- /ui/src/app/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ExclamationTriangleIcon } from '@patternfly/react-icons'; 3 | import { 4 | PageSection, 5 | Title, 6 | Button, 7 | EmptyState, 8 | EmptyStateIcon, 9 | EmptyStateBody, 10 | } from '@patternfly/react-core'; 11 | import { useHistory } from 'react-router-dom'; 12 | 13 | export const NotFound: React.FunctionComponent = () => { 14 | function GoHomeBtn() { 15 | const history = useHistory(); 16 | function handleClick() { 17 | history.push('/'); 18 | } 19 | return ( 20 | 21 | ); 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 404 Page not found 30 | 31 | 32 | We didn't find a page that matches the address you navigated to. 33 | 34 | 35 | 36 | 37 | ) 38 | }; 39 | -------------------------------------------------------------------------------- /internal/server/auth_handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "net/http" 4 | 5 | type Handler struct { 6 | authToken string 7 | isHTTPS bool 8 | origHandler http.Handler 9 | } 10 | 11 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 12 | q := r.URL.Query() 13 | tokReq := q.Get("token") 14 | if tokReq != "" { 15 | http.SetCookie(w, &http.Cookie{ 16 | Name: "token", 17 | Value: tokReq, 18 | Secure: h.isHTTPS, 19 | HttpOnly: true, 20 | SameSite: http.SameSiteStrictMode, 21 | }) 22 | u := r.URL 23 | q.Del("token") 24 | u.RawQuery = q.Encode() 25 | http.Redirect(w, r, u.String(), http.StatusFound) 26 | return 27 | } 28 | c, err := r.Cookie("token") 29 | if err != nil || c.Value != h.authToken { 30 | w.WriteHeader(401) 31 | _, _ = w.Write([]byte("Unauthorized")) 32 | return 33 | } 34 | h.origHandler.ServeHTTP(w, r) 35 | } 36 | 37 | func NewHandler(origHandler http.Handler, authToken string, isHTTPS bool) http.Handler { 38 | return &Handler{ 39 | authToken: authToken, 40 | isHTTPS: isHTTPS, 41 | origHandler: origHandler, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ui/LICENSE-PatternFly: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Red Hat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ui/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | const { stylePaths } = require("./stylePaths"); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 7 | const TerserJSPlugin = require('terser-webpack-plugin'); 8 | 9 | module.exports = merge(common('production'), { 10 | mode: 'production', 11 | devtool: 'source-map', 12 | optimization: { 13 | minimizer: [ 14 | new TerserJSPlugin({}), 15 | new CssMinimizerPlugin({ 16 | minimizerOptions: { 17 | preset: ['default', { mergeLonghand: false }] 18 | } 19 | }) 20 | ], 21 | }, 22 | plugins: [ 23 | new MiniCssExtractPlugin({ 24 | filename: '[name].css', 25 | chunkFilename: '[name].bundle.css' 26 | }) 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.css$/, 32 | // include: [ 33 | // ...stylePaths 34 | // ], 35 | use: [MiniCssExtractPlugin.loader, 'css-loader'] 36 | } 37 | ] 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /ui/src/app/Namespaces/NamespaceSelector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 4 | import { ListSelector } from '@app/Components/ListSelector'; 5 | 6 | export interface Namespace { 7 | name: string 8 | } 9 | 10 | export const NamespaceSelector: React.FunctionComponent< 11 | { 12 | onSelect?: (selected: string) => void 13 | }> = (props) => { 14 | 15 | const [namespaces, setNamespaces] = useState(new Array()) 16 | 17 | const onSelect = (selected: string): void => { 18 | if (props.onSelect) { 19 | props.onSelect(selected) 20 | } 21 | } 22 | useEffect(() => { 23 | fetchWithErrorHandling(`/api/v1/namespaces`, 'GET', 24 | undefined, 25 | (response, body) => { 26 | const ns = body ? body as Namespace[] : [] 27 | setNamespaces(ns) 28 | }, 29 | () => { 30 | setNamespaces([]) 31 | } 32 | ) 33 | }, []) 34 | return ( 35 | (value.name))} 37 | onSelect={onSelect} 38 | /> 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /internal/utils/bind_addr.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | var ErrInvalidAddress = fmt.Errorf("invalid address") 9 | 10 | // GetOutboundIP gets the preferred outbound ip of this machine 11 | func GetOutboundIP() (net.IP, error) { 12 | conn, err := net.Dial("udp", "1.1.1.1:80") 13 | if err != nil { 14 | return nil, err 15 | } 16 | defer func() { 17 | _ = conn.Close() 18 | }() 19 | localAddr, ok := conn.LocalAddr().(*net.UDPAddr) 20 | if !ok { 21 | return nil, ErrInvalidAddress 22 | } 23 | return localAddr.IP, nil 24 | } 25 | 26 | // BindHostToLocalHost takes an address suitable for binding, which might be something like 0.0.0.0, and returns 27 | // an address that can be connected to. 28 | func BindHostToLocalHost(bindAddr string) (string, error) { 29 | globalBindAddrs := []string{ 30 | "", 31 | "0.0.0.0", 32 | "::", 33 | "[::]", 34 | } 35 | isGlobalAddr := false 36 | for _, a := range globalBindAddrs { 37 | if bindAddr == a { 38 | isGlobalAddr = true 39 | break 40 | } 41 | } 42 | if isGlobalAddr { 43 | ga, err := GetOutboundIP() 44 | if err != nil { 45 | return "", err 46 | } 47 | return ga.String(), nil 48 | } 49 | return bindAddr, nil 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/app/Components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Grid, GridItem, Skeleton } from '@patternfly/react-core'; 3 | 4 | export const Loading: React.FunctionComponent<{ 5 | variant: "table" | "dashboard" 6 | }> = (props) => { 7 | if (props.variant == "dashboard") { 8 | return ( 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 | 30 |
31 |
32 |
33 | ) 34 | } else { 35 | return ( 36 | 37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | skip-dirs: 4 | - ui 5 | - clickhouse-operator 6 | 7 | linters: 8 | disable-all: true 9 | enable: 10 | - asciicheck 11 | - bodyclose 12 | # - cyclop 13 | # - deadcode 14 | - depguard 15 | - dogsled 16 | - dupl 17 | - durationcheck 18 | - errcheck 19 | - errorlint 20 | - exportloopref 21 | - forcetypeassert 22 | # - funlen 23 | # - gci 24 | # - gochecknoglobals 25 | # - gochecknoinits 26 | # - gocognit 27 | - gocritic 28 | # - gocyclo 29 | # - godot 30 | - goerr113 31 | # - gofmt 32 | # - gofumpt 33 | - goheader 34 | # - goimports 35 | # - golint 36 | # - gomoddirectives 37 | - gomodguard 38 | - goprintffuncname 39 | - gosec 40 | - gosimple 41 | - govet 42 | # - ifshort 43 | - importas 44 | - ineffassign 45 | - makezero 46 | - misspell 47 | - nakedret 48 | # - nestif 49 | - nilerr 50 | # - nlreturn 51 | - noctx 52 | - nolintlint 53 | - paralleltest 54 | - prealloc 55 | - predeclared 56 | - revive 57 | - rowserrcheck 58 | - sqlclosecheck 59 | - staticcheck 60 | # - structcheck 61 | # - stylecheck 62 | - testpackage 63 | - thelper 64 | - tparallel 65 | # - typecheck 66 | - unconvert 67 | - unparam 68 | - unused 69 | # - varcheck 70 | - wastedassign 71 | - whitespace 72 | # - wrapcheck 73 | -------------------------------------------------------------------------------- /ui/src/app/Components/SimpleModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, Button, ButtonVariant, ModalVariant } from '@patternfly/react-core'; 3 | 4 | export const SimpleModal: React.FunctionComponent< 5 | { 6 | title: string 7 | actionButtonText: string 8 | actionButtonVariant?: ButtonVariant 9 | cancelButtonText?: string 10 | isModalOpen: boolean 11 | onActionClick?: () => void 12 | onClose: () => void 13 | positionTop?: boolean 14 | }> = (props) => { 15 | const actionButtonVariant = props.actionButtonVariant ? props.actionButtonVariant : ButtonVariant.primary 16 | const cancelButtonText = props.cancelButtonText ? props.cancelButtonText : "Cancel" 17 | const closeModal = () => { 18 | props.onClose() 19 | } 20 | const actionClick = () => { 21 | closeModal() 22 | if (props.onActionClick) { 23 | props.onActionClick() 24 | } 25 | } 26 | return ( 27 | 35 | {props.actionButtonText} 36 | , 37 | 40 | ]} 41 | > 42 | {props.children} 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /ui/src/app/Components/ToggleModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, ButtonVariant } from '@patternfly/react-core'; 3 | import { useState } from 'react'; 4 | 5 | // ToggleModalProps are the properties of the ToggleModal component 6 | export interface ToggleModalProps { 7 | modal: React.FunctionComponent 8 | onToggle?: (open: boolean) => void 9 | buttonText?: string 10 | buttonVariant?: ButtonVariant 11 | buttonInline?: boolean 12 | } 13 | 14 | // ToggleModalSubProps are the properties of the Modal within the ToggleModal component 15 | export interface ToggleModalSubProps { 16 | isModalOpen: boolean 17 | closeModal: () => void 18 | } 19 | 20 | export const ToggleModal: React.FunctionComponent = (props: ToggleModalProps) => { 21 | const [isModalOpen, setIsModalOpen] = useState(false) 22 | const handleStateChange = (open: boolean): void => { 23 | setIsModalOpen(open) 24 | if (props.onToggle) { 25 | props.onToggle(open) 26 | } 27 | } 28 | return ( 29 | 30 | 36 | {props.modal({ 37 | isModalOpen: isModalOpen, 38 | closeModal: () => { handleStateChange(false) }, 39 | })} 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // Automatically clear mock calls and instances between every test 6 | clearMocks: true, 7 | 8 | // Indicates whether the coverage information should be collected while executing the test 9 | collectCoverage: false, 10 | 11 | // The directory where Jest should output its coverage files 12 | coverageDirectory: 'coverage', 13 | 14 | // An array of directory names to be searched recursively up from the requiring module's location 15 | moduleDirectories: [ 16 | "node_modules", 17 | "/src" 18 | ], 19 | 20 | // A map from regular expressions to module names that allow to stub out resources with a single module 21 | moduleNameMapper: { 22 | '\\.(css|less)$': '/__mocks__/styleMock.js', 23 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 24 | "@app/(.*)": '/src/app/$1' 25 | }, 26 | 27 | // A preset that is used as a base for Jest's configuration 28 | preset: "ts-jest/presets/js-with-ts", 29 | 30 | // The path to a module that runs some code to configure or set up the testing framework before each test 31 | setupFilesAfterEnv: ['/test-setup.js'], 32 | 33 | // The test environment that will be used for testing. 34 | testEnvironment: "jsdom", 35 | 36 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 37 | snapshotSerializers: ['enzyme-to-json/serializer'], 38 | 39 | }; 40 | -------------------------------------------------------------------------------- /ui/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // tells eslint to use the TypeScript parser 3 | "parser": "@typescript-eslint/parser", 4 | // tell the TypeScript parser that we want to use JSX syntax 5 | "parserOptions": { 6 | "tsx": true, 7 | "jsx": true, 8 | "js": true, 9 | "useJSXTextNode": true, 10 | "project": "./tsconfig.json", 11 | "tsconfigRootDir": "." 12 | }, 13 | // we want to use the recommended rules provided from the typescript plugin 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:react/recommended", 17 | "plugin:@typescript-eslint/recommended" 18 | ], 19 | "globals": { 20 | "window": "readonly", 21 | "describe": "readonly", 22 | "test": "readonly", 23 | "expect": "readonly", 24 | "it": "readonly", 25 | "process": "readonly", 26 | "document": "readonly" 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "^16.11.0" 31 | } 32 | }, 33 | // includes the typescript specific rules found here: https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules 34 | "plugins": [ 35 | "@typescript-eslint", 36 | "react-hooks", 37 | "eslint-plugin-react-hooks" 38 | ], 39 | "rules": { 40 | "@typescript-eslint/explicit-function-return-type": "off", 41 | "react-hooks/rules-of-hooks": "error", 42 | "react-hooks/exhaustive-deps": "warn", 43 | "@typescript-eslint/interface-name-prefix": "off", 44 | "prettier/prettier": "off", 45 | "import/no-unresolved": "off", 46 | "import/extensions": "off", 47 | "react/prop-types": "off" 48 | }, 49 | "env": { 50 | "browser": true, 51 | "node": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ui/src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import '@patternfly/react-core/dist/styles/base.css'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import { AppLayout } from '@app/AppLayout/AppLayout'; 5 | import { AppRoutes } from '@app/routes'; 6 | import '@app/app.css'; 7 | import { Alert, AlertActionCloseButton, AlertGroup, AlertVariant } from '@patternfly/react-core'; 8 | import { useState } from 'react'; 9 | import { AddAlertContextProvider, AddAlertType } from '@app/utils/alertContext'; 10 | 11 | interface AlertData { 12 | title: string 13 | variant: AlertVariant 14 | key: number 15 | } 16 | 17 | const App: React.FunctionComponent = () => { 18 | const [alerts, setAlerts] = useState(new Array()) 19 | const addAlert: AddAlertType = (title: string, variant: AlertVariant): void => { 20 | setAlerts([...alerts, {title: title, variant: variant, key: new Date().getTime()}]) 21 | } 22 | const removeAlert = (key: number): void => { 23 | setAlerts([...alerts.filter(a => a.key !== key)]) 24 | } 25 | return ( 26 | 27 | 28 | 29 | 30 | {alerts.map(({ key, variant, title }) => ( 31 | removeAlert(key)} 39 | /> 40 | } 41 | key={key} /> 42 | ))} 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /ui/src/app/app.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import App from '@app/index'; 3 | import { mount, shallow } from 'enzyme'; 4 | import { Button } from '@patternfly/react-core'; 5 | 6 | describe('App tests', () => { 7 | test('should render default App component', () => { 8 | const view = shallow(); 9 | expect(view).toMatchSnapshot(); 10 | }); 11 | 12 | it('should render a nav-toggle button', () => { 13 | const wrapper = mount(); 14 | const button = wrapper.find(Button); 15 | expect(button.exists()).toBe(true); 16 | }); 17 | 18 | it('should hide the sidebar on smaller viewports', () => { 19 | Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }); 20 | const wrapper = mount(); 21 | window.dispatchEvent(new Event('resize')); 22 | expect(wrapper.find('#page-sidebar').hasClass('pf-m-collapsed')).toBeTruthy(); 23 | }); 24 | 25 | it('should expand the sidebar on larger viewports', () => { 26 | Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); 27 | const wrapper = mount(); 28 | window.dispatchEvent(new Event('resize')); 29 | expect(wrapper.find('#page-sidebar').hasClass('pf-m-expanded')).toBeTruthy(); 30 | }); 31 | 32 | it('should hide the sidebar when clicking the nav-toggle button', () => { 33 | Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 }); 34 | const wrapper = mount(); 35 | window.dispatchEvent(new Event('resize')); 36 | const button = wrapper.find('#nav-toggle').hostNodes(); 37 | expect(wrapper.find('#page-sidebar').hasClass('pf-m-expanded')).toBeTruthy(); 38 | button.simulate('click'); 39 | expect(wrapper.find('#page-sidebar').hasClass('pf-m-collapsed')).toBeTruthy(); 40 | expect(wrapper.find('#page-sidebar').hasClass('pf-m-expanded')).toBeFalsy(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint and Build 3 | 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | push: 8 | branches: [ main ] 9 | 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | env: 15 | GO_VERSION: 1.18 16 | NODE_VERSION: 16 17 | 18 | jobs: 19 | lint-and-build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | 23 | - name: Check out repo 24 | uses: actions/checkout@v4 25 | with: 26 | submodules: recursive 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | 33 | - name: Cache Go build dirs 34 | uses: actions/cache@v4 35 | with: 36 | path: | 37 | ~/.cache/go-build 38 | ~/go/pkg/mod 39 | key: ${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ hashFiles('**/go.sum') }} 40 | restore-keys: | 41 | ${{ runner.os }}-go-${{ env.GO_VERSION }}- 42 | 43 | - name: Install Node 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: ${{ env.NODE_VERSION }} 47 | cache: npm 48 | cache-dependency-path: ui/package-lock.json 49 | 50 | - name: Build the embed files 51 | run: make embed 52 | 53 | - name: Build the UI 54 | run: make ui 55 | 56 | - name: Run Go linter 57 | uses: golangci/golangci-lint-action@v6 58 | with: 59 | version: latest 60 | skip-cache: true 61 | 62 | - name: Run Javascript linter 63 | run: cd ui && npx eslint --max-warnings=0 src/ 64 | 65 | - name: Check node modules security 66 | run: cd ui && npm audit --audit-level moderate 67 | 68 | - name: Build platform binaries (to make sure it works) 69 | run: make bin 70 | 71 | - name: Build container image (to make sure it works) 72 | run: docker build . 73 | 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IS_GIT_REPO := $(shell if git status > /dev/null 2>&1; then echo 1; else echo 0; fi) 2 | ifeq ($(IS_GIT_REPO),1) 3 | LIST_FILES_CMD_PREFIX := git ls-files 4 | LIST_FILES_CMD_SUFFIX := --cached --others --exclude-standard 5 | else 6 | LIST_FILES_CMD_PREFIX := find 7 | LIST_FILES_CMD_SUFFIX := -type f 8 | endif 9 | 10 | export CGO_ENABLED := 0 11 | 12 | adash: adash.go ui/dist embed $(shell $(LIST_FILES_CMD_PREFIX) internal $(LIST_FILES_CMD_SUFFIX)) 13 | go build adash.go 14 | 15 | bin: adash 16 | @mkdir -p bin 17 | GOOS=linux GOARCH=amd64 go build -o bin/adash-linux-x86_64 adash.go 18 | GOOS=linux GOARCH=arm64 go build -o bin/adash-linux-arm64 adash.go 19 | GOOS=windows GOARCH=amd64 go build -o bin/adash-windows-x86_64.exe adash.go 20 | GOOS=darwin GOARCH=amd64 go build -o bin/adash-macos-x86_64 adash.go 21 | GOOS=darwin GOARCH=arm64 go build -o bin/adash-macos-arm64 adash.go 22 | @touch bin 23 | 24 | ui: ui/dist 25 | 26 | ui/dist: $(shell $(LIST_FILES_CMD_PREFIX) ui $(LIST_FILES_CMD_SUFFIX)) 27 | @cd ui && npm ci && npm run build 28 | @touch ui/dist 29 | 30 | ui-devel: adash 31 | @cd ui && npm run devel 32 | 33 | embed: embed/clickhouse-operator-install-template.yaml embed/chop-release embed/version embed/chi-examples 34 | 35 | embed/clickhouse-operator-install-template.yaml: clickhouse-operator/deploy/operator/clickhouse-operator-install-template.yaml 36 | @mkdir -p embed 37 | @cp $< $@ 38 | 39 | embed/chop-release: clickhouse-operator/release 40 | @mkdir -p embed 41 | @cp $< $@ 42 | 43 | embed/version: ui/print-version.js ui/package.json 44 | @( cd ui && node print-version.js ) > $@ 45 | 46 | embed/chi-examples: $(shell $(LIST_FILES_CMD_PREFIX) clickhouse-operator/docs/chi-examples/ $(LIST_FILES_CMD_SUFFIX)) 47 | @mkdir -p embed/chi-examples 48 | @cp clickhouse-operator/docs/chi-examples/*.yaml embed/chi-examples 49 | 50 | lint: 51 | @ui/.husky/pre-commit 52 | 53 | format: 54 | @go fmt ./... 55 | 56 | clean: 57 | @rm -rf adash internal/dev_server/swagger-ui-dist ui/dist embed bin 58 | 59 | .PHONY: ui ui-devel lint format clean 60 | -------------------------------------------------------------------------------- /ui/src/app/utils/fetchWithErrorHandling.tsx: -------------------------------------------------------------------------------- 1 | export function fetchWithErrorHandling(url: string, method: string, body?: object, 2 | onSuccess?: (response: Response, body: object|string|undefined) => number|void, 3 | onFailure?: (response: Response, text: string, error: string) => number|void, 4 | onCheckDelay?: () => number) 5 | { 6 | if (onCheckDelay) { 7 | // Check if we should delay retrieving, perhaps because our tab isn't in focus. The callback should return: 8 | // 0 = proceed as normal 9 | // positive number = delay this long, then resume normal checking 10 | // negative number = cancel, stop repeating 11 | const interval = onCheckDelay() 12 | if (interval > 0) { 13 | setTimeout(() => fetchWithErrorHandling(url, method, body, onSuccess, onFailure, onCheckDelay), interval) 14 | return 15 | } 16 | if (interval < 0) { 17 | return 18 | } 19 | } 20 | const fetchInit: RequestInit = { 21 | method: method, 22 | headers: { 23 | 'Accept': 'application/json', 24 | 'Content-Type': 'application/json' 25 | }, 26 | } 27 | if (body !== undefined) { 28 | fetchInit.body = JSON.stringify(body) 29 | } 30 | let response: Response 31 | let text: string 32 | let responseBody: object|string|undefined 33 | fetch(url, fetchInit) 34 | .then(resp => { 35 | response = resp 36 | return resp.text() 37 | }) 38 | .then (t => { 39 | text = t 40 | if (!response.ok) { 41 | throw Error() 42 | } 43 | try { 44 | const content_type = response.headers.get("content-type") 45 | if (text && content_type && content_type.includes("application/json")) { 46 | responseBody = JSON.parse(text) 47 | } else { 48 | responseBody = text 49 | } 50 | } catch { 51 | throw Error(`JSON parsing error`) 52 | } 53 | if (onSuccess) { 54 | const interval = onSuccess(response, responseBody) 55 | if (interval) { 56 | setTimeout(() => fetchWithErrorHandling(url, method, body, onSuccess, onFailure, onCheckDelay), interval) 57 | } 58 | } 59 | }) 60 | .catch(error => { 61 | if (onFailure) { 62 | const interval = onFailure(response, text, error) 63 | if (interval) { 64 | setTimeout(() => fetchWithErrorHandling(url, method, body, onSuccess, onFailure, onCheckDelay), interval) 65 | } 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /internal/certs/certgen.go: -------------------------------------------------------------------------------- 1 | package certs 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/pem" 9 | "io/ioutil" 10 | "math/big" 11 | "os" 12 | "time" 13 | ) 14 | 15 | var filesToDelete []string 16 | 17 | // GenerateSelfSignedCerts generates self-signed certs, and optionally deletes them on program exit 18 | func GenerateSelfSignedCerts(removeOnExit bool) (certFileName string, keyFileName string, err error) { 19 | // Generate private key 20 | privKey, err := rsa.GenerateKey(rand.Reader, 2048) 21 | if err != nil { 22 | return "", "", err 23 | } 24 | 25 | // Generate certificate 26 | template := &x509.Certificate{ 27 | SerialNumber: big.NewInt(1), 28 | Subject: pkix.Name{ 29 | Organization: []string{"Self-Signed"}, 30 | }, 31 | NotBefore: time.Now(), 32 | NotAfter: time.Now().Add(time.Hour * 24 * 365), 33 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 34 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 35 | BasicConstraintsValid: true, 36 | } 37 | certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) 38 | if err != nil { 39 | return "", "", err 40 | } 41 | 42 | // Write private key to file 43 | keyFile, err := ioutil.TempFile("", "self-signed-*.key") 44 | if err != nil { 45 | return "", "", err 46 | } 47 | err = keyFile.Chmod(0600) 48 | if err != nil { 49 | return "", "", err 50 | } 51 | err = pem.Encode(keyFile, &pem.Block{ 52 | Type: "RSA PRIVATE KEY", 53 | Bytes: x509.MarshalPKCS1PrivateKey(privKey), 54 | }) 55 | if err != nil { 56 | return "", "", err 57 | } 58 | err = keyFile.Close() 59 | if err != nil { 60 | return "", "", err 61 | } 62 | 63 | // Write certificate to file 64 | certFile, err := ioutil.TempFile("", "self-signed-*.crt") 65 | if err != nil { 66 | return "", "", err 67 | } 68 | err = pem.Encode(certFile, &pem.Block{ 69 | Type: "CERTIFICATE", 70 | Bytes: certBytes, 71 | }) 72 | if err != nil { 73 | return "", "", err 74 | } 75 | err = certFile.Close() 76 | if err != nil { 77 | return "", "", err 78 | } 79 | 80 | if removeOnExit { 81 | filesToDelete = append(filesToDelete, keyFile.Name()) 82 | filesToDelete = append(filesToDelete, certFile.Name()) 83 | } 84 | 85 | return certFile.Name(), keyFile.Name(), nil 86 | } 87 | 88 | func init() { 89 | defer func() { 90 | for _, fn := range filesToDelete { 91 | _ = os.Remove(fn) 92 | } 93 | }() 94 | } 95 | -------------------------------------------------------------------------------- /internal/api/dashboard.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "github.com/altinity/altinity-dashboard/internal/utils" 6 | chopv1 "github.com/altinity/clickhouse-operator/pkg/apis/clickhouse.altinity.com/v1" 7 | "github.com/emicklei/go-restful/v3" 8 | v1 "k8s.io/api/apps/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | // DashboardResource is the REST layer to the dashboard 14 | type DashboardResource struct { 15 | } 16 | 17 | // Name returns the name of the web service 18 | func (d *DashboardResource) Name() string { 19 | return "Dashboard" 20 | } 21 | 22 | // WebService creates a new service that can handle REST requests 23 | func (d *DashboardResource) WebService(_ *WebServiceInfo) (*restful.WebService, error) { 24 | ws := new(restful.WebService) 25 | ws. 26 | Path("/api/v1/dashboard"). 27 | Produces(restful.MIME_JSON) 28 | 29 | ws.Route(ws.GET("").To(d.getDashboard). 30 | Doc("get dashboard information"). 31 | Writes(Dashboard{}). 32 | Returns(200, "OK", Dashboard{})) 33 | 34 | return ws, nil 35 | } 36 | 37 | func (d *DashboardResource) getDashboard(_ *restful.Request, response *restful.Response) { 38 | dash := Dashboard{} 39 | 40 | k := utils.GetK8s() 41 | defer func() { k.ReleaseK8s() }() 42 | dash.KubeCluster = k.Config.Host 43 | sv, err := k.Clientset.ServerVersion() 44 | if err == nil { 45 | dash.KubeVersion = sv.String() 46 | } else { 47 | dash.KubeVersion = "unknown" 48 | } 49 | 50 | // Get clickhouse-operator counts 51 | var chops *v1.DeploymentList 52 | chops, err = k.Clientset.AppsV1().Deployments("").List( 53 | context.TODO(), metav1.ListOptions{ 54 | LabelSelector: "app=clickhouse-operator", 55 | }) 56 | if err == nil { 57 | dash.ChopCount = len(chops.Items) 58 | dash.ChopCountAvailable = 0 59 | for _, chop := range chops.Items { 60 | for _, cond := range chop.Status.Conditions { 61 | if cond.Status == corev1.ConditionTrue && cond.Type == v1.DeploymentAvailable { 62 | dash.ChopCountAvailable++ 63 | break 64 | } 65 | } 66 | } 67 | } 68 | 69 | // Get CHI counts 70 | var chis *chopv1.ClickHouseInstallationList 71 | chis, err = k.ChopClientset.ClickhouseV1().ClickHouseInstallations("").List( 72 | context.TODO(), metav1.ListOptions{}) 73 | if err == nil { 74 | dash.ChiCount = len(chis.Items) 75 | dash.ChiCountComplete = 0 76 | for _, chi := range chis.Items { 77 | if chi.Status.Status == chopv1.StatusCompleted { 78 | dash.ChiCountComplete++ 79 | } 80 | } 81 | } 82 | 83 | _ = response.WriteEntity(dash) 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "tagged-release" 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | tagged-release: 11 | name: "Tagged Release" 12 | runs-on: "ubuntu-latest" 13 | 14 | steps: 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Check out repo 22 | uses: actions/checkout@v4 23 | with: 24 | submodules: recursive 25 | 26 | - name: Install Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 16 30 | cache: npm 31 | cache-dependency-path: ui/package-lock.json 32 | 33 | - name: Make sure tag version matches package.json 34 | run: | 35 | if [ $(git tag) != v$(cd ui && node print-version.js) ]; then 36 | echo "::error file=ui/package.json::Tag does not match package.json version" 37 | exit 1 38 | fi 39 | 40 | - name: Build adash platform binaries 41 | run: make bin 42 | 43 | - name: Build container image 44 | run: docker build . -t altinity-dashboard:latest -t altinity-dashboard:$(cd ui && node print-version.js) 45 | 46 | - name: Create the release 47 | uses: "marvinpinto/action-automatic-releases@latest" 48 | with: 49 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 50 | prerelease: true 51 | files: | 52 | bin/* 53 | 54 | - name: Log in to GitHub registry 55 | uses: docker/login-action@v2 56 | with: 57 | registry: ghcr.io 58 | username: ${{ github.actor }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: Extract metadata for container image 62 | id: meta 63 | uses: docker/metadata-action@v5 64 | with: 65 | images: ghcr.io/altinity/altinity-dashboard 66 | 67 | - name: Push to GitHub registry 68 | uses: docker/build-push-action@v6 69 | with: 70 | context: . 71 | push: true 72 | tags: ${{ steps.meta.outputs.tags }} 73 | labels: ${{ steps.meta.outputs.labels }} 74 | 75 | - name: Bump the package.json version number 76 | run: (cd ui && npm version patch) 77 | 78 | - name: Create pull request bumping the version number 79 | uses: peter-evans/create-pull-request@v7 80 | with: 81 | commit-message: 'Automatic version number bump after release' 82 | title: 'Automatic version number bump after release' 83 | base: main 84 | branch: version_update 85 | delete-branch: true 86 | 87 | -------------------------------------------------------------------------------- /internal/api/namespace.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "github.com/altinity/altinity-dashboard/internal/utils" 6 | "github.com/emicklei/go-restful/v3" 7 | v1 "k8s.io/api/core/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "net/http" 10 | ) 11 | 12 | // NamespaceResource is the REST layer to Namespaces 13 | type NamespaceResource struct { 14 | } 15 | 16 | // Name returns the name of the web service 17 | func (n *NamespaceResource) Name() string { 18 | return "Namespaces" 19 | } 20 | 21 | // WebService creates a new service that can handle REST requests 22 | func (n *NamespaceResource) WebService(_ *WebServiceInfo) (*restful.WebService, error) { 23 | ws := new(restful.WebService) 24 | ws. 25 | Path("/api/v1/namespaces"). 26 | Consumes(restful.MIME_JSON). 27 | Produces(restful.MIME_JSON) 28 | 29 | ws.Route(ws.GET("").To(n.getNamespaces). 30 | Doc("get all namespaces"). 31 | Writes([]Namespace{}). 32 | Returns(200, "OK", []Namespace{})) 33 | 34 | ws.Route(ws.PUT("").To(n.createNamespace). 35 | Doc("create a namespace"). 36 | Reads(Namespace{})) // from the request 37 | 38 | return ws, nil 39 | } 40 | 41 | func (n *NamespaceResource) getNamespaces(_ *restful.Request, response *restful.Response) { 42 | k := utils.GetK8s() 43 | defer func() { k.ReleaseK8s() }() 44 | namespaces, err := k.Clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) 45 | if err != nil { 46 | webError(response, http.StatusInternalServerError, err) 47 | return 48 | } 49 | list := make([]Namespace, 0, len(namespaces.Items)) 50 | for _, namespace := range namespaces.Items { 51 | list = append(list, Namespace{ 52 | Name: namespace.Name, 53 | }) 54 | } 55 | _ = response.WriteEntity(list) 56 | } 57 | 58 | func (n *NamespaceResource) createNamespace(request *restful.Request, response *restful.Response) { 59 | namespace := new(Namespace) 60 | err := request.ReadEntity(&namespace) 61 | if err != nil { 62 | webError(response, http.StatusBadRequest, err) 63 | return 64 | } 65 | 66 | // Check if the namespace already exists 67 | k := utils.GetK8s() 68 | defer func() { k.ReleaseK8s() }() 69 | namespaces, err := k.Clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{ 70 | FieldSelector: "metadata.name=" + namespace.Name, 71 | Limit: 1, 72 | }) 73 | if err != nil { 74 | webError(response, http.StatusInternalServerError, err) 75 | return 76 | } 77 | if len(namespaces.Items) > 0 { 78 | _ = response.WriteEntity(namespace) 79 | return 80 | } 81 | 82 | // Create the namespace 83 | _, err = k.Clientset.CoreV1().Namespaces().Create( 84 | context.TODO(), 85 | &v1.Namespace{ 86 | ObjectMeta: metav1.ObjectMeta{ 87 | Name: namespace.Name, 88 | }, 89 | }, 90 | metav1.CreateOptions{}) 91 | if err != nil { 92 | webError(response, http.StatusInternalServerError, err) 93 | return 94 | } 95 | _ = response.WriteEntity(namespace) 96 | } 97 | -------------------------------------------------------------------------------- /ui/src/app/Components/ListSelector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ReactNode, useState } from 'react'; 3 | import { ContextSelector, ContextSelectorItem } from '@patternfly/react-core'; 4 | 5 | export const ListSelector: React.FunctionComponent< 6 | { 7 | onSelect?: (selected: string) => void 8 | listValues: string[] 9 | width?: string 10 | }> = (props) => { 11 | 12 | const [selected, setSelected] = useState("") 13 | const [searchValue, setSearchValue] = useState("") 14 | const [filterValue, setFilterValue] = useState("") 15 | const [isDropDownOpen, setIsDropDownOpen] = useState(false) 16 | 17 | // The following code is a hacky workaround for a problem where when menuApplyTo 18 | // is set to document.body, the ContextSelector's onSelect doesn't fire if you 19 | // select an item quickly after first opening the dropdown. Stepping through 20 | // the process of opening and closing the drop-down seems to establish the 21 | // necessary conditions for onSelect to work correctly. 22 | const [startupState, setStartupState] = useState(0) 23 | const [startupTimer, setStartupTimer] = useState(undefined) 24 | switch(startupState) { 25 | case 0: 26 | setIsDropDownOpen(true) 27 | setStartupTimer(setTimeout(() => {setStartupState(2)}, 5)) 28 | setStartupState(1) 29 | break 30 | case 1: 31 | // waiting for setTimeout to happen 32 | break 33 | case 2: 34 | setIsDropDownOpen(false) 35 | if (startupTimer) clearTimeout(startupTimer) 36 | setStartupTimer(undefined) 37 | setStartupState(3) 38 | break 39 | case 3: 40 | // normal operation 41 | break 42 | } 43 | // End of terrible hack 44 | 45 | const onToggle = (event: Event, isDropDownOpen: boolean): void => { 46 | setIsDropDownOpen(isDropDownOpen) 47 | } 48 | const onSelect = (event: Event, value: ReactNode): void => { 49 | const newSelected = value ? value.toString() : "" 50 | if (props.onSelect) { 51 | props.onSelect(newSelected) 52 | } 53 | setSelected(newSelected) 54 | setIsDropDownOpen(false) 55 | } 56 | const onSearchInputChange = (value: string): void => { 57 | setSearchValue(value) 58 | if (value === "") { 59 | setFilterValue("") 60 | } 61 | } 62 | const onSearchButtonClick = (): void => { 63 | setFilterValue(searchValue) 64 | } 65 | return ( 66 | { return document.body }} 75 | > 76 | { 77 | (filterValue === '' 78 | ? props.listValues 79 | : props.listValues.filter((item): boolean => { 80 | return item.toLowerCase().indexOf(filterValue.toLowerCase()) !== -1 81 | })) 82 | .map((text, index) => { 83 | return ( 84 | {text} 85 | ) 86 | }) 87 | } 88 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /ui/src/app/Components/StringHasher.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | ButtonVariant, 4 | ClipboardCopy, 5 | Grid, GridItem, Modal, Split, SplitItem 6 | } from '@patternfly/react-core'; 7 | import { useState } from 'react'; 8 | import { TextInput } from '@patternfly/react-core/src/components/TextInput/index'; 9 | import { Button } from '@patternfly/react-core/src/components/Button/index'; 10 | import EyeIcon from '@patternfly/react-icons/dist/esm/icons/eye-icon'; 11 | import EyeSlashIcon from '@patternfly/react-icons/dist/esm/icons/eye-slash-icon'; 12 | import { ToggleModal, ToggleModalSubProps } from '@app/Components/ToggleModal'; 13 | import jsSHA from 'jssha'; 14 | 15 | export const StringHasher: React.FunctionComponent<{ 16 | DefaultHidden?: boolean 17 | title?: string 18 | valueName?: string 19 | }> = (props) => { 20 | const [isInputHidden, setIsInputHidden] = useState(props.DefaultHidden === undefined ? true : props.DefaultHidden) 21 | const [inputValue, setInputValue] = useState("") 22 | const [hashValue, setHashValue] = useState("") 23 | const title = props.title ? props.title : "String Hash Tool" 24 | const valueName = props.valueName ? props.valueName : "string" 25 | const onValueChange = (value: string) => { 26 | setInputValue(value) 27 | const jh = new jsSHA('SHA-256', 'TEXT') 28 | jh.update(value) 29 | const hash = jh.getHash("HEX") 30 | setHashValue(hash) 31 | } 32 | return ( 33 | 34 | { 39 | return ( 40 | 45 | 46 | 47 |
Enter a {valueName}:
48 | 49 | 50 | 56 | 57 | 58 | 64 | 65 | 66 |
67 | 68 |
Hex hash using sha-256:
69 | 70 | {hashValue} 71 | 72 |
73 |
74 |
75 | ) 76 | }} 77 | /> 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/altinity/altinity-dashboard 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/altinity/clickhouse-operator v0.0.0-20211101130143-50134723c388 7 | github.com/emicklei/go-restful-openapi/v2 v2.11.0 8 | github.com/emicklei/go-restful/v3 v3.11.0 9 | github.com/go-openapi/spec v0.22.2 10 | k8s.io/api v0.23.1 11 | k8s.io/apimachinery v0.23.1 12 | k8s.io/client-go v0.23.1 13 | sigs.k8s.io/yaml v1.6.0 14 | ) 15 | 16 | require ( 17 | cloud.google.com/go v0.81.0 // indirect 18 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 19 | github.com/Azure/go-autorest/autorest v0.11.18 // indirect 20 | github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect 21 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 22 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 23 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 26 | github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect 27 | github.com/go-logr/logr v1.2.0 // indirect 28 | github.com/go-openapi/jsonpointer v0.22.4 // indirect 29 | github.com/go-openapi/jsonreference v0.21.4 // indirect 30 | github.com/go-openapi/swag/conv v0.25.4 // indirect 31 | github.com/go-openapi/swag/jsonname v0.25.4 // indirect 32 | github.com/go-openapi/swag/jsonutils v0.25.4 // indirect 33 | github.com/go-openapi/swag/loading v0.25.4 // indirect 34 | github.com/go-openapi/swag/stringutils v0.25.4 // indirect 35 | github.com/go-openapi/swag/typeutils v0.25.4 // indirect 36 | github.com/go-openapi/swag/yamlutils v0.25.4 // indirect 37 | github.com/gogo/protobuf v1.3.2 // indirect 38 | github.com/golang/glog v1.0.0 // indirect 39 | github.com/golang/protobuf v1.5.2 // indirect 40 | github.com/google/go-cmp v0.5.9 // indirect 41 | github.com/google/gofuzz v1.1.0 // indirect 42 | github.com/googleapis/gnostic v0.5.5 // indirect 43 | github.com/imdario/mergo v0.3.13 // indirect 44 | github.com/json-iterator/go v1.1.12 // indirect 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 46 | github.com/modern-go/reflect2 v1.0.2 // indirect 47 | github.com/sanity-io/litter v1.3.0 // indirect 48 | github.com/spf13/pflag v1.0.5 // indirect 49 | go.yaml.in/yaml/v2 v2.4.2 // indirect 50 | go.yaml.in/yaml/v3 v3.0.4 // indirect 51 | golang.org/x/crypto v0.45.0 // indirect 52 | golang.org/x/net v0.47.0 // indirect 53 | golang.org/x/oauth2 v0.27.0 // indirect 54 | golang.org/x/sys v0.38.0 // indirect 55 | golang.org/x/term v0.37.0 // indirect 56 | golang.org/x/text v0.31.0 // indirect 57 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 58 | google.golang.org/protobuf v1.33.0 // indirect 59 | gopkg.in/d4l3k/messagediff.v1 v1.2.1 // indirect 60 | gopkg.in/inf.v0 v0.9.1 // indirect 61 | gopkg.in/yaml.v2 v2.4.0 // indirect 62 | gopkg.in/yaml.v3 v3.0.1 // indirect 63 | k8s.io/klog/v2 v2.30.0 // indirect 64 | k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect 65 | k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect 66 | sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect 67 | ) 68 | 69 | replace ( 70 | github.com/altinity/altinity-dashboard => ./ 71 | github.com/altinity/clickhouse-operator => ./clickhouse-operator/ 72 | k8s.io/api => k8s.io/api v0.22.3 73 | k8s.io/apimachinery => k8s.io/apimachinery v0.22.3 74 | k8s.io/client-go => k8s.io/client-go v0.22.3 75 | k8s.io/code-generator => k8s.io/code-generator v0.22.3 76 | ) 77 | -------------------------------------------------------------------------------- /ui/src/app/AppLayout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NavLink, useLocation } from 'react-router-dom'; 3 | import { 4 | Nav, 5 | NavList, 6 | NavItem, 7 | NavExpandable, 8 | Page, 9 | PageHeader, 10 | PageSidebar, 11 | SkipToContent 12 | } from '@patternfly/react-core'; 13 | import { routes, IAppRoute, IAppRouteGroup } from '@app/routes'; 14 | import logo from '@app/images/altinity_horizontal_logo.png'; 15 | 16 | interface IAppLayout { 17 | children: React.ReactNode 18 | } 19 | 20 | export const AppLayout: React.FunctionComponent = (props) => { 21 | const [isNavOpen, setIsNavOpen] = React.useState(true); 22 | const [isNavOpenMobile, setIsNavOpenMobile] = React.useState(false); 23 | const [isMobileView, setIsMobileView] = React.useState(true); 24 | const onNavToggleMobile = () => { 25 | setIsNavOpenMobile(!isNavOpenMobile); 26 | }; 27 | const navMobileClose = () => { 28 | setIsNavOpenMobile(false) 29 | } 30 | const onNavToggle = () => { 31 | setIsNavOpen(!isNavOpen); 32 | } 33 | const onPageResize = (props: { mobileView: boolean; windowSize: number }) => { 34 | setIsMobileView(props.mobileView); 35 | }; 36 | 37 | function LogoImg() { 38 | return ( 39 | Altinity Logo 40 | ); 41 | } 42 | 43 | const Header = ( 44 | } 46 | showNavToggle 47 | isNavOpen={isNavOpen} 48 | onNavToggle={isMobileView ? onNavToggleMobile : onNavToggle} 49 | /> 50 | ); 51 | 52 | const location = useLocation(); 53 | 54 | const renderNavItem = (route: IAppRoute, index: number) => ( 55 | 56 | 57 | {route.label} 58 | 59 | 60 | ); 61 | 62 | const renderNavGroup = (group: IAppRouteGroup, groupIndex: number) => ( 63 | route.path === location.pathname)} 68 | > 69 | {group.routes.map((route, idx) => route.label && renderNavItem(route, idx))} 70 | 71 | ); 72 | 73 | const Navigation = ( 74 | 81 | ); 82 | 83 | const Sidebar = ( 84 | 88 | ); 89 | 90 | const pageId = 'primary-app-container'; 91 | 92 | const PageSkipToContent = ( 93 | { 94 | event.preventDefault(); 95 | const primaryContentContainer = document.getElementById(pageId); 96 | primaryContentContainer && primaryContentContainer.focus(); 97 | }} href={`#${pageId}`}> 98 | Skip to Content 99 | 100 | ); 101 | return ( 102 | 108 | {props.children} 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /ui/src/app/Operators/NewOperatorModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ToggleModalSubProps } from '@app/Components/ToggleModal'; 3 | import { useContext, useState } from 'react'; 4 | import { AlertVariant, Bullseye, Button, Grid, GridItem, Modal, ModalVariant, TextInput } from '@patternfly/react-core'; 5 | import { NamespaceSelector } from '@app/Namespaces/NamespaceSelector'; 6 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 7 | import { AddAlertContext } from '@app/utils/alertContext'; 8 | 9 | export interface NewOperatorModalProps extends ToggleModalSubProps { 10 | isUpgrade: boolean 11 | namespace?: string 12 | } 13 | 14 | export const NewOperatorModal: React.FunctionComponent = (props) => { 15 | const {isModalOpen} = props 16 | const [selectedVersion, setSelectedVersion] = useState("") 17 | let selectedNamespace: string 18 | let setSelectedNamespace: (string) => void 19 | const [selectedNamespaceState, setSelectedNamespaceState] = useState("") 20 | const addAlert = useContext(AddAlertContext) 21 | if (props.namespace) { 22 | selectedNamespace = props.namespace 23 | setSelectedNamespace = () => {return} 24 | } else { 25 | selectedNamespace = selectedNamespaceState 26 | setSelectedNamespace = setSelectedNamespaceState 27 | } 28 | const closeModal = (): void => { 29 | setSelectedVersion("") 30 | setSelectedNamespace("") 31 | props.closeModal() 32 | } 33 | const onDeployClick = (): void => { 34 | fetchWithErrorHandling(`/api/v1/operators/${selectedNamespace}`, 35 | 'PUT', 36 | { 37 | version: selectedVersion 38 | }, 39 | undefined, 40 | (response, text, error) => { 41 | const errorMessage = (error == "") ? text : `${error}: ${text}` 42 | addAlert(`Error updating operator: ${errorMessage}`, AlertVariant.danger) 43 | } 44 | ) 45 | closeModal() 46 | } 47 | const latestChop = (document.querySelector('meta[name="chop-release"]') as HTMLMetaElement)?.content || "latest" 48 | return ( 49 | 58 | {props.isUpgrade ? "Upgrade" : "Deploy"} 59 | , 60 | 63 | ]} 64 | > 65 | 66 | 67 |
68 | Version (leave blank for {latestChop}): 69 |
70 | 75 |
76 | 77 | 78 | See Release Notes for information about available versions. 79 | 80 | 81 | { 82 | props.isUpgrade ? null : 83 | ( 84 | 85 | Select a Namespace: 86 | 87 | 88 | ) 89 | } 90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "altinity-dashboard", 3 | "version": "0.1.7", 4 | "repository": "https://github.com/altinity/altinity-dashboard.git", 5 | "license": "Apache-2.0", 6 | "private": true, 7 | "scripts": { 8 | "devel": "concurrently \"../adash -devmode -notoken\" \"npm run start:dev\"", 9 | "prebuild": "npm run clean", 10 | "build": "webpack --config webpack.prod.js", 11 | "start": "sirv dist --cors --single --host --port 8080", 12 | "start:dev": "webpack serve --color --progress --config webpack.dev.js", 13 | "test": "jest --watch", 14 | "test:coverage": "jest --coverage", 15 | "eslint": "eslint --ext .tsx,.js ./src/", 16 | "lint": "npm run eslint", 17 | "format": "prettier --check --write ./src/**/*.{tsx,ts}", 18 | "type-check": "tsc --noEmit", 19 | "ci-checks": "npm run type-check && npm run lint && npm run test:coverage", 20 | "build:bundle-profile": "webpack --config webpack.prod.js --profile --json > stats.json", 21 | "bundle-profile:analyze": "npm run build:bundle-profile && webpack-bundle-analyzer ./stats.json", 22 | "clean": "rimraf dist", 23 | "install-git-hooks": "cd .. && husky install ui/.husky" 24 | }, 25 | "devDependencies": { 26 | "@types/enzyme": "^3.10.19", 27 | "@types/jest": "^30.0.0", 28 | "@types/react-router-dom": "^5.3.3", 29 | "@typescript-eslint/eslint-plugin": "^8.50.0", 30 | "@typescript-eslint/parser": "^8.49.0", 31 | "@wojtekmaj/enzyme-adapter-react-17": "^0.8.0", 32 | "buffer": "^6.0.3", 33 | "copy-webpack-plugin": "^13.0.1", 34 | "css-loader": "^7.1.2", 35 | "css-minimizer-webpack-plugin": "^7.0.4", 36 | "dotenv-webpack": "^8.1.1", 37 | "enzyme": "^3.11.0", 38 | "enzyme-to-json": "^3.6.2", 39 | "eslint": "^9.39.2", 40 | "eslint-plugin-react": "^7.37.5", 41 | "eslint-plugin-react-hooks": "^7.0.1", 42 | "file-loader": "^6.2.0", 43 | "html-webpack-plugin": "^5.6.5", 44 | "husky": "^9.1.7", 45 | "imagemin": "^9.0.1", 46 | "jest": "^30.2.0", 47 | "mini-css-extract-plugin": "^2.9.4", 48 | "postcss": "^8.5.6", 49 | "prettier": "^3.7.4", 50 | "prop-types": "^15.8.1", 51 | "raw-loader": "^4.0.2", 52 | "react-axe": "^3.5.4", 53 | "react-docgen-typescript-loader": "^2.2.0", 54 | "react-router-dom": "^5.3.0", 55 | "regenerator-runtime": "^0.14.1", 56 | "rimraf": "^6.1.2", 57 | "style-loader": "^4.0.0", 58 | "svg-url-loader": "^8.0.0", 59 | "terser-webpack-plugin": "^5.3.16", 60 | "ts-jest": "^29.4.6", 61 | "ts-loader": "^9.5.4", 62 | "tsconfig-paths-webpack-plugin": "^4.2.0", 63 | "tslib": "^2.8.1", 64 | "typescript": "5.9.3", 65 | "url-loader": "^4.1.1", 66 | "webpack": "^5.104.0", 67 | "webpack-bundle-analyzer": "^5.1.0", 68 | "webpack-cli": "^6.0.1", 69 | "webpack-dev-server": "^5.2.2", 70 | "webpack-merge": "^6.0.1" 71 | }, 72 | "dependencies": { 73 | "@patternfly/react-charts": "^8.4.0", 74 | "@patternfly/react-code-editor": "^6.4.0", 75 | "@patternfly/react-core": "^6.3.0", 76 | "@patternfly/react-icons": "^6.4.0", 77 | "@patternfly/react-styles": "^6.3.0", 78 | "@patternfly/react-table": "^6.4.0", 79 | "async": "^3.2.6", 80 | "concurrently": "^9.2.1", 81 | "jssha": "^3.3.1", 82 | "minimist": "^1.2.8", 83 | "node-forge": "^1.3.3", 84 | "react": "^17.0.2", 85 | "react-dom": "^17.0.2", 86 | "react-monaco-editor": "^0.41.2", 87 | "react-page-visibility": "^7.0.0", 88 | "react-router-last-location": "git://github.com/hinok/react-router-last-location#2d57c676abb49f3f5755c44fbe2c24b01a8bb68b", 89 | "semver": "^7.7.3", 90 | "sirv-cli": "^3.0.1", 91 | "stream": "^0.0.3", 92 | "swagger-ui-react": "^5.31.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /adash.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "fmt" 7 | _ "github.com/altinity/altinity-dashboard/internal/api" 8 | "github.com/altinity/altinity-dashboard/internal/server" 9 | "github.com/altinity/altinity-dashboard/internal/utils" 10 | _ "k8s.io/client-go/plugin/pkg/client/auth" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "runtime" 15 | ) 16 | 17 | // App version info 18 | var appVersion string 19 | var chopRelease string 20 | 21 | // UI embedded files 22 | //go:embed ui/dist 23 | var uiFiles embed.FS 24 | 25 | // ClickHouse Operator deployment template embedded file 26 | //go:embed embed 27 | var embedFiles embed.FS 28 | 29 | // openWebBrowser opens the default web browser to a given URL 30 | func openWebBrowser(url string) { 31 | var err error 32 | switch runtime.GOOS { 33 | case "linux": 34 | err = exec.Command("xdg-open", url).Start() 35 | case "windows": 36 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 37 | case "darwin": 38 | err = exec.Command("open", url).Start() 39 | default: 40 | //nolint:goerr113 41 | err = fmt.Errorf("unsupported platform") 42 | } 43 | if err != nil { 44 | log.Printf("Error opening web browser: %s\n", err) 45 | } 46 | } 47 | 48 | func main() { 49 | // Set up CLI parser 50 | cmdFlags := flag.NewFlagSet("adash", flag.ContinueOnError) 51 | kubeconfig := cmdFlags.String("kubeconfig", "", "path to the kubeconfig file") 52 | devMode := cmdFlags.Bool("devmode", false, "show Developer Tools tab") 53 | bindHost := cmdFlags.String("bindhost", "localhost", "host to bind to (use 0.0.0.0 for all interfaces)") 54 | bindPort := cmdFlags.String("bindport", "", "port to listen on") 55 | tlsCert := cmdFlags.String("tlscert", "", "certificate file to use to serve TLS") 56 | tlsKey := cmdFlags.String("tlskey", "", "private key file to use to serve TLS") 57 | selfSigned := cmdFlags.Bool("selfsigned", false, "run TLS using self-signed key") 58 | noToken := cmdFlags.Bool("notoken", false, "do not require an auth token to access the UI") 59 | openBrowser := cmdFlags.Bool("openbrowser", false, "open the UI in a web browser after starting") 60 | version := cmdFlags.Bool("version", false, "show version and exit") 61 | debug := cmdFlags.Bool("debug", false, "enable debug logging") 62 | 63 | // Parse the CLI flags 64 | err := cmdFlags.Parse(os.Args[1:]) 65 | if err != nil { 66 | os.Exit(1) 67 | } 68 | 69 | // Read version info from embed files 70 | err = utils.ReadFilesToStrings(&embedFiles, []utils.FileToString{ 71 | {Filename: "embed/version", Dest: &appVersion}, 72 | {Filename: "embed/chop-release", Dest: &chopRelease}, 73 | }) 74 | if err != nil { 75 | fmt.Printf("Error reading version information") 76 | os.Exit(1) 77 | } 78 | 79 | // If version was requested, print it and exit 80 | if *version { 81 | fmt.Printf("Altinity Dashboard version %s\n", appVersion) 82 | fmt.Printf(" built using clickhouse-operator version %s\n", chopRelease) 83 | os.Exit(0) 84 | } 85 | 86 | // Start the server 87 | c := server.Config{ 88 | TLSCert: *tlsCert, 89 | TLSKey: *tlsKey, 90 | SelfSigned: *selfSigned, 91 | Debug: *debug, 92 | Kubeconfig: *kubeconfig, 93 | BindHost: *bindHost, 94 | BindPort: *bindPort, 95 | DevMode: *devMode, 96 | NoToken: *noToken, 97 | AppVersion: appVersion, 98 | ChopRelease: chopRelease, 99 | UIFiles: &uiFiles, 100 | EmbedFiles: &embedFiles, 101 | } 102 | err = c.RunServer() 103 | if err != nil { 104 | log.Fatalf("Error: %s", err) 105 | } 106 | log.Printf("Server started. Connect using: %s\n", c.URL) 107 | if *openBrowser { 108 | openWebBrowser(c.URL) 109 | } 110 | <-c.Context.Done() 111 | log.Fatalf("Error: %s", c.ServerError) 112 | } 113 | -------------------------------------------------------------------------------- /ui/src/app/routes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Route, RouteComponentProps, Switch } from 'react-router-dom'; 3 | import { accessibleRouteChangeHandler } from '@app/utils/utils'; 4 | import { Dashboard } from '@app/Dashboard/Dashboard'; 5 | import { Operators } from '@app/Operators/Operators'; 6 | import { NotFound } from '@app/NotFound/NotFound'; 7 | import { useDocumentTitle } from '@app/utils/useDocumentTitle'; 8 | import { LastLocationProvider, useLastLocation } from 'react-router-last-location'; 9 | import { CHIs } from '@app/CHIs/CHIs'; 10 | import { Devel } from '@app/Devel/Devel'; 11 | 12 | let routeFocusTimer: number; 13 | export interface IAppRoute { 14 | label?: string; // Excluding the label will exclude the route from the nav sidebar in AppLayout 15 | /* eslint-disable @typescript-eslint/no-explicit-any */ 16 | component: React.ComponentType> | React.ComponentType; 17 | /* eslint-enable @typescript-eslint/no-explicit-any */ 18 | exact?: boolean; 19 | path: string; 20 | title: string; 21 | isAsync?: boolean; 22 | routes?: undefined; 23 | } 24 | 25 | export interface IAppRouteGroup { 26 | label: string; 27 | routes: IAppRoute[]; 28 | } 29 | 30 | export type AppRouteConfig = IAppRoute | IAppRouteGroup; 31 | 32 | const routes: AppRouteConfig[] = [ 33 | { 34 | component: Dashboard, 35 | exact: true, 36 | isAsync: true, 37 | label: 'Dashboard', 38 | path: '/', 39 | title: 'Altinity Dashboard', 40 | }, 41 | { 42 | component: Operators, 43 | exact: true, 44 | isAsync: true, 45 | label: 'ClickHouse Operators', 46 | path: '/operators', 47 | title: 'Altinity Dashboard | ClickHouse Operators', 48 | }, 49 | { 50 | component: CHIs, 51 | exact: true, 52 | isAsync: true, 53 | label: 'ClickHouse Installations', 54 | path: '/chis', 55 | title: 'Altinity Dashboard | ClickHouse Installations', 56 | }, 57 | ]; 58 | 59 | const devmode = document.querySelector('meta[name="devmode"]') 60 | if (devmode != null && devmode.getAttribute('content') == "true") { 61 | routes.push( 62 | { 63 | component: Devel, 64 | exact: true, 65 | isAsync: true, 66 | label: 'Developer Tools', 67 | path: '/devel', 68 | title: 'Altinity Dashboard | Dev', 69 | }, 70 | ) 71 | } 72 | 73 | // a custom hook for sending focus to the primary content container 74 | // after a view has loaded so that subsequent press of tab key 75 | // sends focus directly to relevant content 76 | const useA11yRouteChange = (isAsync: boolean) => { 77 | const lastNavigation = useLastLocation(); 78 | React.useEffect(() => { 79 | if (!isAsync && lastNavigation !== null) { 80 | routeFocusTimer = accessibleRouteChangeHandler(); 81 | } 82 | return () => { 83 | window.clearTimeout(routeFocusTimer); 84 | }; 85 | }, [isAsync, lastNavigation]); 86 | }; 87 | 88 | const RouteWithTitleUpdates = ({ component: Component, isAsync = false, title, ...rest }: IAppRoute) => { 89 | useA11yRouteChange(isAsync); 90 | useDocumentTitle(title); 91 | 92 | function routeWithTitle(routeProps: RouteComponentProps) { 93 | return ; 94 | } 95 | 96 | return ; 97 | }; 98 | 99 | const PageNotFound = ({ title }: { title: string }) => { 100 | useDocumentTitle(title); 101 | return ; 102 | }; 103 | 104 | const flattenedRoutes: IAppRoute[] = routes.reduce( 105 | (flattened, route) => [...flattened, ...(route.routes ? route.routes : [route])], 106 | [] as IAppRoute[] 107 | ); 108 | 109 | const AppRoutes = (): React.ReactElement => ( 110 | 111 | 112 | {flattenedRoutes.map(({ path, exact, component, title, isAsync }, idx) => ( 113 | 121 | ))} 122 | 123 | 124 | 125 | ); 126 | 127 | export { AppRoutes, routes }; 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Altinity Dashboard 2 | 3 | This is a preliminary version of the Altinity Dashboard. It is used for viewing and managing Kubernetes-based ClickHouse installations controlled by [clickhouse-operator](https://github.com/altinity/clickhouse-operator). It looks like this: 4 | 5 | ![image](https://user-images.githubusercontent.com/2052848/146246541-4073218c-92be-4ccb-8a4d-b5bbc7f9a309.png) 6 | 7 | ### What is this? 8 | 9 | The Altinity Dashboard allows easy deployment and management of ClickHouse in Kubernetes, managed using the Altinity clickhouse-operator. Using the dashboard, you can: 10 | 11 | * Deploy clickhouse-operator to your Kubernetes cluster. 12 | * Upgrade clickhouse-operator. 13 | * Remove clickhouse-operator. 14 | 15 | * Deploy a ClickHouse Installation from a YAML specification (examples are provided), including the ability to define the cluster layout, storage, users and other operational parameters. 16 | * Modify existing ClickHouse Installations, even if they were not created by the Dashboard (as long as they are managed by clickhouse-operator). 17 | 18 | * View containers and storage used by ClickHouse Installations, and their status. 19 | 20 | ### Production Readiness 21 | 22 | Current builds of Altinity Dashboard should be considered pre-release, and are not ready for production deployment. We are using an upstream-first open source development model, so you can see and run the code, but it is not yet a stable release. 23 | 24 | ### How to Use 25 | 26 | * First, make sure you have a valid kubeconfig pointing to the Kubernetes cluster you want to work with. 27 | 28 | * Linux / Mac: 29 | * Download the appropriate file for your platform from https://github.com/Altinity/altinity-dashboard/releases. 30 | * `chmod a+x adash-linux-*` 31 | * `./adash-linux-* --openbrowser` 32 | 33 | * Windows: 34 | * Download and double-click on the Windows EXE file from https://github.com/Altinity/altinity-dashboard/releases. 35 | * A command prompt window will open and will show a URL. 36 | * Copy and paste the URL into a web browser. 37 | * Windows SmartScreen Filter may warn that the EXE file is rarely downloaded. You can ignore this. 38 | 39 | ### Running the container image from the GitHub Container Registry 40 | 41 | Container images are available on the GitHub Container Registry. To use this: 42 | 43 | * Run `docker pull ghcr.io/altinity/altinity-dashboard:latest` to get the latest build of the container. 44 | * Run `docker run -it --rm ghcr.io/altinity/altinity-dashboard:latest adash --help`. If everything is working, you should see command-line help. 45 | * If you run this container inside Kubernetes, it should perform in-cluster auth. 46 | * To run it outside Kubernetes, you will need to volume mount a kubeconfig file and use `-kubeconfig` to point to it. 47 | 48 | ### Building from source 49 | 50 | * Install the following on your development system: 51 | * [**Go**](https://golang.org/doc/install) 1.16 or higher 52 | * [**Node.js**](https://nodejs.org/en/download/) v16 or higher, including npm 7.24 or higher 53 | * **GNU Make** version 4.3 or higher (`yum/dnf/apt install make`) 54 | * Clone the repo (`git clone git@github.com:altinity/altinity-dashboard`). 55 | * Initialize submodules (`git submodule update --init --recursive`). 56 | * Run `make`. 57 | 58 | If you are doing development work, it is recommended to install pre-commit hooks so that linters are run before commit. To set this up: 59 | 60 | * Install [golangci-lint](https://github.com/golangci/golangci-lint#readme). 61 | * From the repo root, run `npm --prefix ./ui run install-git-hooks`. 62 | * Run `make lint` to check that everything is working. 63 | 64 | ### Setting up a development environment 65 | 66 | Back-end development: 67 | 68 | * Set up your IDE to run `make ui` before compiling, so that the most recent UI gets embedded into the Go binary. If nothing in the UI has changed, `make ui` will not re-run the build unnecessarily. 69 | * Run the app in the debugger with `adash -devmode`. This will add a tab with [Swagger UI](https://swagger.io/tools/swagger-ui/) that lets you exercise REST endpoints even if there isn't a UI for them yet. 70 | 71 | Front-end development: 72 | 73 | * `make ui-devel` will start a filesystem watcher / hot reloader for UI development. 74 | 75 | ### Talk to Us 76 | 77 | If you have questions or want to chat, [join the altinitydb Slack](https://join.slack.com/t/altinitydbworkspace/shared_invite/zt-w6mpotc1-fTz9oYp0VM719DNye9UvrQ) and talk to us in the `#kubernetes` channel. 78 | 79 | -------------------------------------------------------------------------------- /internal/api/api_model.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Namespace struct { 4 | Name string `json:"name" description:"name of the namespace"` 5 | } 6 | 7 | type Pod struct { 8 | Name string `json:"name" description:"name of the pod"` 9 | Node string `json:"node" description:"node the pod is nominated to run on"` 10 | Status string `json:"status" description:"status of the pod"` 11 | Containers []Container `json:"containers" description:"containers in the pod"` 12 | PVCs []PersistentVolumeClaim `json:"pvcs" description:"persistent volume claims of this pod"` 13 | } 14 | 15 | type Container struct { 16 | Name string `json:"name" description:"name of the container"` 17 | State string `json:"state" description:"status of the container"` 18 | Image string `json:"image" description:"image used by the container"` 19 | } 20 | 21 | type PersistentVolume struct { 22 | Name string `json:"name" description:"name of the PV"` 23 | Phase string `json:"phase" description:"status of the PV"` 24 | StorageClass string `json:"storage_class" description:"storage class name of the PV"` 25 | Capacity int64 `json:"capacity" description:"capacity of the PV"` 26 | ReclaimPolicy string `json:"reclaim_policy" description:"reclaim policy of the PV"` 27 | } 28 | 29 | type PersistentVolumeClaim struct { 30 | Name string `json:"name" description:"name of the PVC"` 31 | Namespace string `json:"namespace" description:"namespace the PVC is in"` 32 | Phase string `json:"phase" description:"phase of the PVC"` 33 | StorageClass string `json:"storage_class" description:"requested storage class name of the PVC"` 34 | Capacity int64 `json:"capacity" description:"requested capacity of the PVC"` 35 | BoundPV *PersistentVolume `json:"bound_pv,omitempty" description:"PV bound to this PVC"` 36 | } 37 | 38 | type Operator struct { 39 | Name string `json:"name" description:"name of the operator"` 40 | Namespace string `json:"namespace" description:"namespace the operator is in"` 41 | Conditions string `json:"conditions" description:"conditions of the operator"` 42 | Version string `json:"version" description:"version of the operator"` 43 | ConfigYaml string `json:"config_yaml" description:"operator config as a YAML string"` 44 | Pods []OperatorPod `json:"pods" description:"pods managed by the operator"` 45 | } 46 | 47 | type OperatorPod struct { 48 | Pod 49 | Version string `json:"version" description:"version of the pod"` 50 | } 51 | 52 | type ResourceSpecMetadata struct { 53 | Name string `json:"name"` 54 | Namespace string `json:"namespace"` 55 | ResourceVersion string `json:"resourceVersion"` 56 | } 57 | 58 | type ResourceSpec struct { 59 | APIVersion string `json:"apiVersion"` 60 | Kind string `json:"kind"` 61 | Metadata ResourceSpecMetadata `json:"metadata"` 62 | Spec interface{} `json:"spec"` 63 | } 64 | 65 | type Chi struct { 66 | Name string `json:"name" description:"name of the ClickHouse installation"` 67 | Namespace string `json:"namespace" description:"namespace the installation is in"` 68 | Status string `json:"status" description:"status of the installation"` 69 | Clusters int `json:"clusters" description:"number of clusters in the installation"` 70 | Hosts int `json:"hosts" description:"number of hosts in the installation"` 71 | ExternalURL string `json:"external_url" description:"external URL of the loadbalancer service"` 72 | ResourceYAML string `json:"resource_yaml" description:"Kubernetes YAML spec of the CHI resource"` 73 | CHClusterPods []CHClusterPod `json:"ch_cluster_pods" description:"ClickHouse cluster pods"` 74 | } 75 | 76 | type CHClusterPod struct { 77 | Pod 78 | ClusterName string `json:"cluster_name" description:"name of the ClickHouse cluster"` 79 | } 80 | 81 | type Dashboard struct { 82 | KubeCluster string `json:"kube_cluster" description:"kubernetes cluster name"` 83 | KubeVersion string `json:"kube_version" description:"kubernetes cluster version"` 84 | ChopCount int `json:"chop_count" description:"number of clickhouse-operators deployed"` 85 | ChopCountAvailable int `json:"chop_count_available" description:"number of clickhouse-operators available"` 86 | ChiCount int `json:"chi_count" description:"number of ClickHouse Installations deployed"` 87 | ChiCountComplete int `json:"chi_count_complete" description:"number of ClickHouse Installations completed"` 88 | } 89 | -------------------------------------------------------------------------------- /internal/api/api_k8s.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/altinity/altinity-dashboard/internal/utils" 7 | corev1 "k8s.io/api/core/v1" 8 | errors2 "k8s.io/apimachinery/pkg/api/errors" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/labels" 11 | ) 12 | 13 | func getContainersFromPod(pod *corev1.Pod) []Container { 14 | cs := pod.Status.ContainerStatuses 15 | list := make([]Container, 0, len(cs)) 16 | for _, c := range cs { 17 | state := "Unknown" 18 | switch { 19 | case c.State.Terminated != nil: 20 | state = "Terminated" 21 | case c.State.Running != nil: 22 | state = "Running" 23 | case c.State.Waiting != nil: 24 | state = "Waiting" 25 | } 26 | list = append(list, Container{ 27 | Name: c.Name, 28 | State: state, 29 | Image: c.Image, 30 | }) 31 | } 32 | return list 33 | } 34 | 35 | func getPVCsFromPod(pod *corev1.Pod) ([]PersistentVolumeClaim, error) { 36 | k := utils.GetK8s() 37 | defer func() { k.ReleaseK8s() }() 38 | 39 | list := make([]PersistentVolumeClaim, 0) 40 | for _, vol := range pod.Spec.Volumes { 41 | if vol.PersistentVolumeClaim != nil { 42 | pvc, err := k.Clientset.CoreV1().PersistentVolumeClaims(pod.Namespace).Get(context.TODO(), 43 | vol.PersistentVolumeClaim.ClaimName, metav1.GetOptions{}) 44 | if err != nil { 45 | return nil, err 46 | } 47 | var pv *corev1.PersistentVolume 48 | if pvc.Spec.VolumeName != "" { 49 | pv, err = k.Clientset.CoreV1().PersistentVolumes().Get(context.TODO(), 50 | pvc.Spec.VolumeName, metav1.GetOptions{}) 51 | if err != nil { 52 | pv = nil 53 | var sv *errors2.StatusError 54 | if errors.As(err, &sv) { 55 | if sv.ErrStatus.Reason != "NotFound" { 56 | return nil, err 57 | } 58 | } else { 59 | return nil, err 60 | } 61 | } 62 | } 63 | var boundPV *PersistentVolume 64 | if pv != nil { 65 | var storageCapacity int64 66 | stor := pv.Spec.Capacity.Storage() 67 | if stor != nil { 68 | storageCapacity = stor.Value() 69 | } 70 | boundPV = &PersistentVolume{ 71 | Name: pv.Name, 72 | Phase: string(pv.Status.Phase), 73 | StorageClass: pv.Spec.StorageClassName, 74 | Capacity: storageCapacity, 75 | ReclaimPolicy: string(pv.Spec.PersistentVolumeReclaimPolicy), 76 | } 77 | } 78 | var storageClass string 79 | if pvc.Spec.StorageClassName != nil { 80 | storageClass = *pvc.Spec.StorageClassName 81 | } 82 | var storageCapacity int64 83 | stor := pvc.Spec.Resources.Requests.Storage() 84 | if stor != nil { 85 | storageCapacity = stor.Value() 86 | } 87 | list = append(list, PersistentVolumeClaim{ 88 | Name: pvc.Name, 89 | Namespace: pvc.Namespace, 90 | Phase: string(pvc.Status.Phase), 91 | StorageClass: storageClass, 92 | Capacity: storageCapacity, 93 | BoundPV: boundPV, 94 | }) 95 | } 96 | } 97 | return list, nil 98 | } 99 | 100 | func getK8sPodsFromLabelSelector(namespace string, selector *metav1.LabelSelector) (*corev1.PodList, error) { 101 | ls, err := metav1.LabelSelectorAsMap(selector) 102 | if err != nil { 103 | return nil, err 104 | } 105 | k := utils.GetK8s() 106 | defer func() { k.ReleaseK8s() }() 107 | pods, err := k.Clientset.CoreV1().Pods(namespace).List(context.TODO(), 108 | metav1.ListOptions{ 109 | LabelSelector: labels.SelectorFromSet(ls).String(), 110 | }, 111 | ) 112 | if err != nil { 113 | return nil, err 114 | } 115 | return pods, nil 116 | } 117 | 118 | func getK8sServicesFromLabelSelector(namespace string, selector *metav1.LabelSelector) (*corev1.ServiceList, error) { 119 | ls, err := metav1.LabelSelectorAsMap(selector) 120 | if err != nil { 121 | return nil, err 122 | } 123 | k := utils.GetK8s() 124 | defer func() { k.ReleaseK8s() }() 125 | var services *corev1.ServiceList 126 | services, err = k.Clientset.CoreV1().Services(namespace).List(context.TODO(), 127 | metav1.ListOptions{ 128 | LabelSelector: labels.SelectorFromSet(ls).String(), 129 | }, 130 | ) 131 | if err != nil { 132 | return nil, err 133 | } 134 | return services, nil 135 | } 136 | 137 | func getPodFromK8sPod(pod *corev1.Pod) (*Pod, error) { 138 | pvcs, err := getPVCsFromPod(pod) 139 | if err != nil { 140 | return nil, err 141 | } 142 | return &Pod{ 143 | Name: pod.Name, 144 | Node: pod.Spec.NodeName, 145 | Status: string(pod.Status.Phase), 146 | Containers: getContainersFromPod(pod), 147 | PVCs: pvcs, 148 | }, nil 149 | } 150 | 151 | func getPodsFromK8sPods(pods *corev1.PodList) ([]*Pod, error) { 152 | list := make([]*Pod, 0, len(pods.Items)) 153 | for i := range pods.Items { 154 | k8pod := pods.Items[i] 155 | pod, err := getPodFromK8sPod(&k8pod) 156 | if err != nil { 157 | return nil, err 158 | } 159 | list = append(list, pod) 160 | } 161 | return list, nil 162 | } 163 | -------------------------------------------------------------------------------- /ui/src/app/Components/ExpandableTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ExpandableRowContent, TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; 3 | import { ReactElement, useState } from 'react'; 4 | import { TdActionsType } from '@patternfly/react-table/dist/js/components/Table/base'; 5 | import { HelperText, HelperTextItem } from '@patternfly/react-core'; 6 | 7 | export type WarningType = {variant: 'default' | 'indeterminate' | 'warning' | 'success' | 'error', text: string} 8 | 9 | export const ExpandableTable: React.FunctionComponent< 10 | { 11 | data: Array 12 | columns: Array 13 | column_fields: Array 14 | warnings?: Array|undefined> 15 | expanded_content?: (object) => ReactElement 16 | actions?: (object) => TdActionsType 17 | table_variant?: "compact" 18 | keyPrefix: string 19 | data_modifier?: (data: object, field: string) => ReactElement|string 20 | }> = (props) => { 21 | const [rowsExpanded, setRowsExpanded] = useState(new Map()) 22 | const getExpanded = (opIndex: number): boolean => { 23 | return rowsExpanded.get(opIndex) || false 24 | } 25 | const setExpanded = (opIndex: number, expanded: boolean) => { 26 | setRowsExpanded(new Map(rowsExpanded.set(opIndex, expanded))) 27 | } 28 | const handleExpansionToggle = (_event, pairIndex: number) => { 29 | setExpanded(pairIndex, !getExpanded(pairIndex)) 30 | } 31 | let rowIndex = -2 32 | const menuHeader = props.actions ? : null 33 | const menuBody = (item: object): ReactElement|null => { 34 | if (props.actions) { 35 | return () 36 | } else { 37 | return null 38 | } 39 | } 40 | return ( 41 | 42 | 43 | 44 | { 45 | props.expanded_content ? 46 | : 47 | null 48 | } 49 | { 50 | props.columns.map((column, columnIndex) => ( 51 | {column} 52 | )) 53 | } 54 | { menuHeader } 55 | 56 | 57 | { 58 | props.data.map((dataItem, dataIndex) => { 59 | rowIndex += 2 60 | let warningClass: string|undefined = undefined 61 | let warningContent: ReactElement|null = null 62 | if (props.warnings) { 63 | const warnings = props.warnings[dataIndex] 64 | if (warnings) { 65 | warningClass = "table-row-with-warning" 66 | warningContent = ( 67 | 68 | 69 | 70 | 71 | { 72 | warnings.map((warning, index) => { 73 | const { variant, text } = warning 74 | return ( 75 | 76 | {text} 77 | 78 | ) 79 | }) 80 | } 81 | 82 | 83 | 84 | ) 85 | } 86 | } 87 | return ( 88 | 89 | 90 | { 91 | props.expanded_content ? 92 | : 97 | null 98 | } 99 | { 100 | props.columns.map((column, columnIndex) => ( 101 | 102 | { 103 | props.data_modifier ? 104 | props.data_modifier(dataItem, props.column_fields[columnIndex]) : 105 | dataItem[props.column_fields[columnIndex]] 106 | } 107 | 108 | )) 109 | } 110 | {menuBody(dataItem)} 111 | 112 | {warningContent} 113 | { 114 | props.expanded_content ? ( 115 | 117 | 118 | 120 | 121 | {props.expanded_content(dataItem)} 122 | 123 | 124 | 125 | ) : null 126 | } 127 | 128 | ) 129 | }) 130 | } 131 | 132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /ui/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 5 | const Dotenv = require('dotenv-webpack'); 6 | const BG_IMAGES_DIRNAME = 'bgimages'; 7 | const ASSET_PATH = process.env.ASSET_PATH || '/'; 8 | const webpack = require('webpack'); 9 | module.exports = env => { 10 | 11 | return { 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(tsx|ts|jsx)?$/, 16 | use: [ 17 | { 18 | loader: 'ts-loader', 19 | options: { 20 | transpileOnly: true, 21 | experimentalWatchApi: true, 22 | } 23 | } 24 | ] 25 | }, 26 | { 27 | test: /\.(svg|ttf|eot|woff|woff2)$/, 28 | // only process modules with this loader 29 | // if they live under a 'fonts' or 'pficon' directory 30 | include: [ 31 | path.resolve(__dirname, 'node_modules/patternfly/dist/fonts'), 32 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/fonts'), 33 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/pficon'), 34 | path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/fonts'), 35 | path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/pficon') 36 | ], 37 | use: { 38 | loader: 'file-loader', 39 | options: { 40 | // Limit at 50k. larger files emited into separate files 41 | limit: 5000, 42 | outputPath: 'fonts', 43 | name: '[name].[ext]', 44 | } 45 | } 46 | }, 47 | { 48 | test: /\.svg$/, 49 | include: input => input.indexOf('background-filter.svg') > 1, 50 | use: [ 51 | { 52 | loader: 'url-loader', 53 | options: { 54 | limit: 5000, 55 | outputPath: 'svgs', 56 | name: '[name].[ext]', 57 | } 58 | } 59 | ] 60 | }, 61 | { 62 | test: /\.svg$/, 63 | // only process SVG modules with this loader if they live under a 'bgimages' directory 64 | // this is primarily useful when applying a CSS background using an SVG 65 | include: input => input.indexOf(BG_IMAGES_DIRNAME) > -1, 66 | use: { 67 | loader: 'svg-url-loader', 68 | options: {} 69 | } 70 | }, 71 | { 72 | test: /\.svg$/, 73 | // only process SVG modules with this loader when they don't live under a 'bgimages', 74 | // 'fonts', or 'pficon' directory, those are handled with other loaders 75 | include: input => ( 76 | (input.indexOf(BG_IMAGES_DIRNAME) === -1) && 77 | (input.indexOf('fonts') === -1) && 78 | (input.indexOf('background-filter') === -1) && 79 | (input.indexOf('pficon') === -1) 80 | ), 81 | use: { 82 | loader: 'raw-loader', 83 | options: {} 84 | } 85 | }, 86 | { 87 | test: /\.(jpg|jpeg|png|gif)$/i, 88 | include: [ 89 | path.resolve(__dirname, 'src'), 90 | path.resolve(__dirname, 'node_modules/patternfly'), 91 | path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/images'), 92 | path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css/assets/images'), 93 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/images'), 94 | path.resolve(__dirname, 'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images'), 95 | path.resolve(__dirname, 'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images'), 96 | path.resolve(__dirname, 'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images') 97 | ], 98 | use: [ 99 | { 100 | loader: 'url-loader', 101 | options: { 102 | limit: 5000, 103 | outputPath: 'images', 104 | name: '[name].[ext]', 105 | } 106 | } 107 | ] 108 | } 109 | ] 110 | }, 111 | output: { 112 | filename: '[name].bundle.js', 113 | path: path.resolve(__dirname, 'dist'), 114 | publicPath: ASSET_PATH 115 | }, 116 | performance: { 117 | hints: false, 118 | maxEntrypointSize: 5000000, 119 | maxAssetSize: 5000000 120 | }, 121 | plugins: [ 122 | new HtmlWebpackPlugin({ 123 | template: path.resolve(__dirname, 'src', 'index.html') 124 | }), 125 | new Dotenv({ 126 | systemvars: true, 127 | silent: true 128 | }), 129 | new CopyPlugin({ 130 | patterns: [ 131 | { from: './src/favicon.png', to: 'images' }, 132 | ] 133 | }), 134 | new webpack.ProvidePlugin({ 135 | Buffer: ['buffer', 'Buffer'], 136 | }), 137 | ], 138 | resolve: { 139 | extensions: ['.js', '.ts', '.tsx', '.jsx'], 140 | plugins: [ 141 | new TsconfigPathsPlugin({ 142 | configFile: path.resolve(__dirname, './tsconfig.json') 143 | }) 144 | ], 145 | symlinks: false, 146 | cacheWithContext: false, 147 | fallback: { 148 | buffer: require.resolve('buffer/'), 149 | }, 150 | } 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /ui/src/app/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Alert, Bullseye, 4 | Card, 5 | CardBody, 6 | CardTitle, 7 | DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, 8 | Grid, 9 | GridItem, 10 | PageSection, 11 | TextContent, Text, TextVariants 12 | } from '@patternfly/react-core'; 13 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 14 | import { useEffect, useRef, useState } from 'react'; 15 | import { ChartDonutUtilization } from '@patternfly/react-charts'; 16 | import { Loading } from '@app/Components/Loading'; 17 | import { usePageVisibility } from 'react-page-visibility'; 18 | 19 | interface DashboardInfo { 20 | kube_cluster: string 21 | kube_version: string 22 | chop_count: number 23 | chop_count_available: number 24 | chi_count: number 25 | chi_count_complete: number 26 | } 27 | 28 | export const Dashboard: React.FunctionComponent = () => { 29 | const [dashboardInfo, setDashboardInfo] = useState(undefined) 30 | const [retrieveError, setRetrieveError] = useState(undefined) 31 | const [isPageLoading, setIsPageLoading] = useState(true) 32 | const mounted = useRef(false) 33 | const pageVisible = useRef(true) 34 | pageVisible.current = usePageVisibility() 35 | const fetchData = () => { 36 | fetchWithErrorHandling(`/api/v1/dashboard`, 'GET', 37 | undefined, 38 | (response, body) => { 39 | setRetrieveError(undefined) 40 | setDashboardInfo(body as DashboardInfo) 41 | setIsPageLoading(false) 42 | return mounted.current ? 2000 : 0 43 | }, 44 | (response, text, error) => { 45 | const errorMessage = (error == "") ? text : `${error}: ${text}` 46 | setRetrieveError(`Error retrieving CHIs: ${errorMessage}`) 47 | setDashboardInfo(undefined) 48 | setIsPageLoading(false) 49 | return mounted.current ? 10000 : 0 50 | }, 51 | () => { 52 | if (!mounted.current) { 53 | return -1 54 | } else if (!pageVisible.current) { 55 | return 2000 56 | } else { 57 | return 0 58 | } 59 | }) 60 | } 61 | useEffect(() => { 62 | mounted.current = true 63 | fetchData() 64 | return () => { 65 | mounted.current = false 66 | } 67 | }, 68 | // eslint-disable-next-line react-hooks/exhaustive-deps 69 | []) 70 | const retrieveErrorPane = retrieveError === undefined ? null : ( 71 | 72 | ) 73 | const version = (document.querySelector('meta[name="version"]') as HTMLMetaElement)?.content || "unknown" 74 | const chopRelease = (document.querySelector('meta[name="chop-release"]') as HTMLMetaElement)?.content || "unknown" 75 | return ( 76 | 77 | {isPageLoading ? ( 78 | 79 | 80 | 81 | ) : ( 82 | 83 | {retrieveErrorPane} 84 | 85 | 86 | 87 | Details 88 | 89 | 90 | 91 | 92 | Altinity Dashboard 93 | 94 | 95 | {dashboardInfo ? ( 96 | 97 |
altinity-dashboard: {version}
98 |
clickhouse-operator: {chopRelease}
99 |
100 | ) : "unknown"} 101 |
102 |
103 | 104 | 105 | Kubernetes Cluster 106 | 107 | 108 | {dashboardInfo ? ( 109 | 110 |
k8s api: {dashboardInfo.kube_cluster}
111 |
k8s version: {dashboardInfo.kube_version}
112 |
113 | ) : "unknown"} 114 |
115 |
116 |
117 |
118 |
119 |
120 | 121 | 122 | ClickHouse Operators 123 | 124 | 125 | {dashboardInfo ? ( 126 |
127 | 0 132 | ? 100 * dashboardInfo.chop_count_available / dashboardInfo.chop_count 133 | : 0 134 | }} 135 | title={dashboardInfo.chop_count_available.toString() + " available"} 136 | subTitle={"of " + dashboardInfo.chop_count.toString() + " total"} 137 | /> 138 |
139 | ) : "unknown"} 140 |
141 |
142 |
143 |
144 | 145 | 146 | ClickHouse Installations 147 | 148 | 149 | {dashboardInfo ? ( 150 |
151 | 0 156 | ? 100 * dashboardInfo.chi_count_complete / dashboardInfo.chi_count 157 | : 0 158 | }} 159 | title={dashboardInfo.chi_count_complete.toString() + " complete"} 160 | subTitle={"of " + dashboardInfo.chi_count.toString() + " total"} 161 | /> 162 |
163 | ) : "unknown"} 164 |
165 |
166 |
167 |
168 |
169 | 170 | 171 | 172 | Altinity and the Altinity logos are trademarks of Altinity, Inc.   ClickHouse and the ClickHouse logos are trademarks of ClickHouse, Inc. 173 | 174 | 175 | 176 |
177 | )} 178 |
179 | ) 180 | } 181 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "embed" 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "github.com/altinity/altinity-dashboard/internal/api" 12 | "github.com/altinity/altinity-dashboard/internal/certs" 13 | "github.com/altinity/altinity-dashboard/internal/utils" 14 | restfulspec "github.com/emicklei/go-restful-openapi/v2" 15 | "github.com/emicklei/go-restful/v3" 16 | "github.com/go-openapi/spec" 17 | "io/fs" 18 | "net/http" 19 | "regexp" 20 | "strconv" 21 | "time" 22 | ) 23 | 24 | type Config struct { 25 | TLSCert string 26 | TLSKey string 27 | SelfSigned bool 28 | Debug bool 29 | Kubeconfig string 30 | BindHost string 31 | BindPort string 32 | DevMode bool 33 | NoToken bool 34 | AppVersion string 35 | ChopRelease string 36 | UIFiles *embed.FS 37 | EmbedFiles *embed.FS 38 | URL string 39 | IsHTTPS bool 40 | ServerError error 41 | Context context.Context 42 | Cancel func() 43 | } 44 | 45 | var ErrTLSCertKeyBothOrNeither = errors.New("TLS cert and key must both be provided or neither") 46 | var ErrTLSOrSelfSigned = errors.New("cannot provide TLS certificate and also run self-signed") 47 | 48 | func (c *Config) RunServer() error { 49 | // Check CLI flags for correctness 50 | if (c.TLSCert == "") != (c.TLSKey == "") { 51 | return ErrTLSCertKeyBothOrNeither 52 | } 53 | if (c.SelfSigned) && (c.TLSCert != "") { 54 | return ErrTLSOrSelfSigned 55 | } 56 | 57 | // Enable debug logging, if requested 58 | if c.Debug { 59 | api.ErrorsToConsole = true 60 | } 61 | 62 | // Connect to Kubernetes 63 | err := utils.InitK8s(c.Kubeconfig) 64 | if err != nil { 65 | return fmt.Errorf("could not connect to Kubernetes: %w", err) 66 | } 67 | 68 | // If self-signed, generate the certificates 69 | if c.SelfSigned { 70 | c.TLSCert, c.TLSKey, err = certs.GenerateSelfSignedCerts(true) 71 | if err != nil { 72 | return fmt.Errorf("error generating self-signed certificate: %w", err) 73 | } 74 | } 75 | 76 | // Determine default port, if one was not specified 77 | if c.BindPort == "" { 78 | if c.TLSCert != "" { 79 | c.BindPort = "8443" 80 | } else { 81 | c.BindPort = "8080" 82 | } 83 | } 84 | 85 | // Read the index.html from the bundled assets and update its devmode flag 86 | var indexHTML []byte 87 | indexHTML, err = c.UIFiles.ReadFile("ui/dist/index.html") 88 | if err != nil { 89 | return fmt.Errorf("error reading embedded UI files: %w", err) 90 | } 91 | for name, content := range map[string]string{ 92 | "devmode": strconv.FormatBool(c.DevMode), 93 | "version": c.AppVersion, 94 | "chop-release": c.ChopRelease, 95 | } { 96 | re := regexp.MustCompile(fmt.Sprintf(`meta name="%s" content="(\w*)"`, name)) 97 | indexHTML = re.ReplaceAll(indexHTML, 98 | []byte(fmt.Sprintf(`meta name="%s" content="%s"`, name, content))) 99 | } 100 | 101 | // Create HTTP router object 102 | httpMux := http.NewServeMux() 103 | 104 | // Create API handlers & docs 105 | rc := restful.NewContainer() 106 | rc.ServeMux = httpMux 107 | wsi := api.WebServiceInfo{ 108 | Version: c.AppVersion, 109 | ChopRelease: c.ChopRelease, 110 | Embed: c.EmbedFiles, 111 | } 112 | for _, resource := range []api.WebService{ 113 | &api.DashboardResource{}, 114 | &api.NamespaceResource{}, 115 | &api.OperatorResource{}, 116 | &api.ChiResource{}, 117 | } { 118 | var ws *restful.WebService 119 | ws, err = resource.WebService(&wsi) 120 | if err != nil { 121 | return fmt.Errorf("error initializing %s web service: %w", resource.Name(), err) 122 | } 123 | rc.Add(ws) 124 | } 125 | config := restfulspec.Config{ 126 | WebServices: rc.RegisteredWebServices(), // you control what services are visible 127 | APIPath: "/apidocs.json", 128 | PostBuildSwaggerObjectHandler: c.enrichSwaggerObject} 129 | rc.Add(restfulspec.NewOpenAPIService(config)) 130 | 131 | // Create handler for the CHI examples 132 | examples, err := c.EmbedFiles.ReadDir("embed/chi-examples") 133 | if err != nil { 134 | return fmt.Errorf("error reading embedded examples: %w", err) 135 | } 136 | exampleStrings := make([]string, 0, len(examples)) 137 | for _, ex := range examples { 138 | if ex.Type().IsRegular() { 139 | exampleStrings = append(exampleStrings, ex.Name()) 140 | } 141 | } 142 | exampleIndex, err := json.Marshal(exampleStrings) 143 | if err != nil { 144 | return fmt.Errorf("error reading example index JSON: %w", err) 145 | } 146 | subFilesChi, _ := fs.Sub(c.EmbedFiles, "embed/chi-examples") 147 | subServerChi := http.StripPrefix("/chi-examples/", http.FileServer(http.FS(subFilesChi))) 148 | httpMux.HandleFunc("/chi-examples/", func(w http.ResponseWriter, r *http.Request) { 149 | if r.URL.Path == "/chi-examples/index.json" { 150 | w.Header().Set("Content-Type", "application/json") 151 | _, _ = w.Write(exampleIndex) 152 | } else { 153 | subServerChi.ServeHTTP(w, r) 154 | } 155 | }) 156 | 157 | // Create handler for the UI assets 158 | subFiles, _ := fs.Sub(c.UIFiles, "ui/dist") 159 | subServer := http.FileServer(http.FS(subFiles)) 160 | httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 161 | if (r.URL.Path == "/") || 162 | (r.URL.Path == "/index.html") || 163 | (r.URL.Path == "/operators") || 164 | (r.URL.Path == "/chis") || 165 | (r.URL.Path == "/devel") { 166 | _, _ = w.Write(indexHTML) 167 | } else { 168 | subServer.ServeHTTP(w, r) 169 | } 170 | }) 171 | 172 | // Configure auth middleware 173 | c.IsHTTPS = c.TLSCert != "" 174 | var httpHandler http.Handler 175 | var authToken string 176 | if c.NoToken { 177 | httpHandler = httpMux 178 | } else { 179 | // Generate auth token 180 | randBytes := make([]byte, 256/8) 181 | _, err = rand.Read(randBytes) 182 | if err != nil { 183 | return fmt.Errorf("error generating random number: %w", err) 184 | } 185 | authToken = base64.RawURLEncoding.EncodeToString(randBytes) 186 | httpHandler = NewHandler(httpMux, authToken, c.IsHTTPS) 187 | } 188 | 189 | // Set up the server 190 | bindStr := fmt.Sprintf("%s:%s", c.BindHost, c.BindPort) 191 | var authStr string 192 | if authToken != "" { 193 | authStr = fmt.Sprintf("?token=%s", authToken) 194 | } 195 | var connHost string 196 | connHost, err = utils.BindHostToLocalHost(c.BindHost) 197 | if err != nil { 198 | return err 199 | } 200 | var urlScheme string 201 | if c.IsHTTPS { 202 | urlScheme = "https" 203 | } else { 204 | urlScheme = "http" 205 | } 206 | c.URL = fmt.Sprintf("%s://%s:%s%s", urlScheme, connHost, c.BindPort, authStr) 207 | 208 | // Start the server, but capture errors if it immediately fails to start 209 | c.Context, c.Cancel = context.WithCancel(context.Background()) 210 | go func() { 211 | srv := &http.Server{ 212 | Addr: bindStr, 213 | Handler: httpHandler, 214 | ReadHeaderTimeout: 3 * time.Second, 215 | } 216 | if c.IsHTTPS { 217 | c.ServerError = srv.ListenAndServeTLS(c.TLSCert, c.TLSKey) 218 | } else { 219 | c.ServerError = srv.ListenAndServe() 220 | } 221 | c.Cancel() 222 | }() 223 | 224 | select { 225 | case <-c.Context.Done(): 226 | return c.ServerError 227 | case <-time.After(250 * time.Millisecond): 228 | return nil 229 | } 230 | } 231 | 232 | func (c *Config) enrichSwaggerObject(swo *spec.Swagger) { 233 | swo.Info = &spec.Info{ 234 | InfoProps: spec.InfoProps{ 235 | Title: "Altinity Dashboard", 236 | Contact: &spec.ContactInfo{ 237 | ContactInfoProps: spec.ContactInfoProps{ 238 | Name: "Altinity", 239 | Email: "info@altinity.com", 240 | URL: "https://altinity.com", 241 | }, 242 | }, 243 | License: &spec.License{ 244 | LicenseProps: spec.LicenseProps{ 245 | Name: "Apache-2.0", 246 | URL: "https://www.apache.org/licenses/LICENSE-2.0", 247 | }, 248 | }, 249 | Version: c.AppVersion, 250 | }, 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /ui/src/app/CHIs/CHIModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ToggleModalSubProps } from '@app/Components/ToggleModal'; 3 | import { useContext, useEffect, useState } from 'react'; 4 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 5 | import { 6 | AlertVariant, 7 | Button, 8 | EmptyState, EmptyStateBody, EmptyStateIcon, EmptyStateSecondaryActions, Grid, GridItem, 9 | Modal, ModalVariant, Title 10 | } from '@patternfly/react-core'; 11 | import { CodeEditor, Language } from '@patternfly/react-code-editor'; 12 | import { NamespaceSelector } from '@app/Namespaces/NamespaceSelector'; 13 | import { editor } from 'monaco-editor'; 14 | import IStandaloneCodeEditor = editor.IStandaloneCodeEditor; 15 | import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon'; 16 | import { ListSelector } from '@app/Components/ListSelector'; 17 | import { StringHasher } from '@app/Components/StringHasher'; 18 | import { CHI } from '@app/CHIs/model'; 19 | import { AddAlertContext } from '@app/utils/alertContext'; 20 | 21 | export interface CHIModalProps extends ToggleModalSubProps { 22 | isUpdate?: boolean 23 | CHIName?: string 24 | CHINamespace?: string 25 | } 26 | 27 | export const CHIModal: React.FunctionComponent = (props: CHIModalProps) => { 28 | const { isModalOpen, isUpdate, CHIName, CHINamespace } = props 29 | const outerCloseModal = props.closeModal 30 | const [selectedNamespace, setSelectedNamespace] = useState("") 31 | const [yaml, setYaml] = useState("") 32 | const [exampleListValues, setExampleListValues] = useState(new Array()) 33 | const addAlert = useContext(AddAlertContext) 34 | 35 | const closeModal = (): void => { 36 | setSelectedNamespace("") 37 | outerCloseModal() 38 | } 39 | const setYamlFromEditor = (editor: IStandaloneCodeEditor) => { 40 | setYaml(editor.getValue()) 41 | } 42 | const onDeployClick = (): void => { 43 | const [url, method, action] = isUpdate ? 44 | [`/api/v1/chis/${CHINamespace}/${CHIName}`, 'PATCH', 'updating'] : 45 | [`/api/v1/chis/${selectedNamespace}`, 'POST', 'creating'] 46 | fetchWithErrorHandling(url, method, 47 | { 48 | yaml: yaml 49 | }, 50 | () => { 51 | setYaml("") 52 | }, 53 | (response, text, error) => { 54 | const errorMessage = (error == "") ? text : `${error}: ${text}` 55 | addAlert(`Error ${action} CHI: ${errorMessage}`, AlertVariant.danger) 56 | }) 57 | closeModal() 58 | } 59 | useEffect(() => { 60 | if (!isUpdate && exampleListValues.length === 0) { 61 | fetchWithErrorHandling(`/chi-examples/index.json`, 'GET', 62 | undefined, 63 | (response, body) => { 64 | const ev = body ? body as string[] : [] 65 | setExampleListValues(ev) 66 | }, 67 | () => { 68 | setExampleListValues([]) 69 | } 70 | ) 71 | } 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | }, [isUpdate]) 74 | useEffect(() => { 75 | if (isUpdate && isModalOpen) { 76 | fetchWithErrorHandling(`/api/v1/chis/${CHINamespace}/${CHIName}`, 'GET', 77 | undefined, 78 | (response, body) => { 79 | if (typeof body === 'object') { 80 | setYaml((body[0] as CHI).resource_yaml); 81 | } 82 | }, 83 | (response, text, error) => { 84 | addAlert(`Error retrieving CHI: ${error}`, AlertVariant.danger) 85 | closeModal() 86 | } 87 | ) 88 | } else { 89 | setYaml("") 90 | } 91 | // eslint-disable-next-line react-hooks/exhaustive-deps 92 | }, [CHIName, CHINamespace, isModalOpen, isUpdate]) 93 | const buttons = ( 94 | 95 | 99 | 102 | 103 | ) 104 | return ( 105 | 114 | 115 | { isUpdate ? buttons : ( 116 | 117 |
118 | Select a Namespace To Deploy To: 119 |
120 | 121 |
122 | )} 123 |
124 | 125 |
126 | The settings available here are explained in the ClickHouse Custom Resource Documentation. 127 |
128 |
129 | 130 |
131 | Use the to generate values for the user/password_sha256_hex field. 135 |
136 |
137 | { isUpdate ? null : ( 138 | 139 | {buttons} 140 | 141 | )} 142 | 143 | )} 144 | > 145 | 160 | 161 | 162 | Update ClickHouse Installation 163 | 164 | Loading current YAML spec... 165 | 166 | ) : 167 | ( 168 | 169 | 170 | 171 | Start editing 172 | 173 | Drag and drop a file or click the upload icon above to upload one. 174 | 175 | 178 | 179 |
180 | Or start from a predefined example: 181 |
182 |
183 | { 186 | fetchWithErrorHandling(`/chi-examples/${value}`, 'GET', 187 | undefined, 188 | (response, body) => { 189 | if (body && typeof(body) === 'string') { 190 | setYaml(body) 191 | } 192 | }, 193 | undefined 194 | ) 195 | }} 196 | /> 197 |
198 |
199 | ) 200 | } 201 | /> 202 |
203 | ) 204 | } 205 | -------------------------------------------------------------------------------- /ui/src/app/Operators/Operators.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Alert, 4 | AlertVariant, 5 | ButtonVariant, 6 | PageSection, 7 | Split, 8 | SplitItem, 9 | Title 10 | } from '@patternfly/react-core'; 11 | import { useContext, useEffect, useRef, useState } from 'react'; 12 | import { usePageVisibility } from 'react-page-visibility'; 13 | import * as semver from 'semver'; 14 | 15 | import { SimpleModal } from '@app/Components/SimpleModal'; 16 | import { ExpandableTable, WarningType } from '@app/Components/ExpandableTable'; 17 | import { ToggleModal, ToggleModalSubProps } from '@app/Components/ToggleModal'; 18 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 19 | import { NewOperatorModal } from '@app/Operators/NewOperatorModal'; 20 | import { Loading } from '@app/Components/Loading'; 21 | import { AddAlertContext } from '@app/utils/alertContext'; 22 | 23 | interface Container { 24 | name: string 25 | state: string 26 | image: string 27 | } 28 | 29 | interface OperatorPod { 30 | name: string 31 | status: string 32 | version: string 33 | containers: Array 34 | } 35 | 36 | interface Operator { 37 | name: string 38 | namespace: string 39 | conditions: string 40 | version: string 41 | pods: Array 42 | } 43 | 44 | export const Operators: React.FunctionComponent = () => { 45 | const [operators, setOperators] = useState(new Array()) 46 | const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) 47 | const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false) 48 | const [isPageLoading, setIsPageLoading] = useState(true) 49 | const [activeItem, setActiveItem] = useState(undefined) 50 | const [retrieveError, setRetrieveError] = useState(undefined) 51 | const mounted = useRef(false) 52 | const pageVisible = useRef(true) 53 | pageVisible.current = usePageVisibility() 54 | const addAlert = useContext(AddAlertContext) 55 | const fetchData = () => { 56 | fetchWithErrorHandling(`/api/v1/operators`, 'GET', 57 | undefined, 58 | (response, body) => { 59 | setOperators(body as Operator[]) 60 | setRetrieveError(undefined) 61 | setIsPageLoading(false) 62 | return mounted.current ? 2000 : 0 63 | }, 64 | (response, text, error) => { 65 | const errorMessage = (error == "") ? text : `${error}: ${text}` 66 | setRetrieveError(`Error retrieving operators: ${errorMessage}`) 67 | setIsPageLoading(false) 68 | return mounted.current ? 10000 : 0 69 | }, 70 | () => { 71 | if (!mounted.current) { 72 | return -1 73 | } else if (!pageVisible.current) { 74 | return 2000 75 | } else { 76 | return 0 77 | } 78 | }) 79 | } 80 | useEffect(() => { 81 | mounted.current = true 82 | fetchData() 83 | return () => { 84 | mounted.current = false 85 | } 86 | }, 87 | // eslint-disable-next-line react-hooks/exhaustive-deps 88 | []) 89 | const onDeleteClick = (item: Operator) => { 90 | setActiveItem(item) 91 | setIsDeleteModalOpen(true) 92 | } 93 | const onDeleteActionClick = () => { 94 | if (activeItem === undefined) { 95 | return 96 | } 97 | fetchWithErrorHandling(`/api/v1/operators/${activeItem.namespace}`, 98 | 'DELETE', 99 | undefined, 100 | undefined, 101 | (response, text, error) => { 102 | const errorMessage = (error == "") ? text : `${error}: ${text}` 103 | addAlert(`Error deleting operator: ${errorMessage}`, AlertVariant.danger) 104 | } 105 | ) 106 | } 107 | const closeDeleteModal = () => { 108 | setIsDeleteModalOpen(false) 109 | setActiveItem(undefined) 110 | } 111 | const onUpgradeClick = (item: Operator) => { 112 | setActiveItem(item) 113 | setIsUpgradeModalOpen(true) 114 | } 115 | const closeUpgradeModal = () => { 116 | setIsUpgradeModalOpen(false) 117 | setActiveItem(undefined) 118 | } 119 | const retrieveErrorPane = retrieveError === undefined ? null : ( 120 | 121 | ) 122 | const latestChop = (document.querySelector('meta[name="chop-release"]') as HTMLMetaElement)?.content || "latest" 123 | const latestChopVer = semver.valid(latestChop) 124 | const warnings = new Array|undefined>() 125 | operators.forEach(op => { 126 | const warningsList = new Array() 127 | const opVer = semver.valid(op.version) 128 | if (latestChopVer && opVer && semver.lt(opVer, latestChopVer)) { 129 | warningsList.push({ 130 | variant: "warning", 131 | text: "Operator is not the latest version.", 132 | }) 133 | } 134 | warnings.push(warningsList.length > 0 ? warningsList : undefined) 135 | }) 136 | return ( 137 | 138 | 147 | The operator will be removed from the {activeItem ? activeItem.namespace : "UNKNOWN"} namespace. 148 | 149 | 155 | 156 | 157 | 158 | ClickHouse Operators 159 | 160 | 161 | 162 | { 164 | return NewOperatorModal({ 165 | isModalOpen: props.isModalOpen, 166 | closeModal: props.closeModal, 167 | isUpgrade: false 168 | }) 169 | }} 170 | /> 171 | 172 | 173 | {isPageLoading ? ( 174 | 175 | ) : ( 176 | 177 | {retrieveErrorPane} 178 | { 185 | return { 186 | items: [ 187 | { 188 | title: "Upgrade", 189 | variant: "primary", 190 | onClick: () => {onUpgradeClick(item)} 191 | }, 192 | { 193 | title: "Delete", 194 | variant: "danger", 195 | onClick: () => {onDeleteClick(item)} 196 | }, 197 | ] 198 | } 199 | }} 200 | expanded_content={(data) => ( 201 | ( 208 | 214 | )} 215 | /> 216 | )} 217 | /> 218 | 219 | )} 220 | 221 | ) 222 | } 223 | -------------------------------------------------------------------------------- /internal/api/operator.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/altinity/altinity-dashboard/internal/utils" 8 | chopv1 "github.com/altinity/clickhouse-operator/pkg/apis/clickhouse.altinity.com/v1" 9 | "github.com/emicklei/go-restful/v3" 10 | appsv1 "k8s.io/api/apps/v1" 11 | corev1 "k8s.io/api/core/v1" 12 | errors2 "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "log" 16 | "net/http" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | // OperatorResource is the REST layer to Pods 22 | type OperatorResource struct { 23 | opDeployTemplate string 24 | chopRelease string 25 | } 26 | 27 | // OperatorPutParams is the object for parameters to an operator PUT request 28 | type OperatorPutParams struct { 29 | Version string `json:"version" description:"version of clickhouse-operator to deploy"` 30 | } 31 | 32 | // Name returns the name of the web service 33 | func (o *OperatorResource) Name() string { 34 | return "Operators" 35 | } 36 | 37 | // WebService creates a new service that can handle REST requests 38 | func (o *OperatorResource) WebService(wsi *WebServiceInfo) (*restful.WebService, error) { 39 | o.chopRelease = wsi.ChopRelease 40 | err := utils.ReadFilesToStrings(wsi.Embed, []utils.FileToString{ 41 | {Filename: "embed/clickhouse-operator-install-template.yaml", Dest: &o.opDeployTemplate}, 42 | }) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | ws := new(restful.WebService) 48 | ws. 49 | Path("/api/v1/operators"). 50 | Consumes(restful.MIME_JSON). 51 | Produces(restful.MIME_JSON) 52 | 53 | ws.Route(ws.GET("").To(o.handleGetOps). 54 | Doc("get all operators"). 55 | Writes([]Operator{}). 56 | Returns(200, "OK", []Operator{})) 57 | 58 | ws.Route(ws.PUT("/{namespace}").To(o.handlePutOp). 59 | Doc("deploy or update an operator"). 60 | Param(ws.PathParameter("namespace", "namespace to deploy to").DataType("string")). 61 | Reads(OperatorPutParams{}). 62 | Returns(200, "OK", Operator{})) 63 | 64 | ws.Route(ws.DELETE("/{namespace}").To(o.handleDeleteOp). 65 | Doc("delete an operator"). 66 | Param(ws.PathParameter("namespace", "namespace to delete from").DataType("string")). 67 | Returns(200, "OK", nil)) 68 | 69 | return ws, nil 70 | } 71 | 72 | func (o *OperatorResource) getOperatorPodsFromDeployment(namespace string, deployment appsv1.Deployment) ([]OperatorPod, error) { 73 | pods, err := getK8sPodsFromLabelSelector(namespace, deployment.Spec.Selector) 74 | if err != nil { 75 | return nil, err 76 | } 77 | list := make([]OperatorPod, 0, len(pods.Items)) 78 | for i := range pods.Items { 79 | k8pod := pods.Items[i] 80 | l := k8pod.Labels 81 | ver, ok := l["version"] 82 | if !ok { 83 | ver, ok = l["clickhouse.altinity.com/chop"] 84 | if !ok { 85 | ver = "unknown" 86 | } 87 | } 88 | var pod *Pod 89 | pod, err = getPodFromK8sPod(&k8pod) 90 | if err != nil { 91 | return nil, err 92 | } 93 | list = append(list, OperatorPod{ 94 | Pod: *pod, 95 | Version: ver, 96 | }) 97 | } 98 | return list, nil 99 | } 100 | 101 | func (o *OperatorResource) getOperators(namespace string) ([]Operator, error) { 102 | k := utils.GetK8s() 103 | defer func() { k.ReleaseK8s() }() 104 | deployments, err := k.Clientset.AppsV1().Deployments(namespace).List( 105 | context.TODO(), metav1.ListOptions{ 106 | LabelSelector: "app=clickhouse-operator", 107 | }) 108 | if err != nil { 109 | return nil, err 110 | } 111 | list := make([]Operator, 0, len(deployments.Items)) 112 | for _, deployment := range deployments.Items { 113 | conds := deployment.Status.Conditions 114 | condStrs := make([]string, 0, len(conds)) 115 | for _, cond := range conds { 116 | if cond.Status == corev1.ConditionTrue { 117 | condStrs = append(condStrs, string(cond.Type)) 118 | } 119 | } 120 | var condStr string 121 | if len(condStrs) > 0 { 122 | condStr = strings.Join(condStrs, ", ") 123 | } else { 124 | condStr = "Unavailable" 125 | } 126 | l := deployment.Labels 127 | ver, ok := l["version"] 128 | if !ok { 129 | ver, ok = l["clickhouse.altinity.com/chop"] 130 | if !ok { 131 | ver = "unknown" 132 | } 133 | } 134 | var pods []OperatorPod 135 | pods, err = o.getOperatorPodsFromDeployment(deployment.Namespace, deployment) 136 | if err != nil { 137 | return nil, err 138 | } 139 | list = append(list, Operator{ 140 | Name: deployment.Name, 141 | Namespace: deployment.Namespace, 142 | Conditions: condStr, 143 | Version: ver, 144 | Pods: pods, 145 | }) 146 | } 147 | return list, nil 148 | } 149 | 150 | func (o *OperatorResource) handleGetOps(_ *restful.Request, response *restful.Response) { 151 | ops, err := o.getOperators("") 152 | if err != nil { 153 | webError(response, http.StatusInternalServerError, err) 154 | return 155 | } 156 | _ = response.WriteEntity(ops) 157 | } 158 | 159 | // processTemplate replaces all instances of ${VAR} in a string with the map value 160 | func processTemplate(template string, vars map[string]string) string { 161 | for k, v := range vars { 162 | template = strings.ReplaceAll(template, "${"+k+"}", v) 163 | } 164 | return template 165 | } 166 | 167 | var ErrStillHaveCHIs = errors.New("cannot delete the last clickhouse-operator while CHI resources still exist") 168 | 169 | // deployOrDeleteOperator deploys or deletes a clickhouse-operator 170 | func (o *OperatorResource) deployOrDeleteOperator(namespace string, version string, doDelete bool) error { 171 | if version == "" { 172 | version = o.chopRelease 173 | } 174 | deploy := processTemplate(o.opDeployTemplate, map[string]string{ 175 | "OPERATOR_IMAGE": fmt.Sprintf("altinity/clickhouse-operator:%s", version), 176 | "METRICS_EXPORTER_IMAGE": fmt.Sprintf("altinity/metrics-exporter:%s", version), 177 | "OPERATOR_NAMESPACE": namespace, 178 | "METRICS_EXPORTER_NAMESPACE": namespace, 179 | "OPERATOR_IMAGE_PULL_POLICY": "Always", 180 | "METRICS_EXPORTER_IMAGE_PULL_POLICY": "Always", 181 | }) 182 | 183 | // Get existing operators 184 | k := utils.GetK8s() 185 | defer func() { k.ReleaseK8s() }() 186 | var ops []Operator 187 | ops, err := o.getOperators("") 188 | if err != nil { 189 | return err 190 | } 191 | 192 | if doDelete { 193 | if len(ops) == 1 && ops[0].Namespace == namespace { 194 | // Before deleting the last operator, make sure there won't be orphaned CHIs 195 | var chis *chopv1.ClickHouseInstallationList 196 | chis, err = k.ChopClientset.ClickhouseV1().ClickHouseInstallations("").List( 197 | context.TODO(), metav1.ListOptions{}) 198 | if err != nil { 199 | var se *errors2.StatusError 200 | if !errors.As(err, &se) || se.ErrStatus.Reason != metav1.StatusReasonNotFound || 201 | se.ErrStatus.Details.Group != "clickhouse.altinity.com" { 202 | return err 203 | } 204 | } 205 | if len(chis.Items) > 0 { 206 | return ErrStillHaveCHIs 207 | } 208 | // Delete cluster-wide resources (ie, CRDs) if we're really deleting the last operator 209 | namespace = "" 210 | } 211 | err = k.MultiYamlDelete(deploy, namespace) 212 | if err != nil { 213 | return err 214 | } 215 | } else { 216 | isUpgrade := false 217 | for _, op := range ops { 218 | if op.Namespace == namespace { 219 | isUpgrade = true 220 | } 221 | } 222 | if isUpgrade { 223 | err = k.MultiYamlApplySelectively(deploy, namespace, 224 | func(candidates []*unstructured.Unstructured) []*unstructured.Unstructured { 225 | selected := make([]*unstructured.Unstructured, 0) 226 | for _, c := range candidates { 227 | if c.GetKind() == "Deployment" { 228 | selected = append(selected, c) 229 | } 230 | } 231 | return selected 232 | }) 233 | if err != nil { 234 | return err 235 | } 236 | } else { 237 | err = k.MultiYamlApply(deploy, namespace) 238 | if err != nil { 239 | return err 240 | } 241 | } 242 | } 243 | return nil 244 | } 245 | 246 | // waitForOperator waits for an operator to exist in the namespace 247 | func (o *OperatorResource) waitForOperator(namespace string, timeout time.Duration) (*Operator, error) { 248 | startTime := time.Now() 249 | for { 250 | ops, err := o.getOperators(namespace) 251 | if err != nil { 252 | return nil, err 253 | } 254 | if len(ops) > 0 { 255 | return &ops[0], nil 256 | } 257 | if time.Now().After(startTime.Add(timeout)) { 258 | return nil, errors2.NewTimeoutError("timed out waiting for status", 30) 259 | } 260 | time.Sleep(500 * time.Millisecond) 261 | } 262 | } 263 | 264 | func (o *OperatorResource) handlePutOp(request *restful.Request, response *restful.Response) { 265 | namespace := request.PathParameter("namespace") 266 | if namespace == "" { 267 | webError(response, http.StatusBadRequest, ErrNamespaceRequired) 268 | return 269 | } 270 | putParams := OperatorPutParams{} 271 | err := request.ReadEntity(&putParams) 272 | if err != nil { 273 | webError(response, http.StatusBadRequest, err) 274 | return 275 | } 276 | err = o.deployOrDeleteOperator(namespace, putParams.Version, false) 277 | if err != nil { 278 | webError(response, http.StatusInternalServerError, err) 279 | return 280 | } 281 | op, err := o.waitForOperator(namespace, 15*time.Second) 282 | if err != nil { 283 | webError(response, http.StatusInternalServerError, err) 284 | return 285 | } 286 | k := utils.GetK8s() 287 | k.ReleaseK8s() 288 | err = k.Reinit() 289 | if err != nil { 290 | log.Printf("Error reinitializing the Kubernetes client: %s", err) 291 | webError(response, http.StatusInternalServerError, err) 292 | return 293 | } 294 | _ = response.WriteEntity(op) 295 | } 296 | 297 | func (o *OperatorResource) handleDeleteOp(request *restful.Request, response *restful.Response) { 298 | namespace := request.PathParameter("namespace") 299 | if namespace == "" { 300 | webError(response, http.StatusBadRequest, ErrNamespaceRequired) 301 | return 302 | } 303 | err := o.deployOrDeleteOperator(namespace, "", true) 304 | if err != nil { 305 | webError(response, http.StatusInternalServerError, err) 306 | return 307 | } 308 | _ = response.WriteEntity(nil) 309 | } 310 | -------------------------------------------------------------------------------- /internal/api/chi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/altinity/altinity-dashboard/internal/utils" 8 | chopv1 "github.com/altinity/clickhouse-operator/pkg/apis/clickhouse.altinity.com/v1" 9 | "github.com/emicklei/go-restful/v3" 10 | v1 "k8s.io/api/core/v1" 11 | errors2 "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "log" 15 | "net/http" 16 | "sigs.k8s.io/yaml" 17 | ) 18 | 19 | // ChiResource is the REST layer to ClickHouse Installations 20 | type ChiResource struct { 21 | } 22 | 23 | // ChiPutParams is the object for parameters to a CHI PUT request 24 | type ChiPutParams struct { 25 | YAML string `json:"yaml" description:"YAML of the CHI custom resource"` 26 | } 27 | 28 | // Name returns the name of the web service 29 | func (c *ChiResource) Name() string { 30 | return "ClickHouse Instances" 31 | } 32 | 33 | // WebService creates a new service that can handle REST requests 34 | func (c *ChiResource) WebService(_ *WebServiceInfo) (*restful.WebService, error) { 35 | ws := new(restful.WebService) 36 | ws. 37 | Path("/api/v1/chis"). 38 | Consumes(restful.MIME_JSON). 39 | Produces(restful.MIME_JSON) 40 | 41 | ws.Route(ws.GET("").To(c.getCHIs). 42 | Doc("get all ClickHouse Installations"). 43 | Writes([]Chi{}). 44 | Returns(200, "OK", []Chi{})) 45 | 46 | ws.Route(ws.GET("/{namespace}").To(c.getCHIs). 47 | Doc("get all ClickHouse Installations in a namespace"). 48 | Param(ws.PathParameter("namespace", "namespace to get from").DataType("string")). 49 | Writes([]Chi{}). 50 | Returns(200, "OK", []Chi{})) 51 | 52 | ws.Route(ws.GET("/{namespace}/{name}").To(c.getCHIs). 53 | Doc("get a single ClickHouse Installation"). 54 | Param(ws.PathParameter("namespace", "namespace to get from").DataType("string")). 55 | Param(ws.PathParameter("name", "name of the CHI to get").DataType("string")). 56 | Writes([]Chi{}). 57 | Returns(200, "OK", []Chi{})) 58 | 59 | ws.Route(ws.POST("/{namespace}").To(c.handlePostCHI). 60 | Doc("deploy a new ClickHouse Installation from YAML"). 61 | Param(ws.PathParameter("namespace", "namespace to deploy to").DataType("string")). 62 | Reads(ChiPutParams{}). 63 | Returns(200, "OK", nil)) 64 | 65 | ws.Route(ws.PATCH("/{namespace}/{name}").To(c.handlePatchCHI). 66 | Doc("update an existing ClickHouse Installation from YAML"). 67 | Param(ws.PathParameter("namespace", "namespace the CHI is in").DataType("string")). 68 | Param(ws.PathParameter("name", "name of the CHI to update").DataType("string")). 69 | Reads(ChiPutParams{}). 70 | Returns(200, "OK", nil)) 71 | 72 | ws.Route(ws.DELETE("/{namespace}/{name}").To(c.handleDeleteCHI). 73 | Doc("delete a ClickHouse installation"). 74 | Param(ws.PathParameter("namespace", "namespace to delete from").DataType("string")). 75 | Param(ws.PathParameter("name", "name of the CHI to delete").DataType("string")). 76 | Returns(200, "OK", nil)) 77 | 78 | return ws, nil 79 | } 80 | 81 | func (c *ChiResource) getCHIs(request *restful.Request, response *restful.Response) { 82 | namespace, ok := request.PathParameters()["namespace"] 83 | if !ok { 84 | namespace = "" 85 | } 86 | name, ok := request.PathParameters()["name"] 87 | if !ok { 88 | name = "" 89 | } 90 | 91 | k := utils.GetK8s() 92 | defer func() { k.ReleaseK8s() }() 93 | var fieldSelector string 94 | if name != "" { 95 | fieldSelector = "metadata.name=" + name 96 | } 97 | 98 | getCHIs := func() (*chopv1.ClickHouseInstallationList, error) { 99 | chis, err := k.ChopClientset.ClickhouseV1().ClickHouseInstallations(namespace).List( 100 | context.TODO(), metav1.ListOptions{ 101 | FieldSelector: fieldSelector, 102 | }) 103 | if err != nil { 104 | var se *errors2.StatusError 105 | if errors.As(err, &se) { 106 | if se.ErrStatus.Reason == metav1.StatusReasonNotFound && 107 | se.ErrStatus.Details.Group == "clickhouse.altinity.com" { 108 | return nil, utils.ErrOperatorNotDeployed 109 | } 110 | } 111 | return nil, err 112 | } 113 | return chis, nil 114 | } 115 | chis, err := getCHIs() 116 | if errors.Is(err, utils.ErrOperatorNotDeployed) { 117 | // Before returning ErrOperatorNotDeployed, try reinitializing the k8s client, which may 118 | // be holding old information in its cache. (For example, it may not know about a CRD.) 119 | k.ReleaseK8s() 120 | err = k.Reinit() 121 | k = utils.GetK8s() 122 | if err != nil { 123 | log.Printf("Error reinitializing the Kubernetes client: %s", err) 124 | webError(response, http.StatusInternalServerError, err) 125 | return 126 | } 127 | chis, err = getCHIs() 128 | } 129 | if err != nil { 130 | webError(response, http.StatusInternalServerError, err) 131 | return 132 | } 133 | 134 | list := make([]Chi, 0, len(chis.Items)) 135 | for _, chi := range chis.Items { 136 | chClusterPods := make([]CHClusterPod, 0) 137 | errs := chi.WalkClusters(func(cluster *chopv1.ChiCluster) error { 138 | sel := &metav1.LabelSelector{ 139 | MatchLabels: map[string]string{ 140 | "clickhouse.altinity.com/chi": chi.Name, 141 | "clickhouse.altinity.com/cluster": cluster.Name, 142 | }, 143 | MatchExpressions: nil, 144 | } 145 | var kubePods *v1.PodList 146 | kubePods, err = getK8sPodsFromLabelSelector(chi.Namespace, sel) 147 | if err == nil { 148 | var pods []*Pod 149 | pods, err = getPodsFromK8sPods(kubePods) 150 | if err != nil { 151 | return err 152 | } 153 | for _, pod := range pods { 154 | chClusterPod := CHClusterPod{ 155 | Pod: *pod, 156 | ClusterName: cluster.Name, 157 | } 158 | chClusterPods = append(chClusterPods, chClusterPod) 159 | } 160 | } 161 | return nil 162 | }) 163 | for _, werr := range errs { 164 | if werr != nil { 165 | webError(response, http.StatusInternalServerError, werr) 166 | return 167 | } 168 | } 169 | var externalURL string 170 | var services *v1.ServiceList 171 | services, err = getK8sServicesFromLabelSelector(namespace, &metav1.LabelSelector{ 172 | MatchLabels: map[string]string{ 173 | "clickhouse.altinity.com/chi": chi.Name, 174 | }, 175 | }) 176 | if err == nil { 177 | for _, svc := range services.Items { 178 | if _, ok := svc.Labels["clickhouse.altinity.com/cluster"]; !ok && svc.Spec.Type == "LoadBalancer" { 179 | for _, ing := range svc.Status.LoadBalancer.Ingress { 180 | externalHost := "" 181 | if ing.Hostname != "" { 182 | externalHost = ing.Hostname 183 | } else if ing.IP != "" { 184 | externalHost = ing.IP 185 | } 186 | if externalHost == "" { 187 | continue 188 | } 189 | for _, port := range svc.Spec.Ports { 190 | if port.Name == "http" { 191 | externalURL = fmt.Sprintf("http://%s:%d", externalHost, port.Port) 192 | break 193 | } 194 | } 195 | if externalURL != "" { 196 | break 197 | } 198 | } 199 | } 200 | } 201 | } 202 | var y []byte 203 | y, err = yaml.Marshal(ResourceSpec{ 204 | APIVersion: chi.APIVersion, 205 | Kind: chi.Kind, 206 | Metadata: ResourceSpecMetadata{ 207 | Name: chi.Name, 208 | Namespace: chi.Namespace, 209 | ResourceVersion: chi.ResourceVersion, 210 | }, 211 | Spec: chi.Spec, 212 | }) 213 | if err != nil { 214 | y = nil 215 | } 216 | list = append(list, Chi{ 217 | Name: chi.Name, 218 | Namespace: chi.Namespace, 219 | Status: chi.Status.Status, 220 | Clusters: chi.Status.ClustersCount, 221 | Hosts: chi.Status.HostsCount, 222 | ExternalURL: externalURL, 223 | ResourceYAML: string(y), 224 | CHClusterPods: chClusterPods, 225 | }) 226 | } 227 | _ = response.WriteEntity(list) 228 | } 229 | 230 | var ErrNamespaceRequired = errors.New("namespace is required") 231 | var ErrNameRequired = errors.New("name is required") 232 | var ErrYAMLMustBeCHI = errors.New("YAML document must contain a single ClickhouseInstallation definition") 233 | 234 | func (c *ChiResource) handlePostOrPatchCHI(request *restful.Request, response *restful.Response, doPost bool) { 235 | namespace, ok := request.PathParameters()["namespace"] 236 | if !ok || namespace == "" { 237 | webError(response, http.StatusBadRequest, ErrNamespaceRequired) 238 | return 239 | } 240 | name := "" 241 | if !doPost { 242 | name, ok = request.PathParameters()["name"] 243 | if !ok || name == "" { 244 | webError(response, http.StatusBadRequest, ErrNameRequired) 245 | return 246 | } 247 | } 248 | 249 | putParams := ChiPutParams{} 250 | err := request.ReadEntity(&putParams) 251 | if err != nil { 252 | webError(response, http.StatusBadRequest, err) 253 | return 254 | } 255 | 256 | k := utils.GetK8s() 257 | defer func() { k.ReleaseK8s() }() 258 | var obj *unstructured.Unstructured 259 | obj, err = utils.DecodeYAMLToObject(putParams.YAML) 260 | if err != nil { 261 | webError(response, http.StatusBadRequest, err) 262 | return 263 | } 264 | if obj.GetAPIVersion() != "clickhouse.altinity.com/v1" || 265 | obj.GetKind() != "ClickHouseInstallation" || 266 | (!doPost && (obj.GetNamespace() != namespace || 267 | obj.GetName() != name)) { 268 | webError(response, http.StatusBadRequest, ErrYAMLMustBeCHI) 269 | return 270 | } 271 | if doPost { 272 | err = k.SingleObjectCreate(obj, namespace) 273 | } else { 274 | err = k.SingleObjectUpdate(obj, namespace) 275 | } 276 | if err != nil { 277 | webError(response, http.StatusInternalServerError, err) 278 | return 279 | } 280 | _ = response.WriteEntity(nil) 281 | } 282 | 283 | func (c *ChiResource) handlePostCHI(request *restful.Request, response *restful.Response) { 284 | c.handlePostOrPatchCHI(request, response, true) 285 | } 286 | 287 | func (c *ChiResource) handlePatchCHI(request *restful.Request, response *restful.Response) { 288 | c.handlePostOrPatchCHI(request, response, false) 289 | } 290 | 291 | func (c *ChiResource) handleDeleteCHI(request *restful.Request, response *restful.Response) { 292 | namespace, ok := request.PathParameters()["namespace"] 293 | if !ok || namespace == "" { 294 | webError(response, http.StatusBadRequest, ErrNamespaceRequired) 295 | return 296 | } 297 | var name string 298 | name, ok = request.PathParameters()["name"] 299 | if !ok || name == "" { 300 | webError(response, http.StatusBadRequest, ErrNameRequired) 301 | return 302 | } 303 | 304 | k := utils.GetK8s() 305 | defer func() { k.ReleaseK8s() }() 306 | err := k.ChopClientset.ClickhouseV1(). 307 | ClickHouseInstallations(namespace). 308 | Delete(context.TODO(), name, metav1.DeleteOptions{}) 309 | if err != nil { 310 | webError(response, http.StatusInternalServerError, err) 311 | return 312 | } 313 | 314 | _ = response.WriteEntity(nil) 315 | } 316 | -------------------------------------------------------------------------------- /internal/utils/k8s.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | chopclientset "github.com/altinity/clickhouse-operator/pkg/client/clientset/versioned" 8 | errors2 "k8s.io/apimachinery/pkg/api/errors" 9 | "k8s.io/apimachinery/pkg/api/meta" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/apimachinery/pkg/runtime/serializer/yaml" 13 | "k8s.io/apimachinery/pkg/types" 14 | "k8s.io/client-go/discovery" 15 | "k8s.io/client-go/discovery/cached/memory" 16 | "k8s.io/client-go/dynamic" 17 | "k8s.io/client-go/kubernetes" 18 | "k8s.io/client-go/rest" 19 | "k8s.io/client-go/restmapper" 20 | "k8s.io/client-go/tools/clientcmd" 21 | "k8s.io/client-go/util/homedir" 22 | "path/filepath" 23 | "strings" 24 | "sync" 25 | ) 26 | 27 | type K8s struct { 28 | Config *rest.Config 29 | Clientset *kubernetes.Clientset 30 | ChopClientset *chopclientset.Clientset 31 | DiscoveryClient *discovery.DiscoveryClient 32 | RESTMapper *restmapper.DeferredDiscoveryRESTMapper 33 | DynamicClient dynamic.Interface 34 | lock *sync.RWMutex 35 | } 36 | 37 | type SelectorFunc func([]*unstructured.Unstructured) []*unstructured.Unstructured 38 | 39 | var globalK8s *K8s 40 | 41 | func InitK8s(kubeconfig string) error { 42 | var config *rest.Config 43 | var err error 44 | 45 | if kubeconfig == "" { 46 | config, err = rest.InClusterConfig() 47 | if err != nil { 48 | home := homedir.HomeDir() 49 | if home != "" { 50 | config, err = clientcmd.BuildConfigFromFlags("", filepath.Join(home, ".kube", "config")) 51 | } 52 | } 53 | } else { 54 | config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) 55 | } 56 | if err != nil { 57 | return err 58 | } 59 | 60 | globalK8s = &K8s{ 61 | Config: config, 62 | lock: &sync.RWMutex{}, 63 | } 64 | err = globalK8s.Reinit() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // GetK8s gets a reference to the global Kubernetes instance. The caller must call ReleaseK8s. 73 | func GetK8s() *K8s { 74 | if globalK8s == nil { 75 | panic("GetK8s called before InitK8s") 76 | } 77 | globalK8s.lock.RLock() 78 | return globalK8s 79 | } 80 | 81 | // ReleaseK8s releases the reference held by the caller. 82 | func (k *K8s) ReleaseK8s() { 83 | k.lock.RUnlock() 84 | } 85 | 86 | // Reinit reinitializes Kubernetes. The caller must not hold an open GetK8s() reference. 87 | func (k *K8s) Reinit() error { 88 | k.lock.Lock() 89 | defer k.lock.Unlock() 90 | 91 | var err error 92 | 93 | k.Clientset, err = kubernetes.NewForConfig(k.Config) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | k.ChopClientset, err = chopclientset.NewForConfig(k.Config) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | k.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(k.Config) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | k.RESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(k.DiscoveryClient)) 109 | 110 | k.DynamicClient, err = dynamic.NewForConfig(k.Config) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | return nil 116 | } 117 | 118 | var decUnstructured = yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) 119 | 120 | func DecodeYAMLToObject(yaml string) (*unstructured.Unstructured, error) { 121 | obj := &unstructured.Unstructured{} 122 | _, _, err := decUnstructured.Decode([]byte(yaml), nil, obj) 123 | if err != nil { 124 | return nil, err 125 | } 126 | return obj, nil 127 | } 128 | 129 | var fieldManagerName = "altinity-dashboard" 130 | var ErrNoNamespace = errors.New("could not determine namespace for namespace-scoped entity") 131 | var ErrNamespaceConflict = errors.New("provided namespace conflicts with YAML object") 132 | 133 | // doApplyWithSSA does a server-side apply of an object 134 | func doApplyWithSSA(dr dynamic.ResourceInterface, obj *unstructured.Unstructured) error { 135 | // Marshal object into JSON 136 | data, err := json.Marshal(obj) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | // Create or Update the object with SSA 142 | force := true 143 | _, err = dr.Patch(context.TODO(), obj.GetName(), types.ApplyPatchType, data, metav1.PatchOptions{ 144 | FieldManager: fieldManagerName, 145 | Force: &force, 146 | }) 147 | if err != nil { 148 | return err 149 | } 150 | return nil 151 | } 152 | 153 | // doGetVerUpdate does a client-side apply of an object 154 | func doGetVerUpdate(dr dynamic.ResourceInterface, obj *unstructured.Unstructured) error { 155 | // Retrieve current object from Kubernetes 156 | curObj, err := dr.Get(context.TODO(), obj.GetName(), metav1.GetOptions{}) 157 | if err != nil { 158 | se := &errors2.StatusError{} 159 | if !errors.As(err, &se) || se.ErrStatus.Code != 404 { 160 | return err 161 | } 162 | } 163 | 164 | // Create or update the new object 165 | if err == nil { 166 | // If the old object existed, copy its version number to the new object 167 | obj.SetResourceVersion(curObj.GetResourceVersion()) 168 | _, err = dr.Update(context.TODO(), obj, metav1.UpdateOptions{ 169 | FieldManager: fieldManagerName, 170 | }) 171 | if err != nil { 172 | return err 173 | } 174 | } else { 175 | _, err = dr.Create(context.TODO(), obj, metav1.CreateOptions{ 176 | FieldManager: fieldManagerName, 177 | }) 178 | } 179 | if err != nil { 180 | return err 181 | } 182 | return nil 183 | } 184 | 185 | // getDynamicREST gets a dynamic REST interface for a given unstructured object 186 | func (k *K8s) getDynamicRest(obj *unstructured.Unstructured, namespace string) (dynamic.ResourceInterface, string, error) { 187 | k.lock.RLock() 188 | defer k.lock.RUnlock() 189 | 190 | gvk := obj.GroupVersionKind() 191 | var mapping *meta.RESTMapping 192 | mapping, err := k.RESTMapper.RESTMapping(gvk.GroupKind(), gvk.Version) 193 | if err != nil { 194 | return nil, "", err 195 | } 196 | 197 | // Obtain REST interface for the GVR 198 | var dr dynamic.ResourceInterface 199 | var finalNamespace string 200 | if mapping.Scope.Name() == meta.RESTScopeNameNamespace { 201 | // namespaced resources should specify the namespace 202 | objNamespace := obj.GetNamespace() 203 | switch { 204 | case namespace == "" && objNamespace == "": 205 | return nil, "", ErrNoNamespace 206 | case namespace == "": 207 | finalNamespace = objNamespace 208 | case objNamespace == "": 209 | finalNamespace = namespace 210 | case namespace != objNamespace: 211 | return nil, "", ErrNamespaceConflict 212 | default: 213 | finalNamespace = namespace 214 | } 215 | dr = k.DynamicClient.Resource(mapping.Resource).Namespace(finalNamespace) 216 | } else { 217 | dr = k.DynamicClient.Resource(mapping.Resource) 218 | } 219 | return dr, finalNamespace, nil 220 | } 221 | 222 | // doApplyOrDelete does an apply or delete of a given YAML string 223 | // Adapted from https://ymmt2005.hatenablog.com/entry/2020/04/14/An_example_of_using_dynamic_client_of_k8s.io/client-go 224 | func (k *K8s) doApplyOrDelete(yaml string, namespace string, doDelete bool, useSSA bool, selector SelectorFunc) error { 225 | k.lock.RLock() 226 | defer k.lock.RUnlock() 227 | 228 | // Split YAML into individual docs 229 | yamlDocs, err := SplitYAMLDocs(yaml) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | // Parse YAML documents into objects 235 | candidates := make([]*unstructured.Unstructured, 0, len(yamlDocs)) 236 | for _, yd := range yamlDocs { 237 | var obj *unstructured.Unstructured 238 | obj, err = DecodeYAMLToObject(yd) 239 | if err != nil { 240 | return err 241 | } 242 | candidates = append(candidates, obj) 243 | } 244 | 245 | // Call selector to determine which objects should be processed 246 | if selector != nil { 247 | candidates = selector(candidates) 248 | } 249 | 250 | for _, obj := range candidates { 251 | var dr dynamic.ResourceInterface 252 | var finalNamespace string 253 | dr, finalNamespace, err = k.getDynamicRest(obj, namespace) 254 | if doDelete && namespace != "" && finalNamespace == "" { 255 | // don't delete cluster-wide resources if delete is namespace scoped 256 | continue 257 | } 258 | 259 | switch { 260 | case doDelete: 261 | err = dr.Delete(context.TODO(), obj.GetName(), metav1.DeleteOptions{}) 262 | var se *errors2.StatusError 263 | if errors.As(err, &se) { 264 | if se.Status().Reason == metav1.StatusReasonNotFound { 265 | // If we're trying to delete, "not found" is fine 266 | err = nil 267 | } 268 | } 269 | case !doDelete && useSSA: 270 | err = doApplyWithSSA(dr, obj) 271 | case !doDelete && !useSSA: 272 | err = doGetVerUpdate(dr, obj) 273 | } 274 | if err != nil { 275 | return err 276 | } 277 | } 278 | return nil 279 | } 280 | 281 | // MultiYamlApply does a server-side apply of a given YAML string, which may contain multiple documents 282 | func (k *K8s) MultiYamlApply(yaml string, namespace string) error { 283 | return k.doApplyOrDelete(yaml, namespace, false, true, nil) 284 | } 285 | 286 | // MultiYamlApplySelectively does a selective server-side apply of multiple docs from a given YAML string 287 | func (k *K8s) MultiYamlApplySelectively(yaml string, namespace string, selector SelectorFunc) error { 288 | return k.doApplyOrDelete(yaml, namespace, false, true, selector) 289 | } 290 | 291 | // MultiYamlDelete deletes the resources identified in a given YAML string 292 | func (k *K8s) MultiYamlDelete(yaml string, namespace string) error { 293 | return k.doApplyOrDelete(yaml, namespace, true, false, nil) 294 | } 295 | 296 | var ErrOperatorNotDeployed = errors.New("the ClickHouse Operator is not fully deployed") 297 | 298 | // singleYamlCreateOrUpdate creates or updates a new resource from a single YAML spec 299 | func (k *K8s) singleYamlCreateOrUpdate(obj *unstructured.Unstructured, namespace string, doCreate bool) error { 300 | k.lock.RLock() 301 | defer k.lock.RUnlock() 302 | 303 | gdr := func() (dynamic.ResourceInterface, error) { 304 | dr, _, err := k.getDynamicRest(obj, namespace) 305 | if err != nil { 306 | var nkm *meta.NoKindMatchError 307 | if errors.As(err, &nkm) { 308 | if strings.HasPrefix(nkm.GroupKind.Kind, "ClickHouse") { 309 | return nil, ErrOperatorNotDeployed 310 | } 311 | } 312 | return nil, err 313 | } 314 | return dr, nil 315 | } 316 | dr, err := gdr() 317 | if errors.Is(err, ErrOperatorNotDeployed) { 318 | // Before returning ErrOperatorNotDeployed, try reinitializing the K8s client, which may 319 | // be holding old information in its cache. (For example, it may not know about a CRD.) 320 | k.lock.RUnlock() 321 | err = k.Reinit() 322 | k.lock.RLock() 323 | if err != nil { 324 | return err 325 | } 326 | dr, err = gdr() 327 | } 328 | if err != nil { 329 | return err 330 | } 331 | 332 | if doCreate { 333 | _, err = dr.Create(context.TODO(), obj, metav1.CreateOptions{ 334 | FieldManager: fieldManagerName, 335 | }) 336 | } else { 337 | _, err = dr.Update(context.TODO(), obj, metav1.UpdateOptions{ 338 | FieldManager: fieldManagerName, 339 | }) 340 | } 341 | if err != nil { 342 | return err 343 | } 344 | return nil 345 | } 346 | 347 | // SingleObjectCreate creates a new resource from a single unstructured object 348 | func (k *K8s) SingleObjectCreate(obj *unstructured.Unstructured, namespace string) error { 349 | return k.singleYamlCreateOrUpdate(obj, namespace, true) 350 | } 351 | 352 | // SingleObjectUpdate updates an existing object from a single unstructured object 353 | func (k *K8s) SingleObjectUpdate(obj *unstructured.Unstructured, namespace string) error { 354 | return k.singleYamlCreateOrUpdate(obj, namespace, false) 355 | } 356 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ui/src/app/CHIs/CHIs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ReactElement, useContext, useEffect, useRef, useState } from 'react'; 3 | import { 4 | Alert, 5 | AlertVariant, 6 | ButtonVariant, 7 | PageSection, 8 | Split, 9 | SplitItem, 10 | Tab, 11 | Tabs, 12 | TabTitleText, 13 | Title 14 | } from '@patternfly/react-core'; 15 | import { ToggleModal } from '@app/Components/ToggleModal'; 16 | import { SimpleModal } from '@app/Components/SimpleModal'; 17 | import { fetchWithErrorHandling } from '@app/utils/fetchWithErrorHandling'; 18 | import { CHIModal } from '@app/CHIs/CHIModal'; 19 | import { ExpandableTable, WarningType } from '@app/Components/ExpandableTable'; 20 | import { CHI } from '@app/CHIs/model'; 21 | import { ExpandableRowContent, TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; 22 | import { humanFileSize } from '@app/utils/humanFileSize'; 23 | import { Loading } from '@app/Components/Loading'; 24 | import { usePageVisibility } from 'react-page-visibility'; 25 | import { AddAlertContext } from '@app/utils/alertContext'; 26 | 27 | export const CHIs: React.FunctionComponent = () => { 28 | const [CHIs, setCHIs] = useState(new Array()) 29 | const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) 30 | const [isEditModalOpen, setIsEditModalOpen] = useState(false) 31 | const [isPageLoading, setIsPageLoading] = useState(true) 32 | const [activeItem, setActiveItem] = useState(undefined) 33 | const [retrieveError, setRetrieveError] = useState(undefined) 34 | const [activeTabKeys, setActiveTabKeys] = useState>(new Map()) 35 | const mounted = useRef(false) 36 | const pageVisible = useRef(true) 37 | pageVisible.current = usePageVisibility() 38 | const addAlert = useContext(AddAlertContext) 39 | const fetchData = () => { 40 | fetchWithErrorHandling(`/api/v1/chis`, 'GET', 41 | undefined, 42 | (response, body) => { 43 | setCHIs(body as CHI[]) 44 | setRetrieveError(undefined) 45 | setIsPageLoading(false) 46 | return mounted.current ? 2000 : 0 47 | }, 48 | (response, text, error) => { 49 | const errorMessage = (error == "") ? text : `${error}: ${text}` 50 | setRetrieveError(`Error retrieving CHIs: ${errorMessage}`) 51 | setIsPageLoading(false) 52 | return mounted.current ? 10000 : 0 53 | }, 54 | () => { 55 | if (!mounted.current) { 56 | return -1 57 | } else if (!pageVisible.current) { 58 | return 2000 59 | } else { 60 | return 0 61 | } 62 | }) 63 | } 64 | useEffect(() => { 65 | mounted.current = true 66 | fetchData() 67 | return () => { 68 | mounted.current = false 69 | } 70 | }, 71 | // eslint-disable-next-line react-hooks/exhaustive-deps 72 | []) 73 | const onDeleteClick = (item: CHI) => { 74 | setActiveItem(item) 75 | setIsDeleteModalOpen(true) 76 | } 77 | const onDeleteActionClick = () => { 78 | if (activeItem === undefined) { 79 | return 80 | } 81 | fetchWithErrorHandling(`/api/v1/chis/${activeItem.namespace}/${activeItem.name}`, 'DELETE', 82 | undefined, 83 | undefined, 84 | (response, text, error) => { 85 | const errorMessage = (error == "") ? text : `${error}: ${text}` 86 | addAlert(`Error deleting CHI: ${errorMessage}`, AlertVariant.danger) 87 | }) 88 | } 89 | const closeDeleteModal = () => { 90 | setIsDeleteModalOpen(false) 91 | setActiveItem(undefined) 92 | } 93 | const onEditClick = (item: CHI) => { 94 | setActiveItem(item) 95 | setIsEditModalOpen(true) 96 | } 97 | const closeEditModal = () => { 98 | setIsEditModalOpen(false) 99 | setActiveItem(undefined) 100 | } 101 | const retrieveErrorPane = retrieveError === undefined ? null : ( 102 | 103 | ) 104 | const getActiveTabKey = (key: string) => { 105 | const result = activeTabKeys.get(key) 106 | if (result) { 107 | return result 108 | } else { 109 | return 0 110 | } 111 | } 112 | const warnings = new Array|undefined>() 113 | CHIs.forEach(chi => { 114 | let anyNoStorage = false 115 | let anyUnbound = false 116 | chi.ch_cluster_pods.forEach(pod => { 117 | if (pod.pvcs.length === 0) { 118 | anyNoStorage = true 119 | } else { 120 | pod.pvcs.forEach(pvc => { 121 | if (pvc.bound_pv === undefined) { 122 | anyUnbound = true 123 | } 124 | }) 125 | } 126 | }) 127 | const warningsList = new Array() 128 | if (anyNoStorage) { 129 | warningsList.push({ 130 | variant: "error", 131 | text: "One or more pods have no storage configured.", 132 | }) 133 | } 134 | if (anyUnbound) { 135 | warningsList.push({ 136 | variant: "warning", 137 | text: "One or more pods have a PVC not bound to a PV.", 138 | }) 139 | } 140 | warnings.push(warningsList.length > 0 ? warningsList : undefined) 141 | }) 142 | 143 | return ( 144 | 145 | 154 | The ClickHouse Installation named {activeItem ? activeItem.name : "UNKNOWN"} will 155 | be removed from the {activeItem ? activeItem.namespace : "UNKNOWN"} namespace. 156 | 157 | 164 | 165 | 166 | 167 | ClickHouse Installations 168 | 169 | 170 | 171 | 172 | 173 | 174 | {isPageLoading ? ( 175 | 176 | ) : ( 177 | 178 | {retrieveErrorPane} 179 | { 186 | if (field === "name" && "external_url" in data && data["external_url"]) { 187 | return ( 188 | 190 | {data["name"]} 191 | 192 | ) 193 | } else { 194 | return data[field] 195 | } 196 | }} 197 | actions={(item: CHI) => { 198 | return { 199 | items: [ 200 | { 201 | title: "Edit", 202 | variant: "primary", 203 | onClick: () => { 204 | onEditClick(item) 205 | } 206 | }, 207 | { 208 | title: "Delete", 209 | variant: "danger", 210 | onClick: () => { 211 | onDeleteClick(item) 212 | } 213 | }, 214 | ] 215 | } 216 | }} 217 | expanded_content={(data) => ( 218 | ( 225 | , eventKey: string | number) => { 227 | setActiveTabKeys(new Map(activeTabKeys.set(data.name, eventKey))) 228 | } 229 | }> 230 | Containers}> 231 | 238 | 239 | Storage}> 240 | 241 | 242 | 243 | PVC Name 244 | Phase 245 | Capacity 246 | Class 247 | 248 | 249 | { 250 | data.pvcs.map((dataItem, dataIndex) => ( 251 | 252 | 253 | {dataItem.name} 254 | {dataItem.phase} 255 | {humanFileSize(dataItem.capacity)} 256 | {dataItem.storage_class} 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | PV Name 265 | Phase 266 | Capacity 267 | Class 268 | Reclaim Policy 269 | 270 | 271 | 272 | 273 | {dataItem.bound_pv?.name} 274 | {dataItem.bound_pv?.phase} 275 | {humanFileSize(dataItem.bound_pv?.capacity)} 277 | {dataItem.bound_pv?.storage_class} 279 | {dataItem.bound_pv?.reclaim_policy} 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | )) 289 | } 290 | 291 | 292 | 293 | )} 294 | /> 295 | )} 296 | /> 297 | 298 | )} 299 | 300 | ) 301 | } 302 | --------------------------------------------------------------------------------