())
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 |
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 |