diff --git a/src/components/app-overview/index.tsx b/src/components/app-overview/index.tsx index 151254b7d..e045612e3 100644 --- a/src/components/app-overview/index.tsx +++ b/src/components/app-overview/index.tsx @@ -13,6 +13,7 @@ import { DefaultAppAlias } from '../component/default-app-alias'; import { DNSAliases } from '../component/dns-aliases'; import { EnvironmentsSummary } from '../environments-summary'; import { JobsList } from '../jobs-list'; +import { UsedResources } from '../resources'; const LATEST_JOBS_LIMIT = 5; @@ -50,6 +51,7 @@ export function AppOverview({ appName }: { appName: string }) {
+
{appAlias && } diff --git a/src/components/replica-list/replica-name.tsx b/src/components/replica-list/replica-name.tsx index f07d1884a..5d9c575ce 100644 --- a/src/components/replica-list/replica-name.tsx +++ b/src/components/replica-list/replica-name.tsx @@ -68,7 +68,7 @@ export const ReplicaName: FunctionComponent<{ displayName={'Job Manager'} replicaName={replica.name} description={ - 'Job Manager creates, gets, deletes singe jobs and batch jobs with Job API' + 'Job Manager creates, gets, deletes single jobs and batch jobs with Job API' } replicaUrlFunc={replicaUrlFunc} /> diff --git a/src/components/resources/index.tsx b/src/components/resources/index.tsx new file mode 100644 index 000000000..d82ab9ef7 --- /dev/null +++ b/src/components/resources/index.tsx @@ -0,0 +1,165 @@ +import { Icon, Tooltip, Typography } from '@equinor/eds-core-react'; +import { library_books } from '@equinor/eds-icons'; +import * as PropTypes from 'prop-types'; +import type { FunctionComponent } from 'react'; +import { externalUrls } from '../../externalUrls'; +import { + type GetResourcesApiResponse, + useGetResourcesQuery, +} from '../../store/radix-api'; +import { formatDateTimeYear } from '../../utils/datetime'; +import AsyncResource from '../async-resource/async-resource'; + +import './style.css'; + +function getPeriod({ from, to }: GetResourcesApiResponse): string { + return `${formatDateTimeYear(new Date(from))} - ${formatDateTimeYear( + new Date(to) + )}`; +} + +export interface UsedResourcesProps { + appName: string; +} + +export const UsedResources: FunctionComponent = ({ + appName, +}) => { + const { data: resources, ...state } = useGetResourcesQuery( + { appName }, + { skip: !appName } + ); + const formatCpuUsage = (value?: number): string => { + if (!value) { + return '-'; + } + if (value >= 1) { + return parseFloat(value.toPrecision(3)).toString(); + } + + const millicores = value * 1000.0; + let formattedValue: string; + if (millicores >= 1.0) { + formattedValue = parseFloat(millicores.toPrecision(3)).toString(); + return `${formattedValue}m`; + } + let mcStr = millicores.toFixed(20); // Use 20 decimal places to ensure precision + mcStr = mcStr.replace(/0+$/, ''); + // Find the position of the decimal point + const decimalIndex = mcStr.indexOf('.'); + // Find the index of the first non-zero digit after the decimal point + let firstNonZeroIndex = -1; + for (let i = decimalIndex + 1; i < mcStr.length; i++) { + if (mcStr[i] !== '0') { + firstNonZeroIndex = i; + break; + } + } + if (firstNonZeroIndex === -1) { + return '0m'; + } + // Create a new number where the digit at firstNonZeroIndex becomes the first decimal digit + const digits = `0.${mcStr.substring(firstNonZeroIndex)}`; + let num = parseFloat(digits); + // Round the number to one digit + num = Math.round(num * 10) / 10; + // Handle rounding that results in num >= 1 + if (num >= 1) { + num = 1; + } + let numStr = num.toString(); + // Remove the decimal point and any following zeros + numStr = numStr.replace('0.', '').replace(/0+$/, ''); + // Replace the part of mcStr starting from firstNonZeroIndex - 1 + let zerosCount = firstNonZeroIndex - decimalIndex - 1; + // Adjust zerosCount, when num is 1 + if (num === 1) { + zerosCount -= 1; + } + const leadingDigitalZeros = '0'.repeat(Math.max(zerosCount, 0)); + const output = `0.${leadingDigitalZeros}${numStr}`; + return `${output}m`; + }; + + const formatMemoryUsage = (value?: number): string => { + if (!value) { + return '-'; + } + const units = [ + { unit: 'P', size: 1e15 }, + { unit: 'T', size: 1e12 }, + { unit: 'G', size: 1e9 }, + { unit: 'M', size: 1e6 }, + { unit: 'k', size: 1e3 }, + ]; + + let unit = ''; // Default to bytes + + // Determine the appropriate unit + for (const u of units) { + if (value >= u.size) { + value = value / u.size; + unit = u.unit; + break; + } + } + const formattedValue = parseFloat(value.toPrecision(3)).toString(); + return formattedValue + unit; + }; + + return ( +
+
+ Used resources + + + + + +
+ + {resources ? ( +
+
+ Period + + {getPeriod(resources)} + +
+ +
+
+ + CPU{' '} + + min {formatCpuUsage(resources?.cpu?.min)}, avg{' '} + {formatCpuUsage(resources?.cpu?.avg)}, max{' '} + {formatCpuUsage(resources?.cpu?.max)} + + + + Memory{' '} + + min {formatMemoryUsage(resources?.memory?.min)}, avg{' '} + {formatMemoryUsage(resources?.memory?.avg)}, max{' '} + {formatMemoryUsage(resources?.memory?.max)} + + +
+
+
+ ) : ( + No data + )} +
+
+ ); +}; + +UsedResources.propTypes = { + appName: PropTypes.string.isRequired, +}; diff --git a/src/components/resources/style.css b/src/components/resources/style.css new file mode 100644 index 000000000..55a8a74a4 --- /dev/null +++ b/src/components/resources/style.css @@ -0,0 +1,26 @@ +.resources-section > div { + grid-auto-rows: min-content; +} + +@media (min-width: 30rem) { + .resources-section { + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + } +} + +.icon-justify-end { + justify-self: end; +} + +.resources__scrim .resources__scrim-content { + margin: var(--eds_spacing_medium); + margin-top: initial; + align-content: center; + min-width: 500px; +} + +.resources-content { + padding: var(--eds_spacing_medium); + padding-top: 0; + overflow: auto; +} diff --git a/src/externalUrls.ts b/src/externalUrls.ts index d887540b9..ab910cdcf 100644 --- a/src/externalUrls.ts +++ b/src/externalUrls.ts @@ -15,6 +15,9 @@ export const externalUrls = { externalDNSGuide: 'https://www.radix.equinor.com/guides/external-alias/', workloadIdentityGuide: 'https://www.radix.equinor.com/guides/workload-identity/', uptimeDocs: 'https://radix.equinor.com/docs/topic-uptime/', + resourcesDocs: 'https://radix.equinor.com/guides/resource-request/', + kubernetesResourcesCpuUnits: 'https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#meaning-of-cpu', + kubernetesResourcesMemoryUnits: 'https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#meaning-of-memory', radixPlatformWebConsole: `https://console.${clusterBases.radixPlatformWebConsole}/`, radixPlatform2WebConsole: `https://console.${clusterBases.radixPlatform2WebConsole}/`, playgroundWebConsole: `https://console.${clusterBases.playgroundWebConsole}/`, diff --git a/src/store/radix-api.ts b/src/store/radix-api.ts index 5411cad91..45d94b92d 100644 --- a/src/store/radix-api.ts +++ b/src/store/radix-api.ts @@ -1046,6 +1046,21 @@ const injectedRtkApi = api.injectEndpoints({ }, }), }), + getResources: build.query({ + query: (queryArg) => ({ + url: `/applications/${queryArg.appName}/resources`, + headers: { + 'Impersonate-User': queryArg['Impersonate-User'], + 'Impersonate-Group': queryArg['Impersonate-Group'], + }, + params: { + environment: queryArg.environment, + component: queryArg.component, + duration: queryArg.duration, + since: queryArg.since, + }, + }), + }), restartApplication: build.mutation< RestartApplicationApiResponse, RestartApplicationApiArg @@ -2205,6 +2220,24 @@ export type ResetManuallyScaledComponentsInApplicationApiArg = { /** Works only with custom setup of cluster. Allow impersonation of a comma-seperated list of test groups (Required if Impersonate-User is set) */ 'Impersonate-Group'?: string; }; +export type GetResourcesApiResponse = + /** status 200 Successful trigger pipeline */ UsedResources; +export type GetResourcesApiArg = { + /** Name of the application */ + appName: string; + /** Name of the application environment */ + environment?: string; + /** Name of the application component in an environment */ + component?: string; + /** Duration of the period, default is 30d (30 days). Example 10m, 1h, 2d, 3w, where m-minutes, h-hours, d-days, w-weeks */ + duration?: string; + /** End time-point of the period in the past, default is now. Example 10m, 1h, 2d, 3w, where m-minutes, h-hours, d-days, w-weeks */ + since?: string; + /** Works only with custom setup of cluster. Allow impersonation of test users (Required if Impersonate-Group is set) */ + 'Impersonate-User'?: string; + /** Works only with custom setup of cluster. Allow impersonation of a comma-seperated list of test groups (Required if Impersonate-User is set) */ + 'Impersonate-Group'?: string; +}; export type RestartApplicationApiResponse = unknown; export type RestartApplicationApiArg = { /** Name of application */ @@ -3367,6 +3400,24 @@ export type RegenerateDeployKeyAndSecretData = { /** SharedSecret of the shared secret */ sharedSecret?: string; }; +export type UsedResource = { + /** Avg Average resource used */ + avg?: number; + /** Max resource used */ + max?: number; + /** Min resource used */ + min?: number; +}; +export type UsedResources = { + cpu?: UsedResource; + /** From timestamp */ + from: string; + memory?: UsedResource; + /** To timestamp */ + to: string; + /** Warning messages */ + warnings?: string[]; +}; export const { useShowApplicationsQuery, useRegisterApplicationMutation, @@ -3452,6 +3503,7 @@ export const { useUpdatePrivateImageHubsSecretValueMutation, useRegenerateDeployKeyMutation, useResetManuallyScaledComponentsInApplicationMutation, + useGetResourcesQuery, useRestartApplicationMutation, useStartApplicationMutation, useStopApplicationMutation,