diff --git a/.azure-pipelines-k8s-matrix.yml b/.azure-pipelines-k8s-matrix.yml deleted file mode 100644 index cdcc18b740..0000000000 --- a/.azure-pipelines-k8s-matrix.yml +++ /dev/null @@ -1,57 +0,0 @@ -variables: - YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn - node_version: 12.x -pr: - branches: - include: - - master - - releases/* - paths: - exclude: - - .github/* - - docs/* - - mkdocs/* -trigger: none -jobs: - - job: Linux - pool: - vmImage: ubuntu-18.04 - strategy: - matrix: - kube_1.16: - kubernetes_version: v1.16.15 - kube_1.17: - kubernetes_version: v1.17.15 - kube_1.18: - kubernetes_version: v1.18.13 - kube_1.19: - kubernetes_version: v1.19.5 - kube_1.20: - kubernetes_version: v1.20.0 - steps: - - task: NodeTool@0 - inputs: - versionSpec: $(node_version) - displayName: Install Node.js - - task: Cache@2 - inputs: - key: 'yarn | "$(Agent.OS)" | yarn.lock' - restoreKeys: | - yarn | "$(Agent.OS)" - path: $(YARN_CACHE_FOLDER) - displayName: Cache Yarn packages - - bash: | - sudo apt-get update - sudo apt-get install libgconf-2-4 conntrack -y - curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 - sudo install minikube-linux-amd64 /usr/local/bin/minikube - sudo minikube start --driver=none --kubernetes-version $(kubernetes_version) - sudo mv /root/.kube /root/.minikube $HOME - sudo chown -R $USER $HOME/.kube $HOME/.minikube - displayName: Install integration test dependencies - - script: make node_modules - displayName: Install dependencies - - script: make -j2 build - displayName: Run build - - script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' yarn integration - displayName: Run integration tests for Kubernetes $(kubernetes_version) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml deleted file mode 100644 index 523dc17033..0000000000 --- a/.azure-pipelines.yml +++ /dev/null @@ -1,168 +0,0 @@ -variables: - YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn -pr: none -trigger: - tags: - include: - - "*" - paths: - exclude: - - .github/* - - docs/* - - mkdocs/* -jobs: - - job: Windows - pool: - vmImage: windows-2019 - strategy: - matrix: - node: - node_version: 16.x - steps: - - powershell: | - $CI_BUILD_TAG = git describe --tags - Write-Output ("##vso[task.setvariable variable=CI_BUILD_TAG;]$CI_BUILD_TAG") - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - displayName: Set the tag name as an environment variable - - - task: NodeTool@0 - inputs: - versionSpec: $(node_version) - displayName: Install Node.js - - - task: Cache@2 - inputs: - key: 'yarn | "$(Agent.OS)"" | yarn.lock' - restoreKeys: | - yarn | "$(Agent.OS)" - path: $(YARN_CACHE_FOLDER) - displayName: Cache Yarn packages - - - bash: | - set -e - git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay - rm -rf .lens-ide-overlay/.git - cp -r .lens-ide-overlay/* ./ - jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json - env: - GH_TOKEN: $(LENS_IDE_GH_TOKEN) - displayName: Customize config - - - script: make build - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - env: - WIN_CSC_LINK: $(WIN_CSC_LINK) - WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD) - AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) - AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) - BUILD_NUMBER: $(Build.BuildNumber) - ELECTRON_BUILDER_EXTRA_ARGS: "--x64 --ia32" - displayName: Build - - - job: macOS - timeoutInMinutes: 90 - pool: - vmImage: macOS-11 - strategy: - matrix: - node: - node_version: 16.x - steps: - - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - displayName: Set the tag name as an environment variable - - - task: NodeTool@0 - inputs: - versionSpec: $(node_version) - displayName: Install Node.js - - - task: Cache@2 - inputs: - key: 'yarn | "$(Agent.OS)" | yarn.lock' - restoreKeys: | - yarn | "$(Agent.OS)" - path: $(YARN_CACHE_FOLDER) - displayName: Cache Yarn packages - - - bash: | - set -e - git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay - rm -rf .lens-ide-overlay/.git - cp -r .lens-ide-overlay/* ./ - jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json - env: - GH_TOKEN: $(LENS_IDE_GH_TOKEN) - displayName: Customize config - - - bash: | - set -e - - echo "Importing codesign certificate ..." - echo $CSC_LINK | base64 -D > certificate.p12 - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 21600 build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security import certificate.p12 -k build.keychain -P $CSC_KEY_PASSWORD -T /usr/bin/codesign -T /usr/bin/security -A - security set-key-partition-list -S apple-tool:,apple: -k $KEYCHAIN_PASSWORD build.keychain - - rm certificate.p12 - echo "Codesign certificate imported!" - - make build - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - env: - KEYCHAIN_PASSWORD: secretz - APPLEID: $(APPLEID) - APPLEIDPASS: $(APPLEIDPASS) - CSC_LINK: $(CSC_LINK) - CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD) - AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) - AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) - BUILD_NUMBER: $(Build.BuildNumber) - ELECTRON_BUILDER_EXTRA_ARGS: "--x64 --arm64" - displayName: Build - - - job: Linux - pool: - vmImage: ubuntu-18.04 - strategy: - matrix: - node: - node_version: 16.x - steps: - - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - displayName: Set the tag name as an environment variable - - - task: NodeTool@0 - inputs: - versionSpec: $(node_version) - displayName: Install Node.js - - - task: Cache@2 - inputs: - key: 'yarn | "$(Agent.OS)" | yarn.lock' - restoreKeys: | - yarn | "$(Agent.OS)" - path: $(YARN_CACHE_FOLDER) - displayName: Cache Yarn packages - - - bash: | - set -e - git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay - rm -rf .lens-ide-overlay/.git - cp -r .lens-ide-overlay/* ./ - jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json - env: - GH_TOKEN: $(LENS_IDE_GH_TOKEN) - displayName: Customize config - - - script: make build - condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" - env: - AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) - AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) - BUILD_NUMBER: $(Build.BuildNumber) - displayName: Build diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index b56b999b2b..15e72f0d80 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -113,13 +113,13 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { await frame.waitForSelector(".LogList .list span.active"); const showTimestampsButton = await frame.waitForSelector( - ".LogControls .show-timestamps", + "[data-testid='log-controls'] .show-timestamps", ); await showTimestampsButton.click(); const showPreviousButton = await frame.waitForSelector( - ".LogControls .show-previous", + "[data-testid='log-controls'] .show-previous", ); await showPreviousButton.click(); diff --git a/package.json b/package.json index 880ef6ab5c..20483b2a22 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,7 @@ "jsdom": "^16.7.0", "lodash": "^4.17.15", "mac-ca": "^1.0.6", - "marked": "^4.0.19", + "marked": "^4.1.0", "md5-file": "^5.0.0", "mobx": "^6.6.1", "mobx-observable-history": "^2.0.3", @@ -349,7 +349,7 @@ "@types/readable-stream": "^2.3.13", "@types/request": "^2.48.7", "@types/request-promise-native": "^1.0.18", - "@types/semver": "^7.3.10", + "@types/semver": "^7.3.12", "@types/sharp": "^0.30.5", "@types/spdy": "^3.4.5", "@types/tar": "^4.0.5", @@ -379,12 +379,12 @@ "electron": "^19.0.13", "electron-builder": "^23.3.3", "electron-notarize": "^0.3.0", - "esbuild": "^0.15.5", + "esbuild": "^0.15.6", "esbuild-loader": "^2.19.0", - "eslint": "^8.22.0", + "eslint": "^8.23.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-react": "^7.31.0", + "eslint-plugin-react": "7.30.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^2.0.0", "flex.box": "^3.4.4", @@ -405,11 +405,11 @@ "node-gyp": "^8.3.0", "node-loader": "^2.0.0", "nodemon": "^2.0.19", - "playwright": "^1.24.2", + "playwright": "^1.25.1", "postcss": "^8.4.16", "postcss-loader": "^6.2.1", "randomcolor": "^0.6.2", - "react-beautiful-dnd": "^13.1.0", + "react-beautiful-dnd": "^13.1.1", "react-refresh": "^0.14.0", "react-refresh-typescript": "^2.0.7", "react-router-dom": "^5.3.3", @@ -417,7 +417,7 @@ "react-select-event": "^5.5.1", "react-table": "^7.8.0", "react-window": "^1.8.7", - "sass": "^1.54.5", + "sass": "^1.54.7", "sass-loader": "^12.6.0", "sharp": "^0.30.7", "style-loader": "^3.3.1", @@ -427,13 +427,13 @@ "ts-node": "^10.9.1", "type-fest": "^2.14.0", "typed-emitter": "^1.4.0", - "typedoc": "0.23.10", + "typedoc": "0.23.11", "typedoc-plugin-markdown": "^3.13.1", "typescript": "^4.7.4", "typescript-plugin-css-modules": "^3.4.0", "webpack": "^5.74.0", "webpack-cli": "^4.9.2", - "webpack-dev-server": "^4.10.0", + "webpack-dev-server": "^4.10.1", "webpack-node-externals": "^3.0.0", "xterm": "^4.19.0", "xterm-addon-fit": "^0.5.0" diff --git a/src/behaviours/pod-logs/__snapshots__/download-logs.test.tsx.snap b/src/behaviours/pod-logs/__snapshots__/download-logs.test.tsx.snap new file mode 100644 index 0000000000..0dadfade96 --- /dev/null +++ b/src/behaviours/pod-logs/__snapshots__/download-logs.test.tsx.snap @@ -0,0 +1,865 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`download logs options in pod logs dock tab when opening pod logs renders 1`] = ` + +
+
+
+ ))}
diff --git a/src/renderer/components/+workloads-overview/overview-workload-status.tsx b/src/renderer/components/+workloads-overview/overview-workload-status.tsx index 52321d3af4..57422fe461 100644 --- a/src/renderer/components/+workloads-overview/overview-workload-status.tsx +++ b/src/renderer/components/+workloads-overview/overview-workload-status.tsx @@ -8,92 +8,101 @@ import "./overview-workload-status.scss"; import React from "react"; import capitalize from "lodash/capitalize"; import { observer } from "mobx-react"; -import type { DatasetTooltipLabel, PieChartData } from "../chart"; +import type { PieChartData } from "../chart"; import { PieChart } from "../chart"; -import { cssVar, object } from "../../utils"; -import type { ThemeStore } from "../../themes/store"; +import { object } from "../../utils"; +import type { LensTheme } from "../../themes/store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import themeStoreInjectable from "../../themes/store.injectable"; +import type { PascalCase } from "type-fest"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active.injectable"; +import type { Workload } from "./workloads/workload-injection-token"; + +export type LowercaseOrPascalCase = Lowercase | PascalCase; + +export type WorkloadStatus = Partial, number>>; + +function toLowercase(src: T): Lowercase { + return src.toLowerCase() as Lowercase; +} export interface OverviewWorkloadStatusProps { - status: Partial>; + workload: Workload; } interface Dependencies { - themeStore: ThemeStore; + activeTheme: IComputedValue; } -@observer -class NonInjectedOverviewWorkloadStatus extends React.Component { - private elem: HTMLElement | null = null; +const statusBackgroundColorMapping = { + "running": "colorOk", + "scheduled": "colorOk", + "pending": "colorWarning", + "suspended": "colorWarning", + "evicted": "colorError", + "succeeded": "colorSuccess", + "failed": "colorError", + "terminated": "colorTerminated", + "terminating": "colorTerminated", + "unknown": "colorVague", + "complete": "colorSuccess", +} as const; - renderChart() { - if (!this.elem) { - return null; - } +const NonInjectedOverviewWorkloadStatus = observer((props: OverviewWorkloadStatusProps & Dependencies) => { + const { + workload, + activeTheme, + } = props; - const cssVars = cssVar(this.elem); - const chartData: Required = { - labels: [], - datasets: [], - }; + const statusesToBeShown = object.entries(workload.status.get()).filter(([, val]) => val > 0); + const theme = activeTheme.get(); - const statuses = object.entries(this.props.status).filter(([, val]) => val > 0); + const emptyDataSet = { + data: [1], + backgroundColor: [theme.colors.pieChartDefaultColor], + label: "Empty", + }; + const statusDataSet = { + label: "Status", + data: statusesToBeShown.map(([, value]) => value), + backgroundColor: statusesToBeShown.map(([status]) => ( + theme.colors[statusBackgroundColorMapping[toLowercase(status)]] + )), + tooltipLabels: statusesToBeShown.map(([status]) => ( + (percent: string) => `${capitalize(status)}: ${percent}` + )), + }; - if (statuses.length === 0) { - chartData.datasets.push({ - data: [1], - backgroundColor: [this.props.themeStore.activeTheme.colors.pieChartDefaultColor], - label: "Empty", - }); - } else { - const data: number[] = []; - const backgroundColor: string[] = []; - const tooltipLabels: DatasetTooltipLabel[] = []; + const chartData: Required = { + datasets: [statusesToBeShown.length > 0 ? statusDataSet : emptyDataSet], - for (const [status, value] of statuses) { - data.push(value); - backgroundColor.push(cssVars.get(`--workload-status-${status.toLowerCase()}`).toString()); - tooltipLabels.push(percent => `${capitalize(status)}: ${percent}`); - chartData.labels.push(`${capitalize(status)}: ${value}`); - } + labels: statusesToBeShown.map( + ([status, value]) => `${capitalize(status)}: ${value}`, + ), + }; - chartData.datasets.push({ - data, - backgroundColor, - label: "Status", - tooltipLabels, - }); - } - - return ( - +
+ - ); - } - - render() { - return ( -
this.elem = e}> -
- {this.renderChart()} -
+ }} + data-testid={`workload-overview-status-chart-${workload.title.toLowerCase().replace(/\s+/, "-")}`} + />
- ); - } -} +
+ ); +}); export const OverviewWorkloadStatus = withInjectables(NonInjectedOverviewWorkloadStatus, { getProps: (di, props) => ({ ...props, - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), }), }); diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index 0510c96bc9..83a2bf9e84 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -104,7 +104,7 @@ class NonInjectedWorkloadsOverview extends React.Component { render() { return ( -
+
Overview
{this.renderLoadErrors()} diff --git a/src/renderer/components/+workloads-overview/workloads/workload-injection-token.ts b/src/renderer/components/+workloads-overview/workloads/workload-injection-token.ts index 6453efe59b..decb5a1f7c 100644 --- a/src/renderer/components/+workloads-overview/workloads/workload-injection-token.ts +++ b/src/renderer/components/+workloads-overview/workloads/workload-injection-token.ts @@ -4,12 +4,13 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; import type { IComputedValue } from "mobx"; +import type { WorkloadStatus } from "../overview-workload-status"; export interface Workload { resourceName: string; open: () => void; amountOfItems: IComputedValue; - status: IComputedValue>>; + status: IComputedValue; title: string; orderNumber: number; } diff --git a/src/renderer/components/+workloads-pods/container-charts.tsx b/src/renderer/components/+workloads-pods/container-charts.tsx index e9d39ebbdf..718f05420c 100644 --- a/src/renderer/components/+workloads-pods/container-charts.tsx +++ b/src/renderer/components/+workloads-pods/container-charts.tsx @@ -10,27 +10,28 @@ import { BarChart } from "../chart"; import { isMetricsEmpty, normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api"; import { NoMetrics } from "../resource-metrics/no-metrics"; import { ResourceMetricsContext } from "../resource-metrics"; -import type { ThemeStore } from "../../themes/store"; +import type { LensTheme } from "../../themes/store"; import { mapValues } from "lodash"; import { type MetricsTab, metricTabOptions } from "../chart/options"; import { withInjectables } from "@ogre-tools/injectable-react"; -import themeStoreInjectable from "../../themes/store.injectable"; +import activeThemeInjectable from "../../themes/active.injectable"; +import type { IComputedValue } from "mobx"; export interface ContainerChartsProps {} interface Dependencies { - themeStore: ThemeStore; + activeTheme: IComputedValue; } const NonInjectedContainerCharts = observer(({ - themeStore, + activeTheme, }: Dependencies & ContainerChartsProps) => { const { metrics, tab, object } = useContext(ResourceMetricsContext) ?? {}; if (!metrics || !object || !tab) return null; if (isMetricsEmpty(metrics)) return ; - const { chartCapacityColor } = themeStore.activeTheme.colors; + const { chartCapacityColor } = activeTheme.get().colors; const { cpuUsage, cpuRequests, @@ -127,6 +128,6 @@ const NonInjectedContainerCharts = observer(({ export const ContainerCharts = withInjectables(NonInjectedContainerCharts, { getProps: (di, props) => ({ ...props, - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), }), }); diff --git a/src/renderer/components/button/button.tsx b/src/renderer/components/button/button.tsx index 756f50a584..8168c2abf3 100644 --- a/src/renderer/components/button/button.tsx +++ b/src/renderer/components/button/button.tsx @@ -49,7 +49,11 @@ export const Button = withTooltip((props: ButtonProps) => { // render as button return ( - diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index b80a539f6f..b159520309 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -13,11 +13,12 @@ import type { ChartProps } from "./chart"; import { Chart, ChartKind } from "./chart"; import { bytesToUnits, cssNames, isObject } from "../../utils"; import { ZebraStripesPlugin } from "./zebra-stripes.plugin"; -import type { ThemeStore } from "../../themes/store"; +import type { LensTheme } from "../../themes/store"; import { NoMetrics } from "../resource-metrics/no-metrics"; import assert from "assert"; import { withInjectables } from "@ogre-tools/injectable-react"; -import themeStoreInjectable from "../../themes/store.injectable"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active.injectable"; export interface BarChartProps extends ChartProps { name?: string; @@ -27,11 +28,11 @@ export interface BarChartProps extends ChartProps { const getBarColor: Scriptable = ({ dataset }) => Color(dataset?.borderColor).alpha(0.2).string(); interface Dependencies { - themeStore: ThemeStore; + activeTheme: IComputedValue; } const NonInjectedBarChart = observer(({ - themeStore, + activeTheme, name, data, className, @@ -40,7 +41,7 @@ const NonInjectedBarChart = observer(({ options: customOptions, ...settings }: Dependencies & BarChartProps) => { - const { textColorPrimary, borderFaintColor, chartStripesColor } = themeStore.activeTheme.colors; + const { textColorPrimary, borderFaintColor, chartStripesColor } = activeTheme.get().colors; const { datasets: rawDatasets = [], ...rest } = data; const datasets = rawDatasets .filter(set => set.data?.length) @@ -168,7 +169,7 @@ const NonInjectedBarChart = observer(({ export const BarChart = withInjectables(NonInjectedBarChart, { getProps: (di, props) => ({ ...props, - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), }), }); diff --git a/src/renderer/components/chart/chart.tsx b/src/renderer/components/chart/chart.tsx index 3d00c71aed..3d54b90302 100644 --- a/src/renderer/components/chart/chart.tsx +++ b/src/renderer/components/chart/chart.tsx @@ -36,6 +36,7 @@ export interface ChartProps { redraw?: boolean; // If true - recreate chart instance with no animation title?: string; className?: string; + "data-testid"?: string; } export enum ChartKind { @@ -212,25 +213,26 @@ export class Chart extends React.Component { } render() { - const { width, height, showChart, title, className } = this.props; + const { width, height, showChart, title, className, "data-testid": dataTestId } = this.props; return ( - <> -
- {title &&
{title}
} - {showChart && ( -
- -
-
- )} - {this.renderLegend()} -
- +
+ {title &&
{title}
} + {showChart && ( +
+ +
+
+ )} + {this.renderLegend()} +
); } } diff --git a/src/renderer/components/chart/pie-chart.tsx b/src/renderer/components/chart/pie-chart.tsx index 2df1fd05f4..2e4cd1bb0f 100644 --- a/src/renderer/components/chart/pie-chart.tsx +++ b/src/renderer/components/chart/pie-chart.tsx @@ -11,9 +11,10 @@ import ChartJS from "chart.js"; import type { ChartProps } from "./chart"; import { Chart } from "./chart"; import { cssNames } from "../../utils"; -import type { ThemeStore } from "../../themes/store"; +import type { LensTheme } from "../../themes/store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import themeStoreInjectable from "../../themes/store.injectable"; +import type { IComputedValue } from "mobx"; +import activeThemeInjectable from "../../themes/active.injectable"; export interface PieChartProps extends ChartProps { } @@ -44,18 +45,18 @@ function getCutout(length: number | undefined): number { } interface Dependencies { - themeStore: ThemeStore; + activeTheme: IComputedValue; } const NonInjectedPieChart = observer(({ - themeStore, + activeTheme, data, className, options, showChart, ...chartProps }: Dependencies & PieChartProps) => { - const { contentColor } = themeStore.activeTheme.colors; + const { contentColor } = activeTheme.get().colors; const opts: ChartOptions = { maintainAspectRatio: false, tooltips: { @@ -68,18 +69,11 @@ const NonInjectedPieChart = observer(({ 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 tooltipLabel = dataset.tooltipLabels?.[tooltipItem.index]; - let tooltip = `${dataset.label}: ${percentLabel}`; + const tooltipLabelCustomizer = dataset.tooltipLabels?.[tooltipItem.index]; - if (tooltipLabel) { - if (typeof tooltipLabel === "function") { - tooltip = tooltipLabel(percentLabel); - } else { - tooltip = tooltipLabel; - } - } - - return tooltip; + return tooltipLabelCustomizer + ? tooltipLabelCustomizer(percentLabel) + : `${dataset.label}: ${percentLabel}`; }, }, filter: ({ datasetIndex, index }, { datasets = [] }) => { @@ -120,7 +114,7 @@ const NonInjectedPieChart = observer(({ export const PieChart = withInjectables(NonInjectedPieChart, { getProps: (di, props) => ({ ...props, - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), }), }); diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx index 744be3fae9..66874bfd9f 100644 --- a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -57,6 +57,8 @@ function mockLogTabViewModel(tabId: TabId, deps: Partial void; -} - -const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependencies & LogControlsProps) => { +export const LogControls = observer(({ model }: LogControlsProps) => { const tabData = model.logTabData.get(); const pod = model.pod.get(); @@ -44,18 +37,9 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen model.reloadLogs(); }; - const downloadLogs = () => { - const fileName = pod.getName(); - const logsToDownload: string[] = showTimestamps - ? model.logs.get() - : model.logsWithoutTimestamps.get(); - - openSaveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); - }; - return ( -
-
+
+
{since && ( Logs from @@ -77,20 +61,13 @@ const NonInjectedLogControls = observer(({ openSaveFileDialog, model }: Dependen onChange={togglePrevious} className="show-previous" /> -
); }); -export const LogControls = withInjectables(NonInjectedLogControls, { - getProps: (di, props) => ({ - openSaveFileDialog: di.inject(openSaveFileDialogInjectable), - ...props, - }), -}); diff --git a/src/renderer/components/dock/logs/create-logs-tab.injectable.ts b/src/renderer/components/dock/logs/create-logs-tab.injectable.ts index 47cef1dd95..44e3e8a0ec 100644 --- a/src/renderer/components/dock/logs/create-logs-tab.injectable.ts +++ b/src/renderer/components/dock/logs/create-logs-tab.injectable.ts @@ -6,20 +6,21 @@ import { getInjectable } from "@ogre-tools/injectable"; import type { DockTabCreate, DockTab, TabId } from "../dock/store"; import { TabKind } from "../dock/store"; import type { LogTabData } from "./tab-store"; -import * as uuid from "uuid"; import { runInAction } from "mobx"; import createDockTabInjectable from "../dock/create-dock-tab.injectable"; import setLogTabDataInjectable from "./set-log-tab-data.injectable"; +import getRandomIdForPodLogsTabInjectable from "./get-random-id-for-pod-logs-tab.injectable"; export type CreateLogsTabData = Pick & Omit, "owner" | "selectedPodId" | "selectedContainer" | "namespace">; interface Dependencies { createDockTab: (rawTabDesc: DockTabCreate, addNumber?: boolean) => DockTab; setLogTabData: (tabId: string, data: LogTabData) => void; + getRandomId: () => string; } -const createLogsTab = ({ createDockTab, setLogTabData }: Dependencies) => (title: string, data: CreateLogsTabData): TabId => { - const id = `log-tab-${uuid.v4()}`; +const createLogsTab = ({ createDockTab, setLogTabData, getRandomId }: Dependencies) => (title: string, data: CreateLogsTabData): TabId => { + const id = `log-tab-${getRandomId()}`; runInAction(() => { createDockTab({ @@ -43,6 +44,7 @@ const createLogsTabInjectable = getInjectable({ instantiate: (di) => createLogsTab({ createDockTab: di.inject(createDockTabInjectable), setLogTabData: di.inject(setLogTabDataInjectable), + getRandomId: di.inject(getRandomIdForPodLogsTabInjectable), }), }); diff --git a/src/renderer/components/dock/logs/download-all-logs.injectable.ts b/src/renderer/components/dock/logs/download-all-logs.injectable.ts new file mode 100644 index 0000000000..abe87cc352 --- /dev/null +++ b/src/renderer/components/dock/logs/download-all-logs.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 type { PodLogsQuery } from "../../../../common/k8s-api/endpoints"; +import type { ResourceDescriptor } from "../../../../common/k8s-api/kube-api"; +import loggerInjectable from "../../../../common/logger.injectable"; +import openSaveFileDialogInjectable from "../../../utils/save-file.injectable"; +import callForLogsInjectable from "./call-for-logs.injectable"; + +const downloadAllLogsInjectable = getInjectable({ + id: "download-all-logs", + + instantiate: (di) => { + const callForLogs = di.inject(callForLogsInjectable); + const openSaveFileDialog = di.inject(openSaveFileDialogInjectable); + const logger = di.inject(loggerInjectable); + + return async (params: ResourceDescriptor, query: PodLogsQuery) => { + const logs = await callForLogs(params, query).catch(error => { + logger.error("Can't download logs: ", error); + }); + + if (logs) { + openSaveFileDialog(`${params.name}.log`, logs, "text/plain"); + } + }; + }, +}); + +export default downloadAllLogsInjectable; diff --git a/src/renderer/components/dock/logs/download-logs-dropdown.module.scss b/src/renderer/components/dock/logs/download-logs-dropdown.module.scss new file mode 100644 index 0000000000..8c93ff6559 --- /dev/null +++ b/src/renderer/components/dock/logs/download-logs-dropdown.module.scss @@ -0,0 +1,37 @@ +.dropdown { + --accent-color: var(--colorInfo); + + border: 1px solid var(--accent-color); + border-radius: 4px; + color: var(--accent-color); + display: flex; + align-items: center; + padding: calc(var(--padding) / 4) var(--padding); + gap: 6px; + position: relative; + + &:disabled { + cursor: progress; + opacity: .7; + } + + &:hover::before{ + opacity: 0.25; + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--accent-color); + border-color: transparent; + } + + &::before { + content: " "; + position: absolute; + background: var(--accent-color); + width: 100%; + height: 100%; + left: 0; + opacity: 0; + transition: opacity 0.1s; + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/logs/download-logs-dropdown.tsx b/src/renderer/components/dock/logs/download-logs-dropdown.tsx new file mode 100644 index 0000000000..1da7b9f8a6 --- /dev/null +++ b/src/renderer/components/dock/logs/download-logs-dropdown.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./download-logs-dropdown.module.scss"; + +import React, { useState } from "react"; +import { Icon } from "../../icon"; +import { MenuItem } from "../../menu"; +import { Dropdown } from "../../dropdown/dropdown"; + +interface DownloadLogsDropdownProps { + downloadVisibleLogs: () => void; + downloadAllLogs: () => Promise | undefined; +} + +export function DownloadLogsDropdown({ downloadAllLogs, downloadVisibleLogs }: DownloadLogsDropdownProps) { + const [waiting, setWaiting] = useState(false); + + const downloadAll = async () => { + setWaiting(true); + + try { + await downloadAllLogs(); + } finally { + setWaiting(false); + } + }; + + return ( + + Download + + + )} + > + + Visible logs + + + All logs + + + ); +} diff --git a/src/renderer/components/dock/logs/download-logs.injectable.ts b/src/renderer/components/dock/logs/download-logs.injectable.ts new file mode 100644 index 0000000000..c4792a9b36 --- /dev/null +++ b/src/renderer/components/dock/logs/download-logs.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 openSaveFileDialogInjectable from "../../../utils/save-file.injectable"; + +const downloadLogsInjectable = getInjectable({ + id: "download-logs", + + instantiate: (di) => { + const openSaveFileDialog = di.inject(openSaveFileDialogInjectable); + + return (filename: string, logs: string[]) => { + openSaveFileDialog(filename, logs.join("\n"), "text/plain"); + }; + }, +}); + +export default downloadLogsInjectable; diff --git a/src/renderer/components/dock/logs/get-random-id-for-pod-logs-tab.injectable.ts b/src/renderer/components/dock/logs/get-random-id-for-pod-logs-tab.injectable.ts new file mode 100644 index 0000000000..1dcadcf293 --- /dev/null +++ b/src/renderer/components/dock/logs/get-random-id-for-pod-logs-tab.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 getRandomIdInjectable from "../../../../common/utils/get-random-id.injectable"; + +const getRandomIdForPodLogsTabInjectable = getInjectable({ + id: "get-random-id-for-pod-logs-tab", + instantiate: (di) => di.inject(getRandomIdInjectable), +}); + +export default getRandomIdForPodLogsTabInjectable; diff --git a/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model.injectable.ts index a9c027acf9..77e4ca983c 100644 --- a/src/renderer/components/dock/logs/logs-view-model.injectable.ts +++ b/src/renderer/components/dock/logs/logs-view-model.injectable.ts @@ -18,6 +18,8 @@ import areLogsPresentInjectable from "./are-logs-present.injectable"; import searchStoreInjectable from "../../../search-store/search-store.injectable"; import getPodsByOwnerIdInjectable from "../../+workloads-pods/get-pods-by-owner-id.injectable"; import getPodByIdInjectable from "../../+workloads-pods/get-pod-by-id.injectable"; +import downloadLogsInjectable from "./download-logs.injectable"; +import downloadAllLogsInjectable from "./download-all-logs.injectable"; export interface InstantiateArgs { tabId: TabId; @@ -39,6 +41,8 @@ const logsViewModelInjectable = getInjectable({ areLogsPresent: di.inject(areLogsPresentInjectable), getPodById: di.inject(getPodByIdInjectable), getPodsByOwnerId: di.inject(getPodsByOwnerIdInjectable), + downloadLogs: di.inject(downloadLogsInjectable), + downloadAllLogs: di.inject(downloadAllLogsInjectable), searchStore: di.inject(searchStoreInjectable), }), lifecycle: lifecycleEnum.transient, diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts index 021eb43a7a..85b8502df3 100644 --- a/src/renderer/components/dock/logs/logs-view-model.ts +++ b/src/renderer/components/dock/logs/logs-view-model.ts @@ -7,12 +7,13 @@ import type { IComputedValue } from "mobx"; import { computed } from "mobx"; import type { TabId } from "../dock/store"; import type { SearchStore } from "../../../search-store/search-store"; -import type { Pod } from "../../../../common/k8s-api/endpoints"; +import type { Pod, PodLogsQuery } from "../../../../common/k8s-api/endpoints"; import { isDefined } from "../../../utils"; import assert from "assert"; import type { GetPodById } from "../../+workloads-pods/get-pod-by-id.injectable"; import type { GetPodsByOwnerId } from "../../+workloads-pods/get-pods-by-owner-id.injectable"; import type { LoadLogs } from "./load-logs.injectable"; +import type { ResourceDescriptor } from "../../../../common/k8s-api/kube-api"; export interface LogTabViewModelDependencies { getLogs: (tabId: TabId) => string[]; @@ -27,6 +28,8 @@ export interface LogTabViewModelDependencies { getPodById: GetPodById; getPodsByOwnerId: GetPodsByOwnerId; areLogsPresent: (tabId: TabId) => boolean; + downloadLogs: (filename: string, logs: string[]) => void; + downloadAllLogs: (params: ResourceDescriptor, query: PodLogsQuery) => Promise; searchStore: SearchStore; } @@ -77,4 +80,32 @@ export class LogTabViewModel { reloadLogs = () => this.dependencies.reloadLogs(this.tabId, this.pod, this.logTabData); renameTab = (title: string) => this.dependencies.renameTab(this.tabId, title); stopLoadingLogs = () => this.dependencies.stopLoadingLogs(this.tabId); + + downloadLogs = () => { + const pod = this.pod.get(); + const tabData = this.logTabData.get(); + + if (pod && tabData) { + const fileName = pod.getName(); + const logsToDownload: string[] = tabData.showTimestamps + ? this.logs.get() + : this.logsWithoutTimestamps.get(); + + this.dependencies.downloadLogs(`${fileName}.log`, logsToDownload); + } + }; + + downloadAllLogs = () => { + const pod = this.pod.get(); + const tabData = this.logTabData.get(); + + if (pod && tabData) { + const params = { name: pod.getName(), namespace: pod.getNs() }; + const query = { timestamps: tabData.showTimestamps, previous: tabData.showPrevious }; + + return this.dependencies.downloadAllLogs(params, query); + } + + return; + }; } diff --git a/src/renderer/components/dock/terminal/view.tsx b/src/renderer/components/dock/terminal/view.tsx index 01313a661a..6fa9e157c8 100644 --- a/src/renderer/components/dock/terminal/view.tsx +++ b/src/renderer/components/dock/terminal/view.tsx @@ -10,13 +10,14 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { cssNames } from "../../../utils"; import type { Terminal } from "./terminal"; import type { TerminalStore } from "./store"; -import type { ThemeStore } from "../../../themes/store"; +import type { LensTheme } from "../../../themes/store"; import type { DockTab, DockStore } from "../dock/store"; import { withInjectables } from "@ogre-tools/injectable-react"; import dockStoreInjectable from "../dock/store.injectable"; import terminalStoreInjectable from "./store.injectable"; import assert from "assert"; -import themeStoreInjectable from "../../../themes/store.injectable"; +import activeThemeInjectable from "../../../themes/active.injectable"; +import type { IComputedValue } from "mobx"; export interface TerminalWindowProps { tab: DockTab; @@ -25,7 +26,7 @@ export interface TerminalWindowProps { interface Dependencies { dockStore: DockStore; terminalStore: TerminalStore; - themeStore: ThemeStore; + activeTheme: IComputedValue; } @observer @@ -68,7 +69,7 @@ class NonInjectedTerminalWindow extends React.Component this.elem = elem} /> ); @@ -80,7 +81,7 @@ export const TerminalWindow = withInjectables ...props, dockStore: di.inject(dockStoreInjectable), terminalStore: di.inject(terminalStoreInjectable), - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), }), }); diff --git a/src/renderer/components/dropdown/dropdown.tsx b/src/renderer/components/dropdown/dropdown.tsx new file mode 100644 index 0000000000..40efd877c6 --- /dev/null +++ b/src/renderer/components/dropdown/dropdown.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { HTMLAttributes } from "react"; +import React, { useState } from "react"; +import { Menu } from "../menu"; + +interface DropdownProps extends HTMLAttributes { + contentForToggle: React.ReactNode; +} + +export function Dropdown(props: DropdownProps) { + const { id, contentForToggle, children, ...rest } = props; + const [opened, setOpened] = useState(false); + + const toggle = () => { + setOpened(!opened); + }; + + return ( +
+
+ {contentForToggle} +
+ + {React.Children.toArray(children)} + +
+ ); +} diff --git a/src/renderer/components/item-object-list/content.tsx b/src/renderer/components/item-object-list/content.tsx index 8cff8f21bc..07756760a9 100644 --- a/src/renderer/components/item-object-list/content.tsx +++ b/src/renderer/components/item-object-list/content.tsx @@ -7,6 +7,7 @@ import "./item-list-layout.scss"; import type { ReactNode } from "react"; import React from "react"; +import type { IComputedValue } from "mobx"; import { computed, makeObservable } from "mobx"; import { Observer, observer } from "mobx-react"; import type { ConfirmDialogParams } from "../confirm-dialog"; @@ -20,18 +21,18 @@ import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; import type { ItemObject } from "../../../common/item.store"; import type { Filter, PageFiltersStore } from "./page-filters/store"; -import type { ThemeStore } from "../../themes/store"; +import type { LensTheme } from "../../themes/store"; import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import type { UserStore } from "../../../common/user-store"; import type { ItemListStore } from "./list-layout"; import { withInjectables } from "@ogre-tools/injectable-react"; -import themeStoreInjectable from "../../themes/store.injectable"; import userStoreInjectable from "../../../common/user-store/user-store.injectable"; import pageFiltersStoreInjectable from "./page-filters/store.injectable"; import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; +import activeThemeInjectable from "../../themes/active.injectable"; export interface ItemListLayoutContentProps { getFilters: () => Filter[]; @@ -71,7 +72,7 @@ export interface ItemListLayoutContentProps; userStore: UserStore; pageFiltersStore: PageFiltersStore; openConfirmDialog: OpenConfirmDialog; @@ -291,10 +292,10 @@ class NonInjectedItemListLayoutContent< render() { const { store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, - detailsItem, className, tableProps = {}, tableId, getItems, themeStore, + detailsItem, className, tableProps = {}, tableId, getItems, activeTheme, } = this.props; const selectedItemId = detailsItem && detailsItem.getId(); - const classNames = cssNames(className, "box", "grow", themeStore.activeTheme.type); + const classNames = cssNames(className, "box", "grow", activeTheme.get().type); const items = getItems(); const selectedItems = store.pickOnlySelected(items); @@ -377,7 +378,7 @@ class NonInjectedItemListLayoutContent< export const ItemListLayoutContent = withInjectables>(NonInjectedItemListLayoutContent, { getProps: (di, props) => ({ ...props, - themeStore: di.inject(themeStoreInjectable), + activeTheme: di.inject(activeThemeInjectable), userStore: di.inject(userStoreInjectable), pageFiltersStore: di.inject(pageFiltersStoreInjectable), openConfirmDialog: di.inject(openConfirmDialogInjectable), diff --git a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap index 372c6482eb..d4714c2b3c 100644 --- a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap +++ b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap @@ -126,6 +126,7 @@ exports[`kube-object-menu given kube object when removing kube object renders 1`