├── web ├── .env ├── src │ ├── helpers │ │ ├── index.ts │ │ ├── hooks.ts │ │ ├── colors.ts │ │ ├── numbers.ts │ │ └── time.ts │ ├── react-app-env.d.ts │ ├── styles │ │ ├── vars.scss │ │ ├── mixins.scss │ │ └── colors.scss │ ├── components │ │ ├── CustomDataTable │ │ │ ├── CustomDataTable.module.scss │ │ │ └── CustomDataTable.tsx │ │ ├── ContentWrapper │ │ │ ├── ContentWrapper.module.scss │ │ │ └── ContentWrapper.tsx │ │ ├── VNCCredentialsPrompt │ │ │ ├── VNCCredentialsPrompt.module.scss │ │ │ └── VNCCredentialsPrompt.tsx │ │ ├── Name │ │ │ ├── Name.module.scss │ │ │ └── Name.tsx │ │ ├── VNCCtrlAltDelConfirmModal │ │ │ ├── VNCCtrlAltDelConfirmModal.module.scss │ │ │ └── VNCCtrlAltDelConfirmModal.tsx │ │ ├── InstanceActionConfirmationModal │ │ │ ├── InstanceActionConfirmationModal.module.scss │ │ │ └── InstanceActionConfirmationModal.tsx │ │ ├── InstanceActions │ │ │ ├── InstanceActions.module.scss │ │ │ └── InstanceActions.test.tsx │ │ ├── Tag │ │ │ ├── Tag.module.scss │ │ │ └── Tag.tsx │ │ ├── Icon │ │ │ ├── Icon.module.scss │ │ │ └── Icon.tsx │ │ ├── CommandBar │ │ │ ├── SearchResults │ │ │ │ ├── SearchResults.module.scss │ │ │ │ └── SearchResults.tsx │ │ │ ├── SearchResult │ │ │ │ ├── SearchResult.module.scss │ │ │ │ └── SearchResult.tsx │ │ │ ├── SearchInput │ │ │ │ ├── SearchInput.module.scss │ │ │ │ └── SearchInput.tsx │ │ │ └── CommandBar.module.scss │ │ ├── ClusterNotFound │ │ │ ├── ClusterNotFound.module.scss │ │ │ └── ClusterNotFound.tsx │ │ ├── JobSummary │ │ │ ├── JobSummary.module.scss │ │ │ └── JobSummary.tsx │ │ ├── Card │ │ │ ├── Card.module.scss │ │ │ └── Card.tsx │ │ ├── LoadingIndicator │ │ │ ├── LoadingIndicator.module.scss │ │ │ └── LoadingIndicator.tsx │ │ ├── IconButton │ │ │ ├── IconButton.module.scss │ │ │ └── IconButton.tsx │ │ ├── JobStartedAt.tsx │ │ ├── Modal │ │ │ ├── Modal.module.scss │ │ │ └── Modal.tsx │ │ ├── QuickInfoBanner │ │ │ ├── QuickInfoBanner.module.scss │ │ │ └── QuickInfoBanner.tsx │ │ ├── Breadcrumbs │ │ │ ├── Breadcrumbs.module.scss │ │ │ └── Breadcrumbs.tsx │ │ ├── PrefixLink.tsx │ │ ├── InstanceList │ │ │ ├── filters.ts │ │ │ └── InstanceList.module.scss │ │ ├── Checkbox.tsx │ │ ├── ApiDataRenderer │ │ │ ├── ApiDataRenderer.module.scss │ │ │ └── ApiDataRenderer.tsx │ │ ├── TabBar │ │ │ ├── TabBar.tsx │ │ │ └── TabBar.module.scss │ │ ├── JobStatus.tsx │ │ ├── JobList │ │ │ ├── JobList.module.scss │ │ │ └── JobList.tsx │ │ ├── ClusterSelector │ │ │ ├── ClusterSelector.module.scss │ │ │ └── ClusterSelector.tsx │ │ ├── FakeSearchBar │ │ │ ├── FakeSearchBar.module.scss │ │ │ └── FakeSearchBar.tsx │ │ ├── ThemeToggle │ │ │ ├── ThemeToggle.tsx │ │ │ └── ThemeToggle.module.scss │ │ ├── PrefixNavLink.tsx │ │ ├── StatusBadge │ │ │ ├── StatusBadge.module.scss │ │ │ └── StatusBadge.tsx │ │ ├── MemoryUtilisation │ │ │ ├── MemoryUtilisation.module.scss │ │ │ └── MemoryUtilisation.tsx │ │ ├── InstanceBanner │ │ │ ├── InstanceBanner.module.scss │ │ │ └── InstanceBanner.tsx │ │ ├── VNCConsole │ │ │ └── VNCConsole.module.scss │ │ ├── Button │ │ │ ├── Button.module.scss │ │ │ └── Button.tsx │ │ ├── Navbar │ │ │ ├── Navbar.module.scss │ │ │ └── Navbar.tsx │ │ ├── Input │ │ │ ├── Input.module.scss │ │ │ └── Input.tsx │ │ ├── VNCControl │ │ │ ├── VNCControl.module.scss │ │ │ └── VNCControl.tsx │ │ ├── Dropdown │ │ │ ├── Dropdown.tsx │ │ │ └── Dropdown.module.scss │ │ └── JobWatcher │ │ │ ├── helpers.ts │ │ │ └── JobWatcher.module.scss │ ├── views │ │ ├── NodeList │ │ │ └── NodeList.module.scss │ │ ├── NodeDetail │ │ │ ├── NodeDetail.module.scss │ │ │ └── NodeDetail.tsx │ │ ├── Dashboard │ │ │ ├── Dashboard.module.scss │ │ │ └── Dashboard.tsx │ │ ├── Login │ │ │ └── Login.module.scss │ │ ├── JobDetail │ │ │ ├── JobDetail.module.scss │ │ │ └── JobDetail.tsx │ │ ├── NodePrimaryInstances │ │ │ └── NodePrimaryInstances.tsx │ │ ├── NodeSecondaryInstances │ │ │ └── NodeSecondaryInstances.tsx │ │ ├── InstanceDetail │ │ │ ├── InstanceDetail.module.scss │ │ │ └── InstanceDetail.tsx │ │ ├── InstanceConsole │ │ │ └── InstanceConsole.tsx │ │ ├── Instances │ │ │ └── Instances.tsx │ │ ├── Jobs │ │ │ └── Jobs.tsx │ │ └── ClusterWrapper.tsx │ ├── setupTests.ts │ ├── CustomColorBadge │ │ ├── CustomColorBadge.module.scss │ │ └── CustomColorBadge.tsx │ ├── contexts │ │ ├── ThemeContext.ts │ │ ├── AuthContext.ts │ │ ├── SearchBarContext.ts │ │ └── JobWatchContext.ts │ ├── providers │ │ ├── CommandBarProvider.tsx │ │ ├── JobWatchProvider.tsx │ │ ├── AuthProvider.tsx │ │ └── ThemeProvider.tsx │ ├── index.scss │ ├── index.tsx │ ├── api │ │ ├── models.ts │ │ └── index.ts │ └── types │ │ └── novnc.d.ts ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── manifest.json │ └── index.html ├── prettierrc.js ├── .gitignore ├── .stylelintrc.js ├── .eslintrc.js ├── tsconfig.json ├── README.md └── package.json ├── api ├── testfiles │ ├── config.empty.test.yaml │ ├── config.invalid-ldap.test.yaml │ ├── config.missing-users.test.yaml │ ├── config.invalid-auth-method.test.yaml │ ├── rapi_responses │ │ ├── valid_names_response.json │ │ ├── valid_query_response.json │ │ ├── valid_node_response.json │ │ ├── valid_instance_response.json │ │ ├── valid_job_instance_remove_response.json │ │ ├── valid_job_instance_activate_disks_response.json │ │ ├── valid_job_cluster_verify_response.json │ │ └── valid_nodes_response.json │ └── config.default.test.yaml ├── router │ └── router_test.go ├── .gitignore ├── controllers │ ├── messages.go │ ├── helpers.go │ ├── types.go │ ├── cluster.go │ ├── search.go │ ├── statistics.go │ ├── job.go │ └── node.go ├── utils │ ├── utils.go │ └── utils_test.go ├── repository │ ├── cluster.go │ ├── group_types.go │ ├── group.go │ ├── job_types.go │ ├── group_test.go │ └── node_types.go ├── model │ ├── results.go │ ├── errors.go │ └── response.go ├── mocking │ ├── cluster_repository.go │ ├── query_performer.go │ ├── node_repository.go │ ├── instance_repository.go │ └── rapi_client.go ├── services │ ├── types.go │ └── search.go ├── middleware │ └── requireCluster.go ├── config.example.yaml ├── .goreleaser.yml ├── actions │ ├── instance.go │ └── instance_test.go ├── main.go ├── rapi_client │ ├── client.go │ ├── client_test.go │ └── requests.go ├── README.md ├── auth │ ├── user.go │ └── middleware.go ├── config │ └── config_test.go ├── websocket │ └── websocket.go ├── query │ └── query.go └── go.mod ├── .gitignore ├── bin ├── remove-git-hooks.sh └── setup-git-hooks.sh ├── .github ├── codeql │ └── codeql-config.yml └── workflows │ └── build.yml ├── .editorconfig ├── .git-hooks └── pre-push └── .sipgate └── nautilus.yaml /web/.env: -------------------------------------------------------------------------------- 1 | ESLINT_NO_DEV_ERRORS=true 2 | -------------------------------------------------------------------------------- /api/testfiles/config.empty.test.yaml: -------------------------------------------------------------------------------- 1 | --- -------------------------------------------------------------------------------- /web/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./numbers"; 2 | -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/src/styles/vars.scss: -------------------------------------------------------------------------------- 1 | $navbar-height: 80px; 2 | $breadcrumbs-height: 44px; 3 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /web/src/components/CustomDataTable/CustomDataTable.module.scss: -------------------------------------------------------------------------------- 1 | .sortIcon { 2 | margin-left: 0.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor directories and files 2 | .idea 3 | .vscode 4 | *.suo 5 | *.ntvs* 6 | *.njsproj 7 | *.sln 8 | *.sw? 9 | -------------------------------------------------------------------------------- /web/src/components/ContentWrapper/ContentWrapper.module.scss: -------------------------------------------------------------------------------- 1 | .contentWrapper { 2 | padding: 2rem 1rem 4rem; 3 | } 4 | -------------------------------------------------------------------------------- /web/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sipgate/gnt-cc/HEAD/web/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/src/views/NodeList/NodeList.module.scss: -------------------------------------------------------------------------------- 1 | .name { 2 | font-weight: bold; 3 | } 4 | 5 | .badge { 6 | margin-left: 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /bin/remove-git-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | HOOK_DIR=$(git rev-parse --show-toplevel)/.git/hooks 4 | find "$HOOK_DIR" -type l -exec rm {} \; -------------------------------------------------------------------------------- /web/src/components/VNCCredentialsPrompt/VNCCredentialsPrompt.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | .input { 3 | margin: 0.75rem 0; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /api/router/router_test.go: -------------------------------------------------------------------------------- 1 | package router_test 2 | 3 | import ( 4 | _ "gnt-cc/docs" 5 | ) 6 | 7 | // TODO: refactor to work with new tests 8 | -------------------------------------------------------------------------------- /api/testfiles/config.invalid-ldap.test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | publicUrl: "https://gnt-cc.example.com" 3 | jwtSigningKey: "test" 4 | authenticationMethod: "ldap" -------------------------------------------------------------------------------- /api/testfiles/config.missing-users.test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | publicUrl: "https://gnt-cc.example.com" 3 | jwtSigningKey: "test" 4 | authenticationMethod: "builtin" -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "My CodeQL config" 2 | 3 | queries: 4 | - uses: security-and-quality 5 | 6 | paths: 7 | - api 8 | - web/src/ -------------------------------------------------------------------------------- /api/testfiles/config.invalid-auth-method.test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | publicUrl: "https://gnt-cc.example.com" 3 | jwtSigningKey: "test" 4 | authenticationMethod: "test" -------------------------------------------------------------------------------- /web/prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: false, 5 | printWidth: 120, 6 | tabWidth: 4 7 | }; -------------------------------------------------------------------------------- /web/src/components/Name/Name.module.scss: -------------------------------------------------------------------------------- 1 | .name { 2 | font-size: 1.15rem; 3 | font-family: monospace; 4 | white-space: nowrap; 5 | overflow-x: auto; 6 | } 7 | -------------------------------------------------------------------------------- /web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.module.scss: -------------------------------------------------------------------------------- 1 | .buttons { 2 | display: flex; 3 | gap: 1rem; 4 | margin-top: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.module.scss: -------------------------------------------------------------------------------- 1 | .buttons { 2 | display: flex; 3 | gap: 1rem; 4 | margin-top: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /web/src/components/InstanceActions/InstanceActions.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: inline-flex; 3 | gap: 0.75rem; 4 | color: inherit; 5 | margin-left: auto; 6 | } 7 | -------------------------------------------------------------------------------- /web/src/views/NodeDetail/NodeDetail.module.scss: -------------------------------------------------------------------------------- 1 | .tabBarWrapper { 2 | display: flex; 3 | justify-content: center; 4 | } 5 | 6 | .content { 7 | margin-top: 4rem; 8 | } 9 | -------------------------------------------------------------------------------- /web/src/components/Tag/Tag.module.scss: -------------------------------------------------------------------------------- 1 | .tag { 2 | font-size: 0.7rem; 3 | height: 1rem; 4 | border-radius: 0.5rem; 5 | padding: 0 0.5rem; 6 | margin: 0 0.25rem; 7 | } 8 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # Local config 2 | /config.yaml 3 | 4 | # Built documentation 5 | /docs/ 6 | 7 | # Binaries & built files 8 | /gnt-cc 9 | /dist 10 | 11 | # Rice stuff 12 | rice-box.go -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{sh,md,json}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /web/src/components/Icon/Icon.module.scss: -------------------------------------------------------------------------------- 1 | .icon { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-grow: 0; 6 | flex-shrink: 0; 7 | color: inherit; 8 | } 9 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchResults/SearchResults.module.scss: -------------------------------------------------------------------------------- 1 | .searchResults { 2 | border-top: 1px solid var(--color-separator); 3 | 4 | > h1 { 5 | font-size: 0.8rem; 6 | margin: 1rem 2rem; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/src/components/ClusterNotFound/ClusterNotFound.module.scss: -------------------------------------------------------------------------------- 1 | .clusterNotFound { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | padding-top: 20vh; 6 | 7 | .header { 8 | margin-right: 4rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /web/src/components/JobSummary/JobSummary.module.scss: -------------------------------------------------------------------------------- 1 | .jobType { 2 | display: block; 3 | line-height: 1.5; 4 | text-transform: capitalize; 5 | font-weight: bold; 6 | } 7 | 8 | .jobDetails { 9 | display: block; 10 | opacity: 0.75; 11 | } 12 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_names_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "homer", 4 | "uri": "/2/nodes/homer" 5 | }, 6 | { 7 | "id": "marge", 8 | "uri": "/2/nodes/marge" 9 | }, 10 | { 11 | "id": "bart", 12 | "uri": "/2/nodes/bart" 13 | } 14 | ] -------------------------------------------------------------------------------- /web/src/components/Card/Card.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: 1rem; 3 | border: 1px solid var(--color-separator); 4 | border-radius: 0.5rem; 5 | 6 | > header { 7 | display: flex; 8 | gap: 1rem; 9 | font-weight: bold; 10 | margin-bottom: 1rem; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /web/src/CustomColorBadge/CustomColorBadge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | height: 1.5rem; 3 | display: flex; 4 | align-items: center; 5 | gap: 0.5rem; 6 | padding: 0 0.75rem; 7 | border-radius: 0.75rem; 8 | text-transform: uppercase; 9 | font-size: 0.8em; 10 | white-space: nowrap; 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/Name/Name.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from "react"; 2 | import styles from "./Name.module.scss"; 3 | 4 | function Name({ children }: PropsWithChildren): ReactElement { 5 | return {children}; 6 | } 7 | 8 | export default Name; 9 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | 21 | .eslintcache -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchResult/SearchResult.module.scss: -------------------------------------------------------------------------------- 1 | .searchResult { 2 | display: block; 3 | padding: 1rem 2rem; 4 | outline: 0; 5 | 6 | &:hover, 7 | &:focus, 8 | &.selected { 9 | background: var(--color-interaction-background); 10 | text-decoration: none; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /web/src/components/LoadingIndicator/LoadingIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .loadingIndicator { 2 | width: 100%; 3 | display: flex; 4 | justify-content: center; 5 | padding: 6rem; 6 | box-sizing: border-box; 7 | 8 | .icon { 9 | width: 32px; 10 | height: 32px; 11 | opacity: 0.5; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /api/controllers/messages.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | const ( 4 | MsgInstanceNotFound = "instance '%s' not found on cluster '%s'" 5 | MsgJobNotFound = "job '%s' not found on cluster '%s'" 6 | MsgNodeNotFound = "node '%s' not found on cluster '%s'" 7 | MsgInternalServerError = "internal server error" 8 | ) 9 | -------------------------------------------------------------------------------- /web/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "stylelint-config-standard-scss", 3 | rules: { 4 | "selector-class-pattern": null, 5 | "at-rule-no-unknown": null 6 | }, 7 | "overrides": [ 8 | { 9 | "files": ["**/*.scss"], 10 | "customSyntax": "postcss-scss" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /web/src/components/Tag/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./Tag.module.scss"; 3 | 4 | interface Props { 5 | label: string; 6 | } 7 | 8 | const Tag = ({ label }: Props): ReactElement => { 9 | return {label}; 10 | }; 11 | 12 | export default Tag; 13 | -------------------------------------------------------------------------------- /web/src/contexts/ThemeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | type ThemeContextProps = { 4 | isDark: boolean; 5 | toggleTheme: () => void; 6 | }; 7 | 8 | export default createContext({ 9 | isDark: false, 10 | toggleTheme: () => { 11 | throw new Error("Not implemented"); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /web/src/helpers/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | 3 | export const useClusterName = (): string => { 4 | const { clusterName } = useParams<{ clusterName: string }>(); 5 | 6 | if (!clusterName) { 7 | throw new Error("Cannot get cluster name from router params."); 8 | } 9 | 10 | return clusterName; 11 | }; 12 | -------------------------------------------------------------------------------- /api/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | func IsInSlice(needle string, list []string) bool { 6 | for _, entry := range list { 7 | if entry == needle { 8 | return true 9 | } 10 | } 11 | return false 12 | } 13 | 14 | func FileExists(path string) bool { 15 | _, err := os.Stat(path) 16 | 17 | return err == nil 18 | } 19 | -------------------------------------------------------------------------------- /web/src/components/IconButton/IconButton.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | border: 0; 3 | background: transparent; 4 | color: inherit; 5 | padding: 0; 6 | width: 2rem; 7 | height: 2rem; 8 | cursor: pointer; 9 | 10 | &:hover { 11 | color: var(--color-primary); 12 | } 13 | 14 | .icon { 15 | width: 100%; 16 | height: 100%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/repository/cluster.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "gnt-cc/config" 4 | 5 | type ClusterRepository struct { 6 | } 7 | 8 | func (*ClusterRepository) GetAllNames() []string { 9 | c := config.Get() 10 | clusters := make([]string, len(c.Clusters)) 11 | for i, cluster := range c.Clusters { 12 | clusters[i] = cluster.Name 13 | } 14 | return clusters 15 | } 16 | -------------------------------------------------------------------------------- /web/src/contexts/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type AuthContextProps = { 4 | username: string | null; 5 | setUsername: (username: string | null) => void; 6 | }; 7 | 8 | export default createContext({ 9 | username: null, 10 | setUsername: () => { 11 | throw new Error("not implemented"); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "gnt-cc", 3 | "name": "Ganeti Control Center", 4 | "icons": [{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}], 5 | "start_url": ".", 6 | "display": "standalone", 7 | "theme_color": "#000000", 8 | "background_color": "#ffffff" 9 | } 10 | -------------------------------------------------------------------------------- /web/src/components/ContentWrapper/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, PropsWithChildren } from "react"; 2 | import styles from "./ContentWrapper.module.scss"; 3 | 4 | const ContentWrapper = ({ 5 | children, 6 | }: PropsWithChildren): ReactElement => { 7 | return
{children}
; 8 | }; 9 | 10 | export default ContentWrapper; 11 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchInput/SearchInput.module.scss: -------------------------------------------------------------------------------- 1 | .searchInput { 2 | color: var(--color-emphasis-high); 3 | display: flex; 4 | padding: 0 2rem; 5 | 6 | > input { 7 | display: block; 8 | background: transparent; 9 | color: inherit; 10 | border: 0; 11 | outline: 0; 12 | width: 100%; 13 | padding: 1rem; 14 | font-size: 1rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/model/results.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type InstanceResult struct { 4 | Found bool 5 | NetworkPort int 6 | Instance GntInstance 7 | } 8 | 9 | type NodeResult struct { 10 | Found bool 11 | Node GntNodeWithInstances 12 | } 13 | 14 | type GroupResult struct { 15 | Found bool 16 | Group GntGroup 17 | } 18 | 19 | type JobResult struct { 20 | Found bool 21 | Job GntJob 22 | } 23 | -------------------------------------------------------------------------------- /api/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "gnt-cc/utils" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsInSlice(t *testing.T) { 11 | var stringSlice = []string{"this", "that", "these", "those"} 12 | 13 | assert.True(t, utils.IsInSlice("this", stringSlice)) 14 | assert.False(t, utils.IsInSlice("not_in_slice", stringSlice)) 15 | } 16 | -------------------------------------------------------------------------------- /web/src/views/Dashboard/Dashboard.module.scss: -------------------------------------------------------------------------------- 1 | .currentMaster { 2 | display: flex; 3 | align-items: center; 4 | gap: 1rem; 5 | border: 1px solid var(--overlay); 6 | border-top: 0; 7 | border-radius: 0 0 0.25rem 0.25rem; 8 | padding: 1rem; 9 | 10 | .master { 11 | font-weight: bold; 12 | } 13 | } 14 | 15 | .clusterSpecifications { 16 | padding: 2rem; 17 | background: var(--overlay); 18 | } 19 | -------------------------------------------------------------------------------- /api/mocking/cluster_repository.go: -------------------------------------------------------------------------------- 1 | package mocking 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | ) 6 | 7 | type clusterRepository struct { 8 | mock.Mock 9 | } 10 | 11 | func NewClusterRepository() *clusterRepository { 12 | return &clusterRepository{} 13 | } 14 | 15 | func (mock *clusterRepository) GetAllNames() []string { 16 | args := mock.Called() 17 | 18 | return args.Get(0).([]string) 19 | } 20 | -------------------------------------------------------------------------------- /api/model/errors.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type BaseError interface { 4 | error 5 | } 6 | 7 | type ClusterNotFoundError interface { 8 | BaseError 9 | ClusterName() string 10 | } 11 | 12 | type NodeNotFoundError interface { 13 | BaseError 14 | NodeName() string 15 | ClusterName() string 16 | } 17 | 18 | type InstanceNotFoundError interface { 19 | BaseError 20 | InstanceName() string 21 | ClusterName() string 22 | } 23 | -------------------------------------------------------------------------------- /api/testfiles/config.default.test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | jwtSigningKey: "test" 3 | authenticationMethod: "builtin" 4 | publicUrl: "https://gnt-cc.example.com" 5 | users: 6 | - username: "admin" 7 | password: "test" 8 | clusters: 9 | - name: "test" 10 | hostname: "test-cluster.example.com" 11 | port: 5080 12 | description: "Ganeti Test Cluster" 13 | username: "test" 14 | password: "supersecret" 15 | ssl: True -------------------------------------------------------------------------------- /.git-hooks/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | WORK_DIR=$(pwd) 6 | 7 | echo "Executing golang linter" 8 | cd "$WORK_DIR/api" || exit 1 9 | if [ "$(gofmt -d . | tee /dev/tty | wc -l)" -gt 0 ]; then 10 | exit 1 11 | fi 12 | 13 | echo "Executing golang tests..." 14 | cd "$WORK_DIR/api" && go test ./... 15 | 16 | echo "Executing frontend linter..." 17 | cd "$WORK_DIR/web" && npm run lint && npm run test -- --watchAll=false 18 | -------------------------------------------------------------------------------- /web/src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin hover-overlay { 2 | & { 3 | position: relative; 4 | 5 | &::after { 6 | content: ""; 7 | position: absolute; 8 | inset: 0; 9 | opacity: 0; 10 | background: var(--overlay); 11 | transition: opacity 0.1s; 12 | pointer-events: none; 13 | border-radius: inherit; 14 | } 15 | 16 | &:hover::after { 17 | opacity: 1; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /web/src/views/Login/Login.module.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | padding: 20vh; 7 | 8 | .logo { 9 | margin: 2rem 0; 10 | 11 | img { 12 | width: 160px; 13 | height: auto; 14 | } 15 | } 16 | 17 | .input { 18 | margin-bottom: 1rem; 19 | } 20 | 21 | .loginError { 22 | color: red; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/helpers/colors.ts: -------------------------------------------------------------------------------- 1 | const COLORS = [ 2 | "#54478c", 3 | "#2c699a", 4 | "#048ba8", 5 | "#0db39e", 6 | "#16db93", 7 | "#83e377", 8 | "#b9e769", 9 | "#efea5a", 10 | "#f1c453", 11 | "#f29e4c", 12 | ]; 13 | 14 | export function getColorForString(groupName: string): string { 15 | let sum = 0; 16 | for (let i = 0; i < groupName.length; i++) { 17 | sum += groupName.charCodeAt(i); 18 | } 19 | 20 | return COLORS[sum % COLORS.length]; 21 | } 22 | -------------------------------------------------------------------------------- /api/repository/group_types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | type rapiGroupResponse struct { 4 | UUID string `json:"uuid"` 5 | Name string `json:"name"` 6 | Tags []interface{} `json:"tags"` 7 | NodeCnt int `json:"node_cnt"` 8 | AllocPolicy string `json:"alloc_policy"` 9 | NodeList []string `json:"node_list"` 10 | SerialNo int `json:"serial_no"` 11 | } 12 | 13 | type rapiGroupsResponse []rapiGroupResponse 14 | -------------------------------------------------------------------------------- /web/src/contexts/SearchBarContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | type SearchBarContextProps = { 4 | isVisible: boolean; 5 | toggleVisibility: () => void; 6 | setVisible: (visible: boolean) => void; 7 | }; 8 | 9 | export default createContext({ 10 | isVisible: false, 11 | toggleVisibility: () => { 12 | throw new Error("Not implemented"); 13 | }, 14 | setVisible: () => { 15 | throw new Error("Not implemented"); 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /web/src/views/JobDetail/JobDetail.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | gap: 1rem; 4 | align-items: center; 5 | } 6 | 7 | .text { 8 | color: var(--color-emphasis-low); 9 | } 10 | 11 | .log { 12 | padding: 2rem 0; 13 | 14 | .console { 15 | display: flex; 16 | flex-direction: column; 17 | gap: 0.5rem; 18 | font-family: monospace; 19 | font-size: 1rem; 20 | background: var(--overlay); 21 | padding: 1rem; 22 | border-radius: 0.5rem; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchResults/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from "react"; 2 | import styles from "./SearchResults.module.scss"; 3 | 4 | type Props = { 5 | headline: string; 6 | }; 7 | 8 | export default function SearchResults({ 9 | headline, 10 | children, 11 | }: PropsWithChildren): ReactElement { 12 | return ( 13 |
14 |

{headline}

15 | {children} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /web/src/components/JobStartedAt.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { unixToDate } from "../helpers/time"; 3 | 4 | type Props = { 5 | timestamp: number; 6 | }; 7 | 8 | function JobStartedAt({ timestamp }: Props): ReactElement { 9 | if (timestamp < 0) { 10 | return -; 11 | } 12 | 13 | const date = unixToDate(timestamp); 14 | 15 | return ( 16 | [{date.toLocaleTimeString()}] 17 | ); 18 | } 19 | 20 | export default JobStartedAt; 21 | -------------------------------------------------------------------------------- /web/src/helpers/numbers.ts: -------------------------------------------------------------------------------- 1 | export const convertMiBToGiB = (mib: number): number => { 2 | return Math.round(mib / 102.4) / 10; 3 | }; 4 | 5 | export const convertMiBToTiB = (mib: number): number => { 6 | return Math.round(mib / (1024 * 102.4)) / 10; 7 | }; 8 | 9 | export const prettyPrintMiB = (mib: number): string => { 10 | if (mib < 1024) { 11 | return `${mib} MiB`; 12 | } 13 | 14 | if (mib < 1024 * 1024) { 15 | return `${convertMiBToGiB(mib)} GiB`; 16 | } 17 | 18 | return `${convertMiBToTiB(mib)} TiB`; 19 | }; 20 | -------------------------------------------------------------------------------- /api/mocking/query_performer.go: -------------------------------------------------------------------------------- 1 | package mocking 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | "gnt-cc/query" 6 | "gnt-cc/rapi_client" 7 | ) 8 | 9 | type queryPerformer struct { 10 | mock.Mock 11 | } 12 | 13 | func NewQueryPerformer() *queryPerformer { 14 | return &queryPerformer{} 15 | } 16 | 17 | func (mock *queryPerformer) Perform(client rapi_client.Client, config query.RequestConfig) ([]query.Resource, error) { 18 | args := mock.Called(client, config) 19 | 20 | return args.Get(0).([]query.Resource), args.Error(1) 21 | } 22 | -------------------------------------------------------------------------------- /.sipgate/nautilus.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: nautilus.sipgate.cloud/v1alpha1 2 | kind: Service 3 | metadata: 4 | creationTimestamp: "2023-05-25T09:05:27Z" 5 | name: gnt-cc 6 | namespace: default 7 | resourceVersion: "1" 8 | uid: 0116efd0-0280-44ab-963e-2fcd5ec626b7 9 | spec: 10 | backup: "false" 11 | containerized: "true" 12 | localPersistence: "false" 13 | on_call_schedule: bestEffort 14 | pciDssRelevant: "false" 15 | ports: null 16 | repositoryType: tooling 17 | singleton: "false" 18 | tlsProvider: none 19 | virtualized: "true" 20 | -------------------------------------------------------------------------------- /web/src/components/LoadingIndicator/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./LoadingIndicator.module.scss"; 3 | import { faSpinner } from "@fortawesome/free-solid-svg-icons"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | 6 | const LoadingIndicator = (): ReactElement => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | export default LoadingIndicator; 15 | -------------------------------------------------------------------------------- /api/controllers/helpers.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | log "github.com/sirupsen/logrus" 6 | "gnt-cc/model" 7 | ) 8 | 9 | func createErrorBody(msg string) model.ErrorResponse { 10 | return model.ErrorResponse{Message: msg} 11 | } 12 | 13 | func createInternalServerErrorBody() model.ErrorResponse { 14 | return createErrorBody(MsgInternalServerError) 15 | } 16 | 17 | func abortWithInternalServerError(c *gin.Context, err error) { 18 | log.Error(err) 19 | c.AbortWithStatusJSON(500, createInternalServerErrorBody()) 20 | } 21 | -------------------------------------------------------------------------------- /web/src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 3 | import Icon from "../Icon/Icon"; 4 | import styles from "./IconButton.module.scss"; 5 | 6 | type Props = { 7 | icon: IconDefinition; 8 | onClick?: () => void; 9 | }; 10 | 11 | export default function IconButton({ icon, onClick }: Props): ReactElement { 12 | return ( 13 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /web/src/contexts/JobWatchContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type TrackedJob = { 4 | clusterName: string; 5 | id: number; 6 | }; 7 | 8 | type JobWatchContextProps = { 9 | trackedJobs: TrackedJob[]; 10 | trackJob: (job: TrackedJob) => void; 11 | untrackJob: (job: TrackedJob) => void; 12 | }; 13 | 14 | export default createContext({ 15 | trackedJobs: [], 16 | trackJob: () => { 17 | throw new Error("Not implemented"); 18 | }, 19 | untrackJob: () => { 20 | throw new Error("Not implemented"); 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /web/src/views/NodePrimaryInstances/NodePrimaryInstances.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { useOutletContext } from "react-router-dom"; 3 | import { GntInstance } from "../../api/models"; 4 | import InstanceList from "../../components/InstanceList/InstanceList"; 5 | 6 | export default function NodePrimaryInstances(): ReactElement { 7 | const { primaryInstances } = useOutletContext<{ 8 | primaryInstances: GntInstance[]; 9 | }>(); 10 | 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/views/NodeSecondaryInstances/NodeSecondaryInstances.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { useOutletContext } from "react-router-dom"; 3 | import { GntInstance } from "../../api/models"; 4 | import InstanceList from "../../components/InstanceList/InstanceList"; 5 | 6 | export default function NodeSecondaryInstances(): ReactElement { 7 | const { secondaryInstances } = useOutletContext<{ 8 | secondaryInstances: GntInstance[]; 9 | }>(); 10 | 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /web/src/views/InstanceDetail/InstanceDetail.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | gap: 1rem; 4 | align-items: center; 5 | } 6 | 7 | .cards { 8 | display: grid; 9 | grid-template-columns: repeat(auto-fill, minmax(480px, 1fr)); 10 | gap: 2rem; 11 | margin-top: 2rem; 12 | } 13 | 14 | .nic, 15 | .disk { 16 | padding: 0.5rem 0; 17 | 18 | > p { 19 | margin: 0.125rem 0; 20 | } 21 | 22 | > p:last-of-type { 23 | font-family: monospace; 24 | font-size: 1rem; 25 | } 26 | 27 | &:not(:last-child) { 28 | border-bottom: 1px solid var(--color-separator); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/services/types.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import "gnt-cc/model" 4 | 5 | type ( 6 | clusterRepository interface { 7 | GetAllNames() []string 8 | } 9 | 10 | instanceRepository interface { 11 | GetAllNames(clusterName string) ([]string, error) 12 | } 13 | 14 | nodeRepository interface { 15 | GetAllNames(clusterName string) ([]string, error) 16 | } 17 | 18 | CollectResults struct { 19 | Instances []model.ClusterResource 20 | Nodes []model.ClusterResource 21 | Clusters []model.Resource 22 | } 23 | 24 | resourcesService interface { 25 | CollectAll() CollectResults 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /web/src/components/Modal/Modal.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: fixed; 3 | inset: 0; 4 | background: rgba(#000, 0.6); 5 | display: grid; 6 | place-content: center; 7 | padding: 1rem; 8 | 9 | .content { 10 | position: relative; 11 | background: var(--color-elevation-high); 12 | max-width: 800px; 13 | padding: clamp(1rem, 2.5vw, 4rem); 14 | border-radius: 4px; 15 | box-shadow: var(--drop-shadow); 16 | 17 | .close { 18 | position: absolute; 19 | top: 0.5rem; 20 | right: 0.5rem; 21 | cursor: pointer; 22 | color: var(--color-emphasis-low); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /web/src/components/QuickInfoBanner/QuickInfoBanner.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | height: 100%; 6 | 7 | > div { 8 | display: inline-flex; 9 | flex-direction: column; 10 | align-items: center; 11 | padding: 0 1rem; 12 | 13 | .value { 14 | display: block; 15 | font-size: 2em; 16 | font-weight: bold; 17 | margin: 1rem 0; 18 | } 19 | 20 | .label { 21 | display: flex; 22 | align-items: center; 23 | gap: 1rem; 24 | color: var(--color-emphasis-medium); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/CommandBar.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: fixed; 3 | width: 100%; 4 | max-width: 800px; 5 | top: 1rem; 6 | left: 0; 7 | right: 0; 8 | margin: 0 auto; 9 | background: var(--color-elevation-high); 10 | border-radius: 0.5rem; 11 | box-shadow: var(--drop-shadow); 12 | 13 | .resultsWrapper { 14 | max-height: 65vh; 15 | overflow-y: auto; 16 | } 17 | 18 | .footer { 19 | border-radius: 0 0 0.5rem 0.5rem; 20 | padding: 0.5rem 1rem; 21 | text-align: end; 22 | color: var(--color-emphasis-low); 23 | background: var(--color-interaction-background); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: "module", 7 | ecmaFeatures: { 8 | jsx: true 9 | } 10 | }, 11 | settings: { 12 | react: { 13 | version: "detect" 14 | } 15 | }, 16 | plugins: [ 17 | "react", 18 | "react-hooks" 19 | ], 20 | extends: [ 21 | "plugin:react/recommended", 22 | "plugin:@typescript-eslint/recommended", 23 | "plugin:prettier/recommended" 24 | ], 25 | rules: { "react/display-name": "off" }, 26 | ignorePatterns: [ 27 | "build" 28 | ] 29 | }; -------------------------------------------------------------------------------- /web/src/components/Breadcrumbs/Breadcrumbs.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/vars"; 2 | 3 | .root { 4 | padding: 0.75rem 0; 5 | height: $breadcrumbs-height; 6 | background: var(--color-primary-shade); 7 | color: var(--color-elevation-low); 8 | 9 | .crumbs { 10 | display: inline-flex; 11 | align-items: center; 12 | gap: 0.5rem; 13 | padding: 0 1rem; 14 | 15 | .crumb { 16 | color: inherit; 17 | text-transform: uppercase; 18 | font-size: 0.9em; 19 | 20 | &:last-child { 21 | font-weight: bold; 22 | } 23 | } 24 | 25 | .chevron { 26 | opacity: 0.5; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /web/src/components/PrefixLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, PropsWithChildren } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { useClusterName } from "../helpers/hooks"; 4 | 5 | interface Props { 6 | to: string; 7 | title?: string; 8 | className?: string; 9 | } 10 | 11 | const PrefixLink = ({ 12 | to, 13 | title, 14 | className, 15 | children, 16 | }: PropsWithChildren): ReactElement => { 17 | const clusterName = useClusterName(); 18 | 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export default PrefixLink; 27 | -------------------------------------------------------------------------------- /api/middleware/requireCluster.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | "gnt-cc/config" 7 | "gnt-cc/model" 8 | ) 9 | 10 | func RequireCluster() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | clusterName := c.Param("cluster") 13 | 14 | if clusterName == "" { 15 | c.AbortWithStatusJSON(400, model.ErrorResponse{Message: "cluster is required"}) 16 | return 17 | } 18 | 19 | if !config.ClusterExists(clusterName) { 20 | c.AbortWithStatusJSON(404, model.ErrorResponse{ 21 | Message: fmt.Sprintf("cluster cannot be found: %s", clusterName), 22 | }) 23 | return 24 | } 25 | 26 | c.Next() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_query_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": [ 3 | { 4 | "doc": "Instance name", 5 | "kind": "text", 6 | "name": "name", 7 | "title": "Instance" 8 | }, 9 | { 10 | "doc": "Primary node", 11 | "kind": "text", 12 | "name": "pnode", 13 | "title": "Primary_node" 14 | } 15 | ], 16 | "data": [ 17 | [ 18 | [ 19 | 0, 20 | "bart" 21 | ], 22 | [ 23 | 0, 24 | "node4" 25 | ] 26 | ], 27 | [ 28 | [ 29 | 0, 30 | "smithers" 31 | ], 32 | [ 33 | 0, 34 | "node2" 35 | ] 36 | ] 37 | ] 38 | } -------------------------------------------------------------------------------- /web/src/components/InstanceList/filters.ts: -------------------------------------------------------------------------------- 1 | import { GntInstance } from "../../api/models"; 2 | 3 | export const filterInstances = ( 4 | instances: GntInstance[], 5 | filter: string 6 | ): GntInstance[] => { 7 | if (filter === "") { 8 | return instances; 9 | } 10 | 11 | return instances.filter((instance) => { 12 | if (instance.name.includes(filter)) { 13 | return true; 14 | } 15 | 16 | if (instance.primaryNode.includes(filter)) { 17 | return true; 18 | } 19 | 20 | for (const node of instance.secondaryNodes) { 21 | if (node.includes(filter)) { 22 | return true; 23 | } 24 | } 25 | 26 | return false; 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /web/src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./Icon.module.scss"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; 5 | import classNames from "classnames"; 6 | 7 | export interface IconProps { 8 | icon: IconDefinition; 9 | spin?: boolean; 10 | className?: string; 11 | } 12 | 13 | function Icon({ icon, spin, className }: IconProps): ReactElement { 14 | return ( 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default Icon; 22 | -------------------------------------------------------------------------------- /web/src/components/CustomDataTable/CustomDataTable.tsx: -------------------------------------------------------------------------------- 1 | import { faCaretDown } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement } from "react"; 3 | import DataTable, { IDataTableProps } from "react-data-table-component"; 4 | import Icon from "../Icon/Icon"; 5 | import styles from "./CustomDataTable.module.scss"; 6 | export default function CustomDataTable( 7 | props: IDataTableProps 8 | ): ReactElement { 9 | return ( 10 | 11 | pagination 12 | paginationPerPage={20} 13 | noHeader 14 | highlightOnHover 15 | sortIcon={} 16 | {...props} 17 | /> 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /web/src/views/InstanceConsole/InstanceConsole.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { buildWSURL } from "../../api"; 4 | import VNCConsole from "../../components/VNCConsole/VNCConsole"; 5 | import { useClusterName } from "../../helpers/hooks"; 6 | 7 | const InstanceConsole = (): ReactElement => { 8 | const clusterName = useClusterName(); 9 | const { instanceName } = useParams<{ instanceName: string }>(); 10 | 11 | const url = buildWSURL( 12 | `/clusters/${clusterName}/instances/${instanceName}/console` 13 | ); 14 | 15 | return ; 16 | }; 17 | 18 | export default InstanceConsole; 19 | -------------------------------------------------------------------------------- /web/src/components/ClusterNotFound/ClusterNotFound.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./ClusterNotFound.module.scss"; 3 | import ClusterSelector from "../ClusterSelector/ClusterSelector"; 4 | import { GntCluster } from "../../api/models"; 5 | 6 | interface Props { 7 | clusters: GntCluster[]; 8 | } 9 | 10 | const ClusterNotFound = ({ clusters }: Props): ReactElement => { 11 | return ( 12 |
13 |
14 |

Cluster not found

15 |

Please choose a different one

16 |
17 | 18 |
19 | ); 20 | }; 21 | 22 | export default ClusterNotFound; 23 | -------------------------------------------------------------------------------- /bin/setup-git-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | HOOK_NAMES="pre-push" 3 | # assuming the script is in a bin directory, one level into the repo 4 | GIT_DIR=$(git rev-parse --show-toplevel) 5 | HOOK_DIR="$GIT_DIR/.git/hooks" 6 | 7 | for hook in $HOOK_NAMES; do 8 | # If the hook already exists, is executable, and is not a symlink 9 | if [ ! -h "$HOOK_DIR/$hook" -a -x "$HOOK_DIR/$hook" ]; then 10 | mv "$HOOK_DIR/$hook" "$HOOK_DIR/$hook.local" 11 | fi 12 | # create the symlink, overwriting the file if it exists 13 | # probably the only way this would happen is if you're using an old version of git 14 | # -- back when the sample hooks were not executable, instead of being named ____.sample 15 | ln -s -f "$GIT_DIR/.git-hooks/$hook" "$HOOK_DIR/$hook" 16 | done 17 | -------------------------------------------------------------------------------- /web/src/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 2 | import React, { PropsWithChildren, ReactElement } from "react"; 3 | import Icon from "../Icon/Icon"; 4 | import styles from "./Card.module.scss"; 5 | 6 | type Props = { 7 | icon: IconDefinition; 8 | title: string; 9 | }; 10 | 11 | function Card({ 12 | icon, 13 | title, 14 | children, 15 | }: PropsWithChildren): ReactElement { 16 | return ( 17 |
18 |
19 | 20 | {title} 21 |
22 |
{children}
23 |
24 | ); 25 | } 26 | 27 | export default Card; 28 | -------------------------------------------------------------------------------- /web/src/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ChangeEvent } from "react"; 2 | import Icon from "./Icon/Icon"; 3 | import { faSquare, faCheckSquare } from "@fortawesome/free-solid-svg-icons"; 4 | 5 | interface Props { 6 | checked: boolean; 7 | onChange?: (event: ChangeEvent) => void; 8 | } 9 | 10 | const Checkbox = ({ checked, onChange }: Props): ReactElement => { 11 | return ( 12 | <> 13 | 19 | {!checked && } 20 | {checked && } 21 | 22 | ); 23 | }; 24 | 25 | export default Checkbox; 26 | -------------------------------------------------------------------------------- /web/src/components/ApiDataRenderer/ApiDataRenderer.module.scss: -------------------------------------------------------------------------------- 1 | .error { 2 | padding: 10vh 0; 3 | display: flex; 4 | gap: 4rem; 5 | align-content: center; 6 | max-width: 600px; 7 | margin: 0 auto; 8 | 9 | header { 10 | position: relative; 11 | 12 | .robot svg, 13 | .bolt svg { 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | .robot { 19 | width: 6rem; 20 | height: 6rem; 21 | color: var(--color-primary); 22 | } 23 | 24 | .bolt { 25 | color: var(--color-danger); 26 | position: absolute; 27 | bottom: 0.25rem; 28 | right: -0.75rem; 29 | width: 3rem; 30 | height: 3rem; 31 | } 32 | } 33 | 34 | h1 { 35 | color: var(--color-emphasis-medium); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/providers/CommandBarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, PropsWithChildren, useState } from "react"; 2 | import SearchBarContext from "../contexts/SearchBarContext"; 3 | 4 | export default function CommandBarProvider({ 5 | children, 6 | }: PropsWithChildren): ReactElement { 7 | const [isVisible, setIsVisible] = useState(false); 8 | 9 | function toggleVisibility() { 10 | setIsVisible(!isVisible); 11 | } 12 | 13 | function setVisible(visible: boolean) { 14 | setIsVisible(visible); 15 | } 16 | 17 | return ( 18 | 25 | {children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchResult/SearchResult.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { ReactElement } from "react"; 3 | import { Link } from "react-router-dom"; 4 | import styles from "./SearchResult.module.scss"; 5 | 6 | type Props = { 7 | name: string; 8 | url: string; 9 | selected?: boolean; 10 | onClick?: () => void; 11 | }; 12 | 13 | export default function SearchResult({ 14 | name, 15 | url, 16 | selected, 17 | onClick, 18 | }: Props): ReactElement { 19 | return ( 20 | 28 | {name} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /web/src/components/JobSummary/JobSummary.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./JobSummary.module.scss"; 3 | 4 | type Props = { 5 | summary: string; 6 | }; 7 | 8 | function JobSummary({ summary }: Props): ReactElement { 9 | const regex = /([A-Z_]+)(?:\((.*)\))?/; 10 | 11 | const matches = summary.match(regex); 12 | 13 | if (!matches) { 14 | return <>; 15 | } 16 | 17 | const jobType = matches[1] || ""; 18 | const jobDetails = matches[2] || ""; 19 | 20 | return ( 21 | 22 | 23 | {jobType.toLowerCase().replace(/_/g, " ")} 24 | 25 | {jobDetails} 26 | 27 | ); 28 | } 29 | 30 | export default JobSummary; 31 | -------------------------------------------------------------------------------- /web/src/components/TabBar/TabBar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { PropsWithChildren, ReactElement } from "react"; 3 | import { Link } from "react-router-dom"; 4 | import styles from "./TabBar.module.scss"; 5 | 6 | const TabBar = ({ children }: PropsWithChildren): ReactElement => { 7 | return
{children}
; 8 | }; 9 | 10 | type TabProps = { 11 | label: string; 12 | to: string; 13 | isActive?: boolean; 14 | }; 15 | 16 | const Tab = ({ label, to, isActive }: TabProps) => ( 17 | 24 | {label} 25 | 26 | ); 27 | 28 | TabBar.Tab = Tab; 29 | 30 | export default TabBar; 31 | -------------------------------------------------------------------------------- /web/src/components/JobStatus.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import StatusBadge, { BadgeStatus } from "./StatusBadge/StatusBadge"; 3 | 4 | function getBadgeStatus(status: string): BadgeStatus { 5 | if (status === "success") { 6 | return BadgeStatus.SUCCESS; 7 | } 8 | if (["queued", "waiting", "canceling"].includes(status)) { 9 | return BadgeStatus.WARNING; 10 | } 11 | if (status === "error") { 12 | return BadgeStatus.FAILURE; 13 | } 14 | 15 | return BadgeStatus.PRIMARY; 16 | } 17 | 18 | type Props = { 19 | status: string; 20 | }; 21 | 22 | function JobStatus({ status }: Props): ReactElement { 23 | const badgeStatus = getBadgeStatus(status); 24 | 25 | return {status}; 26 | } 27 | 28 | export default JobStatus; 29 | -------------------------------------------------------------------------------- /web/src/components/TabBar/TabBar.module.scss: -------------------------------------------------------------------------------- 1 | .tabBar { 2 | display: inline-flex; 3 | align-items: center; 4 | border: 1px solid var(--color-emphasis-low); 5 | height: 48px; 6 | border-radius: 24px; 7 | overflow: hidden; 8 | 9 | .tab { 10 | color: var(--color-emphasis-high); 11 | text-decoration: none; 12 | padding: 1rem; 13 | transition: all 0.2s; 14 | 15 | &:first-child { 16 | border-radius: 24px 0 0 24px; 17 | } 18 | 19 | &:last-child { 20 | border-radius: 0 24px 24px 0; 21 | } 22 | 23 | &:not(:first-child) { 24 | border-left: 1px solid var(--color-emphasis-low); 25 | } 26 | 27 | &:hover, 28 | &.active { 29 | box-shadow: inset 0 2px 3px rgba(#000, 0.2); 30 | background: var(--color-interaction-background); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /web/src/components/JobList/JobList.module.scss: -------------------------------------------------------------------------------- 1 | .jobList { 2 | .filterSettings { 3 | display: flex; 4 | align-items: center; 5 | 6 | .filterListLabel { 7 | text-transform: uppercase; 8 | font-size: 0.75rem; 9 | font-weight: bold; 10 | margin: 0 1rem; 11 | color: var(--color-emphasis-medium); 12 | } 13 | 14 | .filterList { 15 | display: flex; 16 | align-items: center; 17 | 18 | .filterCheckbox:not(:last-child) { 19 | margin-right: 0.5rem; 20 | } 21 | } 22 | 23 | .filterResetButton { 24 | margin-left: auto; 25 | } 26 | } 27 | 28 | .filterResults { 29 | padding: 1rem 0; 30 | 31 | .filterResultCount { 32 | font-weight: bold; 33 | color: var(--color-emphasis-medium); 34 | padding: 0 0.3rem; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/controllers/types.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import "gnt-cc/model" 4 | 5 | type ( 6 | nodeRepository interface { 7 | Get(clusterName string, nodeName string) (model.NodeResult, error) 8 | GetAll(clusterName string) ([]model.GntNode, error) 9 | } 10 | 11 | instanceRepository interface { 12 | Get(clusterName string, instanceName string) (model.InstanceResult, error) 13 | GetAll(clusterName string) ([]model.GntInstance, error) 14 | } 15 | 16 | jobRepository interface { 17 | GetAll(clusterName string) ([]model.GntJob, error) 18 | Get(clusterName, jobID string) (model.JobResult, error) 19 | } 20 | 21 | instanceActions interface { 22 | PerformSimpleInstanceAction(clusterName string, instanceName string, rapiAction string) (int, error) 23 | } 24 | 25 | searchService interface { 26 | Search(query string) model.SearchResults 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /web/src/components/ClusterSelector/ClusterSelector.module.scss: -------------------------------------------------------------------------------- 1 | .clusterSelector { 2 | .cluster { 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | text-decoration: none; 7 | padding: 1rem; 8 | min-width: 260px; 9 | color: var(--color-emphasis-medium); 10 | opacity: 0.8; 11 | transition: color 0.2s; 12 | border-left: 6px solid transparent; 13 | 14 | .name { 15 | font-weight: bold; 16 | } 17 | 18 | .hostname, 19 | .description { 20 | font-size: 0.8rem; 21 | } 22 | 23 | .hostname { 24 | font-family: monospace; 25 | white-space: nowrap; 26 | } 27 | 28 | .description { 29 | white-space: initial; 30 | } 31 | 32 | &:hover { 33 | border-color: var(--color-primary); 34 | color: var(--color-emphasis-high); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "./styles/colors"; 2 | 3 | body { 4 | margin: 0; 5 | font-family: 6 | -apple-system, 7 | BlinkMacSystemFont, 8 | "Segoe UI", 9 | Roboto, 10 | Oxygen, 11 | Ubuntu, 12 | Cantarell, 13 | "Fira Sans", 14 | "Droid Sans", 15 | "Helvetica Neue", 16 | sans-serif; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | background-color: var(--color-elevation-low); 20 | color: var(--color-emphasis-high); 21 | } 22 | 23 | code { 24 | font-family: 25 | source-code-pro, 26 | Menlo, 27 | Monaco, 28 | Consolas, 29 | "Courier New", 30 | monospace; 31 | } 32 | 33 | * { 34 | box-sizing: border-box; 35 | } 36 | 37 | a { 38 | text-decoration: none; 39 | color: inherit; 40 | 41 | &:hover { 42 | text-decoration: underline; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/src/helpers/time.ts: -------------------------------------------------------------------------------- 1 | export function unixToDate(timestamp: number): Date { 2 | return new Date(timestamp * 1000); 3 | } 4 | 5 | export function durationHumanReadable(totalSeconds: number): string { 6 | if (totalSeconds === 0) { 7 | return "< 1s"; 8 | } 9 | 10 | const hours = Math.floor(totalSeconds / 3600); 11 | const minutes = Math.floor(totalSeconds / 60) % 60; 12 | const seconds = Math.floor(totalSeconds % 60); 13 | 14 | const hoursString = hours > 0 ? `${hours}h` : ""; 15 | const minutesString = minutes > 0 ? `${minutes}m` : ""; 16 | const secondsString = seconds > 0 ? `${seconds}s` : ""; 17 | 18 | const hoursSeparator = hours > 0 && (minutes > 0 || seconds > 0) ? " " : ""; 19 | const minutesSeparator = minutes > 0 && seconds > 0 ? " " : ""; 20 | 21 | return `${hoursString}${hoursSeparator}${minutesString}${minutesSeparator}${secondsString}`; 22 | } 23 | -------------------------------------------------------------------------------- /api/mocking/node_repository.go: -------------------------------------------------------------------------------- 1 | package mocking 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | "gnt-cc/model" 6 | ) 7 | 8 | type nodeRepository struct { 9 | mock.Mock 10 | } 11 | 12 | func NewNodeRepository() *nodeRepository { 13 | return &nodeRepository{} 14 | } 15 | 16 | func (mock *nodeRepository) Get(clusterName string, nodeName string) (model.NodeResult, error) { 17 | args := mock.Called(clusterName, nodeName) 18 | 19 | return args.Get(0).(model.NodeResult), args.Error(1) 20 | } 21 | 22 | func (mock *nodeRepository) GetAll(clusterName string) ([]model.GntNode, error) { 23 | args := mock.Called(clusterName) 24 | 25 | return args.Get(0).([]model.GntNode), args.Error(1) 26 | } 27 | 28 | func (mock *nodeRepository) GetAllNames(clusterName string) ([]string, error) { 29 | args := mock.Called(clusterName) 30 | 31 | return args.Get(0).([]string), args.Error(1) 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/FakeSearchBar/FakeSearchBar.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | $spacing-outer: 1.5rem; 4 | $spacing-inner: 0.5rem; 5 | $transition-duration: 0.2s; 6 | $width: 200px; 7 | $border-radius: 4px; 8 | $size: 40px; 9 | 10 | .root { 11 | position: relative; 12 | height: $size; 13 | cursor: pointer; 14 | min-width: $width; 15 | 16 | .current { 17 | display: flex; 18 | align-items: center; 19 | padding: 0 $spacing-outer; 20 | border: 1px solid var(--color-separator); 21 | border-radius: calc($size / 2); 22 | width: 100%; 23 | height: 100%; 24 | gap: 0.75rem; 25 | 26 | @include hover-overlay; 27 | 28 | .label { 29 | width: 100%; 30 | white-space: nowrap; 31 | text-overflow: ellipsis; 32 | overflow: hidden; 33 | margin: 0; 34 | color: var(--color-emphasis-low); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/ThemeToggle/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { faMoon, faSun } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import classNames from "classnames"; 4 | import React, { ReactElement, useContext } from "react"; 5 | import ThemeContext from "../../contexts/ThemeContext"; 6 | import styles from "./ThemeToggle.module.scss"; 7 | 8 | export const ThemeToggle = (): ReactElement => { 9 | const { isDark, toggleTheme } = useContext(ThemeContext); 10 | 11 | return ( 12 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /web/src/components/PrefixNavLink.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { ReactElement, PropsWithChildren } from "react"; 3 | import { NavLink } from "react-router-dom"; 4 | import { useClusterName } from "../helpers/hooks"; 5 | interface Props { 6 | to: string; 7 | className?: string; 8 | activeClassName?: string; 9 | exact?: boolean; 10 | } 11 | 12 | const PrefixNavLink = ({ 13 | to, 14 | children, 15 | className, 16 | activeClassName, 17 | exact, 18 | }: PropsWithChildren): ReactElement => { 19 | const clusterName = useClusterName(); 20 | 21 | return ( 22 | 24 | isActive ? classNames(className, activeClassName) : className 25 | } 26 | to={`/${clusterName}${to}`} 27 | end={exact} 28 | > 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | export default PrefixNavLink; 35 | -------------------------------------------------------------------------------- /web/src/components/StatusBadge/StatusBadge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | height: 1.5rem; 3 | display: flex; 4 | align-items: center; 5 | gap: 0.5rem; 6 | border: 1px solid var(--color-emphasis-low); 7 | padding: 0 0.75rem; 8 | border-radius: 0.75rem; 9 | text-transform: uppercase; 10 | font-size: 0.8em; 11 | white-space: nowrap; 12 | 13 | &.success { 14 | color: white; 15 | border-color: var(--color-success); 16 | background-color: var(--color-success); 17 | } 18 | 19 | &.warning { 20 | color: black; 21 | border-color: var(--color-warning); 22 | background-color: var(--color-warning); 23 | } 24 | 25 | &.failure { 26 | color: white; 27 | border-color: var(--color-danger); 28 | background-color: var(--color-danger); 29 | } 30 | 31 | &.primary { 32 | color: white; 33 | border-color: var(--color-primary); 34 | background-color: var(--color-primary); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/mocking/instance_repository.go: -------------------------------------------------------------------------------- 1 | package mocking 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | "gnt-cc/model" 6 | ) 7 | 8 | type instanceRepository struct { 9 | mock.Mock 10 | } 11 | 12 | func NewInstanceRepository() *instanceRepository { 13 | return &instanceRepository{} 14 | } 15 | 16 | func (mock *instanceRepository) Get(clusterName string, instanceName string) (model.InstanceResult, error) { 17 | args := mock.Called(clusterName, instanceName) 18 | 19 | return args.Get(0).(model.InstanceResult), args.Error(1) 20 | } 21 | 22 | func (mock *instanceRepository) GetAll(clusterName string) ([]model.GntInstance, error) { 23 | args := mock.Called(clusterName) 24 | 25 | return args.Get(0).([]model.GntInstance), args.Error(1) 26 | } 27 | 28 | func (mock *instanceRepository) GetAllNames(clusterName string) ([]string, error) { 29 | args := mock.Called(clusterName) 30 | return args.Get(0).([]string), args.Error(1) 31 | } 32 | -------------------------------------------------------------------------------- /api/repository/group.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "gnt-cc/model" 7 | "gnt-cc/rapi_client" 8 | ) 9 | 10 | type GroupRepository struct { 11 | RAPIClient rapi_client.Client 12 | } 13 | 14 | func (repo GroupRepository) GetAll(clusterName string) ([]model.GntGroup, error) { 15 | slug := fmt.Sprintf("/2/groups?bulk=1") 16 | response, err := repo.RAPIClient.Get(clusterName, slug) 17 | 18 | if err != nil { 19 | return []model.GntGroup{}, err 20 | } 21 | 22 | var groupsResponse rapiGroupsResponse 23 | err = json.Unmarshal([]byte(response.Body), &groupsResponse) 24 | 25 | if err != nil { 26 | return []model.GntGroup{}, err 27 | } 28 | 29 | groups := make([]model.GntGroup, len(groupsResponse)) 30 | 31 | for i, groupResponse := range groupsResponse { 32 | groups[i] = model.GntGroup{ 33 | UUID: groupResponse.UUID, 34 | Name: groupResponse.Name, 35 | } 36 | } 37 | 38 | return groups, nil 39 | } 40 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # gnt-cc Frontend 2 | 3 | This is the frontend to gnt-cc. 4 | 5 | ## Howto build & run 6 | 7 | ### Development 8 | 9 | The application will be available at [http://localhost:3000](http://localhost:3000). 10 | 11 | ```shell 12 | # Install dependencies 13 | npm install 14 | 15 | # Start development server 16 | npm run start 17 | 18 | # lint application 19 | npm run lint 20 | 21 | # fix linting errors 22 | npm run lint:fix 23 | ``` 24 | 25 | **Note for VSCode:** The ESLint plugin for VSCode requires the following options to be set in `../.vscode/settings.json` for proper linting and to fix errors on save. 26 | 27 | ```json 28 | { 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": true 31 | }, 32 | "eslint.workingDirectories": [ 33 | "./web" 34 | ] 35 | } 36 | ``` 37 | 38 | ### Testing (TODO) 39 | 40 | ```shell 41 | npm run test 42 | ``` 43 | 44 | ### Build (TODO) 45 | 46 | ```shell 47 | npm run build 48 | ``` 49 | -------------------------------------------------------------------------------- /api/controllers/cluster.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "gnt-cc/config" 6 | "gnt-cc/model" 7 | ) 8 | 9 | type ClusterController struct{} 10 | 11 | // GetAll godoc 12 | // @Summary Get all clusters 13 | // @Description Get all clusters configured in the config file. Sensitive values will be omitted 14 | // @Produce json 15 | // @Success 200 {object} model.AllClustersResponse 16 | // @Router /clusters [get] 17 | func (controller *ClusterController) GetAll(c *gin.Context) { 18 | configClusters := config.Get().Clusters 19 | clusters := make([]model.GntCluster, len(configClusters)) 20 | 21 | for i, cluster := range configClusters { 22 | clusters[i] = model.GntCluster{ 23 | Name: cluster.Name, 24 | Hostname: cluster.Hostname, 25 | Description: cluster.Description, 26 | Port: cluster.Port, 27 | } 28 | } 29 | 30 | c.JSON(200, model.AllClustersResponse{ 31 | Clusters: clusters, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /api/controllers/search.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | type SearchController struct { 8 | SearchService searchService 9 | } 10 | 11 | // Search godoc 12 | // @Summary Search in all known resources 13 | // @Description ... 14 | // @Produce json 15 | // @Success 200 {object} model.SearchResultsResponse 16 | // @Failure 400 {object} model.ErrorResponse 17 | // @Failure 500 {object} model.ErrorResponse 18 | // @Router /search [get] 19 | // TODO: Add Tests 20 | func (controller *SearchController) Search(c *gin.Context) { 21 | query, exists := c.GetQuery("query") 22 | if !exists { 23 | c.AbortWithStatusJSON(400, createErrorBody("query parameter is required")) 24 | return 25 | } 26 | 27 | if len(query) == 0 { 28 | c.AbortWithStatusJSON(400, createErrorBody("query parameter must not be empty")) 29 | return 30 | } 31 | 32 | results := controller.SearchService.Search(query) 33 | 34 | c.JSON(200, results) 35 | } 36 | -------------------------------------------------------------------------------- /web/src/components/InstanceList/InstanceList.module.scss: -------------------------------------------------------------------------------- 1 | .instanceList { 2 | .name { 3 | font-weight: bold; 4 | } 5 | 6 | .badge { 7 | margin-left: 1rem; 8 | } 9 | 10 | .filterSettings { 11 | display: flex; 12 | align-items: center; 13 | 14 | .filterListLabel { 15 | text-transform: uppercase; 16 | font-size: 0.75rem; 17 | font-weight: bold; 18 | margin: 0 1rem; 19 | color: var(--color-emphasis-medium); 20 | } 21 | 22 | .filterList { 23 | display: flex; 24 | align-items: center; 25 | 26 | .filterCheckbox:not(:last-child) { 27 | margin-right: 0.5rem; 28 | } 29 | } 30 | 31 | .filterResetButton { 32 | margin-left: auto; 33 | } 34 | } 35 | 36 | .filterResults { 37 | padding: 1rem 0; 38 | 39 | .filterResultCount { 40 | font-weight: bold; 41 | color: var(--color-emphasis-medium); 42 | padding: 0 0.3rem; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/src/components/MemoryUtilisation/MemoryUtilisation.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: relative; 3 | padding: 0.25rem 0; 4 | width: 23ch; 5 | 6 | .total, 7 | .inUse { 8 | padding: 0 0.1rem; 9 | font-weight: bold; 10 | } 11 | 12 | .separator { 13 | color: var(--color-emphasis-medium); 14 | font-weight: bold; 15 | } 16 | 17 | .indicator { 18 | position: absolute; 19 | left: 0; 20 | right: 0; 21 | bottom: 0; 22 | height: 3px; 23 | opacity: 0.5; 24 | background-color: var(--color-separator); 25 | 26 | .progress { 27 | position: absolute; 28 | left: 0; 29 | top: 0; 30 | bottom: 0; 31 | } 32 | } 33 | 34 | &.critical .indicator .progress { 35 | background: var(--color-danger); 36 | } 37 | 38 | &.warn .indicator .progress { 39 | background: var(--color-warning); 40 | } 41 | 42 | &.ok .indicator .progress { 43 | background: var(--color-success); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/src/components/QuickInfoBanner/QuickInfoBanner.tsx: -------------------------------------------------------------------------------- 1 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 2 | import React, { PropsWithChildren, ReactElement } from "react"; 3 | import Icon from "../Icon/Icon"; 4 | 5 | import styles from "./QuickInfoBanner.module.scss"; 6 | 7 | type ItemProps = { 8 | value: string; 9 | label: string; 10 | icon: IconDefinition; 11 | }; 12 | 13 | function Item({ value, label, icon }: ItemProps): ReactElement { 14 | return ( 15 |
16 | {value} 17 | 18 | 19 | {label} 20 | 21 |
22 | ); 23 | } 24 | 25 | function QuickInfoBanner({ 26 | children, 27 | }: PropsWithChildren): ReactElement { 28 | return
{children}
; 29 | } 30 | 31 | QuickInfoBanner.Item = Item; 32 | 33 | export default QuickInfoBanner; 34 | -------------------------------------------------------------------------------- /api/mocking/rapi_client.go: -------------------------------------------------------------------------------- 1 | package mocking 2 | 3 | import ( 4 | "gnt-cc/rapi_client" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | type rapiClient struct { 10 | mock.Mock 11 | } 12 | 13 | func NewRAPIClient() *rapiClient { 14 | return new(rapiClient) 15 | } 16 | 17 | func (mock *rapiClient) Get(clusterName string, slug string) (rapi_client.Response, error) { 18 | args := mock.Called(clusterName, slug) 19 | 20 | return args.Get(0).(rapi_client.Response), args.Error(1) 21 | } 22 | 23 | func (mock *rapiClient) Post(clusterName string, slug string, body interface{}) (rapi_client.Response, error) { 24 | args := mock.Called(clusterName, slug, body) 25 | 26 | return args.Get(0).(rapi_client.Response), args.Error(1) 27 | } 28 | 29 | func (mock *rapiClient) Put(clusterName string, slug string, body interface{}) (rapi_client.Response, error) { 30 | args := mock.Called(clusterName, slug, body) 31 | 32 | return args.Get(0).(rapi_client.Response), args.Error(1) 33 | } 34 | -------------------------------------------------------------------------------- /web/src/components/StatusBadge/StatusBadge.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from "react"; 2 | import classNames from "classnames"; 3 | import styles from "./StatusBadge.module.scss"; 4 | 5 | export enum BadgeStatus { 6 | SUCCESS, 7 | FAILURE, 8 | WARNING, 9 | PRIMARY, 10 | } 11 | 12 | type Props = { 13 | status?: BadgeStatus; 14 | className?: string; 15 | }; 16 | 17 | function StatusBadge({ 18 | children, 19 | status, 20 | className, 21 | }: PropsWithChildren): ReactElement { 22 | return ( 23 | 35 | {children} 36 | 37 | ); 38 | } 39 | 40 | export default StatusBadge; 41 | -------------------------------------------------------------------------------- /web/src/components/ThemeToggle/ThemeToggle.module.scss: -------------------------------------------------------------------------------- 1 | $transition-duration: 0.15s; 2 | $height: 26px; 3 | $width: 54px; 4 | $outer-spacing: 12px; 5 | $padding: 2px; 6 | $knob-size: $height - ($padding * 2); 7 | 8 | .toggle { 9 | position: relative; 10 | border: 1px solid var(--color-separator); 11 | border-radius: calc($height / 2); 12 | height: $height; 13 | width: $width; 14 | cursor: pointer; 15 | display: flex; 16 | justify-content: space-around; 17 | align-items: center; 18 | box-sizing: content-box; 19 | background: var(--color-elevation-low); 20 | 21 | .knob { 22 | position: absolute; 23 | width: $knob-size; 24 | height: $knob-size; 25 | border-radius: calc($knob-size / 2); 26 | background: var(--color-emphasis-medium); 27 | left: $padding; 28 | top: $padding; 29 | bottom: $padding; 30 | transition: transform $transition-duration; 31 | } 32 | 33 | &.isDark { 34 | .knob { 35 | transform: translateX($width - $knob-size - (2 * $padding)); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/components/InstanceBanner/InstanceBanner.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | .divider { 3 | background: var(--color-separator); 4 | width: 1px; 5 | } 6 | 7 | .specifications { 8 | display: grid; 9 | grid-template-columns: 1fr auto 1fr; 10 | gap: 1rem 2rem; 11 | background: var(--overlay); 12 | padding: 1rem; 13 | border-radius: 0.5rem 0.5rem 0 0; 14 | } 15 | 16 | > footer { 17 | display: flex; 18 | justify-content: space-between; 19 | padding: 1rem; 20 | border: 1px solid var(--color-separator); 21 | border-top: 0; 22 | border-radius: 0 0 0.5rem 0.5rem; 23 | } 24 | 25 | .nodes { 26 | > h3 { 27 | font-size: 1rem; 28 | margin: 0 0 1rem; 29 | } 30 | 31 | .node { 32 | display: flex; 33 | justify-content: space-between; 34 | padding: 0.5rem 0; 35 | 36 | &:not(:last-child) { 37 | border-bottom: 1px solid var(--color-separator); 38 | } 39 | } 40 | } 41 | 42 | .tags, 43 | .osName { 44 | display: flex; 45 | gap: 0.5rem; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/src/components/CommandBar/SearchInput/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { faSearch, faSpinner } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement, useEffect, useRef } from "react"; 3 | import Icon from "../../Icon/Icon"; 4 | import styles from "./SearchInput.module.scss"; 5 | 6 | type Props = { 7 | value: string; 8 | isLoading: boolean; 9 | onChange: (value: string) => void; 10 | }; 11 | 12 | export default function SearchInput({ 13 | value, 14 | isLoading, 15 | onChange, 16 | }: Props): ReactElement { 17 | const ref = useRef(null); 18 | 19 | useEffect(() => { 20 | if (ref.current) { 21 | ref.current.select(); 22 | } 23 | }, [ref]); 24 | 25 | return ( 26 |
27 | 28 | 29 | onChange(e.target.value)} 35 | /> 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /web/src/providers/JobWatchProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, PropsWithChildren, useState } from "react"; 2 | import JobWatchContext, { TrackedJob } from "../contexts/JobWatchContext"; 3 | 4 | function compareTo(a: TrackedJob): (b: TrackedJob) => boolean { 5 | return (b) => a.clusterName === b.clusterName && a.id === b.id; 6 | } 7 | 8 | export default function JobWatchProvider({ 9 | children, 10 | }: PropsWithChildren): ReactElement { 11 | const [trackedJobs, setTrackedJobs] = useState([]); 12 | 13 | function trackJob(job: TrackedJob) { 14 | if (trackedJobs.find(compareTo(job))) { 15 | return; 16 | } 17 | 18 | setTrackedJobs([...trackedJobs, job]); 19 | } 20 | 21 | function untrackJob(job: TrackedJob) { 22 | setTrackedJobs(trackedJobs.filter((j) => !compareTo(job)(j))); 23 | } 24 | 25 | return ( 26 | 33 | {children} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /web/src/views/Instances/Instances.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { useApi } from "../../api"; 3 | import { GntInstance } from "../../api/models"; 4 | import ApiDataRenderer from "../../components/ApiDataRenderer/ApiDataRenderer"; 5 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 6 | import InstanceList from "../../components/InstanceList/InstanceList"; 7 | import { useClusterName } from "../../helpers/hooks"; 8 | 9 | interface InstancesResponse { 10 | cluster: string; 11 | numberOfInstances: number; 12 | instances: GntInstance[]; 13 | } 14 | 15 | const Instances = (): ReactElement => { 16 | const clusterName = useClusterName(); 17 | 18 | const [apiProps] = useApi( 19 | `clusters/${clusterName}/instances` 20 | ); 21 | 22 | return ( 23 | 24 | 25 | {...apiProps} 26 | render={({ instances }) => } 27 | /> 28 | 29 | ); 30 | }; 31 | 32 | export default Instances; 33 | -------------------------------------------------------------------------------- /web/src/components/FakeSearchBar/FakeSearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement, useContext } from "react"; 2 | import classNames from "classnames"; 3 | import styles from "./FakeSearchBar.module.scss"; 4 | import { faSearch } from "@fortawesome/free-solid-svg-icons"; 5 | import Icon from "../Icon/Icon"; 6 | import SearchBarContext from "../../contexts/SearchBarContext"; 7 | interface Props { 8 | className?: string; 9 | } 10 | 11 | const FakeSearchBar = ({ 12 | className, 13 | }: PropsWithChildren): ReactElement => { 14 | const { toggleVisibility } = useContext(SearchBarContext); 15 | 16 | return ( 17 |
{ 20 | e.stopPropagation(); 21 | toggleVisibility(); 22 | }} 23 | > 24 |
25 | 26 | 27 | Search instances, nodes and clusters 28 | 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default FakeSearchBar; 35 | -------------------------------------------------------------------------------- /web/src/components/VNCConsole/VNCConsole.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/vars"; 2 | 3 | .vncConsole { 4 | position: relative; 5 | width: 100%; 6 | height: calc(100vh - #{$navbar-height} - #{$breadcrumbs-height}); 7 | 8 | .control { 9 | position: absolute; 10 | right: 1rem; 11 | bottom: 1rem; 12 | } 13 | 14 | .overlay { 15 | position: absolute; 16 | inset: 0; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | 23 | .error { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | right: 0; 28 | margin: auto; 29 | background: var(--color-danger); 30 | color: #fff; 31 | text-align: center; 32 | display: inline-block; 33 | padding: 0.5rem; 34 | font-weight: bold; 35 | 36 | .errorClose { 37 | display: flex; 38 | justify-content: center; 39 | align-items: center; 40 | position: absolute; 41 | top: 0; 42 | right: 0; 43 | bottom: 0; 44 | padding: 0.5rem; 45 | cursor: pointer; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /web/src/views/Jobs/Jobs.tsx: -------------------------------------------------------------------------------- 1 | import { faRedoAlt } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement } from "react"; 3 | import { useApi } from "../../api"; 4 | import { GntJob } from "../../api/models"; 5 | import ApiDataRenderer from "../../components/ApiDataRenderer/ApiDataRenderer"; 6 | import Button from "../../components/Button/Button"; 7 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 8 | import JobList from "../../components/JobList/JobList"; 9 | import { useClusterName } from "../../helpers/hooks"; 10 | 11 | interface JobResponse { 12 | jobs: GntJob[]; 13 | } 14 | 15 | const Jobs = (): ReactElement => { 16 | const clusterName = useClusterName(); 17 | const [apiProps, reload] = useApi( 18 | `clusters/${clusterName}/jobs` 19 | ); 20 | 21 | return ( 22 | 23 | 24 | } 27 | /> 28 | 29 | ); 30 | }; 31 | 32 | export default Jobs; 33 | -------------------------------------------------------------------------------- /api/config.example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | bind: 127.0.0.1 3 | port: 8000 4 | developmentMode: true 5 | logLevel: warning 6 | jwtSigningKey: "weNFEWFWKJEnfWknfewlkjenfFE" 7 | jwtExpire: "2000m" 8 | authenticationMethod: "builtin" 9 | publicUrl: "https://gnt-cc.example.com" 10 | users: 11 | - username: "admin" 12 | password: "admin" 13 | - username: "admin2" 14 | password: "admin" 15 | ldapConfig: 16 | host: "some.server" 17 | port: 389 18 | skipCertificateVerify: false 19 | userFilter: "(&(objectClass=posixAccount)(uid=%s))" 20 | groupFilter: "(&(objectClass=posixGroup)(cn=someGroupName)(memberUid=%s))" 21 | baseDn: "dc=domain,dc=org" 22 | rapiConfig: 23 | skipCertificateVerify: false 24 | clusters: 25 | - name: "test-cluster" 26 | hostname: "test-cluster.example.com" 27 | port: 5080 28 | description: "Ganeti Test Cluster" 29 | username: "gnt-cc" 30 | password: "gnt-cc" 31 | ssl: true 32 | - name: "production-cluster" 33 | hostname: "prod-cluster.example.com" 34 | port: 5080 35 | description: "Ganeti Production Cluster" 36 | username: "gnt-cc" 37 | password: "somepassword" 38 | ssl: true -------------------------------------------------------------------------------- /web/src/components/MemoryUtilisation/MemoryUtilisation.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { ReactElement } from "react"; 3 | import styles from "./MemoryUtilisation.module.scss"; 4 | 5 | interface Props { 6 | memoryTotal: string; 7 | memoryInUse: string; 8 | usagePercent: number; 9 | } 10 | 11 | function MemoryUtilisation({ 12 | memoryInUse, 13 | memoryTotal, 14 | usagePercent, 15 | }: Props): ReactElement { 16 | return ( 17 |
70 && usagePercent <= 90, 21 | [styles.critical]: usagePercent > 90, 22 | })} 23 | > 24 | {memoryInUse} 25 | / 26 | {memoryTotal} 27 | 28 | 32 | 33 |
34 | ); 35 | } 36 | 37 | export default MemoryUtilisation; 38 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_node_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "dfree": null, 3 | "cnodes": 1, 4 | "serial_no": 1, 5 | "dtotal": null, 6 | "sptotal": null, 7 | "mtime": 1598213451.855095, 8 | "pip": "192.0.2.10", 9 | "mfree": 10244, 10 | "sip": "192.0.2.10", 11 | "uuid": "5b94876b-9091-42f3-984d-2e3d93af5d2d", 12 | "drained": false, 13 | "sinst_list": [], 14 | "csockets": 1, 15 | "role": "M", 16 | "ctotal": 8, 17 | "offline": false, 18 | "vm_capable": true, 19 | "pinst_cnt": 2, 20 | "mtotal": 15709, 21 | "secondary_ip": "192.0.2.10", 22 | "tags": [], 23 | "group.uuid": "35ee96ad-49e1-4eae-8117-a243b77cbefe", 24 | "sinst_cnt": 0, 25 | "cnos": 8, 26 | "ctime": 1598213451.855095, 27 | "master_candidate": true, 28 | "name": "node1", 29 | "mnode": 7503, 30 | "pinst_list": [ 31 | "burns", 32 | "milhouse" 33 | ], 34 | "ndparams": { 35 | "ovs": false, 36 | "ssh_port": 22, 37 | "ovs_link": "", 38 | "spindle_count": 1, 39 | "exclusive_storage": false, 40 | "cpu_speed": 1, 41 | "ovs_name": "switch1", 42 | "oob_program": "" 43 | }, 44 | "spfree": null, 45 | "master_capable": true 46 | } -------------------------------------------------------------------------------- /web/src/styles/colors.scss: -------------------------------------------------------------------------------- 1 | @mixin when-light { 2 | html.light & { 3 | @content; 4 | } 5 | } 6 | 7 | body { 8 | --color-emphasis-high: #f5f5f5; 9 | --color-emphasis-medium: #a4aa9d; 10 | --color-emphasis-low: #505050; 11 | --color-separator: #464646; 12 | --color-interaction-background: #303030; 13 | --color-elevation-low: #262626; 14 | --color-elevation-medium: #303030; 15 | --color-elevation-high: #3b3b3b; 16 | --drop-shadow: 0 0.25rem 1rem #000; 17 | --overlay: rgb(255 255 255 / 10%); 18 | --color-primary-shade: #7cb0dc; 19 | --color-primary: #67a0d5; 20 | --color-warning: #febc2c; 21 | --color-success: #5cb85c; 22 | --color-danger: #f84c48; 23 | 24 | @include when-light { 25 | --color-emphasis-high: #000; 26 | --color-emphasis-medium: #333; 27 | --color-emphasis-low: #505050; 28 | --color-separator: #e3e3e3; 29 | --color-interaction-background: rgb(235 235 235); 30 | --color-elevation-low: #fff; 31 | --color-elevation-medium: #fff; 32 | --color-elevation-high: #fff; 33 | --color-primary-shade: #4672af; 34 | --drop-shadow: 0 0.25rem 1rem rgb(0 0 0 / 50%); 35 | --overlay: rgb(0 0 0 / 5%); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | $size: 40px; 4 | $size-small: 28px; 5 | 6 | .button { 7 | position: relative; 8 | cursor: pointer; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | padding: 0 1.5rem; 13 | height: $size; 14 | background: var(--color-interaction-background); 15 | color: var(--color-emphasis-high); 16 | border-radius: 2px; 17 | border: 0; 18 | text-transform: uppercase; 19 | font-size: 0.85rem; 20 | gap: 0.75rem; 21 | 22 | @include hover-overlay; 23 | 24 | &.danger { 25 | color: #fff; 26 | background: #e53935; 27 | border-color: transparent; 28 | } 29 | 30 | &.primary { 31 | background: var(--color-primary); 32 | color: #fff; 33 | border-color: transparent; 34 | } 35 | 36 | &.isSmall { 37 | height: $size-small; 38 | } 39 | 40 | &:not(.hasLabel) { 41 | width: $size; 42 | padding: 0; 43 | 44 | &.isSmall { 45 | width: $size-small; 46 | } 47 | } 48 | 49 | &.isRound { 50 | border-radius: calc($size / 2); 51 | 52 | &.isSmall { 53 | border-radius: calc($size-small / 2); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 3 | version: 2 4 | before: 5 | hooks: 6 | - cp ../README.md ./README.md 7 | - cp ../LICENSE ./LICENSE 8 | - go install github.com/swaggo/swag/cmd/swag@latest 9 | - go install github.com/GeertJohan/go.rice/rice@latest 10 | - go mod download 11 | - swag init 12 | - rice embed-go 13 | 14 | builds: 15 | - goos: 16 | - linux 17 | goarch: 18 | - amd64 19 | 20 | archives: 21 | - files: 22 | - config.example.yaml 23 | - README.md 24 | - LICENSE 25 | 26 | checksum: 27 | algorithm: sha512 28 | 29 | release: 30 | github: 31 | owner: sipgate 32 | name: gnt-cc 33 | draft: true 34 | prerelease: auto 35 | 36 | changelog: 37 | sort: asc 38 | 39 | nfpms: 40 | - package_name: gnt-cc 41 | maintainer: sipgate 42 | vendor: sipgate 43 | homepage: https://github.com/sipgate/gnt-cc 44 | description: API + web frontend for multiple Ganeti virtualisation clusters 45 | license: Apache 2.0 46 | formats: 47 | - deb 48 | contents: 49 | - src: config.example.yaml 50 | dst: /etc/gnt-cc/config.example.yaml 51 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_instance_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "disk_usage": 0, 3 | "oper_vcpus": null, 4 | "nic.uuids": [], 5 | "serial_no": 1, 6 | "ctime": 1598213509.328825, 7 | "hvparams": {}, 8 | "oper_state": false, 9 | "disk_template": "diskless", 10 | "disk.spindles": [], 11 | "mtime": 1598213509.328825, 12 | "nic.modes": [], 13 | "oper_ram": null, 14 | "nic.networks.names": [], 15 | "pnode": "node1", 16 | "nic.names": [], 17 | "nic.bridges": [], 18 | "status": "ADMIN_down", 19 | "custom_hvparams": {}, 20 | "tags": [ 21 | "tag1" 22 | ], 23 | "nic.networks": [], 24 | "snodes": ["node4"], 25 | "nic.macs": [], 26 | "nic.ips": [], 27 | "network_port": null, 28 | "name": "burns", 29 | "custom_beparams": {}, 30 | "disk.uuids": [], 31 | "custom_nicparams": [], 32 | "disk.names": [], 33 | "uuid": "5d42b054-01ec-4b70-bc39-4b3c504fe63f", 34 | "disk.sizes": [], 35 | "admin_state": "down", 36 | "nic.links": [], 37 | "os": "noop", 38 | "beparams": { 39 | "auto_balance": true, 40 | "spindle_use": 1, 41 | "vcpus": 1, 42 | "memory": 128, 43 | "minmem": 128, 44 | "always_failover": false, 45 | "maxmem": 128 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/src/components/Navbar/Navbar.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/vars"; 2 | 3 | .navbar { 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | padding: 0 1rem; 8 | background: var(--color-elevation-medium); 9 | height: $navbar-height; 10 | 11 | .begin { 12 | display: flex; 13 | align-items: center; 14 | gap: 1rem; 15 | flex: 1; 16 | padding-right: 2rem; 17 | 18 | .logoContainer { 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | .logo { 24 | width: 3rem; 25 | height: 3rem; 26 | margin-right: 1rem; 27 | } 28 | } 29 | 30 | .items { 31 | display: flex; 32 | align-items: center; 33 | 34 | .item { 35 | display: block; 36 | font-weight: bold; 37 | color: var(--color-emphasis-high); 38 | padding: 1rem; 39 | 40 | &.active { 41 | color: var(--color-primary); 42 | } 43 | } 44 | } 45 | 46 | .searchBar { 47 | flex: 1; 48 | max-width: 800px; 49 | } 50 | } 51 | 52 | .end { 53 | display: flex; 54 | align-items: center; 55 | gap: 2rem; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactElement } from "react"; 3 | import Button from "../Button/Button"; 4 | import Modal from "../Modal/Modal"; 5 | import Name from "../Name/Name"; 6 | import styles from "./VNCCtrlAltDelConfirmModal.module.scss"; 7 | 8 | type Props = { 9 | isVisible: boolean; 10 | instanceName: string; 11 | onConfirm: () => void; 12 | onHide: () => void; 13 | }; 14 | 15 | export default function VNCCtrlAltDelConfirmModal({ 16 | isVisible, 17 | instanceName, 18 | onConfirm, 19 | onHide, 20 | }: Props): ReactElement { 21 | return ( 22 | 23 |

Are you sure, you would like to send Ctrl + Alt + Del to

24 | 25 | {instanceName} 26 | 27 |

This might trigger an instance restart.

28 | 29 |
30 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import Button from "../Button/Button"; 3 | import Modal from "../Modal/Modal"; 4 | import Name from "../Name/Name"; 5 | import styles from "./InstanceActionConfirmationModal.module.scss"; 6 | 7 | type Props = { 8 | isVisible: boolean; 9 | actionName: string; 10 | instanceName: string; 11 | onConfirm: () => void; 12 | onHide: () => void; 13 | }; 14 | 15 | function InstanceActionConfirmationModal({ 16 | isVisible, 17 | actionName, 18 | instanceName, 19 | onConfirm, 20 | onHide, 21 | }: Props): ReactElement { 22 | return ( 23 | 24 |

Are you sure you would like to {actionName}

25 | {instanceName} 26 | 27 |
28 |
38 |
39 | ); 40 | } 41 | 42 | export default InstanceActionConfirmationModal; 43 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_job_instance_remove_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "end_ts" : [ 3 | 1645198913, 4 | 753307 5 | ], 6 | "start_ts" : [ 7 | 1645198879, 8 | 599481 9 | ], 10 | "id" : 12345, 11 | "status" : "success", 12 | "ops" : [ 13 | { 14 | "debug_level" : 0, 15 | "instance_name" : "homer.local", 16 | "priority" : 0, 17 | "depends" : null, 18 | "comment" : null, 19 | "ignore_failures" : false, 20 | "reason" : [ 21 | [ 22 | "gnt:client:gnt-instance", 23 | "remove", 24 | 1645198879038146048 25 | ], 26 | [ 27 | "gnt:opcode:instance_remove", 28 | "job=12345;index=0", 29 | 1645198879110317000 30 | ] 31 | ], 32 | "shutdown_timeout" : 120, 33 | "OP_ID" : "OP_INSTANCE_REMOVE", 34 | "instance_uuid" : "5485880f-c8e8-4610-9487-cd7087da327e", 35 | "dry_run" : false 36 | } 37 | ], 38 | "oplog" : [ 39 | [] 40 | ], 41 | "opstatus" : [ 42 | "success" 43 | ], 44 | "opresult" : [ 45 | null 46 | ], 47 | "summary" : [ 48 | "INSTANCE_REMOVE(homer.local)" 49 | ], 50 | "received_ts" : [ 51 | 1645198879, 52 | 110317 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/Breadcrumbs/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import React, { ReactElement } from "react"; 4 | import { Link, useLocation } from "react-router-dom"; 5 | import styles from "./Breadcrumbs.module.scss"; 6 | 7 | const Breadcrumbs = (): ReactElement => { 8 | const location = useLocation(); 9 | 10 | const crumbs = location.pathname.split("/").filter((crumb) => !!crumb); 11 | 12 | const crumbElements = []; 13 | let link = ""; 14 | 15 | for (let i = 0; i < crumbs.length; i++) { 16 | link += `/${crumbs[i]}`; 17 | 18 | crumbElements.push( 19 | 20 | {crumbs[i]} 21 | 22 | ); 23 | 24 | if (i < crumbs.length - 1) { 25 | crumbElements.push( 26 | 31 | ); 32 | } 33 | } 34 | 35 | return ( 36 |
37 | {crumbElements} 38 |
39 | ); 40 | }; 41 | 42 | export default Breadcrumbs; 43 | -------------------------------------------------------------------------------- /api/actions/instance.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "fmt" 5 | "gnt-cc/rapi_client" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type rapiActionToMethodMap map[string](func(string, string, interface{}) (rapi_client.Response, error)) 11 | 12 | type InstanceActions struct { 13 | RAPIClient rapi_client.Client 14 | } 15 | 16 | func (actions *InstanceActions) PerformSimpleInstanceAction(clusterName string, instanceName string, rapiAction string) (int, error) { 17 | rapiActionToMethodMapping := rapiActionToMethodMap{ 18 | "startup": actions.RAPIClient.Put, 19 | "reboot": actions.RAPIClient.Post, 20 | "shutdown": actions.RAPIClient.Put, 21 | "migrate": actions.RAPIClient.Put, 22 | "failover": actions.RAPIClient.Put, 23 | } 24 | 25 | rapiMethod, exists := rapiActionToMethodMapping[rapiAction] 26 | 27 | if !exists { 28 | return 0, fmt.Errorf("cannot find rapiClient function for action '%s'", rapiAction) 29 | } 30 | 31 | slug := fmt.Sprintf("/2/instances/%s/%s", instanceName, rapiAction) 32 | response, err := rapiMethod(clusterName, slug, nil) 33 | 34 | if err != nil { 35 | return 0, err 36 | } 37 | 38 | jobID, err := strconv.Atoi(strings.TrimSpace(response.Body)) 39 | 40 | if err != nil { 41 | return 0, fmt.Errorf("cannot parse RAPI response") 42 | } 43 | 44 | return jobID, nil 45 | } 46 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 21 | Ganeti Control Center 22 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - '*' 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | build: 16 | name: Build App 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | 23 | - name: Build web 24 | run: | 25 | cd web 26 | npm ci 27 | npm run test --watchAll=false 28 | npm run build 29 | 30 | - name: Set up Go 1.25 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version: '1.25' 34 | id: go 35 | 36 | - name: Build 37 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 38 | uses: goreleaser/goreleaser-action@v6 39 | with: 40 | version: latest 41 | args: build --snapshot 42 | workdir: ./api 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Release 47 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 48 | uses: goreleaser/goreleaser-action@v6 49 | with: 50 | version: latest 51 | args: release --rm-dist 52 | workdir: ./api 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /web/src/components/ApiDataRenderer/ApiDataRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { faBolt, faRobot } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement } from "react"; 3 | import Icon from "../Icon/Icon"; 4 | import LoadingIndicator from "../LoadingIndicator/LoadingIndicator"; 5 | import styles from "./ApiDataRenderer.module.scss"; 6 | 7 | type Props = { 8 | data: T | null; 9 | isLoading: boolean; 10 | error: string | null; 11 | render: (data: T) => ReactElement; 12 | }; 13 | 14 | function renderError(error: string): ReactElement { 15 | return ( 16 |
17 |
18 | 19 | 20 |
21 |
22 |

Something went wrong

23 |

{error}

24 |
25 |
26 | ); 27 | } 28 | 29 | function ApiDataRenderer({ 30 | data, 31 | isLoading, 32 | error, 33 | render, 34 | }: Props): ReactElement { 35 | if (isLoading) { 36 | return ; 37 | } 38 | 39 | if (error) { 40 | return renderError(error); 41 | } 42 | 43 | if (!data) { 44 | return renderError("No data returned from server"); 45 | } 46 | 47 | return render(data); 48 | } 49 | 50 | export default ApiDataRenderer; 51 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_job_instance_activate_disks_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "received_ts" : [ 3 | 1645197738, 4 | 295405 5 | ], 6 | "summary" : [ 7 | "INSTANCE_ACTIVATE_DISKS(homer.local)" 8 | ], 9 | "opstatus" : [ 10 | "success" 11 | ], 12 | "id" : 12345, 13 | "opresult" : [ 14 | [ 15 | [ 16 | "ganeti-node01.local", 17 | "disk/0", 18 | "/dev/drbd19" 19 | ] 20 | ] 21 | ], 22 | "ops" : [ 23 | { 24 | "instance_name" : "homer.local", 25 | "comment" : null, 26 | "wait_for_sync" : false, 27 | "instance_uuid" : "4eed9718-ace4-4410-9e57-ec19bedd17ed", 28 | "depends" : null, 29 | "reason" : [ 30 | [ 31 | "gnt:watcher", 32 | "Activating disks for instance homer.local", 33 | 1645197738224751872 34 | ], 35 | [ 36 | "gnt:opcode:instance_activate_disks", 37 | "job=12345;index=0", 38 | 1645197738295405000 39 | ] 40 | ], 41 | "ignore_size" : false, 42 | "debug_level" : 0, 43 | "priority" : 0, 44 | "OP_ID" : "OP_INSTANCE_ACTIVATE_DISKS" 45 | } 46 | ], 47 | "end_ts" : [ 48 | 1645197741, 49 | 462349 50 | ], 51 | "start_ts" : [ 52 | 1645197738, 53 | 792726 54 | ], 55 | "oplog" : [ 56 | [] 57 | ], 58 | "status" : "success" 59 | } 60 | -------------------------------------------------------------------------------- /web/src/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { PropsWithChildren, ReactElement, useState } from "react"; 3 | import { createPortal } from "react-dom"; 4 | import IconButton from "../IconButton/IconButton"; 5 | import styles from "./Modal.module.scss"; 6 | 7 | type UseModalState = { 8 | isVisible: boolean; 9 | toggleModal: () => void; 10 | }; 11 | 12 | export function useModal(): UseModalState { 13 | const [isVisible, setIsVisible] = useState(false); 14 | 15 | function toggleModal() { 16 | setIsVisible(!isVisible); 17 | } 18 | 19 | return { 20 | isVisible, 21 | toggleModal, 22 | }; 23 | } 24 | 25 | type Props = { 26 | isVisible: boolean; 27 | hideModal: () => void; 28 | }; 29 | 30 | function Modal({ 31 | isVisible, 32 | hideModal, 33 | children, 34 | }: PropsWithChildren): ReactElement | null { 35 | if (!isVisible) { 36 | return null; 37 | } 38 | 39 | return createPortal( 40 |
41 |
e.stopPropagation()}> 42 | {children} 43 |
44 | 45 |
46 |
47 |
, 48 | document.body 49 | ); 50 | } 51 | 52 | export default Modal; 53 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { createTheme } from "react-data-table-component"; 4 | import "./index.scss"; 5 | import App from "./App"; 6 | 7 | createTheme("default", { 8 | text: { 9 | primary: "var(--color-emphasis-high)", 10 | secondary: "var(--color-emphasis-medium)", 11 | }, 12 | background: { 13 | default: "var(--color-elevation-low)", 14 | }, 15 | context: { 16 | background: "var(--color-elevation-medium)", 17 | text: "var(--color-emphasis-high)", 18 | hover: "var(--color-emphasis-high)", 19 | }, 20 | divider: { 21 | default: "var(--color-separator)", 22 | }, 23 | button: { 24 | default: "var(--color-emphasis-medium)", 25 | focus: "var(--color-interaction-background)", 26 | hover: "var(--color-interaction-background)", 27 | disabled: "var(--color-emphasis-low)", 28 | }, 29 | action: { 30 | button: "var(--color-emphasis-medium)", 31 | hover: "var(--color-emphasis-high)", 32 | disabled: "var(--color-separator)", 33 | }, 34 | highlightOnHover: { 35 | default: "var(--color-interaction-background)", 36 | text: "var(--color-emphasis-high)", 37 | }, 38 | sortFocus: { 39 | default: "var(--color-emphasis-high)", 40 | }, 41 | }); 42 | 43 | ReactDOM.render( 44 | 45 | 46 | , 47 | document.getElementById("root") 48 | ); 49 | -------------------------------------------------------------------------------- /api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gnt-cc/config" 5 | "gnt-cc/router" 6 | "net/http" 7 | "strconv" 8 | 9 | _ "gnt-cc/docs" 10 | 11 | rice "github.com/GeertJohan/go.rice" 12 | "github.com/gin-gonic/gin" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // TODO 17 | // @title GNT-CC 18 | // @version 0.0 19 | // @description An API wrapper with local/ldap authentication around one or more Ganeti RAPI backends 20 | 21 | // @contact.name API Support 22 | // @contact.url https://github.com/sipgate/gnt-cc/issues 23 | 24 | // @license.name Apache 2.0 25 | // @license.url http://www.apache.org/licenses/LICENSE-2.0.html 26 | 27 | // @host localhost:8080 28 | // @BasePath /api/v1 29 | 30 | // @securityDefinitions.apikey ApiKeyAuth 31 | // @in header 32 | // @name Authorization 33 | 34 | func main() { 35 | config.Init() 36 | 37 | if !config.Get().DevelopmentMode { 38 | gin.SetMode(gin.ReleaseMode) 39 | } 40 | 41 | engine := gin.New() 42 | r := router.New(engine) 43 | r.SetupAPIRoutes() 44 | 45 | if !config.Get().DevelopmentMode { 46 | appBox := rice.MustFindBox("../web/build") 47 | staticBox := rice.MustFindBox("../web/build/static") 48 | 49 | r.InitTemplates(appBox) 50 | engine.StaticFS("/static", staticBox.HTTPBox()) 51 | } 52 | 53 | bindInfo := config.Get().Bind + ":" + strconv.Itoa(config.Get().Port) 54 | log.Infof("Starting HTTP server on %s", bindInfo) 55 | if err := http.ListenAndServe(bindInfo, engine); err != nil { 56 | log.Fatal(err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /web/src/CustomColorBadge/CustomColorBadge.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from "react"; 2 | import styles from "./CustomColorBadge.module.scss"; 3 | 4 | type Props = { 5 | color: string; 6 | }; 7 | 8 | function CustomColorBadge({ 9 | children, 10 | color, 11 | }: PropsWithChildren): ReactElement { 12 | return ( 13 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | /* credit goes to David Halford */ 26 | function getContrastingTextColor(hex: string) { 27 | const threshold = 130; 28 | 29 | const hRed = hexToR(hex); 30 | const hGreen = hexToG(hex); 31 | const hBlue = hexToB(hex); 32 | 33 | function hexToR(h: string) { 34 | return parseInt(cutHex(h).substring(0, 2), 16); 35 | } 36 | function hexToG(h: string) { 37 | return parseInt(cutHex(h).substring(2, 4), 16); 38 | } 39 | function hexToB(h: string) { 40 | return parseInt(cutHex(h).substring(4, 6), 16); 41 | } 42 | function cutHex(h: string) { 43 | return h.charAt(0) == "#" ? h.substring(1, 7) : h; 44 | } 45 | 46 | const cBrightness = (hRed * 299 + hGreen * 587 + hBlue * 114) / 1000; 47 | if (cBrightness > threshold) { 48 | return "#000000"; 49 | } else { 50 | return "#ffffff"; 51 | } 52 | } 53 | 54 | export default CustomColorBadge; 55 | -------------------------------------------------------------------------------- /api/controllers/statistics.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "gnt-cc/model" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type StatisticsController struct { 11 | InstanceRepository instanceRepository 12 | NodeRepository nodeRepository 13 | } 14 | 15 | // Get godoc 16 | // @Summary Get statistics for a given cluster 17 | // @Description Get statistics for a given cluster 18 | // @Produce json 19 | // @Success 200 {object} model.StatisticsResponse 20 | // @Router /clusters/{cluster}/statistics [get] 21 | func (controller *StatisticsController) Get(c *gin.Context) { 22 | clusterName := c.Param("cluster") 23 | 24 | instances, err := controller.InstanceRepository.GetAll(clusterName) 25 | 26 | if err != nil { 27 | abortWithInternalServerError(c, err) 28 | return 29 | } 30 | 31 | nodes, err := controller.NodeRepository.GetAll(clusterName) 32 | 33 | if err != nil { 34 | abortWithInternalServerError(c, err) 35 | return 36 | } 37 | 38 | response := model.StatisticsResponse{} 39 | 40 | for _, instance := range instances { 41 | response.Instances.Count++ 42 | response.Instances.CPUCount += instance.CpuCount 43 | response.Instances.MemoryTotal += instance.MemoryTotal 44 | } 45 | 46 | for _, node := range nodes { 47 | response.Nodes.Count++ 48 | response.Nodes.CPUCount += node.CPUCount 49 | response.Nodes.MemoryTotal += node.MemoryTotal 50 | 51 | if node.IsMaster { 52 | response.Master = node.Name 53 | } 54 | } 55 | 56 | c.JSON(http.StatusOK, response) 57 | } 58 | -------------------------------------------------------------------------------- /api/services/search.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "gnt-cc/model" 5 | "strings" 6 | ) 7 | 8 | type SearchService struct { 9 | ResourcesService resourcesService 10 | } 11 | 12 | const RESULTS_LIMIT = 5 13 | 14 | func (s *SearchService) Search(query string) model.SearchResults { 15 | resources := s.ResourcesService.CollectAll() 16 | 17 | clusters := []model.Resource{} 18 | 19 | for _, cluster := range resources.Clusters { 20 | if stringContainsIgnoreCase(cluster.Name, query) { 21 | clusters = append(clusters, model.Resource{ 22 | Name: cluster.Name, 23 | }) 24 | } 25 | } 26 | 27 | instances := filterClusterResources(query, resources.Instances) 28 | nodes := filterClusterResources(query, resources.Nodes) 29 | 30 | return model.SearchResults{ 31 | Nodes: nodes[0:min(len(nodes), RESULTS_LIMIT)], 32 | Instances: instances[0:min(len(instances), RESULTS_LIMIT)], 33 | Clusters: clusters[0:min(len(clusters), RESULTS_LIMIT)], 34 | } 35 | } 36 | 37 | func filterClusterResources(filter string, list []model.ClusterResource) []model.ClusterResource { 38 | filtered := []model.ClusterResource{} 39 | 40 | for _, item := range list { 41 | if stringContainsIgnoreCase(item.Name, filter) { 42 | filtered = append(filtered, item) 43 | } 44 | } 45 | 46 | return filtered 47 | } 48 | 49 | func stringContainsIgnoreCase(a string, b string) bool { 50 | return strings.Contains( 51 | strings.ToLower(a), 52 | strings.ToLower(b), 53 | ) 54 | } 55 | 56 | func min(a, b int) int { 57 | if a < b { 58 | return a 59 | } 60 | return b 61 | } 62 | -------------------------------------------------------------------------------- /web/src/components/Input/Input.module.scss: -------------------------------------------------------------------------------- 1 | $padding: 0.5rem; 2 | $transition-duration: 0.1s; 3 | 4 | .inputWrapper { 5 | position: relative; 6 | overflow: hidden; 7 | border-radius: 3px; 8 | background-color: var(--color-interaction-background); 9 | 10 | // focus overlay 11 | &::after { 12 | content: ""; 13 | position: absolute; 14 | inset: 0; 15 | opacity: 0; 16 | background: var(--overlay); 17 | transition: opacity 0.1s; 18 | pointer-events: none; 19 | border-radius: inherit; 20 | } 21 | 22 | &.isFocused::after { 23 | opacity: 1; 24 | } 25 | 26 | .input { 27 | border: 0; 28 | outline: 0; 29 | font-size: 1rem; 30 | padding: 2 * $padding $padding; 31 | transition: transform $transition-duration; 32 | background: transparent; 33 | color: var(--color-emphasis-high); 34 | } 35 | 36 | .label, 37 | .error { 38 | position: absolute; 39 | right: $padding; 40 | font-size: 0.75rem; 41 | } 42 | 43 | .label { 44 | top: -0.2rem; 45 | left: $padding; 46 | color: var(--color-emphasis-medium); 47 | opacity: 0; 48 | transition: opacity $transition-duration, transform $transition-duration; 49 | } 50 | 51 | .error { 52 | color: var(--color-danger); 53 | top: 0.3rem; 54 | } 55 | 56 | &.hasError, 57 | &.hasError.isFocused { 58 | border-color: var(--color-danger); 59 | } 60 | 61 | &.hasContent { 62 | .input, 63 | .label { 64 | transform: translateY($padding); 65 | } 66 | 67 | .label { 68 | opacity: 1; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_job_cluster_verify_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "start_ts" : [ 3 | 1645199102, 4 | 921001 5 | ], 6 | "id" : 12345, 7 | "opresult" : [ 8 | { 9 | "jobs" : [ 10 | [ 11 | true, 12 | 3366634 13 | ], 14 | [ 15 | true, 16 | 3366635 17 | ], 18 | [ 19 | true, 20 | 3366636 21 | ], 22 | [ 23 | true, 24 | 3366637 25 | ] 26 | ] 27 | } 28 | ], 29 | "summary" : [ 30 | "CLUSTER_VERIFY" 31 | ], 32 | "ops" : [ 33 | { 34 | "error_codes" : false, 35 | "dry_run" : false, 36 | "priority" : 0, 37 | "OP_ID" : "OP_CLUSTER_VERIFY", 38 | "skip_checks" : [], 39 | "debug_simulate_errors" : false, 40 | "verify_clutter" : false, 41 | "ignore_errors" : [], 42 | "depends" : null, 43 | "comment" : null, 44 | "verbose" : false, 45 | "debug_level" : 0, 46 | "reason" : [ 47 | [ 48 | "gnt:client:gnt-cluster", 49 | "verify", 50 | 1645199102206962944 51 | ], 52 | [ 53 | "gnt:opcode:cluster_verify", 54 | "job=12345;index=0", 55 | 1645199102289536000 56 | ] 57 | ] 58 | } 59 | ], 60 | "oplog" : [ 61 | [] 62 | ], 63 | "received_ts" : [ 64 | 1645199102, 65 | 289536 66 | ], 67 | "status" : "success", 68 | "end_ts" : [ 69 | 1645199104, 70 | 365339 71 | ], 72 | "opstatus" : [ 73 | "success" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /web/src/providers/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PropsWithChildren, 3 | ReactElement, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import { buildApiUrl } from "../api"; 8 | import AuthContext from "../contexts/AuthContext"; 9 | 10 | type UserResponse = { 11 | username: string; 12 | }; 13 | 14 | export default function AuthProvider({ 15 | children, 16 | }: PropsWithChildren): ReactElement { 17 | const [username, setUsername] = useState(null); 18 | const [isLoading, setIsLoading] = useState(true); 19 | const [error, setError] = useState(""); 20 | 21 | useEffect(() => { 22 | fetch(buildApiUrl("user")) 23 | .then(async (response) => { 24 | if (response.status === 200) { 25 | const body = (await response.json()) as UserResponse; 26 | setUsername(body.username); 27 | } else if (response.status === 401) { 28 | setUsername(null); 29 | } else { 30 | const body = await response.json(); 31 | setError(body.message ? body.message : "unknown error"); 32 | } 33 | 34 | setIsLoading(false); 35 | }) 36 | .catch((reason) => { 37 | setError(reason.message); 38 | setIsLoading(false); 39 | }); 40 | }, []); 41 | 42 | if (isLoading) { 43 | return
Loading...
; 44 | } 45 | 46 | if (error) { 47 | return
Error: {error}
; 48 | } 49 | 50 | return ( 51 | 57 | {children} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /web/src/components/VNCControl/VNCControl.module.scss: -------------------------------------------------------------------------------- 1 | $toggle-button-radius: 24px; 2 | $container-padding: 4px; 3 | 4 | .wrapper { 5 | position: relative; 6 | } 7 | 8 | .toggleButton { 9 | position: relative; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | width: $toggle-button-radius * 2; 14 | height: $toggle-button-radius * 2; 15 | border-radius: $toggle-button-radius; 16 | background: var(--color-elevation-high); 17 | box-shadow: var(--drop-shadow); 18 | cursor: pointer; 19 | z-index: 1; 20 | } 21 | 22 | .container { 23 | position: absolute; 24 | bottom: calc(100% + 16px); 25 | right: 0; 26 | border-radius: 4px; 27 | background: var(--color-elevation-high); 28 | box-shadow: var(--drop-shadow); 29 | 30 | &::before { 31 | position: absolute; 32 | content: ""; 33 | background: inherit; 34 | width: 16px; 35 | height: 16px; 36 | bottom: -8px; 37 | right: $toggle-button-radius - 8px; 38 | transform: rotate(45deg); 39 | } 40 | 41 | .actions { 42 | position: relative; 43 | background: inherit; 44 | 45 | .action { 46 | display: flex; 47 | flex-direction: row-reverse; 48 | align-items: center; 49 | cursor: pointer; 50 | padding: 1rem; 51 | 52 | &:hover { 53 | background: var(--color-interaction-background); 54 | } 55 | 56 | .actionAside { 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | margin-left: 1rem; 61 | } 62 | 63 | .actionLabel { 64 | white-space: nowrap; 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /api/repository/job_types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type rapiRemoteImportDisk struct { 9 | IpAddress string 10 | Port int 11 | Magic string 12 | HmacDigest string 13 | Salt string 14 | } 15 | 16 | func (r *rapiOpLogEntry) parse(b []byte) error { 17 | tmp := []interface{}{&r.Serial, &r.Timestamps, &r.PayloadType, &r.Payload} 18 | 19 | return unmarshalArrayIntoStruct(&tmp, b) 20 | } 21 | 22 | func (r *rapiRemoteImportDisk) parse(b []byte) error { 23 | tmp := []interface{}{&r.IpAddress, &r.Port, &r.Magic, &r.HmacDigest, &r.Salt} 24 | 25 | return unmarshalArrayIntoStruct(&tmp, b) 26 | } 27 | 28 | func unmarshalArrayIntoStruct(tmp *[]interface{}, b []byte) error { 29 | wantLen := len(*tmp) 30 | if err := json.Unmarshal(b, tmp); err != nil { 31 | return err 32 | } 33 | if g, e := len(*tmp), wantLen; g != e { 34 | return fmt.Errorf("wrong number of fields: %d != %d", g, e) 35 | } 36 | return nil 37 | } 38 | 39 | type rapiRemoteImportPayload struct { 40 | Disks []json.RawMessage `json:"disks"` 41 | } 42 | 43 | type rapiOpLogEntry struct { 44 | Serial int 45 | Timestamps []int 46 | PayloadType string 47 | Payload json.RawMessage 48 | } 49 | 50 | type rapiJobResponse struct { 51 | Status string `json:"status"` 52 | ID int `json:"id"` 53 | StartTs []int `json:"start_ts"` 54 | EndTs []int `json:"end_ts"` 55 | ReceivedTs []int `json:"received_ts"` 56 | Summary []string `json:"summary"` 57 | OpStatus []string `json:"opstatus"` 58 | Ops []interface{} `json:"ops"` 59 | OpLog [][]json.RawMessage `json:"oplog"` 60 | } 61 | -------------------------------------------------------------------------------- /web/src/providers/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PropsWithChildren, 3 | ReactElement, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import ThemeContext from "../contexts/ThemeContext"; 8 | 9 | const LIGHT_CLASS = "light"; 10 | const DARK_THEME_ID = "theme-dark"; 11 | 12 | const savedThemeState = localStorage.getItem(DARK_THEME_ID); 13 | const initialState = savedThemeState 14 | ? (JSON.parse(savedThemeState) as boolean) 15 | : false; 16 | 17 | export default function ThemeProvider({ 18 | children, 19 | }: PropsWithChildren): ReactElement { 20 | const [isDark, setIsDark] = useState(initialState); 21 | 22 | useEffect(() => { 23 | if (isDark) { 24 | document.documentElement.classList.remove(LIGHT_CLASS); 25 | } else { 26 | document.documentElement.classList.add(LIGHT_CLASS); 27 | } 28 | }, [isDark]); 29 | 30 | useEffect(() => { 31 | const setPreferredMode = (prefersDark: boolean) => { 32 | if (savedThemeState === null) { 33 | setIsDark(prefersDark); 34 | } 35 | }; 36 | 37 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 38 | setPreferredMode(mediaQuery.matches); 39 | mediaQuery.addListener(({ matches: prefersDark }) => 40 | setPreferredMode(prefersDark) 41 | ); 42 | }, []); 43 | 44 | function setIsDarkPersistent(value: boolean) { 45 | setIsDark(value); 46 | localStorage.setItem(DARK_THEME_ID, JSON.stringify(value)); 47 | } 48 | 49 | function toggleTheme() { 50 | setIsDarkPersistent(!isDark); 51 | } 52 | 53 | return ( 54 | 60 | {children} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /api/repository/group_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "errors" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/mock" 7 | "gnt-cc/mocking" 8 | "gnt-cc/model" 9 | "gnt-cc/rapi_client" 10 | "gnt-cc/repository" 11 | "io/ioutil" 12 | "testing" 13 | ) 14 | 15 | func TestGroupRepoGetAllFuncReturnsError_WhenRAPIClientReturnsError(t *testing.T) { 16 | client := mocking.NewRAPIClient() 17 | client.On("Get", mock.Anything, mock.Anything). 18 | Once().Return(rapi_client.Response{}, errors.New("expected error")) 19 | repo := repository.GroupRepository{RAPIClient: client} 20 | _, err := repo.GetAll("test") 21 | 22 | assert.EqualError(t, err, "expected error") 23 | } 24 | 25 | func TestGroupRepoGetAllFuncReturnsError_WhenJSONResponseIsInvalid(t *testing.T) { 26 | client := mocking.NewRAPIClient() 27 | client.On("Get", mock.Anything, mock.Anything). 28 | Once().Return(rapi_client.Response{Status: 200, Body: "{"}, nil) 29 | repo := repository.GroupRepository{RAPIClient: client} 30 | _, err := repo.GetAll("test") 31 | 32 | assert.NotNil(t, err) 33 | } 34 | 35 | func TestGroupRepoGetFuncReturnsGroup(t *testing.T) { 36 | validResponse, _ := ioutil.ReadFile("../testfiles/rapi_responses/valid_groups_response.json") 37 | client := mocking.NewRAPIClient() 38 | client.On("Get", mock.Anything, mock.Anything). 39 | Once().Return(rapi_client.Response{Status: 200, Body: string(validResponse)}, nil) 40 | repo := repository.GroupRepository{RAPIClient: client} 41 | result, err := repo.GetAll("test") 42 | 43 | assert.NoError(t, err) 44 | assert.EqualValues(t, []model.GntGroup{ 45 | { 46 | Name: "groupname1", 47 | UUID: "uuid1", 48 | }, 49 | { 50 | Name: "groupname2", 51 | UUID: "uuid2", 52 | }, 53 | }, result) 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/ClusterSelector/ClusterSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./ClusterSelector.module.scss"; 3 | import Dropdown from "../Dropdown/Dropdown"; 4 | import { faServer } from "@fortawesome/free-solid-svg-icons"; 5 | import { GntCluster } from "../../api/models"; 6 | import { useLocation, useNavigate } from "react-router-dom"; 7 | import classNames from "classnames"; 8 | import { useClusterName } from "../../helpers/hooks"; 9 | 10 | interface Props { 11 | clusters: GntCluster[]; 12 | } 13 | 14 | const ClusterSelector = ({ clusters }: Props): ReactElement => { 15 | const clusterName = useClusterName(); 16 | const navigate = useNavigate(); 17 | const location = useLocation(); 18 | 19 | const selectCluster = (name: string): void => { 20 | const { pathname } = location; 21 | 22 | const parts = pathname.split("/"); 23 | const slug = parts[2] ? `/${parts[2]}` : ""; 24 | 25 | navigate(`/${name}${slug}`); 26 | }; 27 | 28 | return ( 29 |
30 | 31 | {clusters.map((cluster) => ( 32 |
selectCluster(cluster.name)} 38 | > 39 | {cluster.name} 40 | {cluster.hostname} 41 | {cluster.description} 42 |
43 | ))} 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default ClusterSelector; 50 | -------------------------------------------------------------------------------- /web/src/components/VNCCredentialsPrompt/VNCCredentialsPrompt.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from "react"; 2 | import Button from "../Button/Button"; 3 | import Input from "../Input/Input"; 4 | import styles from "./VNCCredentialsPrompt.module.scss"; 5 | 6 | type Credentials = { 7 | username: string; 8 | password: string; 9 | }; 10 | 11 | type Props = { 12 | initialValue: Credentials; 13 | onConfirm: (value: Credentials) => void; 14 | }; 15 | 16 | const VNCPasswordPrompt = ({ 17 | initialValue, 18 | onConfirm, 19 | }: Props): ReactElement => { 20 | const [value, setValue] = useState(initialValue); 21 | 22 | return ( 23 |
24 |

Missing credentials

25 |
{ 27 | ev.preventDefault(); 28 | onConfirm(value); 29 | }} 30 | className={styles.form} 31 | > 32 | 37 | setValue({ 38 | ...value, 39 | username: ev.target.value, 40 | }) 41 | } 42 | name="vnc-username" 43 | className={styles.input} 44 | /> 45 | 50 | setValue({ 51 | ...value, 52 | password: ev.target.value, 53 | }) 54 | } 55 | name="vnc-password" 56 | className={styles.input} 57 | /> 58 | 59 |
62 | ); 63 | }; 64 | 65 | export default VNCPasswordPrompt; 66 | -------------------------------------------------------------------------------- /web/src/api/models.ts: -------------------------------------------------------------------------------- 1 | export type GntDisk = { 2 | uuid: string; 3 | name: string; 4 | capacity: number; 5 | template: string; 6 | }; 7 | 8 | export type GntNic = { 9 | uuid: string; 10 | name: string; 11 | mode: string; 12 | bridge: string; 13 | mac: string; 14 | vlan: string; 15 | }; 16 | 17 | export type GntNicInfo = { 18 | nicType: string; 19 | nicTypeFriendly: string; 20 | }; 21 | 22 | export type GntInstance = { 23 | name: string; 24 | primaryNode: string; 25 | secondaryNodes: string[]; 26 | cpuCount: number; 27 | memoryTotal: number; 28 | isRunning: boolean; 29 | offersVnc: boolean; 30 | disks: GntDisk[]; 31 | nics: GntNic[]; 32 | nicInfo: GntNicInfo; 33 | tags: string[]; 34 | OS: string; 35 | }; 36 | 37 | export type GntNode = { 38 | name: string; 39 | memoryFree: number; 40 | memoryTotal: number; 41 | diskTotal: number; 42 | diskFree: number; 43 | cpuCount: number; 44 | primaryInstancesCount: number; 45 | secondaryInstancesCount: number; 46 | isMaster: boolean; 47 | isMasterCandidate: boolean; 48 | isMasterCapable: boolean; 49 | isDrained: boolean; 50 | isOffline: boolean; 51 | isVMCapable: boolean; 52 | groupName: string; 53 | }; 54 | 55 | export type GntCluster = { 56 | name: string; 57 | hostname: string; 58 | description: string; 59 | port: number; 60 | }; 61 | 62 | export type GntJob = { 63 | id: number; 64 | clusterName: string; 65 | summary: string; 66 | receivedAt: number; 67 | startedAt: number; 68 | endedAt: number; 69 | status: string; 70 | }; 71 | 72 | export type GntJobLogEntry = { 73 | serial: number; 74 | message: string; 75 | startedAt: number; 76 | }; 77 | 78 | export type GntJobWithLog = GntJob & { 79 | log: GntJobLogEntry[]; 80 | }; 81 | 82 | export type JobIdResponse = { 83 | jobId: number; 84 | }; 85 | -------------------------------------------------------------------------------- /api/model/response.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ClusterResponse struct { 4 | Cluster GntCluster `json:"cluster"` 5 | } 6 | 7 | type AllClustersResponse struct { 8 | Clusters []GntCluster `json:"clusters"` 9 | } 10 | 11 | type AllInstancesResponse struct { 12 | Cluster string `json:"cluster"` 13 | NumberOfInstances int `json:"numberOfInstances"` 14 | Instances []GntInstance `json:"instances"` 15 | } 16 | 17 | type InstanceResponse struct { 18 | Cluster string `json:"cluster"` 19 | Instance GntInstance `json:"instance"` 20 | } 21 | 22 | type AllNodesResponse struct { 23 | Cluster string `json:"cluster"` 24 | NumberOfNodes int `json:"numberOfNodes"` 25 | Nodes []GntNode `json:"nodes"` 26 | } 27 | 28 | type NodeResponse struct { 29 | Cluster string `json:"cluster"` 30 | Node GntNodeWithInstances `json:"node"` 31 | PrimaryInstances []GntInstance `json:"primaryInstances"` 32 | SecondaryInstances []GntInstance `json:"secondaryInstances"` 33 | } 34 | 35 | type AllJobsResponse struct { 36 | NumberOfJobs int `json:"numberOfJobs"` 37 | Jobs []GntJob `json:"jobs"` 38 | } 39 | 40 | type JobResponse struct { 41 | Job GntJob `json:"job"` 42 | } 43 | 44 | type JobIDResponse struct { 45 | JobID int `json:"jobId"` 46 | } 47 | 48 | type ErrorResponse struct { 49 | Message string `json:"message"` 50 | } 51 | 52 | type StatisticsElement struct { 53 | Count int `json:"count"` 54 | MemoryTotal int `json:"memoryTotal"` 55 | CPUCount int `json:"cpuCount"` 56 | } 57 | 58 | type StatisticsResponse struct { 59 | Instances StatisticsElement `json:"instances"` 60 | Nodes StatisticsElement `json:"nodes"` 61 | Master string `json:"master"` 62 | } 63 | 64 | type SearchResultsResponse SearchResults 65 | -------------------------------------------------------------------------------- /web/src/components/Dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | ReactElement, 4 | useEffect, 5 | PropsWithChildren, 6 | } from "react"; 7 | import styles from "./Dropdown.module.scss"; 8 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 9 | import classNames from "classnames"; 10 | import Icon from "../Icon/Icon"; 11 | 12 | export enum Alignment { 13 | LEFT = "left", 14 | CENTER = "center", 15 | RIGHT = "right", 16 | } 17 | 18 | type Props = { 19 | label?: string; 20 | icon?: IconDefinition; 21 | align?: Alignment; 22 | }; 23 | 24 | function Dropdown({ 25 | label, 26 | icon, 27 | align = Alignment.LEFT, 28 | children, 29 | }: PropsWithChildren): ReactElement { 30 | const [expanded, setExpanded] = useState(false); 31 | 32 | const handleOutsideClick = () => setExpanded(false); 33 | 34 | const toggle = () => setExpanded(!expanded); 35 | 36 | useEffect(() => { 37 | window.addEventListener("click", handleOutsideClick); 38 | 39 | return () => { 40 | window.removeEventListener("click", handleOutsideClick); 41 | }; 42 | }, []); 43 | return ( 44 |
{ 54 | e.stopPropagation(); 55 | toggle(); 56 | }} 57 | > 58 |
59 | {icon && } 60 | {label && {label}} 61 |
62 |
63 | 64 |
{children}
65 |
66 |
67 | ); 68 | } 69 | 70 | export default Dropdown; 71 | -------------------------------------------------------------------------------- /web/src/views/JobDetail/JobDetail.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { useApi } from "../../api"; 4 | import { GntJobWithLog } from "../../api/models"; 5 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 6 | import JobStartedAt from "../../components/JobStartedAt"; 7 | import JobStatus from "../../components/JobStatus"; 8 | import JobSummary from "../../components/JobSummary/JobSummary"; 9 | import LoadingIndicator from "../../components/LoadingIndicator/LoadingIndicator"; 10 | import { useClusterName } from "../../helpers/hooks"; 11 | import styles from "./JobDetail.module.scss"; 12 | 13 | type JobResponse = { 14 | job: GntJobWithLog; 15 | }; 16 | 17 | export default function JobDetail(): ReactElement { 18 | const clusterName = useClusterName(); 19 | const { jobID } = useParams<{ jobID: string }>(); 20 | const [{ data, isLoading, error }] = useApi( 21 | `clusters/${clusterName}/jobs/${jobID}` 22 | ); 23 | 24 | if (isLoading) { 25 | return ; 26 | } 27 | 28 | if (!data) { 29 | return
Failed to load: {error}
; 30 | } 31 | 32 | const { id, status, summary, log } = data.job; 33 | 34 | return ( 35 | 36 |
37 |

{id}

38 | 39 |
40 | 41 |
42 |

Log

43 |
44 | {log.map((entry) => ( 45 |
46 | {" "} 47 | {entry.message} 48 |
49 | ))} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /web/src/views/ClusterWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { Navigate, Outlet, useParams } from "react-router-dom"; 3 | import { useApi } from "../api"; 4 | import { GntCluster } from "../api/models"; 5 | import Breadcrumbs from "../components/Breadcrumbs/Breadcrumbs"; 6 | import ClusterNotFound from "../components/ClusterNotFound/ClusterNotFound"; 7 | import CommandBar from "../components/CommandBar/CommandBar"; 8 | import LoadingIndicator from "../components/LoadingIndicator/LoadingIndicator"; 9 | import Navbar from "../components/Navbar/Navbar"; 10 | 11 | interface ClusterResponse { 12 | clusters: GntCluster[]; 13 | } 14 | 15 | const ClusterWrapper = (): ReactElement => { 16 | const [ 17 | { data: clusterData, isLoading: clustersLoading, error: clusterLoadError }, 18 | ] = useApi("clusters"); 19 | 20 | const { clusterName } = useParams<{ clusterName: string }>(); 21 | 22 | const clusterExists = 23 | clusterData && 24 | clusterData.clusters.find((cluster) => cluster.name === clusterName) !== 25 | undefined; 26 | 27 | return ( 28 | <> 29 | {clusterData && clusterData.clusters.length > 0 && ( 30 | <> 31 | {!clusterName && } 32 | 33 | {!clusterExists && clusterName && ( 34 | 35 | )} 36 | 37 | {clusterExists && ( 38 | <> 39 | 40 | 41 | 42 | 43 | 44 | )} 45 | 46 | )} 47 | {clustersLoading && } 48 | {clusterLoadError &&
API Error: {clusterLoadError}
} 49 | 50 | ); 51 | }; 52 | 53 | export default ClusterWrapper; 54 | -------------------------------------------------------------------------------- /web/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import styles from "./Button.module.scss"; 3 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 4 | import Icon from "../Icon/Icon"; 5 | import classNames from "classnames"; 6 | 7 | export interface ButtonProps { 8 | onClick?: (event: React.MouseEvent) => void; 9 | disabled?: boolean; 10 | label?: string; 11 | icon?: IconDefinition; 12 | type?: "button" | "submit"; 13 | className?: string; 14 | round?: boolean; 15 | small?: boolean; 16 | primary?: boolean; 17 | danger?: boolean; 18 | href?: string; 19 | } 20 | 21 | function Button({ 22 | onClick, 23 | disabled, 24 | icon, 25 | label, 26 | type, 27 | className, 28 | round, 29 | small, 30 | primary, 31 | danger, 32 | href, 33 | }: ButtonProps): ReactElement { 34 | const handleClick = (event: React.MouseEvent) => { 35 | if (!disabled && onClick) { 36 | onClick(event); 37 | } 38 | }; 39 | 40 | const buttonClassNames = classNames(className || null, { 41 | [styles.button]: true, 42 | [styles.hasLabel]: !!label, 43 | [styles.isRound]: !!round, 44 | [styles.isSmall]: !!small, 45 | [styles.primary]: !!primary, 46 | [styles.danger]: !!danger, 47 | }); 48 | 49 | if (href) { 50 | return ( 51 | 52 | {icon && } 53 | {label && {label}} 54 | 55 | ); 56 | } 57 | 58 | return ( 59 | 68 | ); 69 | } 70 | 71 | export default Button; 72 | -------------------------------------------------------------------------------- /web/src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState, ReactElement } from "react"; 2 | import styles from "./Input.module.scss"; 3 | import classNames from "classnames"; 4 | 5 | type InputType = "text" | "email" | "password" | "search"; 6 | 7 | interface Props { 8 | type: InputType; 9 | label: string; 10 | value: string; 11 | name: string; 12 | error?: string | false; 13 | onBlur?: (event: ChangeEvent) => void; 14 | onChange?: (event: ChangeEvent) => void; 15 | className?: string; 16 | } 17 | 18 | const Input = ({ 19 | error, 20 | type, 21 | label, 22 | name, 23 | onBlur, 24 | onChange, 25 | value, 26 | className, 27 | }: Props): ReactElement => { 28 | const [focused, setFocused] = useState(false); 29 | 30 | const handleInputFocus = () => setFocused(true); 31 | 32 | const handleInputBlur = (event: ChangeEvent) => { 33 | setFocused(false); 34 | 35 | if (onBlur) { 36 | onBlur(event); 37 | } 38 | }; 39 | 40 | return ( 41 |
52 | 63 | 64 | 67 | 68 |
69 | {error} 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default Input; 76 | -------------------------------------------------------------------------------- /api/rapi_client/client.go: -------------------------------------------------------------------------------- 1 | package rapi_client 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "gnt-cc/config" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | type Client interface { 12 | Get(clusterName string, slug string) (Response, error) 13 | Post(clusterName string, slug string, body interface{}) (Response, error) 14 | Put(clusterName string, slug string, body interface{}) (Response, error) 15 | } 16 | 17 | type rapiClient struct { 18 | clusterUrls map[string]string 19 | http *http.Client 20 | } 21 | 22 | type Response struct { 23 | Status int 24 | Body string 25 | } 26 | 27 | func New(clusterConfigs []config.ClusterConfig, transport http.RoundTripper) (*rapiClient, error) { 28 | urlMap, err := validateAndCreateClusterUrls(clusterConfigs) 29 | 30 | if err != nil { 31 | return nil, fmt.Errorf("invalid cluster config: %s", err) 32 | } 33 | 34 | return &rapiClient{ 35 | clusterUrls: urlMap, 36 | http: &http.Client{ 37 | Timeout: time.Second * 10, 38 | Transport: transport, 39 | }, 40 | }, nil 41 | } 42 | 43 | func validateAndCreateClusterUrls(clusterConfigs []config.ClusterConfig) (map[string]string, error) { 44 | urlMap := make(map[string]string) 45 | 46 | for _, c := range clusterConfigs { 47 | if c.Name == "" { 48 | return nil, errors.New("empty field 'Name'") 49 | } 50 | 51 | if _, exists := urlMap[c.Name]; exists { 52 | return nil, fmt.Errorf("duplicate cluster name '%s'", c.Name) 53 | } 54 | 55 | urlMap[c.Name] = createClusterURL(c) 56 | } 57 | 58 | return urlMap, nil 59 | } 60 | 61 | func createClusterURL(config config.ClusterConfig) string { 62 | return fmt.Sprintf( 63 | "%s://%s:%s@%s:%d", 64 | getProtocol(config.SSL), 65 | config.Username, 66 | config.Password, 67 | config.Hostname, 68 | config.Port, 69 | ) 70 | } 71 | 72 | func getProtocol(useSSL bool) string { 73 | if useSSL { 74 | return "https" 75 | } 76 | 77 | return "http" 78 | } 79 | -------------------------------------------------------------------------------- /api/actions/instance_test.go: -------------------------------------------------------------------------------- 1 | package actions_test 2 | 3 | import ( 4 | "errors" 5 | "gnt-cc/actions" 6 | "gnt-cc/mocking" 7 | "gnt-cc/rapi_client" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | var rapiActions = []string{ 15 | "startup", 16 | "reboot", 17 | "shutdown", 18 | } 19 | 20 | func TestInstanceMethodReturnsError_WhenRAPIReturnsError(t *testing.T) { 21 | client := mocking.NewRAPIClient() 22 | client.On("Post", "testClusterName", mock.Anything, nil). 23 | Return(rapi_client.Response{}, errors.New("expected error")) 24 | client.On("Put", "testClusterName", mock.Anything, nil). 25 | Return(rapi_client.Response{}, errors.New("expected error")) 26 | 27 | actions := actions.InstanceActions{RAPIClient: client} 28 | 29 | for _, rapiAction := range rapiActions { 30 | _, err := actions.PerformSimpleInstanceAction("testClusterName", "testInstanceName", rapiAction) 31 | assert.EqualError(t, err, "expected error") 32 | } 33 | } 34 | 35 | func TestRAPIEndpointIsCalled_WhenInvokingInstanceMethod(t *testing.T) { 36 | client := mocking.NewRAPIClient() 37 | client.On("Put", "testClusterName", "/2/instances/testInstanceName/startup", nil). 38 | Once().Return(rapi_client.Response{ 39 | Body: "423458", 40 | }, nil) 41 | client.On("Post", "testClusterName", "/2/instances/testInstanceName/reboot", nil). 42 | Once().Return(rapi_client.Response{ 43 | Body: "423458", 44 | }, nil) 45 | client.On("Put", "testClusterName", "/2/instances/testInstanceName/shutdown", nil). 46 | Once().Return(rapi_client.Response{ 47 | Body: "423458", 48 | }, nil) 49 | 50 | actions := actions.InstanceActions{RAPIClient: client} 51 | 52 | for _, rapiAction := range rapiActions { 53 | jobId, err := actions.PerformSimpleInstanceAction("testClusterName", "testInstanceName", rapiAction) 54 | assert.Nil(t, err) 55 | assert.Equal(t, jobId, 423458) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # gnt-cc Backend 2 | 3 | An API wrapper with local/ldap authentication around one or more Ganeti RAPI backends 4 | 5 | ## Howto build & run 6 | 7 | - Init your swag 8 | ``` 9 | go get github.com/swaggo/swag/cmd/swag 10 | swag init 11 | ``` 12 | 13 | - Run 14 | ``` 15 | go run main.go 16 | ``` 17 | 18 | - Build 19 | ``` 20 | go build 21 | ``` 22 | 23 | - Test 24 | ``` 25 | go test ./... -cover 26 | ``` 27 | 28 | ## API Documentation 29 | 30 | You can access the API documentation through `/swagger/index.html` on your gnt-cc instance, e.g. `https://gnt-cc.example.com/swagger/index.html` or `http://localhost:8080/swagger/index.html`. 31 | 32 | ## Features 33 | 34 | ### Implemented 35 | 36 | - Authentication with local or LDAP backend 37 | - Integrated API documentation (using swagger) 38 | - Enumerate clusters, nodes, instances, and jobs 39 | - Read cluster, instance and job details 40 | - Provide a WebSocket proxy to enable authenticated access to the VNC or Spice port of an instance (needs a HTML5 VNC or Spice client) 41 | 42 | ### Planned 43 | 44 | - implement the full RAPI functionallity 45 | 46 | ## Configuration 47 | 48 | Please use the provided `config.yaml.example` as a template. The service expects to find a `config.yaml` file in its working directory. If you use systemd to start `gnt-cc`, please make sure to set the `WorkingDirectory` parameter to the folder in which `config.yaml` resides. 49 | 50 | ### Authentication Backends 51 | 52 | Currently gnt-cc supports `builtin` or `ldap` as authentication backends. `builtin` uses a static and plaintext user/passwort list defined in `config.yaml` and should only be used for development. For production environments, use of the `ldap` backend is recommended. The `config.yaml.example` file contains templates for local users as well as LDAP configurations. 53 | 54 | ## Development 55 | 56 | ### Ganeti Dummy Cluster 57 | 58 | Use [this project](https://github.com/sipgate/ganeti-docker) to setup a fake ganeti cluster on your local machine. 59 | -------------------------------------------------------------------------------- /api/auth/user.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "gnt-cc/config" 5 | 6 | "github.com/jtblin/go-ldap-client" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type User struct { 11 | Username string 12 | } 13 | 14 | func validateUser(userID string, password string) bool { 15 | switch config.Get().AuthenticationMethod { 16 | case "builtin": 17 | return validateLocalUser(userID, password) 18 | case "ldap": 19 | return validateLdapUser(userID, password) 20 | } 21 | return false 22 | } 23 | 24 | func validateLocalUser(userID string, password string) bool { 25 | for _, el := range config.Get().Users { 26 | userSet := config.UserConfig(el) 27 | if userSet.Username == userID && userSet.Password == password { 28 | return true 29 | } 30 | } 31 | return false 32 | } 33 | 34 | func validateLdapUser(userID string, password string) bool { 35 | client := &ldap.LDAPClient{ 36 | Base: config.Get().LDAPConfig.BaseDN, 37 | Host: config.Get().LDAPConfig.Host, 38 | ServerName: config.Get().LDAPConfig.Host, 39 | Port: config.Get().LDAPConfig.Port, 40 | InsecureSkipVerify: config.Get().LDAPConfig.SkipCertificateVerify, 41 | UserFilter: config.Get().LDAPConfig.UserFilter, 42 | GroupFilter: config.Get().LDAPConfig.GroupFilter, 43 | } 44 | defer client.Close() 45 | ok, _, err := client.Authenticate(userID, password) 46 | if err != nil { 47 | log.Errorf("Error authenticating user '%s': %+v", userID, err) 48 | return false 49 | } 50 | if !ok { 51 | log.Warningf("Authenticating failed for user '%s'", userID) 52 | } 53 | 54 | groups, err := client.GetGroupsOfUser(userID) 55 | if err != nil { 56 | log.Errorf("Error getting groups for user %s: %+v", "username", err) 57 | } 58 | 59 | if len(groups) > 0 { 60 | log.Debugf("Authentication for user '%s' successful", userID) 61 | return true 62 | } 63 | 64 | log.Warningf("User '%s' does not belong to LDAP groups matching the search filter", userID) 65 | return false 66 | } 67 | -------------------------------------------------------------------------------- /web/src/views/NodeDetail/NodeDetail.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { Outlet, useLocation, useParams } from "react-router-dom"; 3 | import { useApi } from "../../api"; 4 | import { GntInstance, GntNode } from "../../api/models"; 5 | import ApiDataRenderer from "../../components/ApiDataRenderer/ApiDataRenderer"; 6 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 7 | import TabBar from "../../components/TabBar/TabBar"; 8 | import { useClusterName } from "../../helpers/hooks"; 9 | import styles from "./NodeDetail.module.scss"; 10 | 11 | interface NodeResponse { 12 | node: GntNode; 13 | primaryInstances: GntInstance[]; 14 | secondaryInstances: GntInstance[]; 15 | } 16 | 17 | const NodeDetail = (): ReactElement => { 18 | const { nodeName } = useParams<{ nodeName: string }>(); 19 | const clusterName = useClusterName(); 20 | const { pathname } = useLocation(); 21 | 22 | const [apiProps] = useApi( 23 | `clusters/${clusterName}/nodes/${nodeName}` 24 | ); 25 | 26 | return ( 27 | 28 |
29 | 30 | 35 | 40 | 41 |
42 | 43 | 44 | {...apiProps} 45 | render={({ primaryInstances, secondaryInstances }) => ( 46 |
47 | 53 |
54 | )} 55 | /> 56 |
57 | ); 58 | }; 59 | 60 | export default NodeDetail; 61 | -------------------------------------------------------------------------------- /api/repository/node_types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | type rapiNodeResponse struct { 4 | Name string `json:"name"` 5 | PinstCnt int `json:"pinst_cnt"` 6 | SinstCnt int `json:"sinst_cnt"` 7 | PinstList []string `json:"pinst_list"` 8 | SinstList []string `json:"sinst_list"` 9 | Dtotal int `json:"dtotal"` 10 | Dfree int `json:"dfree"` 11 | Ctotal int `json:"ctotal"` 12 | Mtotal int `json:"mtotal"` 13 | Mfree int `json:"mfree"` 14 | Sptotal interface{} `json:"sptotal"` 15 | Spfree interface{} `json:"spfree"` 16 | Offline bool `json:"offline"` 17 | Drained bool `json:"drained"` 18 | VMCapable bool `json:"vm_capable"` 19 | MasterCandidate bool `json:"master_candidate"` 20 | MasterCapable bool `json:"master_capable"` 21 | Mnode int `json:"mnode"` 22 | Cnodes int `json:"cnodes"` 23 | Ctime float64 `json:"ctime"` 24 | Mtime float64 `json:"mtime"` 25 | SerialNo int `json:"serial_no"` 26 | Pip string `json:"pip"` 27 | Sip string `json:"sip"` 28 | UUID string `json:"uuid"` 29 | Csockets int `json:"csockets"` 30 | Role string `json:"role"` 31 | Tags []string `json:"tags"` 32 | GroupUUID string `json:"group.uuid"` 33 | Cnos int `json:"cnos"` 34 | Ndparams struct { 35 | Ovs bool `json:"ovs"` 36 | SSHPort int `json:"ssh_port"` 37 | OvsLink string `json:"ovs_link"` 38 | SpindleCount int `json:"spindle_count"` 39 | ExclusiveStorage bool `json:"exclusive_storage"` 40 | CPUSpeed int `json:"cpu_speed"` 41 | OvsName string `json:"ovs_name"` 42 | OobProgram string `json:"oob_program"` 43 | } `json:"ndparams"` 44 | } 45 | 46 | type rapiNodeNamesResponse []struct { 47 | ID string `json:"id"` 48 | } 49 | -------------------------------------------------------------------------------- /web/src/components/JobWatcher/helpers.ts: -------------------------------------------------------------------------------- 1 | import { TrackedJob } from "./../../contexts/JobWatchContext"; 2 | import { GntJob } from "../../api/models"; 3 | 4 | export enum WatcherStatus { 5 | InProgress, 6 | Succeeded, 7 | HasFailures, 8 | } 9 | 10 | function isJobInProgress(job: GntJob): boolean { 11 | return job.status !== "error" && job.status !== "success"; 12 | } 13 | 14 | function areSameJobs(a: GntJob, b: GntJob): boolean { 15 | return a.clusterName === b.clusterName && a.id === b.id; 16 | } 17 | 18 | export function getFinishedJobs(jobs: GntJob[]): GntJob[] { 19 | return jobs.filter((job) => !isJobInProgress(job)); 20 | } 21 | 22 | export function getUnfinishedJobs(jobs: GntJob[]): GntJob[] { 23 | return jobs.filter(isJobInProgress); 24 | } 25 | 26 | export function groupJobIdsByCluster( 27 | jobs: TrackedJob[] 28 | ): Map { 29 | const map: Map = new Map(); 30 | 31 | for (const { clusterName, id } of jobs) { 32 | const existing = map.get(clusterName) || []; 33 | 34 | map.set(clusterName, [...existing, id]); 35 | } 36 | 37 | return map; 38 | } 39 | 40 | export function joinJobListsUnique( 41 | originalList: GntJob[], 42 | overrideList: GntJob[] 43 | ): GntJob[] { 44 | return [ 45 | ...originalList.filter( 46 | (aValue) => !overrideList.find((bValue) => areSameJobs(aValue, bValue)) 47 | ), 48 | ...overrideList, 49 | ]; 50 | } 51 | 52 | export function getWatcherStatus(jobs: GntJob[]): WatcherStatus { 53 | for (const job of jobs) { 54 | if (isJobInProgress(job)) { 55 | return WatcherStatus.InProgress; 56 | } 57 | } 58 | 59 | for (const job of jobs) { 60 | if (job.status === "error") { 61 | return WatcherStatus.HasFailures; 62 | } 63 | } 64 | 65 | return WatcherStatus.Succeeded; 66 | } 67 | 68 | export function sortJobs(jobs: GntJob[]): GntJob[] { 69 | return jobs.sort((a, b) => { 70 | if (isJobInProgress(a) && !isJobInProgress(b)) { 71 | return -1; 72 | } 73 | 74 | if (!isJobInProgress(a) && isJobInProgress(b)) { 75 | return 1; 76 | } 77 | 78 | return 0; 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /api/rapi_client/client_test.go: -------------------------------------------------------------------------------- 1 | package rapi_client_test 2 | 3 | import ( 4 | "fmt" 5 | "gnt-cc/config" 6 | "gnt-cc/rapi_client" 7 | "io/ioutil" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func createTestConfigNoSSL(name string) config.ClusterConfig { 15 | return config.ClusterConfig{ 16 | Name: name, 17 | Hostname: "test.gnt", 18 | Port: 5080, 19 | Username: "test", 20 | Password: "supersecret", 21 | SSL: false, 22 | } 23 | } 24 | 25 | func createTestConfigSSL(name string) config.ClusterConfig { 26 | return config.ClusterConfig{ 27 | Name: name, 28 | Hostname: "test.gnt", 29 | Port: 5080, 30 | Username: "test", 31 | Password: "supersecret", 32 | SSL: true, 33 | } 34 | } 35 | 36 | func getDefaultTestClusters() []config.ClusterConfig { 37 | return []config.ClusterConfig{createTestConfigSSL("test")} 38 | } 39 | 40 | type errorReader struct{} 41 | 42 | func (m *errorReader) Read(p []byte) (int, error) { 43 | return 0, fmt.Errorf("error") 44 | } 45 | 46 | func makeResponseWithBodyReaderReturningAnError() *http.Response { 47 | return &http.Response{ 48 | StatusCode: 200, 49 | Body: ioutil.NopCloser(&errorReader{}), 50 | Header: make(http.Header), 51 | } 52 | } 53 | 54 | func TestCreatingRAPIClientShouldNotBePossible_WhenAClusterConfigHasNoName(t *testing.T) { 55 | client, err := rapi_client.New([]config.ClusterConfig{{ 56 | Hostname: "test", 57 | }, { 58 | Name: "test", 59 | }}, nil) 60 | 61 | assert.NotNil(t, err) 62 | assert.Nil(t, client) 63 | } 64 | 65 | func TestCreatingRAPIClientShouldNotBePossible_WhenClusterNamesAreNotUnique(t *testing.T) { 66 | client, err := rapi_client.New([]config.ClusterConfig{{ 67 | Name: "test", 68 | }, { 69 | Name: "test", 70 | }}, nil) 71 | 72 | assert.NotNil(t, err) 73 | assert.Nil(t, client) 74 | } 75 | 76 | func TestCreatingRAPIClientShouldBePossible_WhenAllClusterConfigsAreValid(t *testing.T) { 77 | client, err := rapi_client.New([]config.ClusterConfig{ 78 | createTestConfigNoSSL("test1"), 79 | createTestConfigSSL("test2"), 80 | }, nil) 81 | 82 | assert.Nil(t, err) 83 | assert.NotNil(t, client) 84 | } 85 | -------------------------------------------------------------------------------- /api/rapi_client/requests.go: -------------------------------------------------------------------------------- 1 | package rapi_client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | func (client rapiClient) Get(clusterName string, slug string) (Response, error) { 12 | clusterURL, exists := client.clusterUrls[clusterName] 13 | 14 | if !exists { 15 | return Response{}, fmt.Errorf("cluster not found: %s", clusterName) 16 | } 17 | 18 | httpResponse, err := client.http.Get(clusterURL + slug) 19 | 20 | if err != nil { 21 | return Response{}, fmt.Errorf("request error: %s", err) 22 | } 23 | 24 | return parseRAPIResponse(httpResponse) 25 | } 26 | 27 | func (client rapiClient) Post(clusterName string, slug string, body interface{}) (Response, error) { 28 | return client.modify(clusterName, slug, body, http.MethodPost) 29 | } 30 | 31 | func (client rapiClient) Put(clusterName string, slug string, body interface{}) (Response, error) { 32 | return client.modify(clusterName, slug, body, http.MethodPut) 33 | } 34 | 35 | func (client rapiClient) modify(clusterName string, slug string, body interface{}, method string) (Response, error) { 36 | clusterURL, exists := client.clusterUrls[clusterName] 37 | 38 | if !exists { 39 | return Response{}, fmt.Errorf("cluster not found: %s", clusterName) 40 | } 41 | 42 | jsonBody, err := json.Marshal(body) 43 | 44 | if err != nil { 45 | return Response{}, fmt.Errorf("could not prepare request: %s", err) 46 | } 47 | 48 | request, err := http.NewRequest( 49 | method, 50 | clusterURL+slug, 51 | bytes.NewBuffer(jsonBody), 52 | ) 53 | 54 | if err != nil { 55 | return Response{}, err 56 | } 57 | 58 | request.Header.Set("Content-Type", "application/json") 59 | 60 | response, err := client.http.Do(request) 61 | 62 | if err != nil { 63 | return Response{}, fmt.Errorf("request error: %s", err) 64 | } 65 | 66 | return parseRAPIResponse(response) 67 | } 68 | 69 | func parseRAPIResponse(httpResponse *http.Response) (Response, error) { 70 | defer httpResponse.Body.Close() 71 | body, err := ioutil.ReadAll(httpResponse.Body) 72 | 73 | if err != nil { 74 | return Response{}, fmt.Errorf("could not parse RAPI response: %s", err) 75 | } 76 | 77 | return Response{ 78 | Status: httpResponse.StatusCode, 79 | Body: string(body), 80 | }, nil 81 | } 82 | -------------------------------------------------------------------------------- /api/testfiles/rapi_responses/valid_nodes_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "dfree": 671740, 4 | "cnodes": 1, 5 | "serial_no": 13, 6 | "dtotal": 1660012, 7 | "sptotal": 0, 8 | "mtime": 1583426670.598909, 9 | "pip": "192.0.0.1", 10 | "mfree": 31873, 11 | "sip": "192.0.0.1", 12 | "uuid": "55d5b0e4-0250-4297-a96a-5ce2ff052b79", 13 | "drained": false, 14 | "sinst_list": [], 15 | "csockets": 1, 16 | "role": "R", 17 | "ctotal": 24, 18 | "offline": false, 19 | "vm_capable": true, 20 | "pinst_cnt": 2, 21 | "mtotal": 64420, 22 | "tags": [], 23 | "group.uuid": "uuid1", 24 | "sinst_cnt": 0, 25 | "cnos": 24, 26 | "ctime": 1416904090.241024, 27 | "master_candidate": false, 28 | "name": "node1", 29 | "mnode": 32056, 30 | "pinst_list": [ 31 | "burns", 32 | "milhouse" 33 | ], 34 | "ndparams": { 35 | "ovs": false, 36 | "ssh_port": 22, 37 | "ovs_link": "", 38 | "spindle_count": 1, 39 | "exclusive_storage": false, 40 | "cpu_speed": 1, 41 | "ovs_name": "switch1", 42 | "oob_program": "" 43 | }, 44 | "spfree": 0, 45 | "master_capable": true 46 | }, 47 | { 48 | "dfree": 1119916, 49 | "cnodes": 1, 50 | "serial_no": 8, 51 | "dtotal": 2241324, 52 | "sptotal": 0, 53 | "mtime": 1583511594.715497, 54 | "pip": "192.0.0.2", 55 | "mfree": 82412, 56 | "sip": "192.0.0.2", 57 | "uuid": "81a5c6fe-dd4c-43fd-8205-8c51c026b4fe", 58 | "drained": false, 59 | "sinst_list": [ 60 | "burns", 61 | "milhouse" 62 | ], 63 | "csockets": 1, 64 | "role": "C", 65 | "ctotal": 40, 66 | "offline": false, 67 | "vm_capable": true, 68 | "pinst_cnt": 0, 69 | "mtotal": 128848, 70 | "tags": [], 71 | "group.uuid": "uuid2", 72 | "sinst_cnt": 2, 73 | "cnos": 40, 74 | "ctime": 1453809486.675875, 75 | "master_candidate": true, 76 | "name": "node2", 77 | "mnode": 45961, 78 | "pinst_list": [], 79 | "ndparams": { 80 | "ovs": false, 81 | "ssh_port": 22, 82 | "ovs_link": "", 83 | "spindle_count": 1, 84 | "exclusive_storage": false, 85 | "cpu_speed": 1, 86 | "ovs_name": "switch1", 87 | "oob_program": "" 88 | }, 89 | "spfree": 0, 90 | "master_capable": true 91 | } 92 | ] 93 | -------------------------------------------------------------------------------- /web/src/components/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement, useContext } from "react"; 3 | import { buildApiUrl, HttpMethod } from "../../api"; 4 | import { GntCluster } from "../../api/models"; 5 | import AuthContext from "../../contexts/AuthContext"; 6 | import Button from "../Button/Button"; 7 | import ClusterSelector from "../ClusterSelector/ClusterSelector"; 8 | import FakeSearchBar from "../FakeSearchBar/FakeSearchBar"; 9 | import JobWatcher from "../JobWatcher/JobWatcher"; 10 | import PrefixNavLink from "../PrefixNavLink"; 11 | import { ThemeToggle } from "../ThemeToggle/ThemeToggle"; 12 | import styles from "./Navbar.module.scss"; 13 | 14 | const links = [ 15 | { 16 | to: "", 17 | label: "Dashboard", 18 | exactActive: true, 19 | }, 20 | { 21 | to: "/instances", 22 | label: "Instances", 23 | exactActive: true, 24 | }, 25 | { 26 | to: "/nodes", 27 | label: "Nodes", 28 | exactActive: true, 29 | }, 30 | { 31 | to: "/jobs", 32 | label: "Jobs", 33 | exactActive: true, 34 | }, 35 | ]; 36 | 37 | interface Props { 38 | clusters: GntCluster[]; 39 | } 40 | 41 | const Navbar = function ({ clusters }: Props): ReactElement { 42 | const { setUsername } = useContext(AuthContext); 43 | 44 | async function logout() { 45 | await fetch(buildApiUrl("logout"), { method: HttpMethod.Post }); 46 | setUsername(null); 47 | } 48 | 49 | return ( 50 | 74 | ); 75 | }; 76 | 77 | export default Navbar; 78 | -------------------------------------------------------------------------------- /api/auth/middleware.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "gnt-cc/config" 5 | "net/http" 6 | "time" 7 | 8 | jwt "github.com/appleboy/gin-jwt/v2" 9 | "github.com/gin-gonic/gin" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const identityKey = "id" 14 | 15 | type Credentials struct { 16 | Username string `form:"username" json:"username" binding:"required"` 17 | Password string `form:"password" json:"password" binding:"required"` 18 | } 19 | 20 | func GetMiddleware() (ginJWTMiddleware *jwt.GinJWTMiddleware) { 21 | ginJWTMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{ 22 | Realm: "gnt-cc", 23 | Key: []byte(config.Get().JwtSigningKey), 24 | Timeout: config.Get().JwtExpire, 25 | MaxRefresh: time.Hour, 26 | IdentityKey: identityKey, 27 | PayloadFunc: func(data interface{}) jwt.MapClaims { 28 | if v, ok := data.(*User); ok { 29 | return jwt.MapClaims{ 30 | identityKey: v.Username, 31 | } 32 | } 33 | return jwt.MapClaims{} 34 | }, 35 | IdentityHandler: func(c *gin.Context) interface{} { 36 | claims := jwt.ExtractClaims(c) 37 | return &User{ 38 | Username: claims["id"].(string), 39 | } 40 | }, 41 | Authenticator: func(c *gin.Context) (interface{}, error) { 42 | var loginVals Credentials 43 | if err := c.ShouldBind(&loginVals); err != nil { 44 | return "", jwt.ErrMissingLoginValues 45 | } 46 | userID := loginVals.Username 47 | password := loginVals.Password 48 | 49 | if validateUser(userID, password) { 50 | return &User{ 51 | Username: userID, 52 | }, nil 53 | } 54 | return nil, jwt.ErrFailedAuthentication 55 | }, 56 | Authorizator: func(data interface{}, c *gin.Context) bool { 57 | // for now we just validate if the data is valid User struct 58 | if _, ok := data.(*User); ok { 59 | return true 60 | } 61 | 62 | return false 63 | }, 64 | Unauthorized: func(c *gin.Context, code int, message string) { 65 | c.JSON(code, gin.H{ 66 | "code": code, 67 | "message": message, 68 | }) 69 | }, 70 | 71 | SendCookie: true, 72 | CookieHTTPOnly: true, 73 | SecureCookie: true, 74 | CookieName: "jwt", 75 | TokenLookup: "cookie:jwt", 76 | CookieSameSite: http.SameSiteLaxMode, 77 | 78 | TimeFunc: time.Now, 79 | }) 80 | 81 | if err != nil { 82 | log.Errorf("Error initializing JWT middleware: %s", err) 83 | } 84 | 85 | return 86 | } 87 | -------------------------------------------------------------------------------- /web/src/components/Dropdown/Dropdown.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/mixins"; 2 | 3 | $spacing-outer: 1.5rem; 4 | $spacing-inner: 0.5rem; 5 | $transition-duration: 0.2s; 6 | $width: 200px; 7 | $border-radius: 4px; 8 | $size: 40px; 9 | 10 | .root { 11 | position: relative; 12 | height: $size; 13 | cursor: pointer; 14 | 15 | &.hasLabel { 16 | min-width: $width; 17 | } 18 | 19 | .current { 20 | display: flex; 21 | align-items: center; 22 | padding: 0 $spacing-outer; 23 | border: 1px solid var(--color-separator); 24 | border-radius: calc($size / 2); 25 | width: 100%; 26 | height: 100%; 27 | gap: 0.75rem; 28 | 29 | @include hover-overlay; 30 | 31 | .label { 32 | width: 100%; 33 | white-space: nowrap; 34 | text-overflow: ellipsis; 35 | overflow: hidden; 36 | color: inherit; 37 | margin: 0; 38 | } 39 | } 40 | 41 | .optionsWrapper { 42 | position: absolute; 43 | visibility: hidden; 44 | top: calc(100% + 1rem); 45 | min-width: $width; 46 | border-top: 0; 47 | background: var(--color-elevation-high); 48 | box-shadow: var(--drop-shadow); 49 | opacity: 0; 50 | border-radius: $border-radius; 51 | transition: opacity $transition-duration, visibility 0s $transition-duration; 52 | z-index: 99; 53 | 54 | .triangle { 55 | width: 1rem; 56 | height: 1rem; 57 | background: var(--color-elevation-high); 58 | position: absolute; 59 | top: -0.5rem; 60 | transform: rotate(45deg); 61 | } 62 | 63 | .options { 64 | overflow: hidden; 65 | overflow-y: auto; 66 | max-height: 600px; 67 | border-radius: inherit; 68 | } 69 | } 70 | 71 | &.expanded .optionsWrapper { 72 | visibility: visible; 73 | opacity: 1; 74 | transition: opacity $transition-duration, visibility 0s; 75 | } 76 | 77 | &.left .optionsWrapper { 78 | left: 0; 79 | 80 | .triangle { 81 | left: $spacing-outer; 82 | } 83 | } 84 | 85 | &.center .optionsWrapper { 86 | left: 50%; 87 | transform: translateX(-50%); 88 | 89 | .triangle { 90 | left: 50%; 91 | transform: translateX(-50%) rotate(45deg); 92 | } 93 | } 94 | 95 | &.right .optionsWrapper { 96 | right: 0; 97 | 98 | .triangle { 99 | right: $spacing-outer; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "SEE LICENSE IN ../LICENSE", 6 | "dependencies": { 7 | "@fortawesome/fontawesome-svg-core": "^6.4.0", 8 | "@fortawesome/free-solid-svg-icons": "^6.4.0", 9 | "@fortawesome/react-fontawesome": "^0.2.0", 10 | "@novnc/novnc": "^1.4.0", 11 | "@testing-library/jest-dom": "^5.16.5", 12 | "@testing-library/react": "^14.0.0", 13 | "@testing-library/user-event": "^14.4.3", 14 | "@types/classnames": "^2.3.0", 15 | "@types/jest": "^29.5.1", 16 | "@types/node": "^20.2.4", 17 | "@types/react": "^18.2.7", 18 | "@types/react-dom": "^18.2.4", 19 | "@types/react-router-dom": "^5.3.3", 20 | "@types/styled-components": "^5.1.26", 21 | "@typescript-eslint/eslint-plugin": "^5.59.7", 22 | "@typescript-eslint/parser": "^5.59.7", 23 | "classnames": "^2.3.2", 24 | "eslint-config-prettier": "^8.8.0", 25 | "eslint-plugin-prettier": "^4.2.1", 26 | "eslint-plugin-react": "^7.32.2", 27 | "formik": "^2.2.9", 28 | "http-proxy-middleware": "^2.0.6", 29 | "msw": "^1.2.1", 30 | "postcss-scss": "^4.0.6", 31 | "prettier": "^2.8.8", 32 | "react": "^18.2.0", 33 | "react-data-table-component": "^7.5.3", 34 | "react-dom": "^18.2.0", 35 | "react-router-dom": "^6.11.2", 36 | "react-scripts": "5.0.1", 37 | "sass": "^1.62.1", 38 | "styled-components": "^5.3.11", 39 | "stylelint": "^15.6.2", 40 | "stylelint-config-standard-scss": "^9.0.0", 41 | "typescript": "~4.9.5" 42 | }, 43 | "scripts": { 44 | "start": "DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start", 45 | "build": "react-scripts build", 46 | "test": "react-scripts test", 47 | "lint": "npm run lint:ts && npm run lint:style", 48 | "lint:ts": "eslint '*/**/*.{js,ts,tsx}'", 49 | "lint:style": "stylelint '**/*.scss'", 50 | "lint:fix": "npm run lint:ts:fix && npm run lint:style:fix", 51 | "lint:ts:fix": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix", 52 | "lint:style:fix": "stylelint --fix '**/*.scss'" 53 | }, 54 | "proxy": "http://127.0.0.1:8000", 55 | "eslintConfig": { 56 | "extends": "react-app" 57 | }, 58 | "browserslist": { 59 | "production": [ 60 | ">0.2%", 61 | "not dead", 62 | "not op_mini all" 63 | ], 64 | "development": [ 65 | "last 1 chrome version", 66 | "last 1 firefox version", 67 | "last 1 safari version" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/src/components/InstanceBanner/InstanceBanner.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faComputer, 3 | faHdd, 4 | faMemory, 5 | faMicrochip, 6 | faTags, 7 | } from "@fortawesome/free-solid-svg-icons"; 8 | import React from "react"; 9 | import { GntInstance } from "../../api/models"; 10 | import { prettyPrintMiB } from "../../helpers"; 11 | import Icon from "../Icon/Icon"; 12 | import PrefixLink from "../PrefixLink"; 13 | import QuickInfoBanner from "../QuickInfoBanner/QuickInfoBanner"; 14 | import StatusBadge, { BadgeStatus } from "../StatusBadge/StatusBadge"; 15 | import styles from "./InstanceBanner.module.scss"; 16 | 17 | type Props = { 18 | instance: GntInstance; 19 | }; 20 | 21 | function InstanceBanner({ instance }: Props) { 22 | const totalStorage = instance.disks 23 | .map(({ capacity }) => capacity) 24 | .reduce((prev, cur) => prev + cur); 25 | 26 | return ( 27 |
28 |
29 | 30 | 35 | 40 | 45 | 46 |
47 |
48 |

Nodes

49 |
50 | 51 | {instance.primaryNode} 52 | 53 | 54 | Primary 55 |
56 | {instance.secondaryNodes.map((node) => ( 57 |
58 | {node} 59 |
60 | ))} 61 |
62 |
63 |
64 |
65 | 66 | {instance.OS} 67 |
68 |
69 | 70 | {instance.tags.map((tag) => ( 71 | {tag} 72 | ))} 73 |
74 |
75 |
76 | ); 77 | } 78 | 79 | export default InstanceBanner; 80 | -------------------------------------------------------------------------------- /web/src/views/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faMemory, 3 | faMicrochip, 4 | faServer, 5 | } from "@fortawesome/free-solid-svg-icons"; 6 | import React, { ReactElement } from "react"; 7 | import { useApi } from "../../api"; 8 | import ApiDataRenderer from "../../components/ApiDataRenderer/ApiDataRenderer"; 9 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 10 | import PrefixLink from "../../components/PrefixLink"; 11 | import QuickInfoBanner from "../../components/QuickInfoBanner/QuickInfoBanner"; 12 | import StatusBadge from "../../components/StatusBadge/StatusBadge"; 13 | import { prettyPrintMiB } from "../../helpers"; 14 | import { useClusterName } from "../../helpers/hooks"; 15 | import styles from "./Dashboard.module.scss"; 16 | 17 | interface StatisticElement { 18 | count: number; 19 | memoryTotal: number; 20 | cpuCount: number; 21 | } 22 | interface StatisticsResponse { 23 | instances: StatisticElement; 24 | nodes: StatisticElement; 25 | master: string; 26 | } 27 | 28 | function Dashboard(): ReactElement { 29 | const clusterName = useClusterName(); 30 | 31 | const [apiProps] = useApi( 32 | `clusters/${clusterName}/statistics` 33 | ); 34 | 35 | return ( 36 | 37 | 38 | {...apiProps} 39 | render={({ master, nodes, instances }) => ( 40 | <> 41 |
42 | 43 | 48 | 53 | 58 | 63 | 64 |
65 |
66 | Master 67 | 68 | {master} 69 | 70 |
71 | 72 | )} 73 | /> 74 |
75 | ); 76 | } 77 | 78 | export default Dashboard; 79 | -------------------------------------------------------------------------------- /web/src/components/VNCControl/VNCControl.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faBars, 3 | faCircle, 4 | faClipboard, 5 | faPowerOff, 6 | faTimes, 7 | IconDefinition, 8 | } from "@fortawesome/free-solid-svg-icons"; 9 | import classNames from "classnames"; 10 | import React, { ReactElement, useState } from "react"; 11 | import Icon from "../Icon/Icon"; 12 | import styles from "./VNCControl.module.scss"; 13 | 14 | type Props = { 15 | onShutdown?: () => void; 16 | onCtrlAltDel?: () => void; 17 | onClipboardPaste?: () => void; 18 | onDisconnect?: () => void; 19 | isConnected: boolean; 20 | enablePowerControl?: boolean; 21 | enablePasting?: boolean; 22 | className?: string; 23 | }; 24 | 25 | const VNCControl = ({ 26 | onClipboardPaste, 27 | onCtrlAltDel, 28 | onDisconnect, 29 | onShutdown, 30 | isConnected, 31 | enablePowerControl, 32 | enablePasting, 33 | className, 34 | }: Props): ReactElement => { 35 | const [expanded, setExpanded] = useState(true); 36 | 37 | const renderAction = ({ 38 | icon, 39 | label, 40 | onClick, 41 | }: { 42 | icon?: IconDefinition; 43 | label: string; 44 | onClick?: () => void; 45 | }): ReactElement => ( 46 |
47 | 48 | {icon ? : } 49 | 50 | {label} 51 |
52 | ); 53 | 54 | return ( 55 |
61 | setExpanded(!expanded)} 64 | > 65 | 66 | 67 | 68 | {expanded && ( 69 |
setExpanded(false)}> 70 |
71 | {enablePasting && 72 | renderAction({ 73 | onClick: onClipboardPaste, 74 | label: "Send clipboard", 75 | icon: faClipboard, 76 | })} 77 | {renderAction({ 78 | onClick: onCtrlAltDel, 79 | label: "Send ctrl+alt+del", 80 | })} 81 | {renderAction({ 82 | onClick: onDisconnect, 83 | label: "Disconnect", 84 | })} 85 | {enablePowerControl && 86 | renderAction({ 87 | onClick: onShutdown, 88 | label: "Shutdown options", 89 | icon: faPowerOff, 90 | })} 91 |
92 |
93 | )} 94 |
95 | ); 96 | }; 97 | 98 | export default VNCControl; 99 | -------------------------------------------------------------------------------- /api/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "gnt-cc/config" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetFuncReturnsLoadedConfig(t *testing.T) { 11 | config.Parse("../testfiles/config.default.test.yaml") 12 | assert.EqualValues(t, config.Config{ 13 | JwtSigningKey: "test", 14 | AuthenticationMethod: "builtin", 15 | Bind: "127.0.0.1", 16 | PublicUrl: "https://gnt-cc.example.com", 17 | Port: 8000, 18 | JwtExpire: 60000000000, 19 | Loglevel: "warning", 20 | Users: []config.UserConfig{{ 21 | Username: "admin", 22 | Password: "test", 23 | }}, 24 | Clusters: []config.ClusterConfig{{ 25 | Name: "test", 26 | Hostname: "test-cluster.example.com", 27 | Port: 5080, 28 | Description: "Ganeti Test Cluster", 29 | Username: "test", 30 | Password: "supersecret", 31 | SSL: true, 32 | }}, 33 | LDAPConfig: config.LDAPConfig{}, 34 | }, config.Get()) 35 | } 36 | 37 | func TestGetClusterConfig(t *testing.T) { 38 | config.Parse("../testfiles/config.default.test.yaml") 39 | 40 | validClusterName := "test" 41 | 42 | cluster, err := config.GetClusterConfig(validClusterName) 43 | assert.Nil(t, err) 44 | assert.Equal(t, validClusterName, cluster.Name) 45 | 46 | invalidClusterName := "invalid-cluster" 47 | cluster, err = config.GetClusterConfig(invalidClusterName) 48 | assert.NotNil(t, err) 49 | } 50 | 51 | func TestIsValidCluster(t *testing.T) { 52 | config.Parse("../testfiles/config.default.test.yaml") 53 | 54 | validClusterName := "test" 55 | assert.True(t, config.ClusterExists(validClusterName)) 56 | 57 | invalidClusterName := "invalid-cluster" 58 | assert.False(t, config.ClusterExists(invalidClusterName)) 59 | } 60 | 61 | func TestParsingConfigShouldPanicIfAuthMethodIsSetToBuiltInWithMissingUsers(t *testing.T) { 62 | assert.PanicsWithError(t, "Authentication Method has been set to 'builtin' but no user is specified", func() { 63 | config.Parse("../testfiles/config.missing-users.test.yaml") 64 | }) 65 | } 66 | 67 | func TestParsingConfigShouldPanicIfAuthMethodIsSetTOLDAPAndNoLDAPConfigIsPresent(t *testing.T) { 68 | assert.PanicsWithError(t, "Authentication Method has been set to 'ldap' but no LDAP host is specified", func() { 69 | config.Parse("../testfiles/config.invalid-ldap.test.yaml") 70 | }) 71 | } 72 | 73 | func TestParsingConfigShouldPanicWithAnInvalidAuthMethod(t *testing.T) { 74 | assert.PanicsWithError(t, "Invalid authentication method 'test'", func() { 75 | config.Parse("../testfiles/config.invalid-auth-method.test.yaml") 76 | }) 77 | } 78 | 79 | func TestParsingConfigShouldPanicWithEmptyConfig(t *testing.T) { 80 | assert.PanicsWithError(t, "No JWT signing key is set", func() { 81 | config.Parse("../testfiles/config.empty.test.yaml") 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /api/websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/websocket" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func checkAndLogMessage(msgType int) { 13 | var typeStr string 14 | switch msgType { 15 | case websocket.BinaryMessage: 16 | typeStr = "binary" 17 | case websocket.TextMessage: 18 | typeStr = "text" 19 | case websocket.PingMessage: 20 | typeStr = "ping" 21 | case websocket.PongMessage: 22 | typeStr = "pong" 23 | case websocket.CloseMessage: 24 | typeStr = "close" 25 | } 26 | log.Debugf("Websocket: received message type '%s'", typeStr) 27 | } 28 | 29 | func PassThrough(w http.ResponseWriter, r *http.Request, host string, port int) error { 30 | log.Infoln("Upgrading connection to websocket") 31 | 32 | var upgrader = websocket.Upgrader{ 33 | ReadBufferSize: 1024, 34 | WriteBufferSize: 1024, 35 | Subprotocols: []string{"binary"}, 36 | EnableCompression: false, 37 | } 38 | 39 | // origin check is done by gin 40 | upgrader.CheckOrigin = func(r *http.Request) bool { 41 | return true 42 | } 43 | 44 | conn, err := upgrader.Upgrade(w, r, nil) 45 | if err != nil { 46 | log.Errorf("Failed to set websocket upgrade: %s", err) 47 | return err 48 | } 49 | 50 | remoteSrv, err := net.Dial("tcp", host+":"+strconv.Itoa(port)) 51 | log.Infof("Connecting to remote target %s:%d", host, port) 52 | if err != nil { 53 | log.Errorf("Failed to connect to remote target: %s", err) 54 | conn.Close() 55 | return err 56 | } 57 | 58 | go func() { 59 | defer remoteSrv.Close() 60 | defer conn.Close() 61 | counter := 0 62 | for { 63 | counter++ 64 | log.Debugf("node->gnt-cc: Loop #%d", counter) 65 | buf := make([]byte, 1024) 66 | size, err := remoteSrv.Read(buf) 67 | if err != nil { 68 | log.Warningf("node->gnt-cc: failed to read from remote socket: %s", err) 69 | return 70 | } 71 | data := buf[:size] 72 | log.Debugf("node->gnt-cc: Writing %d Bytes to websocket", len(data)) 73 | err = conn.WriteMessage(websocket.BinaryMessage, data) 74 | if err != nil { 75 | log.Warningf("node->gnt-cc: failed to write to websocket: %s", err) 76 | return 77 | } 78 | } 79 | }() 80 | 81 | go func() { 82 | defer remoteSrv.Close() 83 | defer conn.Close() 84 | counter := 0 85 | for { 86 | counter++ 87 | log.Debugf("gnt-cc->node: Loop #%d", counter) 88 | msgType, message, err := conn.ReadMessage() 89 | if _, ok := err.(*websocket.CloseError); ok { 90 | log.Debug("gnt-cc->node: closing websocket") 91 | return 92 | } 93 | if err != nil { 94 | log.Warningf("gnt-cc->node: failed to read websocket message: %s", err) 95 | return 96 | } 97 | checkAndLogMessage(msgType) 98 | log.Debugf("gnt-cc->node: Writing %d Bytes to remote socket", len(message)) 99 | _, err = remoteSrv.Write(message) 100 | if err != nil { 101 | log.Warningf("gnt-cc->node: failed to write to remote socket: %s", err) 102 | return 103 | } 104 | } 105 | }() 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /web/src/components/JobWatcher/JobWatcher.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | 4 | .job { 5 | position: relative; 6 | display: grid; 7 | grid-template-columns: 2rem 1fr; 8 | grid-template-areas: "actions ."; 9 | min-width: 400px; 10 | 11 | .actions { 12 | grid-area: actions; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | 17 | .untrackButton { 18 | position: relative; 19 | border: 0; 20 | display: block; 21 | height: 100%; 22 | width: 32px; 23 | cursor: pointer; 24 | color: #fff; 25 | 26 | &::after { 27 | content: ""; 28 | position: absolute; 29 | inset: 0; 30 | opacity: 0; 31 | transition: opacity 0.15s; 32 | background-color: rgba(#fff, 0.2); 33 | } 34 | 35 | &:hover::after { 36 | opacity: 1; 37 | } 38 | } 39 | } 40 | 41 | .content { 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: center; 45 | padding: 1rem; 46 | 47 | .statusDot { 48 | position: absolute; 49 | top: 0.5rem; 50 | right: 0.5rem; 51 | width: 0.75rem; 52 | height: 0.75rem; 53 | border-radius: 100%; 54 | } 55 | } 56 | 57 | &.running .actions .untrackButton { 58 | background-color: var(--color-primary); 59 | } 60 | 61 | &.error .actions .untrackButton { 62 | background-color: var(--color-danger); 63 | } 64 | 65 | &.success .actions .untrackButton { 66 | background-color: var(--color-success); 67 | } 68 | 69 | &.pending .actions .untrackButton { 70 | background-color: var(--color-warning); 71 | color: #000; 72 | } 73 | } 74 | 75 | .count, 76 | .successIndicator, 77 | .failureIndicator { 78 | position: absolute; 79 | top: -6px; 80 | right: -6px; 81 | width: 24px; 82 | height: 24px; 83 | z-index: 1; 84 | border-radius: 100%; 85 | display: flex; 86 | justify-content: center; 87 | align-items: center; 88 | animation: notification 0.5s; 89 | 90 | &::after { 91 | content: ""; 92 | position: absolute; 93 | inset: 0; 94 | border-radius: inherit; 95 | background-color: inherit; 96 | opacity: 0.5; 97 | pointer-events: none; 98 | animation: notification-ring 0.5s; 99 | z-index: -1; 100 | } 101 | } 102 | 103 | .count { 104 | background: var(--color-primary); 105 | } 106 | 107 | .successIndicator { 108 | background: var(--color-success); 109 | color: #fff; 110 | } 111 | 112 | .failureIndicator { 113 | background: var(--color-danger); 114 | color: #fff; 115 | } 116 | } 117 | 118 | @keyframes notification { 119 | from { 120 | transform: scale(0); 121 | } 122 | 123 | to { 124 | transform: scale(1); 125 | } 126 | } 127 | 128 | @keyframes notification-ring { 129 | to { 130 | transform: scale(3); 131 | opacity: 0; 132 | } 133 | } -------------------------------------------------------------------------------- /api/controllers/job.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "gnt-cc/model" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | type JobController struct { 12 | Repository jobRepository 13 | } 14 | 15 | type GetManyWithLogsOptions struct { 16 | IDs string `form:"ids"` 17 | } 18 | 19 | // GetAll godoc 20 | // @Summary Get all jobs in a given cluster 21 | // @Description ... 22 | // @Produce json 23 | // @Success 200 {object} model.AllJobsResponse 24 | // @Failure 400 {object} model.ErrorResponse 25 | // @Failure 404 {object} model.ErrorResponse 26 | // @Failure 500 {object} model.ErrorResponse 27 | // @Router /clusters/{cluster}/jobs [get] 28 | func (controller *JobController) GetAll(c *gin.Context) { 29 | clusterName := c.Param("cluster") 30 | 31 | jobs, err := controller.Repository.GetAll(clusterName) 32 | 33 | if err != nil { 34 | abortWithInternalServerError(c, err) 35 | return 36 | } 37 | 38 | c.JSON(200, model.AllJobsResponse{ 39 | NumberOfJobs: len(jobs), 40 | Jobs: jobs, 41 | }) 42 | } 43 | 44 | // GetAll godoc 45 | // @Summary Get specific jobs in a given cluster including their logs 46 | // @Description ... 47 | // @Produce json 48 | // @Success 200 {object} model.AllJobsResponse 49 | // @Failure 400 {object} model.ErrorResponse 50 | // @Failure 500 {object} model.ErrorResponse 51 | // @Router /clusters/{cluster}/jobs/many [get] 52 | // @Param ids query []int true "job ids to retrieve" 53 | func (controller *JobController) GetManyWithLogs(c *gin.Context) { 54 | clusterName := c.Param("cluster") 55 | 56 | var options GetManyWithLogsOptions 57 | if c.BindQuery(&options) != nil { 58 | c.AbortWithStatusJSON(400, createErrorBody("ids parameter is required")) 59 | return 60 | } 61 | 62 | jobs := []model.GntJob{} 63 | for _, id := range strings.Split(options.IDs, ",") { 64 | job, err := controller.Repository.Get(clusterName, id) 65 | 66 | if err != nil { 67 | abortWithInternalServerError(c, err) 68 | return 69 | } 70 | 71 | jobs = append(jobs, job.Job) 72 | } 73 | 74 | c.JSON(200, model.AllJobsResponse{ 75 | NumberOfJobs: len(jobs), 76 | Jobs: jobs, 77 | }) 78 | } 79 | 80 | // Get godoc 81 | // @Summary Get a single job in a given cluster 82 | // @Description ... 83 | // @Produce json 84 | // @Success 200 {object} model.JobResponse 85 | // @Failure 400 {object} model.ErrorResponse 86 | // @Failure 404 {object} model.ErrorResponse 87 | // @Failure 500 {object} model.ErrorResponse 88 | // @Router /clusters/{cluster}/job/{job} [get] 89 | func (controller *JobController) Get(c *gin.Context) { 90 | clusterName := c.Param("cluster") 91 | jobID := c.Param("job") 92 | 93 | if jobID == "" { 94 | c.AbortWithStatusJSON(400, createErrorBody("job ID is required")) 95 | return 96 | } 97 | 98 | result, err := controller.Repository.Get(clusterName, jobID) 99 | 100 | if err != nil { 101 | abortWithInternalServerError(c, err) 102 | return 103 | } 104 | 105 | if !result.Found { 106 | c.AbortWithStatusJSON(404, createErrorBody( 107 | fmt.Sprintf(MsgJobNotFound, jobID, clusterName))) 108 | } 109 | 110 | c.JSON(200, model.JobResponse{ 111 | Job: result.Job, 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /api/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "gnt-cc/rapi_client" 8 | "strings" 9 | ) 10 | 11 | var validResources = []string{ 12 | "node", 13 | "group", 14 | "network", 15 | "lock", 16 | "filter", 17 | "instance", 18 | "job", 19 | "export", 20 | } 21 | 22 | type ( 23 | Performer struct{} 24 | RequestConfig struct { 25 | ClusterName string 26 | ResourceType string 27 | Fields []string 28 | } 29 | Resource map[string]interface{} 30 | fieldDescription struct { 31 | Doc string `json:"doc"` 32 | Kind string `json:"kind"` 33 | Name string `json:"name"` 34 | Title string `json:"title"` 35 | } 36 | responseBody struct { 37 | Fields []fieldDescription `json:"fields"` 38 | Data [][][]interface{} `json:"data"` 39 | } 40 | ) 41 | 42 | func (p *Performer) Perform(client rapi_client.Client, config RequestConfig) ([]Resource, error) { 43 | if err := validateRequestConfig(config); err != nil { 44 | return nil, err 45 | } 46 | 47 | httpResponse, err := client.Get( 48 | config.ClusterName, 49 | buildQuerySlug(config), 50 | ) 51 | 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if httpResponse.Status > 299 { 57 | return nil, fmt.Errorf("RAPI returned status code %d", httpResponse.Status) 58 | } 59 | 60 | parsedBody, err := parseQueryResponseBody(httpResponse.Body) 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return createResourcesArray(parsedBody), nil 67 | } 68 | 69 | func parseQueryResponseBody(response string) (*responseBody, error) { 70 | var parsed responseBody 71 | err := json.Unmarshal([]byte(response), &parsed) 72 | 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | return &parsed, nil 78 | } 79 | 80 | func createResourcesArray(parsedResponse *responseBody) []Resource { 81 | var resources = make([]Resource, len(parsedResponse.Data)) 82 | 83 | for i, values := range parsedResponse.Data { 84 | resources[i] = createResource(parsedResponse.Fields, values) 85 | } 86 | 87 | return resources 88 | } 89 | 90 | func createResource(fields []fieldDescription, values [][]interface{}) Resource { 91 | resource := make(Resource) 92 | 93 | for i, field := range fields { 94 | resource[field.Name] = values[i][1] 95 | } 96 | 97 | return resource 98 | } 99 | 100 | func validateRequestConfig(config RequestConfig) error { 101 | if !isValidResourceType(config.ResourceType) { 102 | return fmt.Errorf("invalid resource type: %s", config.ResourceType) 103 | } 104 | 105 | if len(config.Fields) == 0 { 106 | return fmt.Errorf("fields are required") 107 | } 108 | 109 | if config.ClusterName == "" { 110 | return errors.New("cluster name is required") 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func isValidResourceType(resource string) bool { 117 | for _, r := range validResources { 118 | if resource == r { 119 | return true 120 | } 121 | } 122 | return false 123 | } 124 | 125 | func buildQuerySlug(config RequestConfig) string { 126 | queryParams := fmt.Sprintf("?fields=%s", strings.Join(config.Fields, ",")) 127 | return fmt.Sprintf("/2/query/%s%s", config.ResourceType, queryParams) 128 | } 129 | -------------------------------------------------------------------------------- /web/src/components/JobList/JobList.tsx: -------------------------------------------------------------------------------- 1 | import { faEye } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement, useContext, useMemo } from "react"; 3 | import { TableColumn } from "react-data-table-component"; 4 | import { GntJob } from "../../api/models"; 5 | import JobWatchContext from "../../contexts/JobWatchContext"; 6 | import { useClusterName } from "../../helpers/hooks"; 7 | import { durationHumanReadable } from "../../helpers/time"; 8 | import CustomDataTable from "../CustomDataTable/CustomDataTable"; 9 | import IconButton from "../IconButton/IconButton"; 10 | import JobStartedAt from "../JobStartedAt"; 11 | import JobStatus from "../JobStatus"; 12 | import JobSummary from "../JobSummary/JobSummary"; 13 | import PrefixLink from "../PrefixLink"; 14 | import styles from "./JobList.module.scss"; 15 | 16 | interface Props { 17 | jobs: GntJob[]; 18 | } 19 | 20 | function JobList({ jobs }: Props): ReactElement { 21 | const clusterName = useClusterName(); 22 | const { trackJob } = useContext(JobWatchContext); 23 | 24 | const columns: TableColumn[] = useMemo( 25 | () => [ 26 | { 27 | id: "id", 28 | name: "ID", 29 | sortable: true, 30 | selector: (row) => row.id, 31 | cell: (row) => {row.id}, 32 | width: "120px", 33 | }, 34 | { 35 | id: "status", 36 | name: "Status", 37 | sortable: true, 38 | selector: (row) => row.status, 39 | width: "120px", 40 | cell: (row) => , 41 | }, 42 | { 43 | id: "summary", 44 | name: "Summary", 45 | sortable: true, 46 | selector: (row) => row.summary, 47 | cell: (row) => ( 48 | 49 | 50 | 51 | ), 52 | }, 53 | { 54 | id: "start", 55 | name: "Start", 56 | sortable: true, 57 | selector: (row) => row.startedAt, 58 | format: (row) => , 59 | width: "200px", 60 | }, 61 | { 62 | name: "Duration", 63 | sortable: false, 64 | width: "120px", 65 | selector: (row) => row.endedAt, 66 | format: (row) => { 67 | if (row.startedAt < 0 || row.endedAt < 0) { 68 | return "-"; 69 | } 70 | 71 | return durationHumanReadable(row.endedAt - row.startedAt); 72 | }, 73 | }, 74 | { 75 | name: "Watch", 76 | sortable: false, 77 | width: "120px", 78 | cell: (row) => ( 79 | trackJob({ clusterName, id: row.id })} 82 | /> 83 | ), 84 | }, 85 | ], 86 | [trackJob] 87 | ); 88 | 89 | return ( 90 |
91 | 92 | columns={columns} 93 | data={jobs} 94 | keyField="id" 95 | defaultSortAsc={false} 96 | defaultSortFieldId="id" 97 | /> 98 |
99 | ); 100 | } 101 | 102 | export default JobList; 103 | -------------------------------------------------------------------------------- /api/controllers/node.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | "gnt-cc/model" 7 | "gnt-cc/utils" 8 | ) 9 | 10 | type NodeController struct { 11 | Repository nodeRepository 12 | InstanceRepository instanceRepository 13 | } 14 | 15 | // GetAll godoc 16 | // @Summary Get all nodes in a given cluster 17 | // @Description ... 18 | // @Produce json 19 | // @Success 200 {object} model.AllNodesResponse 20 | // @Failure 400 {object} model.ErrorResponse 21 | // @Failure 404 {object} model.ErrorResponse 22 | // @Failure 500 {object} model.ErrorResponse 23 | // @Router /clusters/{cluster}/nodes [get] 24 | func (controller *NodeController) GetAll(c *gin.Context) { 25 | clusterName := c.Param("cluster") 26 | 27 | nodes, err := controller.Repository.GetAll(clusterName) 28 | 29 | if err != nil { 30 | abortWithInternalServerError(c, err) 31 | return 32 | } 33 | 34 | c.JSON(200, model.AllNodesResponse{ 35 | Cluster: clusterName, 36 | NumberOfNodes: len(nodes), 37 | Nodes: nodes, 38 | }) 39 | } 40 | 41 | // GetAll godoc 42 | // @Summary Get a node in a given cluster 43 | // @Description ... 44 | // @Produce json 45 | // @Success 200 {object} model.NodeResponse 46 | // @Failure 400 {object} model.ErrorResponse 47 | // @Failure 404 {object} model.ErrorResponse 48 | // @Failure 500 {object} model.ErrorResponse 49 | // @Router /clusters/{cluster}/nodes/{node} [get] 50 | func (controller *NodeController) Get(c *gin.Context) { 51 | clusterName := c.Param("cluster") 52 | nodeName := c.Param("node") 53 | 54 | if nodeName == "" { 55 | c.AbortWithStatusJSON(400, createErrorBody("node name is required")) 56 | return 57 | } 58 | 59 | nodeResult, err := controller.Repository.Get(clusterName, nodeName) 60 | 61 | if err != nil { 62 | abortWithInternalServerError(c, err) 63 | return 64 | } 65 | 66 | if !nodeResult.Found { 67 | c.AbortWithStatusJSON(404, createErrorBody(fmt.Sprintf(MsgNodeNotFound, nodeName, clusterName))) 68 | } 69 | 70 | instances, err := controller.InstanceRepository.GetAll(clusterName) 71 | 72 | if err != nil { 73 | abortWithInternalServerError(c, err) 74 | return 75 | } 76 | 77 | node := nodeResult.Node 78 | 79 | primaryInstances := filterInstances(instances, func(instance model.GntInstance) bool { 80 | return instance.PrimaryNode == node.Name 81 | }) 82 | secondaryInstances := filterInstances(instances, func(instance model.GntInstance) bool { 83 | return utils.IsInSlice(node.Name, instance.SecondaryNodes) 84 | }) 85 | 86 | c.JSON(200, model.NodeResponse{ 87 | Cluster: clusterName, 88 | Node: node, 89 | PrimaryInstances: instanceArrayNotNil(primaryInstances), 90 | SecondaryInstances: instanceArrayNotNil(secondaryInstances), 91 | }) 92 | } 93 | 94 | func filterInstances(arr []model.GntInstance, closure func(model.GntInstance) bool) []model.GntInstance { 95 | var result []model.GntInstance 96 | for i := range arr { 97 | if closure(arr[i]) { 98 | result = append(result, arr[i]) 99 | } 100 | } 101 | return result 102 | } 103 | 104 | func instanceArrayNotNil(array []model.GntInstance) []model.GntInstance { 105 | if array == nil { 106 | return []model.GntInstance{} 107 | } 108 | return array 109 | } 110 | -------------------------------------------------------------------------------- /api/go.mod: -------------------------------------------------------------------------------- 1 | module gnt-cc 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/GeertJohan/go.rice v1.0.3 7 | github.com/appleboy/gin-jwt/v2 v2.10.3 8 | github.com/gin-contrib/cors v1.7.6 9 | github.com/gin-gonic/gin v1.10.1 10 | github.com/gorilla/websocket v1.5.3 11 | github.com/jarcoal/httpmock v1.4.0 12 | github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33 13 | github.com/sirupsen/logrus v1.9.3 14 | github.com/spf13/viper v1.20.1 15 | github.com/stretchr/testify v1.10.0 16 | github.com/swaggo/files v1.0.1 17 | github.com/swaggo/gin-swagger v1.6.0 18 | github.com/swaggo/swag v1.16.6 19 | ) 20 | 21 | require ( 22 | github.com/KyleBanks/depth v1.2.1 // indirect 23 | github.com/bytedance/sonic v1.14.0 // indirect 24 | github.com/bytedance/sonic/loader v0.3.0 // indirect 25 | github.com/cloudwego/base64x v0.1.6 // indirect 26 | github.com/daaku/go.zipexe v1.0.2 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/fsnotify/fsnotify v1.9.0 // indirect 29 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 30 | github.com/gin-contrib/sse v1.1.0 // indirect 31 | github.com/go-openapi/jsonpointer v0.21.2 // indirect 32 | github.com/go-openapi/jsonreference v0.21.0 // indirect 33 | github.com/go-openapi/spec v0.21.0 // indirect 34 | github.com/go-openapi/swag v0.23.1 // indirect 35 | github.com/go-playground/locales v0.14.1 // indirect 36 | github.com/go-playground/universal-translator v0.18.1 // indirect 37 | github.com/go-playground/validator/v10 v10.27.0 // indirect 38 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 39 | github.com/goccy/go-json v0.10.5 // indirect 40 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 41 | github.com/josharian/intern v1.0.0 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/mailru/easyjson v0.9.0 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 48 | github.com/modern-go/reflect2 v1.0.2 // indirect 49 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 50 | github.com/pmezard/go-difflib v1.0.0 // indirect 51 | github.com/sagikazarmark/locafero v0.10.0 // indirect 52 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 53 | github.com/spf13/afero v1.14.0 // indirect 54 | github.com/spf13/cast v1.9.2 // indirect 55 | github.com/spf13/pflag v1.0.7 // indirect 56 | github.com/stretchr/objx v0.5.2 // indirect 57 | github.com/subosito/gotenv v1.6.0 // indirect 58 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 59 | github.com/ugorji/go/codec v1.3.0 // indirect 60 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 61 | golang.org/x/arch v0.20.0 // indirect 62 | golang.org/x/crypto v0.41.0 // indirect 63 | golang.org/x/mod v0.27.0 // indirect 64 | golang.org/x/net v0.43.0 // indirect 65 | golang.org/x/sync v0.16.0 // indirect 66 | golang.org/x/sys v0.35.0 // indirect 67 | golang.org/x/text v0.28.0 // indirect 68 | golang.org/x/tools v0.36.0 // indirect 69 | google.golang.org/protobuf v1.36.7 // indirect 70 | gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect 71 | gopkg.in/ldap.v2 v2.5.1 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /web/src/components/InstanceActions/InstanceActions.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | findByText as findByTextGlobal, 4 | fireEvent, 5 | Matcher, 6 | render, 7 | waitFor, 8 | } from "@testing-library/react"; 9 | import each from "jest-each"; 10 | import { rest } from "msw"; 11 | import { setupServer } from "msw/node"; 12 | import InstanceActions from "./InstanceActions"; 13 | import { GntInstance } from "../../api/models"; 14 | import JobWatchContext from "../../contexts/JobWatchContext"; 15 | 16 | type Actions = "failover" | "migrate" | "start" | "restart" | "shutdown"; 17 | 18 | const jobIds = { 19 | failover: 421, 20 | migrate: 422, 21 | start: 423, 22 | restart: 424, 23 | shutdown: 425, 24 | }; 25 | 26 | const server = setupServer( 27 | rest.post( 28 | "/api/v1/clusters/testClusterName/instances/testInstance/:action", 29 | (req, res, ctx) => { 30 | const jobId = jobIds[req.params.action]; 31 | 32 | if (!jobId) { 33 | return res(ctx.status(400), ctx.body("invalid action")); 34 | } 35 | 36 | return res( 37 | ctx.json({ 38 | jobId, 39 | }) 40 | ); 41 | } 42 | ) 43 | ); 44 | 45 | beforeAll(() => server.listen()); 46 | afterEach(() => server.resetHandlers()); 47 | afterAll(() => server.close()); 48 | 49 | function createMockInstance(overrideParams: Partial): GntInstance { 50 | return { 51 | name: "testInstance", 52 | cpuCount: 1, 53 | disks: [], 54 | isRunning: true, 55 | memoryTotal: 1024, 56 | nics: [], 57 | nicInfo: { 58 | nicTypeFriendly: "Realtek RTL8139", 59 | nicType: "rtl8139", 60 | }, 61 | offersVnc: false, 62 | primaryNode: "", 63 | secondaryNodes: [], 64 | tags: [], 65 | OS: "noop", 66 | ...overrideParams, 67 | }; 68 | } 69 | 70 | each([ 71 | ["failover", /^failover$/i, createMockInstance({ isRunning: true })], 72 | ["migrate", /^migrate$/i, createMockInstance({ isRunning: true })], 73 | ["start", /^start$/i, createMockInstance({ isRunning: false })], 74 | ["restart", /^restart$/i, createMockInstance({ isRunning: true })], 75 | ["shutdown", /^shutdown$/i, createMockInstance({ isRunning: true })], 76 | ]).test( 77 | "%s action button triggers corresponding action", 78 | async (name: Actions, matcher: Matcher, instance: GntInstance) => { 79 | const mockTrackJob = jest.fn(); 80 | 81 | const { findByText, findByRole } = render( 82 | 89 | 90 | 91 | ); 92 | 93 | const button = await findByText(matcher); 94 | 95 | fireEvent.click(button); 96 | 97 | const dialog = await findByRole("dialog"); 98 | const confirmButton = await findByTextGlobal(dialog, matcher); 99 | 100 | fireEvent.click(confirmButton); 101 | 102 | await waitFor(() => { 103 | expect(mockTrackJob).toHaveBeenCalledWith({ 104 | clusterName: "testClusterName", 105 | id: jobIds[name], 106 | }); 107 | }); 108 | } 109 | ); 110 | -------------------------------------------------------------------------------- /web/src/types/novnc.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@novnc/novnc/core/rfb.js" { 2 | type RFBCredentials = { 3 | username?: string; 4 | password?: string; 5 | target?: string; 6 | }; 7 | type RFBOptions = { 8 | shared?: boolean; 9 | credentials?: RFBCredentials; 10 | wsProtocols?: string[]; 11 | repeaterID?: string; 12 | }; 13 | type RFBCapabilities = { 14 | power: boolean; 15 | }; 16 | type RFBEvent = Event & { 17 | detail: T; 18 | }; 19 | 20 | export type DisconnectCallback = (ev: RFBEvent<{ clean: boolean }>) => void; 21 | export type CredentialsRequiredCallback = ( 22 | ev: RFBEvent<{ types: string[] }> 23 | ) => void; 24 | export type SecurityFailureCallback = ( 25 | ev: RFBEvent<{ status: number; reason: string }> 26 | ) => void; 27 | export type ClipboardCallback = (ev: RFBEvent<{ text: string }>) => void; 28 | export type DesktopNameCallback = (ev: RFBEvent<{ name: string }>) => void; 29 | export type CapabilitiesCallback = ( 30 | ev: RFBEvent<{ capabilities: RFBCapabilities }> 31 | ) => void; 32 | 33 | export default class RFB { 34 | viewOnly: boolean; 35 | focusOnClick: boolean; 36 | clipViewport: boolean; 37 | dragViewport: boolean; 38 | scaleViewport: boolean; 39 | resizeSession: boolean; 40 | showDotCursor: boolean; 41 | background: string; 42 | qualityLevel: number; 43 | compressionLevel: number; 44 | readonly capabilities: RFBCapabilities; 45 | 46 | constructor(target: HTMLElement, url: string, options?: RFBOptions); 47 | 48 | disconnect(): void; 49 | 50 | sendCredentials(credentials: RFBCredentials): void; 51 | 52 | sendKey(keysym: number, code: string | null, down?: boolean): void; 53 | 54 | sendCtrlAltDel(): void; 55 | 56 | focus(): void; 57 | 58 | blur(): void; 59 | 60 | machineShutdown(): void; 61 | 62 | machineReboot(): void; 63 | 64 | machineReset(): void; 65 | 66 | clipboardPasteFrom(text: string): void; 67 | 68 | addEventListener(event: "connect", cb: () => void): void; 69 | addEventListener(event: "disconnect", cb: DisconnectCallback): void; 70 | addEventListener( 71 | event: "credentialsrequired", 72 | cb: CredentialsRequiredCallback 73 | ): void; 74 | addEventListener( 75 | event: "securityfailure", 76 | cb: SecurityFailureCallback 77 | ): void; 78 | addEventListener(event: "clipboard", cb: ClipboardCallback): void; 79 | addEventListener(event: "bell", cb: () => void): void; 80 | addEventListener(event: "desktopname", cb: DesktopNameCallback): void; 81 | addEventListener(event: "capabilities", cb: CapabilitiesCallback): void; 82 | 83 | removeEventListener(event: "connect", cb: () => void): void; 84 | removeEventListener(event: "disconnect", cb: DisconnectCallback): void; 85 | removeEventListener( 86 | event: "credentialsrequired", 87 | cb: CredentialsRequiredCallback 88 | ): void; 89 | removeEventListener( 90 | event: "securityfailure", 91 | cb: SecurityFailureCallback 92 | ): void; 93 | removeEventListener(event: "clipboard", cb: ClipboardCallback): void; 94 | removeEventListener(event: "bell", cb: () => void): void; 95 | removeEventListener(event: "desktopname", cb: DesktopNameCallback): void; 96 | removeEventListener(event: "capabilities", cb: CapabilitiesCallback): void; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /web/src/views/InstanceDetail/InstanceDetail.tsx: -------------------------------------------------------------------------------- 1 | import { faHdd, faNetworkWired } from "@fortawesome/free-solid-svg-icons"; 2 | import React, { ReactElement } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import { useApi } from "../../api"; 5 | import { GntInstance } from "../../api/models"; 6 | import ApiDataRenderer from "../../components/ApiDataRenderer/ApiDataRenderer"; 7 | import Card from "../../components/Card/Card"; 8 | import ContentWrapper from "../../components/ContentWrapper/ContentWrapper"; 9 | import InstanceActions from "../../components/InstanceActions/InstanceActions"; 10 | import InstanceBanner from "../../components/InstanceBanner/InstanceBanner"; 11 | import StatusBadge, { 12 | BadgeStatus, 13 | } from "../../components/StatusBadge/StatusBadge"; 14 | import { useClusterName } from "../../helpers/hooks"; 15 | import { prettyPrintMiB } from "../../helpers/numbers"; 16 | import styles from "./InstanceDetail.module.scss"; 17 | 18 | type InstanceResponse = { 19 | instance: GntInstance; 20 | }; 21 | 22 | const InstanceDetail = (): ReactElement => { 23 | const { instanceName } = useParams<{ instanceName: string }>(); 24 | const clusterName = useClusterName(); 25 | 26 | const [apiProps] = useApi( 27 | `clusters/${clusterName}/instances/${instanceName}` 28 | ); 29 | 30 | return ( 31 | 32 | 33 | {...apiProps} 34 | render={({ instance }) => { 35 | const hostname = instance.name.split(".")[0]; 36 | 37 | return ( 38 | <> 39 |
40 |

{hostname}

41 | {instance.isRunning ? ( 42 | Online 43 | ) : ( 44 | 45 | Offline 46 | 47 | )} 48 | 49 | 53 |
54 | 55 | 56 | 57 |
58 | 59 | {instance.nics.map(({ name, mode, bridge, mac }) => ( 60 |
61 |

62 | {name} 63 |

64 |

65 | {mode}: {mode === "bridged" ? bridge : ""} • {mac} 66 |

67 |
68 | ))} 69 |
70 | 71 | {instance.disks.map(({ name, template, capacity }) => ( 72 |
73 |

74 | {name} 75 |

76 |

77 | {template} • {prettyPrintMiB(capacity)} 78 |

79 |
80 | ))} 81 |
82 |
83 | 84 | ); 85 | }} 86 | /> 87 |
88 | ); 89 | }; 90 | 91 | export default InstanceDetail; 92 | -------------------------------------------------------------------------------- /web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | export enum HttpMethod { 4 | Get = "GET", 5 | Post = "POST", 6 | Put = "PUT", 7 | Delete = "DELETE", 8 | Patch = "PATCH", 9 | } 10 | 11 | export interface RequestConfig { 12 | headers?: HeadersInit; 13 | 14 | body?: BodyInit | null; 15 | 16 | slug: string; 17 | 18 | method?: HttpMethod; 19 | } 20 | 21 | const stripLeadingSlug = (slug: string): string => { 22 | if (slug.length > 0 && slug[0] === "/") { 23 | return slug.slice(1); 24 | } 25 | 26 | return slug; 27 | }; 28 | 29 | export const buildApiUrl = (slug: string): string => { 30 | return `/api/v1/${stripLeadingSlug(slug)}`; 31 | }; 32 | 33 | export const buildWSURL = (slug: string): string => { 34 | const { hostname, port, protocol } = window.location; 35 | 36 | const wsProtocol = protocol === "http:" ? "ws:" : "wss:"; 37 | return `${wsProtocol}//${hostname}:${port}/api/v1/${stripLeadingSlug(slug)}`; 38 | }; 39 | 40 | const makeRequestInit = (config?: RequestConfig): RequestInit => { 41 | const requestInit: RequestInit = { 42 | method: config?.method ? config.method : HttpMethod.Get, 43 | headers: config?.headers, 44 | }; 45 | 46 | if (config?.body) { 47 | requestInit.body = config.body; 48 | requestInit.headers = { 49 | ...requestInit.headers, 50 | "Content-type": "application/json", 51 | }; 52 | } 53 | 54 | return requestInit; 55 | }; 56 | 57 | interface UseApiState { 58 | data: TData | null; 59 | isLoading: boolean; 60 | error: string | null; 61 | } 62 | 63 | interface RequestOptions { 64 | noAuth?: boolean; 65 | manual?: boolean; 66 | } 67 | 68 | export const useApi = ( 69 | requestConfig: RequestConfig | string, 70 | options?: RequestOptions 71 | ): [UseApiState, () => Promise] => { 72 | if (typeof requestConfig === "string") { 73 | requestConfig = { 74 | slug: requestConfig, 75 | }; 76 | } 77 | 78 | options = { 79 | manual: false, 80 | noAuth: false, 81 | ...options, 82 | }; 83 | 84 | const stringifiedConfig = JSON.stringify(requestConfig); 85 | 86 | const [data, setData] = useState(null); 87 | const [isLoading, setIsLoading] = useState(true); 88 | const [error, setError] = useState(null); 89 | 90 | const performRequest = async (): Promise => { 91 | setError(null); 92 | setIsLoading(true); 93 | 94 | const response = await fetch( 95 | buildApiUrl((requestConfig as RequestConfig).slug), 96 | makeRequestInit(requestConfig as RequestConfig) 97 | ); 98 | 99 | if (response.status === 401) { 100 | // TODO: try to refresh token 101 | setIsLoading(false); 102 | 103 | return response.statusText; 104 | } 105 | 106 | if (!response.ok) { 107 | setError(response.statusText); 108 | setIsLoading(false); 109 | 110 | return response.statusText; 111 | } 112 | 113 | const data = (await response.json()) as TData; 114 | setData(data); 115 | setIsLoading(false); 116 | return data; 117 | }; 118 | 119 | useEffect(() => { 120 | if (!options?.manual) { 121 | performRequest(); 122 | } 123 | }, [stringifiedConfig]); 124 | 125 | const execute = useCallback(() => { 126 | return performRequest(); 127 | }, [stringifiedConfig]); 128 | 129 | return [{ data, isLoading, error }, execute]; 130 | }; 131 | --------------------------------------------------------------------------------