diff --git a/package-lock.json b/package-lock.json index 2cb6fcc272..12af991bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8503,7 +8503,6 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dev": true, "dependencies": { "acorn": "^7.0.0", "acorn-walk": "^7.0.0", @@ -8514,7 +8513,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -8526,7 +8524,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -9614,7 +9611,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, "engines": { "node": "*" } @@ -10374,7 +10370,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "engines": { "node": ">= 6" } @@ -10457,7 +10452,6 @@ "version": "2.9.4", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", - "dev": true, "dependencies": { "chartjs-color": "^2.1.0", "moment": "^2.10.2" @@ -10467,7 +10461,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz", "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==", - "dev": true, "dependencies": { "chartjs-color-string": "^0.6.0", "color-convert": "^1.9.3" @@ -10477,7 +10470,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", - "dev": true, "dependencies": { "color-name": "^1.0.0" } @@ -10486,7 +10478,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -10494,8 +10485,7 @@ "node_modules/chartjs-color/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/chokidar": { "version": "3.5.3", @@ -12187,7 +12177,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12301,7 +12290,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", - "dev": true, "dependencies": { "acorn-node": "^1.8.2", "defined": "^1.0.0", @@ -12336,8 +12324,7 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, "node_modules/diff": { "version": "4.0.2", @@ -12418,8 +12405,7 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, "node_modules/dmg-builder": { "version": "23.6.0", @@ -13040,7 +13026,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, "engines": { "node": ">= 4" } @@ -22054,7 +22039,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -23472,14 +23456,12 @@ "node_modules/monaco-editor": { "version": "0.29.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.29.1.tgz", - "integrity": "sha512-rguaEG/zrPQSaKzQB7IfX/PpNa0qxF1FY8ZXRkN4WIl8qZdTQRSRJCtRto7IMcSgrU6H53RXI+fTcywOBC4aVw==", - "dev": true + "integrity": "sha512-rguaEG/zrPQSaKzQB7IfX/PpNa0qxF1FY8ZXRkN4WIl8qZdTQRSRJCtRto7IMcSgrU6H53RXI+fTcywOBC4aVw==" }, "node_modules/monaco-editor-webpack-plugin": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-5.0.0.tgz", "integrity": "sha512-KrUUTmMO3lDCNK4honZ6rrrKjOI7FFLeyCktPetIo5HlRqr5dfE6ewaA9qNLH96XY7CekE3Z+v/+I6ufAs3ObA==", - "dev": true, "dependencies": { "loader-utils": "^2.0.0" }, @@ -27802,7 +27784,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -28320,7 +28301,6 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", - "dev": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -28337,7 +28317,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -28461,7 +28440,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", - "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -29330,7 +29308,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "dependencies": { "pify": "^2.3.0" } @@ -31874,7 +31851,6 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz", "integrity": "sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==", - "dev": true, "dependencies": { "arg": "^5.0.2", "chokidar": "^3.5.3", @@ -31915,7 +31891,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -31927,7 +31902,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "engines": { "node": ">= 6" } @@ -34993,7 +34967,7 @@ "@swc/core": "^1.3.38", "@swc/jest": "^0.2.23", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", + "@testing-library/react": "^12.1.5", "@types/jest": "^29.2.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.5.0", @@ -36326,8 +36300,10 @@ "css-loader": "^6.7.2", "fork-ts-checker-webpack-plugin": "^7.3.0", "mini-css-extract-plugin": "^2.7.3", + "sass": "^1.58.2", "sass-loader": "^13.2.0", "style-loader": "^3.3.1", + "tailwindcss": "^3.2.4", "ts-loader": "^9.4.1", "webpack": "^5.76.0", "webpack-cli": "^4.10.0", @@ -37077,11 +37053,24 @@ "version": "6.5.0-alpha.1", "license": "MIT", "devDependencies": { - "@k8slens/eslint-config": "6.5.0-alpha.1" + "@k8slens/eslint-config": "6.5.0-alpha.1", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0" }, "peerDependencies": { "@k8slens/feature-core": "^6.5.0-alpha.0", - "@ogre-tools/injectable": "^15.1.2" + "@k8slens/utilities": "^1.0.0-alpha.1", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-react": "^15.1.2", + "auto-bind": "^4.0.0", + "chart.js": "^2.9.4", + "js-yaml": "^4.1.0", + "mobx": "^6.8.0", + "mobx-react": "^7.6.0", + "moment": "^2.29.4", + "monaco-editor": "^0.29.1", + "monaco-editor-webpack-plugin": "^5.0.0", + "react": "^17.0.2" } }, "packages/utility-features/run-many": { diff --git a/packages/core/src/common/logger.injectable.ts b/packages/core/src/common/logger.injectable.ts index bc1c5de71b..e6cf9bbef6 100644 --- a/packages/core/src/common/logger.injectable.ts +++ b/packages/core/src/common/logger.injectable.ts @@ -6,15 +6,13 @@ import { getInjectable } from "@ogre-tools/injectable"; import { createLogger, format } from "winston"; import type { Logger } from "./logger"; import { loggerTransportInjectionToken } from "./logger/transports"; +import { loggerInjectionToken } from "@k8slens/metrics"; const loggerInjectable = getInjectable({ id: "logger", instantiate: (di): Logger => { const baseLogger = createLogger({ - format: format.combine( - format.splat(), - format.simple(), - ), + format: format.combine(format.splat(), format.simple()), transports: di.injectMany(loggerTransportInjectionToken), }); @@ -28,6 +26,8 @@ const loggerInjectable = getInjectable({ }, decorable: false, + + injectionToken: loggerInjectionToken, }); export default loggerInjectable; diff --git a/packages/core/src/common/logger.ts b/packages/core/src/common/logger.ts index ad81271bfa..464438ecb8 100644 --- a/packages/core/src/common/logger.ts +++ b/packages/core/src/common/logger.ts @@ -3,11 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +import type { Logger } from "@k8slens/metrics"; -export interface Logger { - info: (message: string, ...args: any) => void; - error: (message: string, ...args: any) => void; - debug: (message: string, ...args: any) => void; - warn: (message: string, ...args: any) => void; - silly: (message: string, ...args: any) => void; -} +export { Logger }; diff --git a/packages/core/src/common/user-store/user-store.injectable.ts b/packages/core/src/common/user-store/user-store.injectable.ts index 3b45b03b1d..144fc3ff92 100644 --- a/packages/core/src/common/user-store/user-store.injectable.ts +++ b/packages/core/src/common/user-store/user-store.injectable.ts @@ -18,6 +18,7 @@ import { persistStateToConfigInjectionToken } from "../base-store/save-to-file"; import getBasenameOfPathInjectable from "../path/get-basename.injectable"; import { enlistMessageChannelListenerInjectionToken } from "../utils/channel/enlist-message-channel-listener-injection-token"; import userStorePreferenceDescriptorsInjectable from "./preference-descriptors.injectable"; +import { userStoreInjectionToken } from "@k8slens/metrics"; const userStoreInjectable = getInjectable({ id: "user-store", @@ -37,6 +38,8 @@ const userStoreInjectable = getInjectable({ shouldDisableSyncInListener: di.inject(shouldBaseStoreDisableSyncInIpcListenerInjectionToken), preferenceDescriptors: di.inject(userStorePreferenceDescriptorsInjectable), }), + + injectionToken: userStoreInjectionToken }); export default userStoreInjectable; diff --git a/packages/core/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store-indirection.injectable.ts b/packages/core/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store-indirection.injectable.ts new file mode 100644 index 0000000000..7f72ef3110 --- /dev/null +++ b/packages/core/src/renderer/components/+cluster/cluster-overview-store/cluster-overview-store-indirection.injectable.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectable } from "@ogre-tools/injectable"; +import clusterOverviewStoreInjectable from "./cluster-overview-store.injectable"; +import { clusterOverviewStoreInjectionToken } from "@k8slens/metrics"; + +const clusterOverviewStoreIndirectionInjectable = getInjectable({ + id: "cluster-overview-store-indirection", + instantiate: (di) => di.inject(clusterOverviewStoreInjectable), + injectionToken: clusterOverviewStoreInjectionToken +}); + +export default clusterOverviewStoreIndirectionInjectable; diff --git a/packages/core/src/renderer/components/+cluster/navigate-to-preferences-of-metrics.injectable.ts b/packages/core/src/renderer/components/+cluster/navigate-to-preferences-of-metrics.injectable.ts new file mode 100644 index 0000000000..9680600913 --- /dev/null +++ b/packages/core/src/renderer/components/+cluster/navigate-to-preferences-of-metrics.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable"; +import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable"; +import { navigateToPreferencesOfMetricsInjectionToken } from "@k8slens/metrics"; + +const navigateToPreferencesOfMetricsInjectable = getInjectable({ + id: "navigate-to-preferences-of-metrics", + + instantiate: (di) => { + const cluster = di.inject(hostedClusterInjectable); + + const navigateToEntitySettings = di.inject( + navigateToEntitySettingsInjectable + ); + + if (!cluster?.id) { + throw new Error( + "Tried to inject way to navigate to preferences, but unnaturally no related cluster was available." + ); + } + + return () => navigateToEntitySettings(cluster.id, "metrics"); + }, + + injectionToken: navigateToPreferencesOfMetricsInjectionToken, +}); + +export default navigateToPreferencesOfMetricsInjectable; diff --git a/packages/core/src/renderer/components/+nodes/node-store-indirection.injectable.ts b/packages/core/src/renderer/components/+nodes/node-store-indirection.injectable.ts new file mode 100644 index 0000000000..8dc86405a1 --- /dev/null +++ b/packages/core/src/renderer/components/+nodes/node-store-indirection.injectable.ts @@ -0,0 +1,11 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import nodeStoreInjectable from "./store.injectable"; +import { nodeStoreInjectionToken } from "@k8slens/metrics"; + +const nodeStoreIndirectionInjectable = getInjectable({ + id: "node-store-indirection", + instantiate: (di) => di.inject(nodeStoreInjectable), + injectionToken: nodeStoreInjectionToken +}); + +export default nodeStoreIndirectionInjectable; diff --git a/packages/core/src/renderer/themes/active.injectable.ts b/packages/core/src/renderer/themes/active.injectable.ts index e22aee237b..16baecd8b7 100644 --- a/packages/core/src/renderer/themes/active.injectable.ts +++ b/packages/core/src/renderer/themes/active.injectable.ts @@ -10,6 +10,7 @@ import { lensThemeDeclarationInjectionToken } from "./declaration"; import defaultLensThemeInjectable from "./default-theme.injectable"; import systemThemeConfigurationInjectable from "./system-theme.injectable"; import lensThemesInjectable from "./themes.injectable"; +import { activeThemeInjectionToken } from "@k8slens/metrics"; const activeThemeInjectable = getInjectable({ id: "active-theme", @@ -35,6 +36,8 @@ const activeThemeInjectable = getInjectable({ return lensThemes.get(pref.lensThemeId) ?? defaultLensTheme; }); }, + + injectionToken: activeThemeInjectionToken }); export default activeThemeInjectable; diff --git a/packages/infrastructure/jest/package.json b/packages/infrastructure/jest/package.json index 6f75802b77..04c89056f5 100644 --- a/packages/infrastructure/jest/package.json +++ b/packages/infrastructure/jest/package.json @@ -23,7 +23,7 @@ "@swc/core": "^1.3.38", "@swc/jest": "^0.2.23", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", + "@testing-library/react": "^12.1.5", "@types/jest": "^29.2.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.5.0", diff --git a/packages/infrastructure/webpack/src/get-react-config.js b/packages/infrastructure/webpack/src/get-react-config.js index 439d913368..bdff0caae8 100644 --- a/packages/infrastructure/webpack/src/get-react-config.js +++ b/packages/infrastructure/webpack/src/get-react-config.js @@ -1,9 +1,11 @@ const getNodeConfig = require("./get-node-config"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin"); const path = require("path"); // hack: hard-coded -const sassCommonVars = "/Users/maspiala/work/lens/packages/core/src/renderer"; +const sassCommonVars = + "/Users/maspiala/work/lens/packages/core/src/renderer/components/vars.scss"; module.exports = ({ miniCssExtractPluginLoader = MiniCssExtractPlugin.loader } = {}) => @@ -22,6 +24,13 @@ module.exports = new MiniCssExtractPlugin({ filename: "[name].css", }), + + // see also: https://github.com/Microsoft/monaco-editor-webpack-plugin#options + new MonacoWebpackPlugin({ + languages: ["json", "yaml"], + // Hack: should be true only for development. + globalAPI: true + }), ], module: { @@ -55,7 +64,8 @@ module.exports = options: { sourceMap: false, postcssOptions: { - plugins: ["tailwindcss"], + // hack: commented + // plugins: ["tailwindcss"], }, }, }, diff --git a/packages/technical-features/metrics/index.ts b/packages/technical-features/metrics/index.ts index 96f1e6559d..7ffedde47a 100644 --- a/packages/technical-features/metrics/index.ts +++ b/packages/technical-features/metrics/index.ts @@ -4,5 +4,17 @@ */ export { metricsFeature } from "./src/feature"; -export type { ClusterOverviewUIBlock } from "./src/cluster-overview/cluster-overview-ui-block"; -export { clusterOverviewUIBlockInjectionToken } from "./src/cluster-overview/cluster-overview-ui-block"; +export type { ClusterOverviewUIBlock, MetricType } from "./src/cluster-overview/injection-tokens"; + +export { + activeThemeInjectionToken, + clusterOverviewStoreInjectionToken, + clusterOverviewUIBlockInjectionToken, + loggerInjectionToken, + navigateToPreferencesOfMetricsInjectionToken, + nodeStoreInjectionToken, + userStoreInjectionToken, +} from "./src/cluster-overview/injection-tokens"; + +export type { Logger } from "./src/cluster-overview/injection-tokens"; + diff --git a/packages/technical-features/metrics/package.json b/packages/technical-features/metrics/package.json index 6fdf6aa236..d82c72c662 100644 --- a/packages/technical-features/metrics/package.json +++ b/packages/technical-features/metrics/package.json @@ -33,11 +33,22 @@ }, "peerDependencies": { "@k8slens/feature-core": "^6.5.0-alpha.0", + "@k8slens/utilities": "^1.0.0-alpha.1", "@ogre-tools/injectable": "^15.1.2", "@ogre-tools/injectable-react": "^15.1.2", - "chart.js": "^2.9.4" + "auto-bind": "^4.0.0", + "chart.js": "^2.9.4", + "js-yaml": "^4.1.0", + "mobx": "^6.8.0", + "mobx-react": "^7.6.0", + "moment": "^2.29.4", + "monaco-editor": "^0.29.1", + "monaco-editor-webpack-plugin": "^5.0.0", + "react": "^17.0.2" }, "devDependencies": { + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", "@k8slens/eslint-config": "6.5.0-alpha.1" } } diff --git a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-block.ts b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-block.ts deleted file mode 100644 index 8ab0eaea6b..0000000000 --- a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-block.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectionToken } from "@ogre-tools/injectable"; - -export type ClusterOverviewUIBlock = { - id: string; - Component: React.ElementType -}; - -export const clusterOverviewUIBlockInjectionToken = getInjectionToken({ - id: "cluster-overview-ui-block-injection-token", -}); diff --git a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metric-switchers.tsx b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metric-switchers.tsx new file mode 100644 index 0000000000..c160ff3db1 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metric-switchers.tsx @@ -0,0 +1,84 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { ClusterOverviewStore, NodeStore } from "../injection-tokens"; +import { Radio, RadioGroup } from "../components/radio"; +import { + MetricType, + MetricNodeRole, + clusterOverviewStoreInjectionToken, nodeStoreInjectionToken +} from "../injection-tokens"; +import { normalizeMetrics } from "../metrics.api"; + +interface Dependencies { + clusterOverviewStore: ClusterOverviewStore; + nodeStore: NodeStore; +} + +const NonInjectedClusterMetricSwitchers = observer(({ + clusterOverviewStore, + nodeStore, +}: Dependencies) => { + const { masterNodes, workerNodes } = nodeStore; + const { cpuUsage, memoryUsage } = clusterOverviewStore.metrics ?? {}; + const hasMasterNodes = masterNodes.length > 0; + const hasWorkerNodes = workerNodes.length > 0; + const hasCpuMetrics = normalizeMetrics(cpuUsage).data.result[0].values.length > 0; + const hasMemoryMetrics = normalizeMetrics(memoryUsage).data.result[0].values.length > 0; + + return ( +
+
+ clusterOverviewStore.metricNodeRole = metric} + > + + + +
+
+ clusterOverviewStore.metricType = value} + > + + + +
+
+ ); +}); + +export const ClusterMetricSwitchers = withInjectables(NonInjectedClusterMetricSwitchers, { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectionToken), + nodeStore: di.inject(nodeStoreInjectionToken), + }), +}); + diff --git a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metrics.injectable.tsx b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metrics.injectable.tsx index 03f2170e31..9f22f208f6 100644 --- a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metrics.injectable.tsx +++ b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-metrics.injectable.tsx @@ -3,152 +3,169 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import React from 'react'; -// import styles from "./cluster-metrics.module.scss"; -// -// import React, { useState } from "react"; -// import { observer } from "mobx-react"; +import styles from "./cluster-metrics.module.scss"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; // import type { ChartOptions, ChartPoint } from "chart.js"; // import type { ClusterOverviewStore } from "../cluster-overview-store/cluster-overview-store"; // import { MetricType } from "../cluster-overview-store/cluster-overview-store"; // import { BarChart } from "../../chart"; -// import { bytesToUnits, cssNames } from "@k8slens/utilities"; +import { bytesToUnits, cssNames } from "@k8slens/utilities"; // import { Spinner } from "../../spinner"; -// import { ZebraStripesPlugin } from "../../chart/zebra-stripes.plugin"; // import { ClusterNoMetrics } from "../cluster-no-metrics"; // import { ClusterMetricSwitchers } from "../cluster-metric-switchers"; // import { getMetricLastPoints } from "../../../../common/k8s-api/endpoints/metrics.api"; // import { withInjectables } from "@ogre-tools/injectable-react"; // import clusterOverviewStoreInjectable from "../cluster-overview-store/cluster-overview-store.injectable"; import { getInjectable } from "@ogre-tools/injectable"; -import { clusterOverviewUIBlockInjectionToken } from "../cluster-overview-ui-block"; +import { + clusterOverviewStoreInjectionToken, + clusterOverviewUIBlockInjectionToken, + MetricType, +} from "../injection-tokens"; +import { ZebraStripesPlugin } from "../components/chart/zebra-stripes.plugin"; +import { getMetricLastPoints } from "../metrics.api"; +import type { ChartOptions, ChartPoint } from "chart.js"; +import { Spinner } from "../components/spinner"; +import { ClusterNoMetrics } from "../components/cluster-no-metrics"; +import { BarChart } from "../components/chart"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; -// interface Dependencies { -// clusterOverviewStore: ClusterOverviewStore; -// } +interface Dependencies { + // hack + clusterOverviewStore: any; +} -// const NonInjectedClusterMetrics = observer( -// ({ -// clusterOverviewStore: { -// metricType, -// metricNodeRole, -// getMetricsValues, -// metricsLoaded, -// metrics, -// }, -// }: Dependencies) => { -// const [plugins] = useState([new ZebraStripesPlugin()]); -// const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {}); -// const metricValues = getMetricsValues(metrics ?? {}); -// const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; -// const data = metricValues.map((value) => ({ -// x: value[0], -// y: parseFloat(value[1]).toFixed(3), -// })); -// -// const datasets = [ -// { -// id: metricType + metricNodeRole, -// label: `${metricType.toUpperCase()} usage`, -// borderColor: colors[metricType], -// data, -// }, -// ]; -// const cpuOptions: ChartOptions = { -// scales: { -// yAxes: [ -// { -// ticks: { -// suggestedMax: cpuCapacity, -// callback: (value) => value, -// }, -// }, -// ], -// }, -// tooltips: { -// callbacks: { -// label: ({ index }, data) => { -// if (!index) { -// return ""; -// } -// -// const value = data.datasets?.[0].data?.[index] as ChartPoint; -// -// return value.y?.toString() ?? ""; -// }, -// }, -// }, -// }; -// const memoryOptions: ChartOptions = { -// scales: { -// yAxes: [ -// { -// ticks: { -// suggestedMax: memoryCapacity, -// callback: (value: string) => -// !value ? 0 : bytesToUnits(parseInt(value)), -// }, -// }, -// ], -// }, -// tooltips: { -// callbacks: { -// label: ({ index }, data) => { -// if (!index) { -// return ""; -// } -// -// const value = data.datasets?.[0].data?.[index] as ChartPoint; -// -// return bytesToUnits(parseInt(value.y as string), { precision: 3 }); -// }, -// }, -// }, -// }; -// const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; -// -// const renderMetrics = () => { -// if (!metricValues.length && !metricsLoaded) { -// return ; -// } -// -// if (!memoryCapacity || !cpuCapacity) { -// return ; -// } -// -// return ( -// -// ); -// }; -// -// return ( -//
-// -// {renderMetrics()} -//
-// ); -// } -// ); +const NonInjectedClusterMetrics = observer( + ({ + clusterOverviewStore: { + metricType, + metricNodeRole, + getMetricsValues, + metricsLoaded, + metrics, + }, + }: Dependencies) => { + const [plugins] = useState([new ZebraStripesPlugin()]); + const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {}); + const metricValues = getMetricsValues(metrics ?? {}); + const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; -// const ClusterMetrics = withInjectables( -// NonInjectedClusterMetrics, -// -// { -// getProps: (di) => ({ -// clusterOverviewStore: di.inject(clusterOverviewStoreInjectable), -// }), -// } -// ); + // hack: any being used + // @ts-ignore + const data = metricValues.map((value) => ({ + x: value[0], + y: parseFloat(value[1]).toFixed(3), + })); -const ClusterMetrics = () =>
cluster-metrics.injectable
; + const datasets = [ + { + id: metricType + metricNodeRole, + label: `${metricType.toUpperCase()} usage`, + // hack: ignore + // @ts-ignore + borderColor: colors[metricType], + data, + }, + ]; + + const cpuOptions: ChartOptions = { + scales: { + yAxes: [ + { + ticks: { + suggestedMax: cpuCapacity, + + // hack: any + callback: (value: any) => value, + }, + }, + ], + }, + tooltips: { + callbacks: { + label: ({ index }, data) => { + if (!index) { + return ""; + } + + const value = data.datasets?.[0].data?.[index] as ChartPoint; + + return value.y?.toString() ?? ""; + }, + }, + }, + }; + const memoryOptions: ChartOptions = { + scales: { + yAxes: [ + { + ticks: { + suggestedMax: memoryCapacity, + callback: (value: string) => + !value ? 0 : bytesToUnits(parseInt(value)), + }, + }, + ], + }, + tooltips: { + callbacks: { + label: ({ index }, data) => { + if (!index) { + return ""; + } + + const value = data.datasets?.[0].data?.[index] as ChartPoint; + + return bytesToUnits(parseInt(value.y as string), { precision: 3 }); + }, + }, + }, + }; + const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; + + const renderMetrics = () => { + if (!metricValues.length && !metricsLoaded) { + return ; + } + + if (!memoryCapacity || !cpuCapacity) { + return ; + } + + return ( + + ); + }; + + return ( +
+ + {renderMetrics()} +
+ ); + } +); + +const ClusterMetrics = withInjectables( + NonInjectedClusterMetrics, + + { + getProps: (di) => ({ + clusterOverviewStore: di.inject(clusterOverviewStoreInjectionToken), + }), + } +); const clusterMetricsOverviewBlockInjectable = getInjectable({ id: "cluster-metrics-overview-block", diff --git a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-pie-charts.injectable.tsx b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-pie-charts.injectable.tsx index 737876482e..e4fcbbdcd9 100644 --- a/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-pie-charts.injectable.tsx +++ b/packages/technical-features/metrics/src/cluster-overview/cluster-overview-ui-blocks/cluster-pie-charts.injectable.tsx @@ -25,7 +25,7 @@ import React from "react"; // import activeThemeInjectable from "../../../themes/active.injectable"; // import type { ClusterMetricData } from "../../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable"; import { getInjectable } from "@ogre-tools/injectable"; -import { clusterOverviewUIBlockInjectionToken } from "../cluster-overview-ui-block"; +import { clusterOverviewUIBlockInjectionToken } from "../injection-tokens"; // function createLabels(rawLabelData: [string, number | undefined][]): string[] { // return rawLabelData.map( diff --git a/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.module.scss b/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.module.scss new file mode 100644 index 0000000000..2223129ebb --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.module.scss @@ -0,0 +1,50 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.badge { + position: relative; + display: inline-block; + max-width: 100%; +} + +// might be used in a scrollable area with {overflow: auto} +.badge.scrollable { + max-width: unset; + + &:not(.isExpanded) { + white-space: unset; + text-overflow: unset; + } +} + + +.badge.interactive:hover { + background-color: var(--mainBackground); + cursor: pointer; +} + +.badge:not(.scrollable):not(.isExpanded) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge:not(.flat) { + background: var(--colorVague); + border-radius: 3px; + padding: .2em .4em; +} + +.small { + font-size: var(--font-size-small); +} + +.clickable { + cursor: pointer; +} + +.disabled { + opacity: 0.5; +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.tsx b/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.tsx new file mode 100644 index 0000000000..5fe12ade09 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/badge/badge.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./badge.module.scss"; + +import React, { useEffect, useRef, useState } from "react"; +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { cssNames } from "@k8slens/utilities"; +import { withTooltip } from "../tooltip"; + +export interface BadgeProps extends React.HTMLAttributes { + small?: boolean; + flat?: boolean; + label?: React.ReactNode; + expandable?: boolean; + disabled?: boolean; + scrollable?: boolean; +} + +// Common handler for all Badge instances +document.addEventListener("selectionchange", () => { + badgeMeta.hasTextSelected ||= (window.getSelection()?.toString().trim().length ?? 0) > 0; +}); + +const badgeMeta = observable({ + hasTextSelected: false, +}); + +export const Badge = withTooltip(observer(({ + small, + flat, + label, + expandable = true, + disabled, + scrollable, + className, + children, + ...elemProps +}: BadgeProps) => { + const elem = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isExpandable, setIsExpandable] = useState(false); + + useEffect(() => { + const { clientWidth = 0, scrollWidth = 0 } = elem.current ?? {}; + + setIsExpandable(expandable && (clientWidth < scrollWidth)); + }, [expandable, elem.current]); + + const onMouseUp = action(() => { + if (!isExpandable || badgeMeta.hasTextSelected) { + badgeMeta.hasTextSelected = false; + } else { + setIsExpanded(!isExpanded); + } + }); + + return ( +
+ {label} + {children} +
+ ); +})); diff --git a/packages/technical-features/metrics/src/cluster-overview/components/badge/has-text-selected.injectable.ts b/packages/technical-features/metrics/src/cluster-overview/components/badge/has-text-selected.injectable.ts new file mode 100644 index 0000000000..f2cba73cbb --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/badge/has-text-selected.injectable.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const badgeHasTextSelectedStateInjectable = getInjectable({ + id: "badge-has-text-selected-state", + instantiate: () => observable.box(false), +}); + +export default badgeHasTextSelectedStateInjectable; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/badge/index.ts b/packages/technical-features/metrics/src/cluster-overview/components/badge/index.ts new file mode 100644 index 0000000000..cb50a5a967 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/badge/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./badge"; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/bar-chart.tsx b/packages/technical-features/metrics/src/cluster-overview/components/chart/bar-chart.tsx new file mode 100644 index 0000000000..f711bc717a --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/bar-chart.tsx @@ -0,0 +1,247 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import merge from "lodash/merge"; +import moment from "moment"; +import Color from "color"; +import { observer } from "mobx-react"; +import type { ChartOptions, ChartTooltipCallback, ChartTooltipItem, Scriptable } from "chart.js"; +import type { ChartProps } from "./chart"; +import { Chart, ChartKind } from "./chart"; +import { bytesToUnits, cssNames, isObject } from "@k8slens/utilities"; +import { ZebraStripesPlugin } from "./zebra-stripes.plugin"; +import type { LensTheme } from "./lens-theme"; +import assert from "assert"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import { NoMetrics } from "./no-metrics"; +import { activeThemeInjectionToken } from "../../injection-tokens"; + +export interface BarChartProps extends ChartProps { + name?: string; + timeLabelStep?: number; // Minute labels appearance step +} + +const getBarColor: Scriptable = ({ dataset }) => Color(dataset?.borderColor).alpha(0.2).string(); + +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedBarChart = observer(({ + activeTheme, + name, + data, + className, + timeLabelStep = 10, + plugins, + options: customOptions, + ...settings +}: Dependencies & BarChartProps) => { + const { textColorPrimary, borderFaintColor, chartStripesColor } = activeTheme.get().colors; + const { datasets: rawDatasets = [], ...rest } = data; + const datasets = rawDatasets + .filter(set => set.data?.length) + .map(item => ({ + type: ChartKind.BAR, + borderWidth: { top: 3 }, + barPercentage: 1, + categoryPercentage: 1, + ...item, + })); + + plugins ??= [new ZebraStripesPlugin({ + stripeColor: chartStripesColor, + interval: datasets[0]?.data?.length, + })]; + + if (datasets.length === 0) { + return ; + } + + const formatTimeLabels = (timestamp: string, index: number) => { + const label = moment(parseInt(timestamp)).format("HH:mm"); + const offset = " "; + + if (index == 0) return offset + label; + if (index == 60) return label + offset; + + return index % timeLabelStep == 0 ? label : ""; + }; + + const barOptions: ChartOptions = { + maintainAspectRatio: false, + responsive: true, + scales: { + xAxes: [{ + type: "time", + offset: true, + gridLines: { + display: false, + }, + stacked: true, + ticks: { + callback: formatTimeLabels, + autoSkip: false, + source: "data", + backdropColor: "white", + fontColor: textColorPrimary, + fontSize: 11, + maxRotation: 0, + minRotation: 0, + }, + bounds: "data", + time: { + unit: "minute", + displayFormats: { + minute: "x", + }, + parser: timestamp => moment.unix(parseInt(timestamp)), + }, + }], + yAxes: [{ + position: "right", + gridLines: { + color: borderFaintColor, + drawBorder: false, + tickMarkLength: 0, + zeroLineWidth: 0, + }, + ticks: { + maxTicksLimit: 6, + fontColor: textColorPrimary, + fontSize: 11, + padding: 8, + min: 0, + }, + }], + }, + tooltips: { + mode: "index", + position: "cursor", + callbacks: { + title([tooltip]: ChartTooltipItem[]) { + const xLabel = tooltip?.xLabel; + const skipLabel = xLabel == null || new Date(xLabel).getTime() > Date.now(); + + if (skipLabel) return ""; + + return String(xLabel); + }, + labelColor: ({ datasetIndex }) => ( + typeof datasetIndex === "number" + ? { + borderColor: "darkgray", + backgroundColor: datasets[datasetIndex].borderColor as string, + } + : { + borderColor: "darkgray", + backgroundColor: "gray", + } + ), + }, + }, + animation: { + duration: 0, + }, + elements: { + rectangle: { + backgroundColor: getBarColor.bind(null), + }, + }, + }; + + return ( + + ); +}); + +export const BarChart = withInjectables(NonInjectedBarChart, { + getProps: (di, props) => ({ + ...props, + activeTheme: di.inject(activeThemeInjectionToken), + }), +}); + +const tooltipCallbackWith = (precision: number): ChartTooltipCallback["label"] => ( + ({ datasetIndex, index }, { datasets = [] }) => { + if (typeof datasetIndex !== "number" || typeof index !== "number") { + return ""; + } + + const { label, data } = datasets[datasetIndex]; + + if (!label || !data) { + return ""; + } + + const value = data[index]; + + assert(isObject(value) && !Array.isArray(value) && typeof value.y === "number"); + + return `${label}: ${bytesToUnits(parseInt(value.y.toString()), { precision })}`; + } +); + +// Default options for all charts containing memory units (network, disk, memory, etc) +export const memoryOptions: ChartOptions = { + scales: { + yAxes: [{ + ticks: { + callback: (value) => { + if (typeof value == "string") { + const float = parseFloat(value); + + if (float < 1) { + return float.toFixed(3); + } + + return bytesToUnits(parseInt(value)); + } + + return bytesToUnits(value); + }, + stepSize: 1, + }, + }], + }, + tooltips: { + callbacks: { + label: tooltipCallbackWith(3), + }, + }, +}; + +// Default options for all charts with cpu units or other decimal numbers +export const cpuOptions: ChartOptions = { + scales: { + yAxes: [{ + ticks: { + callback: (value) => { + const float = parseFloat(`${value}`); + + if (float == 0) return "0"; + if (float < 10) return float.toFixed(3); + if (float < 100) return float.toFixed(2); + + return float.toFixed(1); + }, + }, + }], + }, + tooltips: { + callbacks: { + label: tooltipCallbackWith(2), + }, + }, +}; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.scss b/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.scss new file mode 100644 index 0000000000..11718ec6e3 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.scss @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.Chart { + position: relative; + + &.BarChart { + margin-left: -$margin * 2.2; + height: 100%; + overflow: hidden; + } + + .legend { + .LegendBadge { + background: transparent; + transition: background-color 250ms; + white-space: normal; + + &:hover { + background: var(--colorVague); + cursor: default; + } + } + } + + .zebra-cover { + position: absolute; + pointer-events: none; + } +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.tsx b/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.tsx new file mode 100644 index 0000000000..06572330fd --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/chart.tsx @@ -0,0 +1,238 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./chart.scss"; +import type { CSSProperties } from "react"; +import React from "react"; +import type { PluginServiceRegistrationOptions } from "chart.js"; +import ChartJS from "chart.js"; +import { remove } from "lodash"; +import { cssNames } from "@k8slens/utilities"; +import { Badge } from "../badge"; +import { StatusBrick } from "../status-brick"; + +export interface ChartData extends ChartJS.ChartData { + datasets?: ChartDataSets[]; +} + +export interface ChartDataSets extends ChartJS.ChartDataSets { + id?: string; + tooltip?: string; +} + +export interface ChartProps { + data: ChartData; + options?: ChartJS.ChartOptions; // Passed to ChartJS instance + width?: number | string; + height?: number | string; + type?: ChartKind; + showChart?: boolean; // Possible to show legend only if false + showLegend?: boolean; + legendPosition?: "bottom"; + legendColors?: string[]; // Hex colors for each of the labels in data object + plugins?: PluginServiceRegistrationOptions[]; + redraw?: boolean; // If true - recreate chart instance with no animation + title?: string; + className?: string; + "data-testid"?: string; +} + +export enum ChartKind { + PIE = "pie", + BAR = "bar", + LINE = "line", + DOUGHNUT = "doughnut", +} + +const defaultProps: Partial = { + type: ChartKind.DOUGHNUT, + options: {}, + showChart: true, + showLegend: true, + legendPosition: "bottom", + plugins: [], + redraw: false, +}; + +export class Chart extends React.Component { + static defaultProps = defaultProps as object; + + private canvas = React.createRef(); + private chart: ChartJS | null = null; + // ChartJS adds _meta field to any data object passed to it. + // We clone new data prop into currentChartData to compare props and prevProps + private currentChartData?: ChartData; + + componentDidMount() { + const { showChart } = this.props; + + if (showChart) { + this.renderChart(); + } + } + + componentDidUpdate() { + const { showChart, redraw } = this.props; + + if (redraw) { + this.chart?.destroy(); + this.renderChart(); + } else if (showChart) { + if (!this.chart) { + this.renderChart(); + } else { + this.updateChart(); + } + } + } + + memoizeDataProps() { + const { data } = this.props; + + this.currentChartData = { + ...data, + datasets: data.datasets && data.datasets.map(set => { + return { + ...set, + }; + }), + }; + } + + updateChart() { + const { options } = this.props; + + if (!this.chart) return; + + this.chart.options = ChartJS.helpers.configMerge(this.chart.options, options); + + this.memoizeDataProps(); + + const datasets: ChartDataSets[] = (this.chart.config.data ??= {}).datasets ??= []; + const nextDatasets: ChartDataSets[] = (this.currentChartData ??= {}).datasets ??= []; + + // Remove stale datasets if they're not available in nextDatasets + if (datasets.length > nextDatasets.length) { + const sets = [...datasets]; + + sets.forEach(set => { + if (!nextDatasets.find(next => next.id === set.id)) { + remove(datasets, (item => item.id === set.id)); + } + }); + } + + // Mutating inner chart datasets to enable seamless transitions + nextDatasets.forEach((next, datasetIndex) => { + const index = datasets.findIndex(set => set.id === next.id); + + if (index !== -1) { + const data = datasets[index].data = (datasets[index].data ?? []).slice(); // "Clean" mobx observables data to use in ChartJS + const nextData = next.data ??= []; + + data.splice(next.data.length); + + for (let dataIndex = 0; dataIndex < nextData.length; dataIndex += 1) { + data[dataIndex] = nextData[dataIndex]; + } + + // Merge other fields + const { data: _data, ...props } = next; + + datasets[index] = { + ...datasets[index], + ...props, + }; + } else { + datasets[datasetIndex] = next; + } + }); + this.chart.update(); + } + + renderLegend() { + if (!this.props.showLegend) return null; + const { data, legendColors } = this.props; + const { labels, datasets } = data; + const labelElem = (title: string | undefined, color: string | CSSProperties["backgroundColor"], tooltip?: string) => ( + + + {title} + + )} + tooltip={tooltip} + expandable={false} + /> + ); + + return ( +
+ { + labels + ? labels.map((label, index) => { + const { backgroundColor = [] } = datasets?.[0] ?? {}; + const color = legendColors ? legendColors[index] : (backgroundColor as string[])[index]; + + return labelElem(label as string, color); + }) + : datasets?.map(({ borderColor, label, tooltip }) => + labelElem(label, borderColor as string, tooltip), + ) + } +
+ ); + } + + renderChart() { + const { type, options, plugins } = this.props; + const canvas = this.canvas.current; + + if (!canvas) { + return; + } + + this.memoizeDataProps(); + + this.chart = new ChartJS(canvas, { + type, + plugins, + options: { + ...options, + legend: { + display: false, + }, + }, + data: this.currentChartData, + }); + } + + render() { + const { width, height, showChart, title, className, "data-testid": dataTestId } = this.props; + + return ( +
+ {title &&
{title}
} + {showChart && ( +
+ +
+
+ )} + {this.renderLegend()} +
+ ); + } +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/index.ts b/packages/technical-features/metrics/src/cluster-overview/components/chart/index.ts new file mode 100644 index 0000000000..d84743cb68 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./chart"; +export * from "./pie-chart"; +export * from "./bar-chart"; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/lens-theme.ts b/packages/technical-features/metrics/src/cluster-overview/components/chart/lens-theme.ts new file mode 100644 index 0000000000..960bf09abf --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/lens-theme.ts @@ -0,0 +1,154 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MonacoTheme } from "../monaco-editor"; + +// hack for being a duplication. +export type ThemeId = string; +export type LensThemeType = "dark" | "light"; +export interface LensTheme { + name: string; + type: LensThemeType; + colors: Record; + terminalColors: Partial>; + description: string; + author: string; + monacoTheme: MonacoTheme; + isDefault?: boolean; +} + +export type TerminalColorName = + | "foreground" + | "background" + | "cursor" + | "cursorAccent" + | "selection" + | "selectionForeground" + | "black" + | "red" + | "green" + | "yellow" + | "blue" + | "magenta" + | "cyan" + | "white" + | "brightBlack" + | "brightRed" + | "brightGreen" + | "brightYellow" + | "brightBlue" + | "brightMagenta" + | "brightCyan" + | "brightWhite"; + +export type LensColorName = + | "blue" + | "magenta" + | "golden" + | "halfGray" + | "primary" + | "textColorPrimary" + | "textColorSecondary" + | "textColorTertiary" + | "textColorAccent" + | "textColorDimmed" + | "borderColor" + | "borderFaintColor" + | "mainBackground" + | "secondaryBackground" + | "contentColor" + | "layoutBackground" + | "layoutTabsBackground" + | "layoutTabsActiveColor" + | "layoutTabsLineColor" + | "sidebarLogoBackground" + | "sidebarActiveColor" + | "sidebarSubmenuActiveColor" + | "sidebarBackground" + | "sidebarItemHoverBackground" + | "badgeBackgroundColor" + | "buttonPrimaryBackground" + | "buttonDefaultBackground" + | "buttonLightBackground" + | "buttonAccentBackground" + | "buttonDisabledBackground" + | "tableBgcStripe" + | "tableBgcSelected" + | "tableHeaderBackground" + | "tableHeaderColor" + | "tableSelectedRowColor" + | "helmLogoBackground" + | "helmStableRepo" + | "helmIncubatorRepo" + | "helmDescriptionHr" + | "helmDescriptionBlockquoteColor" + | "helmDescriptionBlockquoteBorder" + | "helmDescriptionBlockquoteBackground" + | "helmDescriptionHeaders" + | "helmDescriptionH6" + | "helmDescriptionTdBorder" + | "helmDescriptionTrBackground" + | "helmDescriptionCodeBackground" + | "helmDescriptionPreBackground" + | "helmDescriptionPreColor" + | "colorSuccess" + | "colorOk" + | "colorInfo" + | "colorError" + | "colorSoftError" + | "colorWarning" + | "colorVague" + | "colorTerminated" + | "dockHeadBackground" + | "dockInfoBackground" + | "dockInfoBorderColor" + | "dockEditorBackground" + | "dockEditorTag" + | "dockEditorKeyword" + | "dockEditorComment" + | "dockEditorActiveLineBackground" + | "dockBadgeBackground" + | "dockTabBorderColor" + | "dockTabActiveBackground" + | "logsBackground" + | "logsForeground" + | "logRowHoverBackground" + | "dialogTextColor" + | "dialogBackground" + | "dialogHeaderBackground" + | "dialogFooterBackground" + | "drawerTogglerBackground" + | "drawerTitleText" + | "drawerSubtitleBackground" + | "drawerItemNameColor" + | "drawerItemValueColor" + | "clusterMenuBackground" + | "clusterMenuBorderColor" + | "clusterMenuCellBackground" + | "clusterSettingsBackground" + | "addClusterIconColor" + | "boxShadow" + | "iconActiveColor" + | "iconActiveBackground" + | "filterAreaBackground" + | "chartLiveBarBackground" + | "chartStripesColor" + | "chartCapacityColor" + | "pieChartDefaultColor" + | "inputOptionHoverColor" + | "inputControlBackground" + | "inputControlBorder" + | "inputControlHoverBorder" + | "lineProgressBackground" + | "radioActiveBackground" + | "menuActiveBackground" + | "menuSelectedOptionBgc" + | "canvasBackground" + | "scrollBarColor" + | "settingsBackground" + | "settingsColor" + | "navSelectedBackground" + | "navHoverColor" + | "hrColor" + | "tooltipBackground"; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/no-metrics.tsx b/packages/technical-features/metrics/src/cluster-overview/components/chart/no-metrics.tsx new file mode 100644 index 0000000000..cd69b8bbca --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/no-metrics.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { Icon } from "../icon"; + +export function NoMetrics() { + return ( +
+ + Metrics not available at the moment +
+ ); +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/options.ts b/packages/technical-features/metrics/src/cluster-overview/components/chart/options.ts new file mode 100644 index 0000000000..24a6dc5fd4 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/options.ts @@ -0,0 +1,150 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ChartOptions, ChartPoint } from "chart.js"; +import { bytesToUnits, isDefined } from "@k8slens/utilities"; + +export type MetricsTab = "CPU" | "Memory" | "Disk" | "Pods" | "Network" | "Filesystem" | "Duration"; + +const memoryLikeOptions: ChartOptions = { + scales: { + yAxes: [{ + ticks: { + callback: (value: number | string): string => { + if (typeof value == "string") { + const float = parseFloat(value); + + if (float < 1) { + return float.toFixed(3); + } + + return bytesToUnits(parseInt(value)); + } + + return bytesToUnits(value); + }, + stepSize: 1, + }, + }], + }, + tooltips: { + callbacks: { + label: ({ datasetIndex, index }, { datasets }) => { + if (!isDefined(datasetIndex) || !isDefined(index) || !isDefined(datasets)) { + return ""; + } + + const { label, data } = datasets[datasetIndex]; + + if (!data) { + return label ?? ""; + } + + const value = data[index] as { y: number }; + + return `${label}: ${bytesToUnits(parseInt(value.y.toString()), { precision: 3 })}`; + }, + }, + }, +}; + +export const metricTabOptions: Record = { + Memory: memoryLikeOptions, + Disk: memoryLikeOptions, + Network: memoryLikeOptions, + Filesystem: memoryLikeOptions, + CPU: { + scales: { + yAxes: [{ + ticks: { + callback: (value: number | string): string => { + const float = parseFloat(`${value}`); + + if (float == 0) return "0"; + if (float < 10) return float.toFixed(3); + if (float < 100) return float.toFixed(2); + + return float.toFixed(1); + }, + }, + }], + }, + tooltips: { + callbacks: { + label: ({ datasetIndex, index }, { datasets }) => { + if (!isDefined(datasetIndex) || !isDefined(index) || !isDefined(datasets)) { + return ""; + } + + const { label, data } = datasets[datasetIndex]; + + if (!data) { + return label ?? ""; + } + + const value = data[index] as ChartPoint; + + return `${label}: ${parseFloat(value.y as string).toPrecision(2)}`; + }, + }, + }, + }, + Pods: { + scales: { + yAxes: [{ + ticks: { + callback: value => value, + }, + }], + }, + tooltips: { + callbacks: { + label: ({ datasetIndex, index }, { datasets }) => { + if (!isDefined(datasetIndex) || !isDefined(index) || !isDefined(datasets)) { + return ""; + } + + const { label, data } = datasets[datasetIndex]; + + if (!data) { + return label ?? ""; + } + + const value = data[index] as ChartPoint; + + return `${label}: ${value.y}`; + }, + }, + }, + }, + Duration: { + scales: { + yAxes: [{ + ticks: { + callback: value => value, + }, + }], + }, + tooltips: { + callbacks: { + label: ({ datasetIndex, index }, { datasets }) => { + if (!isDefined(datasetIndex) || !isDefined(index) || !isDefined(datasets)) { + return ""; + } + + const { label, data } = datasets[datasetIndex]; + + if (!data) { + return label ?? ""; + } + + const value = data[index] as { y: string }; + + return `${label}: ${parseFloat(value.y).toFixed(3)} sec`; + }, + }, + }, + }, +}; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.scss b/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.scss new file mode 100644 index 0000000000..d2edcf5aa9 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.scss @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.PieChart { + .chart-container { + width: 120px; + min-height: 150px; + } + + .legend { + margin-top: $margin; + flex-direction: column; + + > * { + &.LegendBadge:hover { + background-color: transparent; + } + + margin-bottom: $margin; + } + } +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.tsx b/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.tsx new file mode 100644 index 0000000000..7d3d592799 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/pie-chart.tsx @@ -0,0 +1,123 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./pie-chart.scss"; +import React from "react"; +import { observer } from "mobx-react"; +import type { ChartOptions } from "chart.js"; +import ChartJS from "chart.js"; +import type { ChartProps } from "./chart"; +import { Chart } from "./chart"; +import { cssNames } from "@k8slens/utilities"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import type { IComputedValue } from "mobx"; +import type { LensTheme } from "./lens-theme"; +import { activeThemeInjectionToken } from "../../injection-tokens"; + +export interface PieChartProps extends ChartProps { +} + +export interface PieChartData extends ChartJS.ChartData { + datasets?: PieChartDataSets[]; +} + +export type DatasetTooltipLabel = (percent: string) => string | string; + +interface PieChartDataSets extends ChartJS.ChartDataSets { + id?: string; + tooltipLabels?: DatasetTooltipLabel[]; +} + +function getCutout(length: number | undefined): number { + switch (length) { + case 0: + case 1: + return 88; + case 2: + return 76; + case 3: + return 63; + default: + return 50; + } +} + +interface Dependencies { + activeTheme: IComputedValue; +} + +const NonInjectedPieChart = observer(({ + activeTheme, + data, + className, + options, + showChart, + ...chartProps +}: Dependencies & PieChartProps) => { + const { contentColor } = activeTheme.get().colors; + const opts: ChartOptions = { + maintainAspectRatio: false, + tooltips: { + mode: "index", + callbacks: { + title: () => "", + label: (tooltipItem: { datasetIndex: number; index: number }, data: PieChartData) => { + const dataset = data.datasets?.[tooltipItem.datasetIndex] ?? {}; + const datasetData = (dataset.data ?? []) as number[]; + const total = datasetData.reduce((acc, cur) => acc + cur, 0); + const percent = Math.round((datasetData[tooltipItem.index] as number / total) * 100); + const percentLabel = isNaN(percent) ? "N/A" : `${percent}%`; + const tooltipLabelCustomizer = dataset.tooltipLabels?.[tooltipItem.index]; + + return tooltipLabelCustomizer + ? tooltipLabelCustomizer(percentLabel) + : `${dataset.label}: ${percentLabel}`; + }, + }, + filter: ({ datasetIndex, index }, { datasets = [] }) => { + if (datasetIndex === undefined) { + return false; + } + + const { data = [] } = datasets[datasetIndex]; + + if (datasets.length === 1) return true; + + return index !== data.length - 1; + }, + position: "cursor", + }, + elements: { + arc: { + borderWidth: 1, + borderColor: contentColor, + }, + }, + cutoutPercentage: getCutout(data.datasets?.length), + responsive: true, + ...options, + }; + + return ( + + ); +}); + +export const PieChart = withInjectables(NonInjectedPieChart, { + getProps: (di, props) => ({ + ...props, + activeTheme: di.inject(activeThemeInjectionToken), + }), +}); + +ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { + return position; +}; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/chart/zebra-stripes.plugin.ts b/packages/technical-features/metrics/src/cluster-overview/components/chart/zebra-stripes.plugin.ts new file mode 100644 index 0000000000..020429530f --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/chart/zebra-stripes.plugin.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Plugin for drawing stripe bars on top of any timeseries barchart +// Based on cover DIV element with repeating-linear-gradient style + +import type ChartJS from "chart.js"; +import type { Moment } from "moment"; +import moment from "moment"; +import type { PluginServiceRegistrationOptions } from "chart.js"; + +const defaultOptions = { + stripeColor: "#ffffff08", + interval: 10, +}; + +export interface ZebraStripesOptions { + stripeColor: string; + interval: number; +} + +export class ZebraStripesPlugin implements PluginServiceRegistrationOptions { + updated: Moment | null = null; + options: ZebraStripesOptions; + + constructor(options?: Partial) { + this.options = Object.assign({}, defaultOptions, options); + } + + getOptions(chart: ChartJS): ZebraStripesOptions | undefined { + return chart.options.plugins?.ZebraStripes; + } + + getLastUpdate(chart: ChartJS) { + const data = chart.data.datasets?.[0]?.data?.[0] as ChartJS.ChartPoint; + + return moment.unix(parseInt(data.x as string)); + } + + getStripesElem(chart: ChartJS) { + return chart.canvas?.parentElement?.querySelector(".zebra-cover"); + } + + removeStripesElem(chart: ChartJS) { + const elem = this.getStripesElem(chart); + + if (elem) { + chart.canvas?.parentElement?.removeChild(elem); + } + } + + updateOptions(chart: ChartJS) { + this.options = { + ...defaultOptions, + ...this.getOptions(chart), + }; + } + + getStripeMinutes() { + return this.options.interval < 10 ? 0 : 10; + } + + renderStripes(chart: ChartJS) { + if (!chart.data.datasets?.length) return; + const { interval, stripeColor } = this.options; + const { top, left, bottom, right } = chart.chartArea; + const step = (right - left) / interval; + const stripeWidth = step * this.getStripeMinutes(); + const cover = document.createElement("div"); + const styles = cover.style; + + if (this.getStripesElem(chart)) return; + + cover.className = "zebra-cover"; + styles.width = `${right - left}px`; + styles.left = `${left}px`; + styles.top = `${top}px`; + styles.height = `${bottom - top}px`; + styles.backgroundImage = ` + repeating-linear-gradient(to right, ${stripeColor} 0px, ${stripeColor} ${stripeWidth}px, + transparent ${stripeWidth}px, transparent ${stripeWidth * 2 + step}px) + `; + chart.canvas?.parentElement?.appendChild(cover); + } + + afterInit(chart: ChartJS) { + if (!chart.data.datasets?.length) return; + this.updateOptions(chart); + this.updated = this.getLastUpdate(chart); + } + + afterUpdate(chart: ChartJS) { + this.updateOptions(chart); + this.renderStripes(chart); + } + + resize(chart: ChartJS) { + this.removeStripesElem(chart); + } + + afterDatasetUpdate(chart: ChartJS): void { + this.updated ??= this.getLastUpdate(chart); + + const { interval } = this.options; + const { left, right } = chart.chartArea; + const step = (right - left) / interval; + const diff = moment(this.updated).diff(this.getLastUpdate(chart), "minutes"); + const minutes = Math.abs(diff); + + this.removeStripesElem(chart); + this.renderStripes(chart); + + if (minutes > 0) { + // Move position regarding to difference in time + const cover = this.getStripesElem(chart); + + if (cover) { + cover.style.backgroundPositionX = `${-step * minutes}px`; + } + } + } +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.module.scss b/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.module.scss new file mode 100644 index 0000000000..2ad031f434 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.module.scss @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.ClusterNoMetrics { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + line-height: 1.7; + flex-grow: 1; + + .link { + color: var(--blue); + cursor: pointer; + } +} \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.tsx b/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.tsx new file mode 100644 index 0000000000..849306770c --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/cluster-no-metrics.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./cluster-no-metrics.module.scss"; + +import React from "react"; +import { cssNames } from "@k8slens/utilities"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { Icon } from "./icon"; +import { navigateToPreferencesOfMetricsInjectionToken } from "../injection-tokens"; + +export interface ClusterNoMetricsProps { + className: string; +} + +interface Dependencies { + navigateToPreferencesOfMetrics: () => void; +} + +export function NonInjectedClusterNoMetrics({ className, navigateToPreferencesOfMetrics }: Dependencies & ClusterNoMetricsProps) { + return ( +
+ +

Metrics are not available due to missing or invalid Prometheus configuration.

+

Open cluster settings

+
+ ); +} + +export const ClusterNoMetrics = withInjectables( + NonInjectedClusterNoMetrics, + + { + getProps: (di, props) => ({ + navigateToPreferencesOfMetrics: di.inject(navigateToPreferencesOfMetricsInjectionToken), + ...props, + }), + }, +); diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/arrow-spinner.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/arrow-spinner.svg new file mode 100644 index 0000000000..413b93f3a2 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/arrow-spinner.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/configuration.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/configuration.svg new file mode 100644 index 0000000000..9f28ca0250 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/configuration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/crane.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/crane.svg new file mode 100644 index 0000000000..0adc3806ea --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/crane.svg @@ -0,0 +1,182 @@ + + + + +Artboard 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/group.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/group.svg new file mode 100644 index 0000000000..5399ef5f13 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/helm.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/helm.svg new file mode 100644 index 0000000000..a5b6321722 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/helm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.scss b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.scss new file mode 100644 index 0000000000..de8d6fbdf2 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.scss @@ -0,0 +1,132 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.Icon { + --size: 21px; + --small-size: 18px; + --smallest-size: 16px; + --big-size: 32px; + --color-active: var(--iconActiveColor); + --bgc-active: var(--iconActiveBackground); + --focus-color: var(--icon-focus-color, var(--blue)); + + display: inline-flex; + flex-shrink: 0; + font-style: normal; + vertical-align: middle; + align-items: center; + justify-content: center; + text-decoration: none; + user-select: none; + box-sizing: content-box; // allow to use padding for outer spacing + -webkit-user-select: none; /* safari */ + -moz-user-select: none; /* firefox */ + + font-size: var(--size); + width: var(--size); + height: var(--size); + + &.smallest { + font-size: var(--smallest-size); + width: var(--smallest-size); + height: var(--smallest-size); + } + + &.small { + font-size: var(--small-size); + width: var(--small-size); + height: var(--small-size); + } + + &.big { + font-size: var(--big-size); + width: var(--big-size); + height: var(--big-size); + } + + // material-icon + &.material { + > .icon { + font-family: "Material Icons"; + font-size: inherit; + font-weight: inherit; + font-style: inherit; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + + /* Support for all WebKit browsers. */ + -webkit-font-smoothing: antialiased; + /* Support for Safari and Chrome. */ + text-rendering: optimizeLegibility; + /* Support for Firefox. */ + -moz-osx-font-smoothing: grayscale; + /* Support for IE. */ + font-feature-settings: 'liga'; + } + } + + // inline svg icon + &.svg { + box-sizing: content-box; + + > .icon { + width: 100%; + height: 100%; + } + + svg { + pointer-events: none; + width: 100%; + height: 100%; + + * { + fill: currentColor; + } + + line { + stroke: currentColor; + } + } + } + + &.disabled { + opacity: .5; + color: inherit !important; + cursor: not-allowed !important; + } + + &.sticker { + background: var(--colorOk); + color: var(--textColorAccent); + border-radius: 50%; + box-sizing: content-box; + padding: $padding * 0.5; + } + + &.active { + color: var(--color-active); + box-shadow: 0 0 0 2px var(--iconActiveBackground); + background-color: var(--iconActiveBackground); + } + + &.interactive { + cursor: pointer; + transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow; + border-radius: var(--border-radius); + + &.focusable:focus-visible { + box-shadow: 0 0 0 2px var(--focus-color); + } + + &:hover { + @extend .active; + } + } +} diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.test.tsx b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.test.tsx new file mode 100644 index 0000000000..f927caa7da --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.test.tsx @@ -0,0 +1,93 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import type { Logger } from "../../../common/logger"; +import loggerInjectable from "../../../common/logger.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import { Icon } from "./icon"; + +describe(" href technical tests", () => { + let render: DiRender; + let logger: jest.MockedObject; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + silly: jest.fn(), + warn: jest.fn(), + }; + + di.override(loggerInjectable, () => logger); + + render = renderFor(di); + }); + + it("should render an with http href", () => { + const result = render(( + + )); + + const icon = result.queryByTestId("my-icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("href", "http://localhost"); + expect(logger.warn).not.toBeCalled(); + }); + + it("should render an with https href", () => { + const result = render(( + + )); + + const icon = result.queryByTestId("my-icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("href", "https://localhost"); + expect(logger.warn).not.toBeCalled(); + }); + + it("should warn about ws hrefs", () => { + const result = render(( + + )); + + const icon = result.queryByTestId("my-icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).not.toHaveAttribute("href", "ws://localhost"); + expect(logger.warn).toBeCalled(); + }); + + it("should warn about javascript: hrefs", () => { + const result = render(( + + )); + + const icon = result.queryByTestId("my-icon"); + + expect(icon).toBeInTheDocument(); + expect(icon).not.toHaveAttribute("href", "javascript:void 0"); + expect(logger.warn).toBeCalled(); + }); +}); diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.tsx b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.tsx new file mode 100644 index 0000000000..0a3f8d3b5c --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/icon.tsx @@ -0,0 +1,283 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./icon.scss"; + +import type { ReactNode } from "react"; +import React, { createRef } from "react"; +import { NavLink } from "react-router-dom"; +import type { LocationDescriptor } from "history"; +import { cssNames } from "@k8slens/utilities"; +import isNumber from "lodash/isNumber"; +import Configuration from "./configuration.svg"; +import Crane from "./crane.svg"; +import Group from "./group.svg"; +import Helm from "./helm.svg"; +import Install from "./install.svg"; +import Kube from "./kube.svg"; +import LensLogo from "./lens-logo.svg"; +import License from "./license.svg"; +import LogoLens from "./logo-lens.svg"; +import Logout from "./logout.svg"; +import Nodes from "./nodes.svg"; +import PushOff from "./push_off.svg"; +import PushPin from "./push_pin.svg"; +import Spinner from "./spinner.svg"; +import Ssh from "./ssh.svg"; +import Storage from "./storage.svg"; +import Terminal from "./terminal.svg"; +import Notice from "./notice.svg"; +import User from "./user.svg"; +import Users from "./users.svg"; +import Wheel from "./wheel.svg"; +import Workloads from "./workloads.svg"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { withTooltip } from "../tooltip"; +import { Logger, loggerInjectionToken } from "../../injection-tokens"; + +const hrefValidation = /https?:\/\//; + +const hrefIsSafe = (href: string) => Boolean(href.match(hrefValidation)); + +/** + * Mapping between the local file names and the svgs + * + * Because we only really want a fixed list of bundled icons, this is safer so that consumers of + * `` cannot pass in a `../some/path`. + */ +const localSvgIcons = new Map([ + ["configuration", Configuration], + ["crane", Crane], + ["group", Group], + ["helm", Helm], + ["install", Install], + ["kube", Kube], + ["lens-logo", LensLogo], + ["license", License], + ["logo-lens", LogoLens], + ["logout", Logout], + ["nodes", Nodes], + ["push_off", PushOff], + ["push_pin", PushPin], + ["spinner", Spinner], + ["ssh", Ssh], + ["storage", Storage], + ["terminal", Terminal], + ["notice", Notice], + ["user", User], + ["users", Users], + ["wheel", Wheel], + ["workloads", Workloads], +]); + +export interface BaseIconProps { + /** + * One of the names from https://material.io/icons/ + */ + material?: string; + + /** + * Either an SVG XML or one of the following names + * - configuration + * - crane + * - group + * - helm + * - install + * - kube + * - lens-logo + * - license + * - logo-lens + * - logout + * - nodes + * - push_off + * - push_pin + * - spinner + * - ssh + * - storage + * - terminal + * - user + * - users + * - wheel + * - workloads + */ + svg?: string; + + /** + * render icon as NavLink from react-router-dom + */ + link?: LocationDescriptor; + + /** + * render icon as hyperlink + */ + href?: string; + + /** + * The icon size (css units) + */ + size?: string | number; + + /** + * A pre-defined icon-size + */ + small?: boolean; + + /** + * A pre-defined icon-size + */ + smallest?: boolean; + + /** + * A pre-defined icon-size + */ + big?: boolean; + + /** + * apply active-state styles + */ + active?: boolean; + + /** + * indicates that icon is interactive and highlight it on focus/hover + */ + interactive?: boolean; + + /** + * Allow focus to the icon to show `.active` styles. Only applicable if {@link IconProps.interactive} is `true`. + * + * @default true + */ + focusable?: boolean; + sticker?: boolean; + disabled?: boolean; + "data-testid"?: string; +} + +export interface IconProps extends React.HTMLAttributes, BaseIconProps {} + +export function isSvg(content: string): boolean { + // source code of the asset + return String(content).includes(" { + const ref = createRef(); + + const { + // skip passing props to icon's html element + className, href, link, material, svg, size, smallest, small, big, + disabled, sticker, active, + focusable = true, + children, + interactive, onClick, onKeyDown, + logger, + ...elemProps + } = props; + const isInteractive = interactive ?? !!(onClick || href || link); + + const boundOnClick = (event: React.MouseEvent) => { + if (!disabled) { + onClick?.(event); + } + }; + const boundOnKeyDown = (event: React.KeyboardEvent) => { + switch (event.nativeEvent.code) { + case "Space": + + // fallthrough + case "Enter": { + ref.current?.click(); + event.preventDefault(); + break; + } + } + + onKeyDown?.(event); + }; + + let iconContent: ReactNode; + const iconProps: Partial = { + className: cssNames("Icon", className, + { svg, material, interactive: isInteractive, disabled, sticker, active, focusable }, + !size ? { smallest, small, big } : {}, + ), + onClick: isInteractive ? boundOnClick : undefined, + onKeyDown: isInteractive ? boundOnKeyDown : undefined, + tabIndex: isInteractive && focusable && !disabled ? 0 : undefined, + style: size ? { "--size": size + (isNumber(size) ? "px" : "") } as React.CSSProperties : undefined, + ...elemProps, + }; + + // render as inline svg-icon + if (typeof svg === "string") { + const svgIconText = isSvg(svg) + ? svg + : localSvgIcons.get(svg) ?? ""; + + iconContent = ( + + ); + } + + // render as material-icon + if (typeof material === "string") { + iconContent = {material}; + } + + // wrap icon's content passed from decorator + iconProps.children = ( + <> + {iconContent} + {children} + + ); + + // render icon type + if (link) { + const { className, children } = iconProps; + + return ( + + {children} + + ); + } + + if (href) { + if (hrefIsSafe(href)) { + return ( + + ); + } + + logger.warn("[ICON]: href prop is unsafe, blocking", { href }); + } + + return ; +}; + +const InjectedIcon = withInjectables(RawIcon, { + getProps: (di, props) => ({ + ...props, + logger: di.inject(loggerInjectionToken), + }), +}); + +export const Icon = Object.assign(withTooltip(InjectedIcon), { isSvg }); diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/index.ts b/packages/technical-features/metrics/src/cluster-overview/components/icon/index.ts new file mode 100644 index 0000000000..7b7999604d --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./icon"; diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/install.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/install.svg new file mode 100644 index 0000000000..28c00c0b5d --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/install.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/kube.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/kube.svg new file mode 100644 index 0000000000..e0e7d1636b --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/kube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/lens-logo.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/lens-logo.svg new file mode 100644 index 0000000000..9b961a56c6 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/lens-logo.svg @@ -0,0 +1,10 @@ + + +Lens logo + + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/license.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/license.svg new file mode 100644 index 0000000000..a8e2ff5ad1 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/license.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/logo-lens.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/logo-lens.svg new file mode 100644 index 0000000000..0ac3453ad3 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/logo-lens.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/logout.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/logout.svg new file mode 100644 index 0000000000..abb046acb3 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/nodes.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/nodes.svg new file mode 100644 index 0000000000..86ee1ff08a --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/nodes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/notice.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/notice.svg new file mode 100644 index 0000000000..2774b185f6 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/notice.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/push_off.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/push_off.svg new file mode 100644 index 0000000000..07221c1cc2 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/push_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/push_pin.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/push_pin.svg new file mode 100644 index 0000000000..5435ce2d82 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/push_pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/spinner.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/spinner.svg new file mode 100644 index 0000000000..abea10570a --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/spinner.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/ssh.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/ssh.svg new file mode 100644 index 0000000000..ec6c65683d --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/ssh.svg @@ -0,0 +1,4 @@ + + ssh + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/storage.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/storage.svg new file mode 100644 index 0000000000..588325018c --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/storage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/terminal.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/terminal.svg new file mode 100644 index 0000000000..8a203f8157 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/terminal.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/user.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/user.svg new file mode 100644 index 0000000000..76db024102 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/users.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/users.svg new file mode 100644 index 0000000000..fc5d6e3e67 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/wheel.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/wheel.svg new file mode 100644 index 0000000000..b4a9068420 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/wheel.svg @@ -0,0 +1,46 @@ + + + + + + diff --git a/packages/technical-features/metrics/src/cluster-overview/components/icon/workloads.svg b/packages/technical-features/metrics/src/cluster-overview/components/icon/workloads.svg new file mode 100644 index 0000000000..5148bd6e95 --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/icon/workloads.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/packages/technical-features/metrics/src/cluster-overview/components/monaco-editor/__mocks__/monaco-editor.tsx b/packages/technical-features/metrics/src/cluster-overview/components/monaco-editor/__mocks__/monaco-editor.tsx new file mode 100644 index 0000000000..23524a476b --- /dev/null +++ b/packages/technical-features/metrics/src/cluster-overview/components/monaco-editor/__mocks__/monaco-editor.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { editor } from "monaco-editor"; +import React from "react"; +import type { MonacoEditorProps, MonacoEditorRef } from "../monaco-editor"; +import { monacoValidators } from "../monaco-validators"; + +class FakeMonacoEditor extends React.Component { + render() { + const { id, value, onChange, onError, language = "yaml" } = this.props; + + return ( +