diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx index 95defbbcf..96f0dad33 100644 --- a/src/components/Drawer/Drawer.tsx +++ b/src/components/Drawer/Drawer.tsx @@ -32,6 +32,7 @@ interface DrawerPaneContentWrapperProps { detectClickOutside?: boolean; defaultWidth?: number; isPercentageWidth?: boolean; + hideVeil?: boolean; } const DrawerPaneContentWrapper = ({ @@ -45,6 +46,7 @@ const DrawerPaneContentWrapper = ({ className, detectClickOutside = false, isPercentageWidth, + hideVeil = true, }: DrawerPaneContentWrapperProps) => { const [drawerWidth, setDrawerWidth] = React.useState(() => { const savedWidth = localStorage.getItem(storageKey); @@ -113,7 +115,7 @@ const DrawerPaneContentWrapper = ({ { React.useEffect(() => { return () => { @@ -220,6 +224,7 @@ export const DrawerWrapper = ({ {children} - - + + + ); } diff --git a/src/components/EnableFullscreenButton/i18n/en.json b/src/components/EnableFullscreenButton/i18n/en.json new file mode 100644 index 000000000..fb05e75f2 --- /dev/null +++ b/src/components/EnableFullscreenButton/i18n/en.json @@ -0,0 +1,3 @@ +{ + "title_fullscreen": "Fullscreen" +} diff --git a/src/components/EnableFullscreenButton/i18n/index.ts b/src/components/EnableFullscreenButton/i18n/index.ts new file mode 100644 index 000000000..7d147fc39 --- /dev/null +++ b/src/components/EnableFullscreenButton/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-fullscreen-button'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/components/HealthcheckStatus/HealthcheckStatus.tsx b/src/components/HealthcheckStatus/HealthcheckStatus.tsx new file mode 100644 index 000000000..69a2eea45 --- /dev/null +++ b/src/components/HealthcheckStatus/HealthcheckStatus.tsx @@ -0,0 +1,69 @@ +import { + CircleCheck, + CircleExclamation, + CircleInfo, + PlugConnection, + TriangleExclamation, +} from '@gravity-ui/icons'; +import type {LabelProps} from '@gravity-ui/uikit'; +import {Icon, Label} from '@gravity-ui/uikit'; + +import {SelfCheckResult} from '../../types/api/healthcheck'; + +import i18n from './i18n'; + +const SelfCheckResultToLabelTheme: Record = { + [SelfCheckResult.GOOD]: 'success', + [SelfCheckResult.DEGRADED]: 'info', + [SelfCheckResult.MAINTENANCE_REQUIRED]: 'warning', + [SelfCheckResult.EMERGENCY]: 'danger', + [SelfCheckResult.UNSPECIFIED]: 'normal', +}; + +const SelfCheckResultToIcon: Record< + SelfCheckResult, + (props: React.SVGProps) => React.JSX.Element +> = { + [SelfCheckResult.GOOD]: CircleCheck, + [SelfCheckResult.DEGRADED]: CircleInfo, + [SelfCheckResult.MAINTENANCE_REQUIRED]: TriangleExclamation, + [SelfCheckResult.EMERGENCY]: CircleExclamation, + [SelfCheckResult.UNSPECIFIED]: PlugConnection, +}; + +const SelfCheckResultToText: Record = { + get [SelfCheckResult.GOOD]() { + return i18n('title_good'); + }, + get [SelfCheckResult.DEGRADED]() { + return i18n('title_degraded'); + }, + get [SelfCheckResult.MAINTENANCE_REQUIRED]() { + return i18n('title_maintenance'); + }, + get [SelfCheckResult.EMERGENCY]() { + return i18n('title_emergency'); + }, + get [SelfCheckResult.UNSPECIFIED]() { + return i18n('title_unspecified'); + }, +}; + +interface HealthcheckStatusProps { + status: SelfCheckResult; + size?: LabelProps['size']; +} + +export function HealthcheckStatus({status, size = 'm'}: HealthcheckStatusProps) { + const theme = SelfCheckResultToLabelTheme[status]; + + return ( + + ); +} diff --git a/src/components/HealthcheckStatus/i18n/en.json b/src/components/HealthcheckStatus/i18n/en.json new file mode 100644 index 000000000..716d60dcd --- /dev/null +++ b/src/components/HealthcheckStatus/i18n/en.json @@ -0,0 +1,7 @@ +{ + "title_degraded": "Degraded", + "title_emergency": "Emergency", + "title_maintenance": "Maintenance required", + "title_good": "Good", + "title_unspecified": "Unspecified" +} diff --git a/src/components/HealthcheckStatus/i18n/index.ts b/src/components/HealthcheckStatus/i18n/index.ts new file mode 100644 index 000000000..a44218ead --- /dev/null +++ b/src/components/HealthcheckStatus/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-healthcheck-status'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/components/ProgressViewer/ProgressViewer.tsx b/src/components/ProgressViewer/ProgressViewer.tsx index 94ec1f3b9..f7ef0001b 100644 --- a/src/components/ProgressViewer/ProgressViewer.tsx +++ b/src/components/ProgressViewer/ProgressViewer.tsx @@ -35,6 +35,7 @@ Props description: 6) inverseColorize - invert the colors of the progress bar 7) warningThreshold - the percentage of fullness at which the color of the progress bar changes to yellow 8) dangerThreshold - the percentage of fullness at which the color of the progress bar changes to red +9) withOverflow - percents may be more that 100% */ export interface ProgressViewerProps { @@ -49,6 +50,7 @@ export interface ProgressViewerProps { warningThreshold?: number; dangerThreshold?: number; hideCapacity?: boolean; + withOverflow?: boolean; } export function ProgressViewer({ @@ -56,6 +58,7 @@ export function ProgressViewer({ capacity, formatValues = defaultFormatValues, percents, + withOverflow, className, size = 'xs', colorizeProgress, @@ -68,12 +71,13 @@ export function ProgressViewer({ let fillWidth = Math.floor((parseFloat(String(value)) / parseFloat(String(capacity))) * 100) || 0; + const rawFillWidth = fillWidth; fillWidth = fillWidth > 100 ? 100 : fillWidth; let valueText: number | string | undefined = value, capacityText: number | string | undefined = capacity, divider = '/'; if (percents) { - valueText = fillWidth + '%'; + valueText = (withOverflow ? rawFillWidth : fillWidth) + '%'; capacityText = ''; divider = ''; } else if (formatValues) { diff --git a/src/components/SplitPane/SplitPane.scss b/src/components/SplitPane/SplitPane.scss index e05af1df4..83063853f 100644 --- a/src/components/SplitPane/SplitPane.scss +++ b/src/components/SplitPane/SplitPane.scss @@ -22,7 +22,6 @@ .gutter { position: relative; - z-index: 10; background: var(--g-color-base-background); diff --git a/src/containers/Storage/PaginatedStorageGroupsTable/StorageGroupsEmptyDataMessage.tsx b/src/containers/Storage/PaginatedStorageGroupsTable/StorageGroupsEmptyDataMessage.tsx index 623edd976..1abd94eef 100644 --- a/src/containers/Storage/PaginatedStorageGroupsTable/StorageGroupsEmptyDataMessage.tsx +++ b/src/containers/Storage/PaginatedStorageGroupsTable/StorageGroupsEmptyDataMessage.tsx @@ -1,6 +1,6 @@ +import {EmptyFilter} from '../../../components/EmptyFilter/EmptyFilter'; import {VISIBLE_ENTITIES} from '../../../store/reducers/storage/constants'; import type {VisibleEntities} from '../../../store/reducers/storage/types'; -import {EmptyFilter} from '../EmptyFilter/EmptyFilter'; import i18n from './i18n'; diff --git a/src/containers/Storage/PaginatedStorageNodesTable/StorageNodesEmptyDataMessage.tsx b/src/containers/Storage/PaginatedStorageNodesTable/StorageNodesEmptyDataMessage.tsx index 2091614c4..c866c951b 100644 --- a/src/containers/Storage/PaginatedStorageNodesTable/StorageNodesEmptyDataMessage.tsx +++ b/src/containers/Storage/PaginatedStorageNodesTable/StorageNodesEmptyDataMessage.tsx @@ -1,7 +1,7 @@ +import {EmptyFilter} from '../../../components/EmptyFilter/EmptyFilter'; import {VISIBLE_ENTITIES} from '../../../store/reducers/storage/constants'; import type {VisibleEntities} from '../../../store/reducers/storage/types'; import {NodesUptimeFilterValues} from '../../../utils/nodes'; -import {EmptyFilter} from '../EmptyFilter/EmptyFilter'; import i18n from './i18n'; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/Healthcheck.scss b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/Healthcheck.scss deleted file mode 100644 index f9bb6b523..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/Healthcheck.scss +++ /dev/null @@ -1,106 +0,0 @@ -@use '../../../../../styles/mixins.scss'; - -.healthcheck { - $block: &; - - &__details { - width: 872px; - } - - &__details-content-wrapper { - overflow-x: hidden; - } - - &__preview { - display: flex; - flex-direction: column; - - height: 100%; - } - - &__preview-title { - font-weight: 600; - - color: var(--g-color-text-link); - - @include mixins.lead-typography(); - } - - &__preview-content { - margin: auto; - - line-height: 24px; - } - - &__preview-status-icon { - width: 64px; - height: 64px; - } - - &__preview-title-wrapper { - display: flex; - align-items: center; - gap: 8px; - - margin-bottom: 4px; - } - - &__preview-issue { - position: relative; - top: -8px; - - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - - &_good { - color: var(--g-color-text-positive); - & #{$block}__self-check-status-indicator { - background-color: var(--g-color-base-positive-light); - } - } - &_degraded { - color: var(--g-color-text-info); - & #{$block}__self-check-status-indicator { - background-color: var(--g-color-base-info-light); - } - } - &_emergency { - color: var(--g-color-text-danger); - & #{$block}__self-check-status-indicator { - background-color: var(--g-color-base-danger-light); - } - } - &_unspecified { - color: var(--g-color-text-misc); - & #{$block}__self-check-status-indicator { - background-color: var(--g-color-base-misc-light); - } - } - &_maintenance_required { - color: var(--g-color-text-warning-heavy); - & #{$block}__self-check-status-indicator { - background-color: var(--g-color-base-warning-light); - } - } - } - - &__self-check-status-indicator { - display: inline-block; - - padding: 0 8px; - - font-size: 13px; - line-height: 24px; - text-wrap: nowrap; - - border-radius: 4px; - } - &__icon-warn { - color: var(--g-color-text-warning); - } - &__icon-wrapper { - display: flex; - } -} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckDetails.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckDetails.tsx deleted file mode 100644 index e6bc168d3..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckDetails.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; - -import {ResponseError} from '../../../../../components/Errors/ResponseError'; -import {Loader} from '../../../../../components/Loader'; -import {useClusterBaseInfo} from '../../../../../store/reducers/cluster/cluster'; -import {cn} from '../../../../../utils/cn'; -import {useAutoRefreshInterval} from '../../../../../utils/hooks'; -import {useHealthcheck} from '../useHealthcheck'; - -import IssueTree from './IssuesViewer/IssueTree'; -import i18n from './i18n'; - -import './Healthcheck.scss'; - -const b = cn('healthcheck'); - -interface HealthcheckDetailsProps { - tenantName: string; -} - -export function HealthcheckDetails({tenantName}: HealthcheckDetailsProps) { - const [autoRefreshInterval] = useAutoRefreshInterval(); - const {name} = useClusterBaseInfo(); - const {issueTrees, loading, error} = useHealthcheck(tenantName, { - //FIXME https://github.com/ydb-platform/ydb-embedded-ui/issues/1889 - autorefresh: name === 'ydb_ru' ? undefined : autoRefreshInterval, - }); - - const renderContent = () => { - if (error) { - return ; - } - - if (loading) { - return ; - } - - if (!issueTrees || !issueTrees.length) { - return i18n('status_message.ok'); - } - - return ( - - {issueTrees.map((issueTree) => ( - - ))} - - ); - }; - - return ( -
-
{renderContent()}
-
- ); -} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.scss b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.scss new file mode 100644 index 000000000..f5b75ce77 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.scss @@ -0,0 +1,41 @@ +.ydb-healthcheck-preview { + $block: &; + + &__icon-wrapper { + display: inline-flex; + + color: var(--g-color-text-warning); + } + + &__skeleton { + width: 100%; + height: 60px; + } + + &__alert-message { + height: 30px; + } + + &__icon { + display: flex; + + height: 100%; + margin: auto; + + &_good { + color: var(--g-color-text-positive); + } + &_degraded { + color: var(--g-color-text-info); + } + &_emergency { + color: var(--g-color-text-danger); + } + &_unspecified { + color: var(--g-color-text-misc); + } + &_maintenance_required { + color: var(--g-color-text-warning-heavy); + } + } +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.tsx index a5ea1043e..ad18e975a 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/HealthcheckPreview.tsx @@ -1,53 +1,46 @@ import React from 'react'; -import { - CircleCheck, - CircleInfo, - CircleQuestion, - CircleXmark, - TriangleExclamationFill, -} from '@gravity-ui/icons'; -import type {IconData} from '@gravity-ui/uikit'; -import {Icon, Popover} from '@gravity-ui/uikit'; - -import {DiagnosticCard} from '../../../../../components/DiagnosticCard/DiagnosticCard'; +import type {AlertProps} from '@gravity-ui/uikit'; +import {Alert, Button, Flex, Icon, Popover, Skeleton} from '@gravity-ui/uikit'; + import {ResponseError} from '../../../../../components/Errors/ResponseError'; -import {Loader} from '../../../../../components/Loader'; import {useClusterBaseInfo} from '../../../../../store/reducers/cluster/cluster'; import {healthcheckApi} from '../../../../../store/reducers/healthcheckInfo/healthcheckInfo'; import {SelfCheckResult} from '../../../../../types/api/healthcheck'; import {cn} from '../../../../../utils/cn'; -import {useAutoRefreshInterval, useTypedSelector} from '../../../../../utils/hooks'; +import {useAutoRefreshInterval} from '../../../../../utils/hooks'; +import {HEALTHCHECK_RESULT_TO_ICON, HEALTHCHECK_RESULT_TO_TEXT} from '../../../constants'; +import {useTenantQueryParams} from '../../../useTenantQueryParams'; import i18n from './i18n'; import CircleExclamationIcon from '@gravity-ui/icons/svgs/circle-exclamation.svg'; -import './Healthcheck.scss'; +import './HealthcheckPreview.scss'; -const b = cn('healthcheck'); +const b = cn('ydb-healthcheck-preview'); interface HealthcheckPreviewProps { tenantName: string; active?: boolean; } -const icons: Record = { - [SelfCheckResult.UNSPECIFIED]: CircleQuestion, - [SelfCheckResult.GOOD]: CircleCheck, - [SelfCheckResult.DEGRADED]: CircleInfo, - [SelfCheckResult.MAINTENANCE_REQUIRED]: CircleXmark, - [SelfCheckResult.EMERGENCY]: TriangleExclamationFill, +const checkResultToAlertTheme: Record = { + [SelfCheckResult.UNSPECIFIED]: 'normal', + [SelfCheckResult.GOOD]: 'success', + [SelfCheckResult.DEGRADED]: 'info', + [SelfCheckResult.MAINTENANCE_REQUIRED]: 'warning', + [SelfCheckResult.EMERGENCY]: 'danger', }; export function HealthcheckPreview(props: HealthcheckPreviewProps) { - const {tenantName, active} = props; + const {tenantName} = props; const [autoRefreshInterval] = useAutoRefreshInterval(); - const {metricsTab} = useTypedSelector((state) => state.tenant); - const {name} = useClusterBaseInfo(); + const {handleShowHealthcheckChange} = useTenantQueryParams(); + const healthcheckPreviewDisabled = name === 'ydb_ru'; const { @@ -67,10 +60,10 @@ export function HealthcheckPreview(props: HealthcheckPreviewProps) { healthcheckApi.useLazyGetHealthcheckInfoQuery(); React.useEffect(() => { - if (metricsTab === 'healthcheck' && healthcheckPreviewDisabled) { + if (healthcheckPreviewDisabled) { getHealthcheckQuery({database: tenantName}); } - }, [metricsTab, healthcheckPreviewDisabled, tenantName, getHealthcheckQuery]); + }, [healthcheckPreviewDisabled, tenantName, getHealthcheckQuery]); React.useEffect(() => { const fetchHealthcheck = () => { @@ -87,60 +80,69 @@ export function HealthcheckPreview(props: HealthcheckPreviewProps) { const loading = (isFetching && data === undefined) || (isFetchingManually && manualData === undefined); - const renderHeader = () => { + const selfCheckResult: SelfCheckResult = + data?.self_check_result || manualData?.self_check_result || SelfCheckResult.UNSPECIFIED; + + const modifier = selfCheckResult.toLowerCase(); + + if (loading) { + return ; + } + + const issuesCount = data?.issue_log?.filter((issue) => !issue.reason).length; + + const issuesText = issuesCount ? i18n('description_problems', {count: issuesCount}) : ''; + + const renderAlertMessage = () => { + if (error) { + return ; + } return ( -
-
-
{i18n('title.healthcheck')}
- {/* FIXME https://github.com/ydb-platform/ydb-embedded-ui/issues/1889 */} + + + {HEALTHCHECK_RESULT_TO_TEXT[selfCheckResult]} + {issuesText ? ` ${issuesText}` : ''} {healthcheckPreviewDisabled ? ( - {() => ( - - )} + {() => } ) : null} -
-
- ); - }; - - const renderContent = () => { - if (error) { - return ; - } - - if (loading) { - return ; - } - - const selfCheckResult = - data?.self_check_result || manualData?.self_check_result || SelfCheckResult.UNSPECIFIED; - const modifier = selfCheckResult.toLowerCase(); - return ( -
-
- -
- {selfCheckResult.replace(/_/g, ' ')} -
-
-
+ + {issuesCount && ( + + )} + ); }; return ( - - {renderHeader()} - {renderContent()} - + + } + /> ); } diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.scss b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.scss deleted file mode 100644 index ec576b391..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.scss +++ /dev/null @@ -1,51 +0,0 @@ -@use '../../../../../../styles/mixins.scss'; - -.issue-tree { - display: flex; - - &__block { - width: 100%; - } - - &__checkbox { - margin: 5px 0 10px; - } - - &__info-panel { - position: sticky; - - height: 100%; - margin: 11px 0; - padding: 8px 20px; - - border-radius: 4px; - background: var(--g-color-base-generic); - - .ydb-json-viewer { - --toolbar-background-color: var(--g-color-base-simple-hover-solid); - } - } - - .ydb-tree-view { - $calculated-margin: calc(24px * var(--ydb-tree-view-level)); - - &__item { - height: 40px; - } - - .tree-view_arrow { - width: 40px; - height: 40px; - } - - // Without !important this class does not have enough weight compared to styles set in TreeView - .ydb-tree-view__item { - margin-left: $calculated-margin !important; - padding-left: 0 !important; - } - - .issue-tree__info-panel { - margin-left: $calculated-margin; - } - } -} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.tsx deleted file mode 100644 index 94ab34b9c..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTree.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; - -import _omit from 'lodash/omit'; -import {TreeView} from 'ydb-ui-components'; - -import {JsonViewer} from '../../../../../../components/JsonViewer/JsonViewer'; -import {unipikaConvert} from '../../../../../../components/JsonViewer/unipika/unipika'; -import type {IssuesTree} from '../../../../../../store/reducers/healthcheckInfo/types'; -import {hcStatusToColorFlag} from '../../../../../../store/reducers/healthcheckInfo/utils'; -import {cn} from '../../../../../../utils/cn'; - -import {IssueTreeItem} from './IssueTreeItem'; - -import './IssueTree.scss'; - -const b = cn('issue-tree'); - -interface IssuesViewerProps { - issueTree: IssuesTree; -} - -const IssueTree = ({issueTree}: IssuesViewerProps) => { - const [collapsedIssues, setCollapsedIssues] = React.useState>({}); - - const renderInfoPanel = React.useCallback((info?: object) => { - if (!info) { - return null; - } - - return ( -
- -
- ); - }, []); - - const renderTree = React.useCallback( - (data: IssuesTree[]) => { - return data.map((item) => { - const {id} = item; - const {status, message, type, reasonsItems, level, ...rest} = item; - - const isCollapsed = - typeof collapsedIssues[id] === 'undefined' || collapsedIssues[id]; - - const toggleCollapsed = () => { - setCollapsedIssues((collapsedIssues) => ({ - ...collapsedIssues, - [id]: !isCollapsed, - })); - }; - - return ( - - } - collapsed={isCollapsed} - hasArrow={true} - onClick={toggleCollapsed} - onArrowClick={toggleCollapsed} - level={level - 1} - > - {renderInfoPanel(_omit(rest, ['reason']))} - {renderTree(reasonsItems || [])} - - ); - }); - }, - [collapsedIssues, renderInfoPanel], - ); - - return ( -
-
{renderTree([issueTree])}
-
- ); -}; - -export default IssueTree; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.scss b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.scss deleted file mode 100644 index b6c58d107..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.scss +++ /dev/null @@ -1,50 +0,0 @@ -.issue-tree-item { - display: flex; - justify-content: space-between; - align-items: center; - - height: 40px; - - cursor: pointer; - - &__field { - display: flex; - overflow: hidden; - - &_status { - display: flex; - - white-space: nowrap; - } - &_additional { - width: max-content; - - cursor: pointer; - - color: var(--g-color-text-link); - - &:hover { - color: var(--g-color-text-link-hover); - } - } - &_message { - overflow: hidden; - flex-shrink: 0; - - width: 300px; - - white-space: normal; - } - } - - &__field-tooltip { - &#{&} { - min-width: 500px; - max-width: 500px; - } - } - - &__field-label { - color: var(--g-color-text-secondary); - } -} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.tsx deleted file mode 100644 index 1dac61e59..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/IssueTreeItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {EntityStatus} from '../../../../../../../components/EntityStatus/EntityStatus'; -import type {EFlag} from '../../../../../../../types/api/enums'; -import {cn} from '../../../../../../../utils/cn'; - -import './IssueTreeItem.scss'; - -const b = cn('issue-tree-item'); - -interface IssueRowProps { - status: EFlag; - message: string; - type: string; - onClick?: VoidFunction; -} - -export const IssueTreeItem = ({status, message, type, onClick}: IssueRowProps) => { - return ( -
-
- -
-
{message}
-
- ); -}; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/index.ts b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/index.ts deleted file mode 100644 index 070f94e44..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/IssuesViewer/IssueTreeItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueTreeItem'; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/en.json b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/en.json index 440ae011a..1d572c4cf 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/en.json +++ b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/en.json @@ -1,8 +1,10 @@ { - "title.healthcheck": "Healthcheck", - "label.update": "Update", - "label.show-details": "Show details", - "label.issues": "Issues:", - "status_message.ok": "No issues", - "no-data": "no healthcheck data" + "description_problems": [ + "There is {{count}} issue.", + "There are {{count}} issues.", + "There are {{count}} issues.", + "There are {{count}} issues." + ], + "action_review-issues": "Review issues", + "description_no-data": "No healthcheck data" } diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/index.ts b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/index.ts index b0fa69fde..2902318e6 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/index.ts +++ b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/index.ts @@ -1,8 +1,7 @@ import {registerKeysets} from '../../../../../../utils/i18n'; import en from './en.json'; -import ru from './ru.json'; const COMPONENT = 'ydb-diagnostics-healthcheck'; -export default registerKeysets(COMPONENT, {ru, en}); +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/ru.json b/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/ru.json deleted file mode 100644 index b3c3de530..000000000 --- a/src/containers/Tenant/Diagnostics/TenantOverview/Healthcheck/i18n/ru.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title.healthcheck": "Healthcheck", - "label.update": "Обновить", - "label.show-details": "Посмотреть подробности", - "label.issues": "Проблемы:", - "status_message.ok": "Нет проблем", - "no-data": "нет данных healthcheck" -} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.scss b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.scss index dc863b062..acf60cb51 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.scss +++ b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.scss @@ -14,6 +14,13 @@ margin-bottom: 10px; } + &__note { + display: flex; + .g-help-mark__button { + display: flex; + } + } + &__label { font-weight: 600; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.tsx index cdd419b45..f43595cda 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricCard/MetricCard.tsx @@ -1,3 +1,5 @@ +import {Flex, HelpMark} from '@gravity-ui/uikit'; + import {DiagnosticCard} from '../../../../../../components/DiagnosticCard/DiagnosticCard'; import {ProgressViewer} from '../../../../../../components/ProgressViewer/ProgressViewer'; import type {ProgressViewerProps} from '../../../../../../components/ProgressViewer/ProgressViewer'; @@ -36,9 +38,11 @@ interface MetricCardProps { label?: string; status?: MetricStatus; metrics: DiagnosticsCardMetric[]; + interactive?: boolean; + note?: string; } -export function MetricCard({active, label, status, metrics}: MetricCardProps) { +export function MetricCard({active, label, status, metrics, interactive, note}: MetricCardProps) { const renderContent = () => { return metrics.map(({title, ...progressViewerProps}, index) => { return ( @@ -49,10 +53,25 @@ export function MetricCard({active, label, status, metrics}: MetricCardProps) { ); }); }; + const renderNote = () => { + if (!note) { + return null; + } + return ( + + {note} + + ); + }; return ( - +
- {label &&
{label}
} + {label && ( + + {label} + {renderNote()} + + )} {getStatusIcon(status)}
{renderContent()}
diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricsCards.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricsCards.tsx index 609cdc31e..84290700d 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricsCards.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsCards/MetricsCards.tsx @@ -14,11 +14,12 @@ import type { TenantStorageStats, } from '../../../../../store/reducers/tenants/utils'; import {getMetricStatusFromUsage} from '../../../../../store/reducers/tenants/utils'; +import {formatBytes} from '../../../../../utils/bytesParsers'; import {cn} from '../../../../../utils/cn'; +import {SHOW_NETWORK_UTILIZATION} from '../../../../../utils/constants'; import {formatStorageValues} from '../../../../../utils/dataFormatters/dataFormatters'; -import {useTypedSelector} from '../../../../../utils/hooks'; +import {useSetting, useTypedSelector} from '../../../../../utils/hooks'; import {TenantTabsGroups, getTenantPath} from '../../../TenantPages'; -import {HealthcheckPreview} from '../Healthcheck/HealthcheckPreview'; import i18n from '../i18n'; import type {DiagnosticsCardMetric} from './MetricCard/MetricCard'; @@ -42,7 +43,7 @@ interface MetricsCardsProps { memoryStats?: TenantMetricStats[]; blobStorageStats?: TenantStorageStats[]; tabletStorageStats?: TenantStorageStats[]; - tenantName: string; + networkStats?: TenantMetricStats[]; } export function MetricsCards({ @@ -50,7 +51,7 @@ export function MetricsCards({ memoryStats, blobStorageStats, tabletStorageStats, - tenantName, + networkStats, }: MetricsCardsProps) { const location = useLocation(); @@ -80,10 +81,6 @@ export function MetricsCards({ ...queryParams, [TenantTabsGroups.metricsTab]: getTabIfNotActive(TENANT_METRICS_TABS_IDS.memory), }), - [TENANT_METRICS_TABS_IDS.healthcheck]: getTenantPath({ - ...queryParams, - [TenantTabsGroups.metricsTab]: getTabIfNotActive(TENANT_METRICS_TABS_IDS.healthcheck), - }), }; return ( @@ -107,12 +104,7 @@ export function MetricsCards({ active={metricsTab === TENANT_METRICS_TABS_IDS.memory} /> - - - + ); } @@ -223,3 +215,41 @@ function MemoryCard({active, memoryStats = []}: MemoryCardProps) { /> ); } +interface NetworkCardProps { + networkStats?: TenantMetricStats[]; +} + +function NetworkCard({networkStats}: NetworkCardProps) { + const [showNetworkUtilization] = useSetting(SHOW_NETWORK_UTILIZATION); + if (!showNetworkUtilization || !networkStats) { + return null; + } + let status: MetricStatus = METRIC_STATUS.Unspecified; + + const metrics: DiagnosticsCardMetric[] = networkStats.map((metric) => { + const {used, limit, usage} = metric; + + const metricStatus = getMetricStatusFromUsage(usage); + if (MetricStatusToSeverity[metricStatus] > MetricStatusToSeverity[status]) { + status = metricStatus; + } + + return { + title: formatBytes({value: limit, withSpeedLabel: true}), + value: used, + capacity: limit, + percents: true, + withOverflow: true, + }; + }); + + return ( + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.scss index c12c761f0..3faa57b27 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.scss +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.scss @@ -6,10 +6,6 @@ height: 100%; padding-bottom: 20px; - &__loader { - display: flex; - justify-content: center; - } &__tenant-name-wrapper { display: flex; overflow: hidden; @@ -35,7 +31,7 @@ position: sticky; left: 0; - width: max-content; + width: 100%; } &__title { diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx index 1a82e28b4..192e22e3a 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx @@ -1,6 +1,7 @@ -import {Flex, Loader} from '@gravity-ui/uikit'; +import {Flex} from '@gravity-ui/uikit'; import {EntityStatus} from '../../../../components/EntityStatus/EntityStatus'; +import {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; import {LogsButton} from '../../../../components/LogsButton/LogsButton'; import {MonitoringButton} from '../../../../components/MonitoringButton/MonitoringButton'; import {overviewApi} from '../../../../store/reducers/overview/overview'; @@ -14,7 +15,7 @@ import {useClusterNameFromQuery} from '../../../../utils/hooks/useDatabaseFromQu import {mapDatabaseTypeToDBName} from '../../utils/schema'; import {DefaultOverviewContent} from './DefaultOverviewContent/DefaultOverviewContent'; -import {HealthcheckDetails} from './Healthcheck/HealthcheckDetails'; +import {HealthcheckPreview} from './Healthcheck/HealthcheckPreview'; import {MetricsCards} from './MetricsCards/MetricsCards'; import {TenantCpu} from './TenantCpu/TenantCpu'; import {TenantMemory} from './TenantMemory/TenantMemory'; @@ -85,6 +86,7 @@ export function TenantOverview({ poolsStats, memoryStats, + networkStats, blobStorageStats, tabletStorageStats, } = calculateTenantMetrics(tenantData); @@ -133,46 +135,40 @@ export function TenantOverview({ /> ); } - case TENANT_METRICS_TABS_IDS.healthcheck: { - return ; - } default: { return ; } } }; - if (tenantLoading) { - return ( -
- -
- ); - } - const monitoringLink = additionalTenantProps?.getMonitoringLink?.(Name, Type); const logsLink = additionalTenantProps?.getLogsLink?.(Name); return ( -
-
-
{tenantType}
- - {renderName()} - - {monitoringLink && } - {logsLink && } + +
+
+
{tenantType}
+ + {renderName()} + + {monitoringLink && } + {logsLink && } + - - + + + + +
+ {renderTabContent()}
- {renderTabContent()} -
+ ); } diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json b/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json index 996c1722f..51bc2fa59 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json +++ b/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json @@ -24,6 +24,8 @@ "cards.cpu-label": "CPU", "cards.storage-label": "Storage", "cards.memory-label": "Memory", + "cards.network-label": "Network", + "cards.network-note": "Network usage is the average outgoing bandwidth usage across all nodes in the database", "charts.queries-per-second": "Queries per second", "charts.transaction-latency": "Transactions latencies {{percentile}}", diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicData.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicData.tsx index 168088b9b..5bca9439e 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/TopicData.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicData.tsx @@ -5,6 +5,7 @@ import {skipToken} from '@reduxjs/toolkit/query'; import {isNil} from 'lodash'; import {DrawerWrapper} from '../../../../components/Drawer'; +import {EmptyFilter} from '../../../../components/EmptyFilter/EmptyFilter'; import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton'; import Fullscreen from '../../../../components/Fullscreen/Fullscreen'; import { @@ -19,7 +20,6 @@ import {useAutoRefreshInterval} from '../../../../utils/hooks'; import {useSelectedColumns} from '../../../../utils/hooks/useSelectedColumns'; import {renderPaginatedTableErrorMessage} from '../../../../utils/renderPaginatedTableErrorMessage'; import {safeParseNumber} from '../../../../utils/utils'; -import {EmptyFilter} from '../../../Storage/EmptyFilter/EmptyFilter'; import {TopicDataControls} from './TopicDataControls/TopicDataControls'; import {TopicMessageDetails} from './TopicMessageDetails/TopicMessageDetails'; diff --git a/src/containers/Tenant/Healthcheck/Healthcheck.scss b/src/containers/Tenant/Healthcheck/Healthcheck.scss new file mode 100644 index 000000000..3b5b58f9f --- /dev/null +++ b/src/containers/Tenant/Healthcheck/Healthcheck.scss @@ -0,0 +1,112 @@ +.ydb-healthcheck { + &__stub-wrapper { + margin: auto; + } + &__control-wrapper { + width: max-content; + } + &__controls { + position: sticky; + z-index: 1; + top: 0; + left: 0; + + padding: var(--g-spacing-4); + + background-color: var(--g-color-base-background); + } + &__controls_fullscreen { + padding-right: calc(var(--g-spacing-7) * 2); + } + &__issues { + padding: 0 var(--g-spacing-4) var(--g-spacing-4) var(--g-spacing-4); + } + + &__animation-container { + overflow: hidden; + + min-height: 0; + } + &__issue-wrapper { + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-s); + .g-disclosure_enter_active { + display: grid; + + animation: disclosure-expanded 0.2s cubic-bezier(0.23, 1, 0.32, 1) forwards; + } + .g-disclosure_exit_active { + display: grid; + + animation: disclosure-collapsed 0.2s cubic-bezier(0.23, 1, 0.32, 1) forwards; + } + } + &__issue-summary { + padding: var(--g-spacing-4); + + cursor: pointer; + + border-radius: var(--g-border-radius-s); + + &:hover { + background-color: var(--g-color-base-generic-ultralight); + } + } + &__issue-content { + --g-definition-list-item-gap: var(--g-spacing-2); + width: 100%; + } + &__issue-divider { + height: 28px; + } + &__issue-tab { + cursor: pointer; + &_active { + color: var(--g-color-text-primary); + } + } + + &__tab-status { + height: 6px; + + border-radius: 50%; + background-color: var(--g-color-text-misc); + aspect-ratio: 1; + } + &__tab-status_green { + background-color: var(--g-color-text-positive); + } + &__tab-status_blue { + background-color: var(--g-color-text-info); + } + &__tab-status_yellow { + background-color: var(--g-color-text-warning); + } + &__tab-status_orange { + background-color: var(--g-color-text-warning-heavy); + } + &__tab-status_red { + background-color: var(--g-color-text-danger); + } + &__issue-details { + padding: 0 var(--g-spacing-4) var(--g-spacing-4) var(--g-spacing-4); + } +} + +@keyframes disclosure-expanded { + 0% { + grid-template-rows: 0fr; + } + 100% { + grid-template-rows: 1fr; + } +} + +@keyframes disclosure-collapsed { + 0% { + grid-template-rows: 1fr; + } + 100% { + grid-template-rows: 0fr; + } +} diff --git a/src/containers/Tenant/Healthcheck/Healthcheck.tsx b/src/containers/Tenant/Healthcheck/Healthcheck.tsx new file mode 100644 index 000000000..294e939f2 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/Healthcheck.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import {Flex, Icon} from '@gravity-ui/uikit'; + +import {ResponseError} from '../../../components/Errors/ResponseError'; +import Fullscreen from '../../../components/Fullscreen/Fullscreen'; +import {HealthcheckStatus} from '../../../components/HealthcheckStatus/HealthcheckStatus'; +import {Illustration} from '../../../components/Illustration'; +import {Loader} from '../../../components/Loader'; +import {useClusterBaseInfo} from '../../../store/reducers/cluster/cluster'; +import type {IssuesTree} from '../../../store/reducers/healthcheckInfo/types'; +import {SelfCheckResult} from '../../../types/api/healthcheck'; +import {uiFactory} from '../../../uiFactory/uiFactory'; +import {useAutoRefreshInterval, useTypedSelector} from '../../../utils/hooks'; +import {HEALTHCHECK_RESULT_TO_TEXT} from '../constants'; + +import {HealthcheckFilter} from './components/HealthcheckFilter'; +import {Issues} from './components/HealthcheckIssues'; +import {HealthcheckRefresh} from './components/HealthcheckRefresh'; +import {HealthcheckView} from './components/HealthcheckView'; +import i18n from './i18n'; +import type {CommonIssueType} from './shared'; +import {b} from './shared'; +import {useHealthcheck} from './useHealthcheck'; + +import cryCatIcon from '../../../assets/icons/cry-cat.svg'; + +import './Healthcheck.scss'; + +interface HealthcheckDetailsProps { + tenantName: string; + countIssueTypes?: ( + issueTrees: IssuesTree[], + ) => Record & Record; +} + +export function Healthcheck({ + tenantName, + countIssueTypes = uiFactory.countHealthcheckIssuesByType, +}: HealthcheckDetailsProps) { + const [autoRefreshInterval] = useAutoRefreshInterval(); + const fullscreen = useTypedSelector((state) => state.fullscreen); + const {name} = useClusterBaseInfo(); + const {loading, error, selfCheckResult, fulfilledTimeStamp, leavesIssues, refetch} = + useHealthcheck(tenantName, { + //FIXME https://github.com/ydb-platform/ydb-embedded-ui/issues/1889 + autorefresh: name === 'ydb_ru' ? undefined : autoRefreshInterval, + }); + + const issuesCount = React.useMemo( + () => countIssueTypes(leavesIssues), + [leavesIssues, countIssueTypes], + ); + + const renderControls = () => { + return ( + + + + + + + + + ); + }; + + const renderContent = () => { + if (error) { + return ( + + + + + ); + } + + if (loading) { + return ; + } + + if (selfCheckResult === SelfCheckResult.GOOD && (!leavesIssues || !leavesIssues.length)) { + return ( + + + {HEALTHCHECK_RESULT_TO_TEXT[selfCheckResult]} + + ); + } + + return ( + + {renderControls()} + + + + + ); + }; + + return ( + + + {renderContent()} + + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckFilter.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckFilter.tsx new file mode 100644 index 000000000..454a100b8 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckFilter.tsx @@ -0,0 +1,15 @@ +import {Search} from '../../../../components/Search'; +import {useTenantQueryParams} from '../../useTenantQueryParams'; +import i18n from '../i18n'; + +export function HealthcheckFilter() { + const {issuesFilter, handleIssuesFilterChange} = useTenantQueryParams(); + return ( + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssue.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssue.tsx new file mode 100644 index 000000000..6d2ed4873 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssue.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import {ArrowToggle, Disclosure, Divider, Flex, Text} from '@gravity-ui/uikit'; + +import {EntityStatus} from '../../../../components/EntityStatusNew/EntityStatus'; +import type {IssuesTree} from '../../../../store/reducers/healthcheckInfo/types'; +import {hcStatusToColorFlag} from '../../../../store/reducers/healthcheckInfo/utils'; +import {b} from '../shared'; + +import {IssueDetails} from './HealthcheckIssueDetails/HealthcheckIssueDetails'; +import {HealthcheckIssueTabs} from './HealthcheckIssueTabs'; + +interface HealthcheckIssueProps { + issue: IssuesTree; + expanded?: boolean; +} + +export function HealthcheckIssue({issue, expanded}: HealthcheckIssueProps) { + const [selectedTab, setSelectedTab] = React.useState(issue.id); + const parents = React.useMemo(() => { + const parents = []; + let current: IssuesTree | undefined = issue; + while (current) { + parents.push(current); + current = current.parent; + } + return parents.reverse(); + }, [issue]); + + const currentIssue = React.useMemo(() => { + return parents.find((parent) => parent.id === selectedTab); + }, [parents, selectedTab]); + + return ( + + + + {(props) => ( +
+ + + {issue.message} + + {issue.status && ( +
+ +
+ )} +
+ + + + +
+
+ )} +
+
+ + + {currentIssue && } + +
+
+
+ ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/ComputeLocation.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/ComputeLocation.tsx new file mode 100644 index 000000000..5daf64f53 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/ComputeLocation.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import {Flex} from '@gravity-ui/uikit'; +import {isEmpty} from 'lodash'; + +import {InternalLink} from '../../../../../components/InternalLink'; +import {getTabletPagePath} from '../../../../../routes'; +import type {Location} from '../../../../../types/api/healthcheck'; +import {useTenantQueryParams} from '../../../useTenantQueryParams'; +import i18n from '../../i18n'; + +import {NodeInfo} from './NodeInfo'; +import {PoolInfo} from './PoolInfo'; +import {IdList, LocationDetails, SectionWithTitle} from './utils'; + +export type LocationFieldCompute = 'tablet' | 'schema' | 'node' | 'pool'; + +type ComputeLocationType = Location['compute']; + +const LocationFieldRenderer: Record< + LocationFieldCompute, + (location: ComputeLocationType) => React.ReactNode +> = { + node: (location: ComputeLocationType) => , + pool: (location: ComputeLocationType) => , + tablet: (location: ComputeLocationType) => , + schema: (location: ComputeLocationType) => , +}; + +interface ComputeLocationProps { + location: ComputeLocationType; + hiddenFields?: LocationFieldCompute[]; +} + +export function ComputeLocation({location, hiddenFields = []}: ComputeLocationProps) { + const {node, tablet, schema, pool} = location ?? {}; + + const fields = React.useMemo(() => { + const fields: LocationFieldCompute[] = []; + if (node) { + fields.push('node'); + } + if (pool) { + fields.push('pool'); + } + if (tablet) { + fields.push('tablet'); + } + if (schema) { + fields.push('schema'); + } + return fields.filter((field) => !hiddenFields.includes(field)); + }, [node, pool, tablet, schema, hiddenFields]); + + if (!location || isEmpty(location) || fields.length === 0) { + return null; + } + + return ( + + + {fields.map((field) => LocationFieldRenderer[field](location))} + + + ); +} + +interface ComputeSectionProps { + location?: ComputeLocationType; +} + +function TabletInfo({location}: ComputeSectionProps) { + const {tablet} = location ?? {}; + const {database} = useTenantQueryParams(); + + if (!tablet) { + return null; + } + + return ( + ( + + {id} + + )} + /> + ) : undefined, + title: i18n('label_tablet-id'), + }, + {value: tablet.type, title: i18n('label_tablet-type')}, + {value: tablet.count, title: i18n('label_tablet-count')}, + ]} + /> + ); +} + +function SchemaInfo({location}: ComputeSectionProps) { + const {schema} = location ?? {}; + + if (!schema) { + return null; + } + + return ( + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/HealthcheckIssueDetails.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/HealthcheckIssueDetails.tsx new file mode 100644 index 000000000..12bf3ff35 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/HealthcheckIssueDetails.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import {Flex} from '@gravity-ui/uikit'; +import {isEmpty} from 'lodash'; + +import {InternalLink} from '../../../../../components/InternalLink'; +import {getTabletPagePath} from '../../../../../routes'; +import type {IssuesTree} from '../../../../../store/reducers/healthcheckInfo/types'; +import type {Location} from '../../../../../types/api/healthcheck'; +import {useTenantQueryParams} from '../../../useTenantQueryParams'; +import i18n from '../../i18n'; + +import {ComputeLocation} from './ComputeLocation'; +import type {LocationFieldCompute} from './ComputeLocation'; +import {NodeInfo} from './NodeInfo'; +import type {LocationFieldStorage} from './StorageLocation'; +import {StorageLocation} from './StorageLocation'; +import {IdList, LocationDetails, SectionWithTitle} from './utils'; + +interface HealthcheckIssueDetailsProps { + issue: IssuesTree; +} + +export function IssueDetails({issue}: HealthcheckIssueDetailsProps) { + const {database} = useTenantQueryParams(); + const {location} = issue; + + const {detailsFields, hiddenStorageFields, hiddenComputeFields} = React.useMemo(() => { + const hiddenStorageFields: LocationFieldStorage[] = []; + const hiddenComputeFields: LocationFieldCompute[] = []; + const fields: {value?: React.ReactNode; title: string}[] = [ + {value: issue.message, title: i18n('label_description')}, + ]; + if (issue.type === 'STORAGE_POOL') { + fields.push({ + value: issue.location?.storage?.pool?.name, + title: i18n('label_pool-name'), + }); + hiddenStorageFields.push('pool'); + } + if (issue.type === 'COMPUTE_POOL') { + fields.push({ + value: issue.location?.compute?.pool?.name, + title: i18n('label_pool-name'), + }); + hiddenComputeFields.push('pool'); + } + if (issue.type === 'TABLET') { + const tablet = issue.location?.compute?.tablet; + fields.push( + { + value: tablet?.id?.length ? ( + ( + + {id} + + )} + /> + ) : undefined, + title: i18n('label_tablet-id'), + }, + { + value: tablet?.type, + title: i18n('label_tablet-type'), + }, + { + value: tablet?.count, + title: i18n('label_tablet-count'), + }, + ); + hiddenComputeFields.push('tablet'); + } + return {detailsFields: fields, hiddenComputeFields, hiddenStorageFields}; + }, [issue, database]); + + return ( + + + + + + + + ); +} + +interface NodeLocationProps { + location: Location['node']; +} + +function NodeLocation({location}: NodeLocationProps) { + if (!location || isEmpty(location)) { + return null; + } + + return ( + + + + ); +} + +interface PeerLocationProps { + location: Location['peer']; +} + +function PeerLocation({location}: PeerLocationProps) { + if (!location || isEmpty(location)) { + return null; + } + + return ( + + + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/NodeInfo.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/NodeInfo.tsx new file mode 100644 index 000000000..1d61e7a21 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/NodeInfo.tsx @@ -0,0 +1,34 @@ +import {InternalLink} from '../../../../../components/InternalLink'; +import type {Location} from '../../../../../types/api/healthcheck'; +import {getDefaultNodePath} from '../../../../Node/NodePages'; +import {useTenantQueryParams} from '../../../useTenantQueryParams'; +import i18n from '../../i18n'; + +import {LocationDetails} from './utils'; + +interface NodeInfoProps { + node?: Location['node']; + title?: string; +} + +export function NodeInfo({node, title}: NodeInfoProps) { + const {database} = useTenantQueryParams(); + if (!node) { + return null; + } + + const nodeLink = node.id ? ( + {node.id} + ) : undefined; + + return ( + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/PoolInfo.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/PoolInfo.tsx new file mode 100644 index 000000000..d5dd378d5 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/PoolInfo.tsx @@ -0,0 +1,19 @@ +import type {Location} from '../../../../../types/api/healthcheck'; +import i18n from '../../i18n'; + +import {LocationDetails} from './utils'; + +interface PoolInfoProps { + location?: Location['storage'] | Location['compute']; +} + +export function PoolInfo({location}: PoolInfoProps) { + const {pool} = location ?? {}; + const {name} = pool ?? {}; + + if (!name) { + return null; + } + + return ; +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/StorageLocation.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/StorageLocation.tsx new file mode 100644 index 000000000..b745a099a --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/StorageLocation.tsx @@ -0,0 +1,165 @@ +import React from 'react'; + +import {Flex} from '@gravity-ui/uikit'; +import {isEmpty} from 'lodash'; + +import {InternalLink} from '../../../../../components/InternalLink'; +import {getPDiskPagePath, getVDiskPagePath} from '../../../../../routes'; +import type {Location} from '../../../../../types/api/healthcheck'; +import i18n from '../../i18n'; + +import {NodeInfo} from './NodeInfo'; +import {PoolInfo} from './PoolInfo'; +import {IdList, LocationDetails, SectionWithTitle} from './utils'; + +export type LocationFieldStorage = 'pool' | 'node' | 'pdisk' | 'vdisk' | 'group'; + +type StorageLocationType = Location['storage']; + +const LocationFieldRenderer: Record< + LocationFieldStorage, + (location: StorageLocationType) => React.ReactNode +> = { + node: (location: StorageLocationType) => , + pool: (location: StorageLocationType) => , + group: (location: StorageLocationType) => , + vdisk: (location: StorageLocationType) => , + pdisk: (location: StorageLocationType) => , +}; + +interface StorageLocationProps { + location: StorageLocationType; + hiddenFields?: LocationFieldStorage[]; +} + +export function StorageLocation({location, hiddenFields = []}: StorageLocationProps) { + const {node, pool} = location ?? {}; + const {group} = pool ?? {}; + const {vdisk} = group ?? {}; + const {pdisk} = vdisk ?? {}; + + const fields = React.useMemo(() => { + const fields: LocationFieldStorage[] = []; + if (node) { + fields.push('node'); + } + if (pool) { + fields.push('pool'); + } + if (group) { + fields.push('group'); + } + if (vdisk) { + fields.push('vdisk'); + } + if (pdisk) { + fields.push('pdisk'); + } + return fields.filter((field) => !hiddenFields.includes(field)); + }, [node, pool, group, vdisk, pdisk, hiddenFields]); + + if (!location || isEmpty(location) || fields.length === 0) { + return null; + } + + return ( + + + {fields.map((field) => LocationFieldRenderer[field](location))} + + + ); +} + +interface StorageSectionProps { + location?: Location['storage']; +} + +function GroupInfo({location}: StorageSectionProps) { + const {pool} = location ?? {}; + const {group} = pool ?? {}; + + const ids = group?.id; + + if (!ids?.length) { + return null; + } + + return ( + : undefined, + title: i18n('label_group-id'), + }, + ]} + /> + ); +} + +function VDiskInfo({location}: StorageSectionProps) { + const {node, pool} = location ?? {}; + const {group} = pool ?? {}; + const {vdisk} = group ?? {}; + + const ids = vdisk?.id; + + if (!ids?.length) { + return null; + } + + return ( + ( + + {id} + + )} + /> + ) : undefined, + title: i18n('label_vdisk-id'), + }, + ]} + /> + ); +} +function PDiskInfo({location}: StorageSectionProps) { + const {node, pool} = location ?? {}; + const {group} = pool ?? {}; + const {vdisk} = group ?? {}; + const {pdisk} = vdisk ?? {}; + + if (!pdisk?.length) { + return null; + } + + return pdisk.map((disk: {id: string; path: string}) => ( + + {disk.id} + + ) : ( + disk.id + ), + title: i18n('label_pdisk-id'), + }, + {value: disk.path, title: i18n('label_pdisk-path')}, + ]} + /> + )); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/utils.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/utils.tsx new file mode 100644 index 000000000..a803d629d --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueDetails/utils.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import type {FlexProps, TextProps} from '@gravity-ui/uikit'; +import {DefinitionList, Flex, Text} from '@gravity-ui/uikit'; + +interface SectionWithTitleProps { + title?: string; + children: React.ReactNode; + titleVariant?: TextProps['variant']; + gap?: FlexProps['gap']; +} + +export function SectionWithTitle({ + title, + children, + titleVariant = 'body-2', + gap = 2, +}: SectionWithTitleProps) { + return ( + + {title && {title}} + {children} + + ); +} + +interface LocationDetailsProps { + title?: string; + fields: {value?: React.ReactNode; title: string; copy?: string}[]; + titleVariant?: TextProps['variant']; +} + +export function LocationDetails({title, fields, titleVariant}: LocationDetailsProps) { + const filteredFields = fields.filter((f) => f.value); + + if (filteredFields.length === 0) { + return null; + } + + return ( + + + {filteredFields.map((field) => ( + + {field.value} + + ))} + + + ); +} + +interface IdListProps { + ids: string[]; + renderItem?: (id: string) => React.ReactNode; +} + +export function IdList({ids, renderItem}: IdListProps) { + return ( + + {ids.map((id) => ( + {renderItem ? renderItem(id) : id} + ))} + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssueTabs.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueTabs.tsx new file mode 100644 index 000000000..24ab7c356 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssueTabs.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import {Flex, Text} from '@gravity-ui/uikit'; + +import type {IssuesTree} from '../../../../store/reducers/healthcheckInfo/types'; +import type {StatusFlag} from '../../../../types/api/healthcheck'; +import {b} from '../shared'; + +function getTypeText(type: string) { + const normalizedType = type.split('_').join(' '); + let result = normalizedType.charAt(0).toUpperCase() + normalizedType.slice(1).toLowerCase(); + result = result.replace(/\bvdisk\b/gi, 'VDisk').replace(/\bpdisk\b/gi, 'PDisk'); + return result; +} + +interface HealthcheckIssueTabsProps { + parents: IssuesTree[]; + selectedTab: string; + setSelectedTab: (tab: string) => void; +} + +export function HealthcheckIssueTabs({ + parents, + selectedTab, + setSelectedTab, +}: HealthcheckIssueTabsProps) { + //parent.length === 1 means that it's only issue itself, no need to render tabs + if (parents.length <= 1) { + return null; + } + + return ( + + {parents.map((parent, index) => ( + + setSelectedTab(parent.id)} + > + + + {getTypeText(parent.type)} + + + {index !== parents.length - 1 && /} + + ))} + + ); +} + +interface HealthcheckIssueTabProps { + children: React.ReactNode; + active?: boolean; + onClick: VoidFunction; +} + +function HealthcjeckIssueTab({children, active, onClick}: HealthcheckIssueTabProps) { + return ( + + {children} + + ); +} + +interface TabStatusProps { + status: StatusFlag; +} + +function TabStatus({status}: TabStatusProps) { + return
; +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckIssues.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckIssues.tsx new file mode 100644 index 000000000..208dbd1b5 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckIssues.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import {NoSearchResults} from '@gravity-ui/illustrations'; +import {Flex} from '@gravity-ui/uikit'; + +import {EmptyState} from '../../../../components/EmptyState'; +import type {IssuesTree} from '../../../../store/reducers/healthcheckInfo/types'; +import {useTenantQueryParams} from '../../useTenantQueryParams'; +import i18n from '../i18n'; + +import {HealthcheckIssue} from './HealthcheckIssue'; + +interface IssuesProps { + issues: IssuesTree[]; +} + +export function Issues({issues}: IssuesProps) { + const {view, issuesFilter} = useTenantQueryParams(); + + const filteredIssues = React.useMemo(() => { + const normalizedFilter = issuesFilter?.toLowerCase().trim(); + if (!normalizedFilter) { + return issues; + } + return issues.filter((issue) => { + const stack = Object.values(issue); + while (stack.length) { + const value = stack.pop(); + if (typeof value === 'object') { + stack.push(...Object.values(value)); + } else if (String(value).toLowerCase().includes(normalizedFilter)) { + return true; + } + } + return false; + }); + }, [issues, issuesFilter]); + + const filteredIssuesCurrentView = React.useMemo( + () => + view + ? filteredIssues.filter((issue) => { + const type = issue.firstParentType || issue.type; + return type.toLowerCase().startsWith(view); + }) + : [], + [filteredIssues, view], + ); + + if (filteredIssuesCurrentView.length === 0) { + return ( + + } + position="center" + size="xs" + title={i18n('label_no-issues')} + description={i18n('description_no-issues')} + /> + + ); + } + + return filteredIssuesCurrentView.map((issue) => ( + + )); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckRefresh.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckRefresh.tsx new file mode 100644 index 000000000..541688a93 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckRefresh.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import {duration} from '@gravity-ui/date-utils'; +import {ArrowsRotateLeft} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Text} from '@gravity-ui/uikit'; +import {isNil} from 'lodash'; + +import {DAY_IN_SECONDS, HOUR_IN_SECONDS, SECOND_IN_MS} from '../../../../utils/constants'; +import i18n from '../i18n'; + +function getPassedMilliseconds(lastFullfiled = 0) { + return Date.now() - lastFullfiled; +} + +interface HealthcheckRefreshProps { + lastFullfiled?: number; + refresh: () => void; +} + +export function HealthcheckRefresh({lastFullfiled, refresh}: HealthcheckRefreshProps) { + const [passedFromLastFullfiled, setPassedFromLastFullfiled] = React.useState( + getPassedMilliseconds(lastFullfiled), + ); + + React.useEffect(() => { + setPassedFromLastFullfiled(getPassedMilliseconds(lastFullfiled)); + const interval = setInterval(() => { + setPassedFromLastFullfiled(getPassedMilliseconds(lastFullfiled)); + }, 60 * SECOND_IN_MS); + return () => clearInterval(interval); + }, [lastFullfiled]); + + const renderPassedFromLastFullfiled = () => { + if (isNil(lastFullfiled)) { + return null; + } + const showHours = passedFromLastFullfiled > HOUR_IN_SECONDS * SECOND_IN_MS; + const showDays = passedFromLastFullfiled > DAY_IN_SECONDS * SECOND_IN_MS; + + const preparedDuration = duration(passedFromLastFullfiled); + + let durationText = ''; + + if (showDays) { + const days = preparedDuration.asDays(); + durationText = i18n('description_days', {count: Math.round(days)}); + } else if (showHours) { + const hours = preparedDuration.asHours(); + durationText = i18n('description_hours', {count: Math.round(hours)}); + } else { + const minutes = preparedDuration.asMinutes(); + durationText = i18n('description_minutes', {count: Math.round(minutes)}); + } + + return {durationText}; + }; + + return ( + + {renderPassedFromLastFullfiled()} + + + + + ); +} diff --git a/src/containers/Tenant/Healthcheck/components/HealthcheckView.tsx b/src/containers/Tenant/Healthcheck/components/HealthcheckView.tsx new file mode 100644 index 000000000..cf3a4f431 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/components/HealthcheckView.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import {RadioButton, Text} from '@gravity-ui/uikit'; + +import {uiFactory} from '../../../../uiFactory/uiFactory'; +import {useTenantQueryParams} from '../../useTenantQueryParams'; +import type {CommonIssueType} from '../shared'; +import {b} from '../shared'; + +const HealthcheckViewValues: Record = { + storage: 'storage', + compute: 'compute', +}; + +interface HealthcheckViewProps { + issuesCount: Record; + viewTitles?: Record; + sortOrder?: T[]; +} + +export function HealthcheckView({ + issuesCount, + viewTitles = uiFactory.healthcheck.getHealthckechViewTitles(), + sortOrder = uiFactory.healthcheck.getHealthcheckViewsOrder(), +}: HealthcheckViewProps) { + const {view, handleHealthcheckViewChange, handleIssuesFilterChange} = useTenantQueryParams(); + + const issuesTypes = React.useMemo(() => Object.keys(issuesCount), [issuesCount]); + + React.useEffect(() => { + if (view) { + return; + } + if (issuesCount[HealthcheckViewValues.storage]) { + handleHealthcheckViewChange(HealthcheckViewValues.storage); + } else if (issuesCount[HealthcheckViewValues.compute]) { + handleHealthcheckViewChange(HealthcheckViewValues.compute); + } else { + const firstIssueTypeWithIssues = sortOrder.find( + (issueType) => issuesCount[issueType] > 0, + ); + handleHealthcheckViewChange(firstIssueTypeWithIssues); + } + }, [view, handleHealthcheckViewChange, issuesCount, issuesTypes, sortOrder]); + + const renderCount = (view: (typeof sortOrder)[number]) => { + return {issuesCount[view] ?? 0}; + }; + + const renderHealthcheckViewOption = (view: (typeof sortOrder)[number]) => { + return ( + + {viewTitles[view] ?? view}  + {renderCount(view)} + + ); + }; + + return ( + { + handleHealthcheckViewChange(newView); + handleIssuesFilterChange(''); + }} + className={b('control-wrapper')} + > + {sortOrder.map((type) => renderHealthcheckViewOption(type))} + + ); +} diff --git a/src/containers/Tenant/Healthcheck/i18n/en.json b/src/containers/Tenant/Healthcheck/i18n/en.json new file mode 100644 index 000000000..caef14688 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/i18n/en.json @@ -0,0 +1,41 @@ +{ + "description_no-data": "No healthcheck data", + "label_storage": "Storage", + "label_compute": "Compute", + "description_search-issue": "Search issue...", + "action_refresh": "Refresh", + "description_minutes": "{{count}} min ago", + "description_hours": [ + "{{count}} hour ago", + "{{count}} hours ago", + "{{count}} hours ago", + "{{count}} hours ago" + ], + "description_days": [ + "{{count}} day ago", + "{{count}} days ago", + "{{count}} days ago", + "{{count}} days ago" + ], + "label_no-issues": "No issues", + "description_no-issues": "Here you will see issues that require your attention", + "label_details": "Details", + "label_storage_location": "Storage Location", + "label_compute_location": "Compute Location", + "label_description": "Description", + "label_node-id": "Node ID", + "label_node-host": "Node Host", + "label_node-port": "Node Port", + "label_pool-name": "Pool Name", + "label_tablet-id": "Tablet ID", + "label_tablet-type": "Tablet Type", + "label_tablet-count": "Tablets Count", + "label_schema-type": "Schema Type", + "label_schema-path": "Schema Path", + "label_group-id": "Group ID", + "label_vdisk-id": "VDisk ID", + "label_pdisk-id": "Pdisk ID", + "label_pdisk-path": "PDisk Path", + "label_node_location": "Node Location", + "label_peer_location": "Peer Node Location" +} diff --git a/src/containers/Tenant/Healthcheck/i18n/index.ts b/src/containers/Tenant/Healthcheck/i18n/index.ts new file mode 100644 index 000000000..ce86ce422 --- /dev/null +++ b/src/containers/Tenant/Healthcheck/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-healthcheck-details'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/Healthcheck/shared.ts b/src/containers/Tenant/Healthcheck/shared.ts new file mode 100644 index 000000000..49786d48b --- /dev/null +++ b/src/containers/Tenant/Healthcheck/shared.ts @@ -0,0 +1,50 @@ +import type {IssuesTree} from '../../../store/reducers/healthcheckInfo/types'; +import {cn} from '../../../utils/cn'; + +import i18n from './i18n'; + +export const b = cn('ydb-healthcheck'); + +export type CommonIssueType = 'compute' | 'storage'; + +const HealthcheckViewTitles = { + get storage() { + return i18n('label_storage'); + }, + get compute() { + return i18n('label_compute'); + }, +}; + +const DefaultSortOrder: CommonIssueType[] = ['storage', 'compute']; + +export function getHealthckechViewTitles() { + return HealthcheckViewTitles; +} + +export function getHealthcheckViewsOrder() { + return DefaultSortOrder; +} + +export function countHealthcheckIssuesByType( + issueTrees: IssuesTree[], +): Record { + const result: Record = { + storage: 0, + compute: 0, + }; + + for (const issue of issueTrees) { + const type = issue.firstParentType ?? issue.type; + if (type.startsWith('STORAGE')) { + result.storage++; + } else if (type.startsWith('COMPUTE')) { + result.compute++; + } + } + return result; +} + +export type GetHealthcheckViewTitles = () => Record; + +export type GetHealthcheckViewsOrder = () => T[]; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/useHealthcheck.ts b/src/containers/Tenant/Healthcheck/useHealthcheck.ts similarity index 51% rename from src/containers/Tenant/Diagnostics/TenantOverview/useHealthcheck.ts rename to src/containers/Tenant/Healthcheck/useHealthcheck.ts index b0ae2a42a..e52a2c9bb 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/useHealthcheck.ts +++ b/src/containers/Tenant/Healthcheck/useHealthcheck.ts @@ -1,20 +1,18 @@ import { healthcheckApi, - selectIssuesStatistics, - selectIssuesTrees, -} from '../../../../store/reducers/healthcheckInfo/healthcheckInfo'; -import type {IssuesTree} from '../../../../store/reducers/healthcheckInfo/types'; -import {SelfCheckResult} from '../../../../types/api/healthcheck'; -import type {StatusFlag} from '../../../../types/api/healthcheck'; -import {useTypedSelector} from '../../../../utils/hooks'; + selectLeavesIssues, +} from '../../../store/reducers/healthcheckInfo/healthcheckInfo'; +import type {IssuesTree} from '../../../store/reducers/healthcheckInfo/types'; +import {SelfCheckResult} from '../../../types/api/healthcheck'; +import {useTypedSelector} from '../../../utils/hooks'; interface HealthcheckParams { - issueTrees: IssuesTree[]; - issuesStatistics: [StatusFlag, number][]; + leavesIssues: IssuesTree[]; loading: boolean; error?: unknown; refetch: () => void; selfCheckResult: SelfCheckResult; + fulfilledTimeStamp?: number; } export const useHealthcheck = ( @@ -26,22 +24,23 @@ export const useHealthcheck = ( isFetching, error, refetch, + fulfilledTimeStamp, } = healthcheckApi.useGetHealthcheckInfoQuery( {database: tenantName}, { pollingInterval: autorefresh, }, ); + const selfCheckResult = data?.self_check_result || SelfCheckResult.UNSPECIFIED; - const issuesStatistics = useTypedSelector((state) => selectIssuesStatistics(state, tenantName)); - const issueTrees = useTypedSelector((state) => selectIssuesTrees(state, tenantName)); + const leavesIssues = useTypedSelector((state) => selectLeavesIssues(state, tenantName)); return { - issueTrees, - issuesStatistics, loading: data === undefined && isFetching, error, refetch, selfCheckResult, + fulfilledTimeStamp, + leavesIssues, }; }; diff --git a/src/containers/Tenant/Tenant.tsx b/src/containers/Tenant/Tenant.tsx index b03b3e294..15117085b 100644 --- a/src/containers/Tenant/Tenant.tsx +++ b/src/containers/Tenant/Tenant.tsx @@ -1,7 +1,6 @@ import React from 'react'; import {Helmet} from 'react-helmet-async'; -import {StringParam, useQueryParams} from 'use-query-params'; import {PageError} from '../../components/Errors/PageError/PageError'; import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; @@ -17,7 +16,9 @@ import {isAccessError} from '../../utils/response'; import ObjectGeneral from './ObjectGeneral/ObjectGeneral'; import {ObjectSummary} from './ObjectSummary/ObjectSummary'; +import {TenantDrawerWrapper} from './TenantDrawerWrappers'; import i18n from './i18n'; +import {useTenantQueryParams} from './useTenantQueryParams'; import { PaneVisibilityActionTypes, paneVisibilityToggleReducerCreator, @@ -49,55 +50,41 @@ export function Tenant(props: TenantProps) { getTenantSummaryState, ); - // TODO: name is used together with database to keep old links valid - // Remove it after some time - 1-2 weeks - const [{database, name, schema}, setQuery] = useQueryParams({ - database: StringParam, - name: StringParam, - schema: StringParam, - }); + const {database, schema} = useTenantQueryParams(); - React.useEffect(() => { - if (name && !database) { - setQuery({database: name, name: undefined}, 'replaceIn'); - } - }, [database, name, setQuery]); - - const tenantName = database ?? name; - - if (!tenantName) { + if (!database) { throw new Error('Tenant name is not defined'); } const previousTenant = React.useRef(); React.useEffect(() => { - if (previousTenant.current !== tenantName) { + if (previousTenant.current !== database) { const register = async () => { const {registerYQLCompletionItemProvider} = await import( '../../utils/monaco/yql/yql.completionItemProvider' ); - registerYQLCompletionItemProvider(tenantName); + registerYQLCompletionItemProvider(database); }; register().catch(console.error); - previousTenant.current = tenantName; + previousTenant.current = database; } - }, [tenantName]); + }, [database]); const dispatch = useTypedDispatch(); React.useEffect(() => { - dispatch(setHeaderBreadcrumbs('tenant', {tenantName})); - }, [tenantName, dispatch]); + dispatch(setHeaderBreadcrumbs('tenant', {tenantName: database})); + }, [database, dispatch]); - const path = schema ?? tenantName; + const path = schema ?? database; const { currentData: currentItem, error, isLoading, - } = overviewApi.useGetOverviewQuery({path, database: tenantName}); + } = overviewApi.useGetOverviewQuery({path, database: database}); const preloadedData = useTypedSelector((state) => - selectSchemaObjectData(state, path, tenantName), + selectSchemaObjectData(state, path, database), ); // Use preloaded data if there is no current item data yet @@ -135,34 +122,36 @@ export function Tenant(props: TenantProps) { /> - - -
- + + -
-
+
+ +
+ +
diff --git a/src/containers/Tenant/TenantDrawerWrappers.tsx b/src/containers/Tenant/TenantDrawerWrappers.tsx new file mode 100644 index 000000000..0d4c1a9b5 --- /dev/null +++ b/src/containers/Tenant/TenantDrawerWrappers.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +import {ArrowDownToLine} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon, Text} from '@gravity-ui/uikit'; + +import {DrawerWrapper} from '../../components/Drawer'; +import {DrawerContextProvider} from '../../components/Drawer/DrawerContext'; +import EnableFullscreenButton from '../../components/EnableFullscreenButton/EnableFullscreenButton'; +import { + selectAllHealthcheckInfo, + selectCheckStatus, +} from '../../store/reducers/healthcheckInfo/healthcheckInfo'; +import type {SelfCheckResult} from '../../types/api/healthcheck'; +import {createAndDownloadJsonFile} from '../../utils/downloadFile'; +import {useTypedSelector} from '../../utils/hooks'; + +import {Healthcheck} from './Healthcheck/Healthcheck'; +import {HEALTHCHECK_RESULT_TO_TEXT} from './constants'; +import i18n from './i18n'; +import {useTenantQueryParams} from './useTenantQueryParams'; + +interface TenantDrawerWrapperProps { + children: React.ReactNode; + database: string; +} + +export function TenantDrawerWrapper({children, database}: TenantDrawerWrapperProps) { + const { + handleShowHealthcheckChange, + showHealthcheck, + handleIssuesFilterChange, + handleHealthcheckViewChange, + } = useTenantQueryParams(); + + const healthcheckStatus = useTypedSelector((state) => selectCheckStatus(state, database || '')); + + const healthcheckData = useTypedSelector((state) => + selectAllHealthcheckInfo(state, database || ''), + ); + + const handleCloseDrawer = React.useCallback(() => { + handleShowHealthcheckChange(false); + handleIssuesFilterChange(undefined); + handleHealthcheckViewChange(undefined); + }, [handleShowHealthcheckChange, handleIssuesFilterChange, handleHealthcheckViewChange]); + + const renderDrawerContent = React.useCallback(() => { + return ; + }, []); + + return ( + + + + + ), + }, + { + type: 'custom', + node: , + key: 'fullscreen', + }, + {type: 'close'}, + ]} + title={} + > + {children} + + + ); +} + +interface DrawerTitleProps { + status?: SelfCheckResult; +} + +function DrawerTitle({status}: DrawerTitleProps) { + return ( + + {i18n('label_healthcheck-dashboard')} + {status && HEALTHCHECK_RESULT_TO_TEXT[status]} + + ); +} diff --git a/src/containers/Tenant/TenantPages.tsx b/src/containers/Tenant/TenantPages.tsx index 81bd20850..c357730e5 100644 --- a/src/containers/Tenant/TenantPages.tsx +++ b/src/containers/Tenant/TenantPages.tsx @@ -14,6 +14,8 @@ type AdditionalQueryParams = { backend?: string; selectedPartition?: string; activeOffset?: string; + metricsTab?: string; + showPreview?: boolean; }; export type TenantQuery = TenantQueryParams | AdditionalQueryParams; diff --git a/src/containers/Tenant/constants.ts b/src/containers/Tenant/constants.ts new file mode 100644 index 000000000..a6f4a0008 --- /dev/null +++ b/src/containers/Tenant/constants.ts @@ -0,0 +1,28 @@ +import { + CircleCheckFill, + CircleInfoFill, + CircleQuestionFill, + CircleXmarkFill, + TriangleExclamationFill, +} from '@gravity-ui/icons'; +import type {IconData} from '@gravity-ui/uikit'; + +import {SelfCheckResult} from '../../types/api/healthcheck'; + +import i18n from './i18n'; + +export const HEALTHCHECK_RESULT_TO_TEXT: Record = { + [SelfCheckResult.UNSPECIFIED]: '', + [SelfCheckResult.GOOD]: i18n('description_ok'), + [SelfCheckResult.DEGRADED]: i18n('description_degraded'), + [SelfCheckResult.MAINTENANCE_REQUIRED]: i18n('description_maintenance'), + [SelfCheckResult.EMERGENCY]: i18n('description_emergency'), +}; + +export const HEALTHCHECK_RESULT_TO_ICON: Record = { + [SelfCheckResult.UNSPECIFIED]: CircleQuestionFill, + [SelfCheckResult.GOOD]: CircleCheckFill, + [SelfCheckResult.DEGRADED]: CircleInfoFill, + [SelfCheckResult.MAINTENANCE_REQUIRED]: CircleXmarkFill, + [SelfCheckResult.EMERGENCY]: TriangleExclamationFill, +}; diff --git a/src/containers/Tenant/i18n/en.json b/src/containers/Tenant/i18n/en.json index a7d9313e4..1f588674a 100644 --- a/src/containers/Tenant/i18n/en.json +++ b/src/containers/Tenant/i18n/en.json @@ -58,5 +58,12 @@ "schema.tree.dialog.header": "Create directory", "schema.tree.dialog.description": "Inside", "schema.tree.dialog.buttonCancel": "Cancel", - "schema.tree.dialog.buttonApply": "Create" + "schema.tree.dialog.buttonApply": "Create", + "label_healthcheck-dashboard": "Healthcheck Dashboard", + + "description_ok": "Everything is working fine.", + "description_degraded": "Some issues, but still working.", + "description_maintenance": "Working, but needs urgent attention to avoid failure.", + "description_emergency": "Something is broken and not working.", + "label_download": "Download healthcheck data" } diff --git a/src/containers/Tenant/useTenantQueryParams.ts b/src/containers/Tenant/useTenantQueryParams.ts new file mode 100644 index 000000000..04f620424 --- /dev/null +++ b/src/containers/Tenant/useTenantQueryParams.ts @@ -0,0 +1,59 @@ +import React from 'react'; + +import {BooleanParam, StringParam, useQueryParams} from 'use-query-params'; + +export function useTenantQueryParams() { + const [{showHealthcheck, database, schema, view, issuesFilter}, setQueryParams] = + useQueryParams({ + showHealthcheck: BooleanParam, + database: StringParam, + schema: StringParam, + view: StringParam, + issuesFilter: StringParam, + }); + const handleShowHealthcheckChange = React.useCallback( + (value?: boolean) => { + setQueryParams({showHealthcheck: value}, 'replaceIn'); + }, + [setQueryParams], + ); + + const handleDatabaseChange = React.useCallback( + (value?: string) => { + setQueryParams({database: value}, 'replaceIn'); + }, + [setQueryParams], + ); + + const handleSchemaChange = React.useCallback( + (value?: string) => { + setQueryParams({schema: value}, 'replaceIn'); + }, + [setQueryParams], + ); + const handleIssuesFilterChange = React.useCallback( + (value?: string) => { + setQueryParams({issuesFilter: value}, 'replaceIn'); + }, + [setQueryParams], + ); + const handleHealthcheckViewChange = React.useCallback( + (value?: string) => { + setQueryParams({view: value}, 'replaceIn'); + }, + [setQueryParams], + ); + + return { + showHealthcheck, + handleShowHealthcheckChange, + database, + handleDatabaseChange, + schema, + handleSchemaChange, + view, + handleHealthcheckViewChange, + issuesFilter, + handleIssuesFilterChange, + }; +} diff --git a/src/containers/UserSettings/i18n/en.json b/src/containers/UserSettings/i18n/en.json index 638c0b82d..f8788754e 100644 --- a/src/containers/UserSettings/i18n/en.json +++ b/src/containers/UserSettings/i18n/en.json @@ -38,7 +38,7 @@ "settings.enableNetworkTable.title": "Enable network table", - "settings.showNetworkUtilization.title": "Show cluster network utilization", + "settings.showNetworkUtilization.title": "Show network utilization", "settings.useShowPlanToSvg.title": "Execution plan", "settings.useShowPlanToSvg.description": " Show \"Execution plan\" button in query result widow. Opens svg with execution plan in a new window.", diff --git a/src/store/reducers/healthcheckInfo/healthcheckInfo.ts b/src/store/reducers/healthcheckInfo/healthcheckInfo.ts index 402f74ff9..f94a1f544 100644 --- a/src/store/reducers/healthcheckInfo/healthcheckInfo.ts +++ b/src/store/reducers/healthcheckInfo/healthcheckInfo.ts @@ -4,7 +4,7 @@ import type {IssueLog, StatusFlag} from '../../../types/api/healthcheck'; import type {RootState} from '../../defaultStore'; import {api} from '../api'; -import type {IssuesTree} from './types'; +import {getLeavesFromTree} from './utils'; export const healthcheckApi = api.injectEndpoints({ endpoints: (builder) => ({ @@ -18,7 +18,9 @@ export const healthcheckApi = api.injectEndpoints({ {database, maxLevel}, {signal}, ); - return {data}; + return { + data, + }; } catch (error) { return {error}; } @@ -46,10 +48,6 @@ const sortIssues = (data: IssueLog[]): IssueLog[] => { }); }; -const getReasonsForIssue = ({issue, data}: {issue: IssueLog; data: IssueLog[]}) => { - return sortIssues(data.filter((item) => issue.reason && issue.reason.indexOf(item.id) !== -1)); -}; - const getRoots = (data: IssueLog[]): IssueLog[] => { return sortIssues( data.filter((item) => { @@ -58,51 +56,17 @@ const getRoots = (data: IssueLog[]): IssueLog[] => { ); }; -const getInvertedConsequencesTree = ({ - data, - roots, -}: { - data: IssueLog[]; - roots?: IssueLog[]; -}): IssuesTree[] => { - return roots - ? roots.map((issue) => { - const reasonsItems = getInvertedConsequencesTree({ - roots: getReasonsForIssue({issue, data}), - data, - }); - - return { - ...issue, - reasonsItems, - }; - }) - : []; -}; - -const getIssuesStatistics = (data: IssueLog[]): [StatusFlag, number][] => { - const issuesMap = {} as Record; - - for (const issue of data) { - if (!issuesMap[issue.status]) { - issuesMap[issue.status] = 0; - } - issuesMap[issue.status]++; - } - - return (Object.entries(issuesMap) as [StatusFlag, number][]).sort(([aStatus], [bStatus]) => { - const bPriority = mapStatusToPriority[bStatus] || 0; - const aPriority = mapStatusToPriority[aStatus] || 0; - - return aPriority - bPriority; - }); -}; - const createGetHealthcheckInfoSelector = createSelector( (database: string) => database, (database) => healthcheckApi.endpoints.getHealthcheckInfo.select({database}), ); +export const selectCheckStatus = createSelector( + (state: RootState) => state, + (_state: RootState, database: string) => createGetHealthcheckInfoSelector(database), + (state: RootState, selectGetPost) => selectGetPost(state).data?.self_check_result, +); + const getIssuesLog = createSelector( (state: RootState) => state, (_state: RootState, database: string) => createGetHealthcheckInfoSelector(database), @@ -111,13 +75,15 @@ const getIssuesLog = createSelector( const selectIssuesTreesRoots = createSelector(getIssuesLog, (issues = []) => getRoots(issues)); -export const selectIssuesTrees = createSelector( +export const selectLeavesIssues = createSelector( [getIssuesLog, selectIssuesTreesRoots], (data = [], roots = []) => { - return getInvertedConsequencesTree({data, roots}); + return roots.map((root) => getLeavesFromTree(data, root)).flat(); }, ); -export const selectIssuesStatistics = createSelector(getIssuesLog, (issues = []) => - getIssuesStatistics(issues), +export const selectAllHealthcheckInfo = createSelector( + (state: RootState) => state, + (_state: RootState, database: string) => createGetHealthcheckInfoSelector(database), + (state: RootState, selectGetPost) => selectGetPost(state).data, ); diff --git a/src/store/reducers/healthcheckInfo/types.ts b/src/store/reducers/healthcheckInfo/types.ts index ad0db05dd..055dcfa5f 100644 --- a/src/store/reducers/healthcheckInfo/types.ts +++ b/src/store/reducers/healthcheckInfo/types.ts @@ -2,4 +2,6 @@ import type {IssueLog} from '../../../types/api/healthcheck'; export interface IssuesTree extends IssueLog { reasonsItems?: IssuesTree[]; + parent?: IssuesTree; + firstParentType?: string; } diff --git a/src/store/reducers/healthcheckInfo/utils.ts b/src/store/reducers/healthcheckInfo/utils.ts index 0657b409f..d70c2bcb2 100644 --- a/src/store/reducers/healthcheckInfo/utils.ts +++ b/src/store/reducers/healthcheckInfo/utils.ts @@ -1,6 +1,9 @@ import {EFlag} from '../../../types/api/enums'; +import type {IssueLog} from '../../../types/api/healthcheck'; import {StatusFlag} from '../../../types/api/healthcheck'; +import type {IssuesTree} from './types'; + export const hcStatusToColorFlag: Record = { [StatusFlag.UNSPECIFIED]: EFlag.Grey, [StatusFlag.GREY]: EFlag.Grey, @@ -10,3 +13,45 @@ export const hcStatusToColorFlag: Record = { [StatusFlag.ORANGE]: EFlag.Orange, [StatusFlag.RED]: EFlag.Red, }; + +export function getLeavesFromTree(issues: IssueLog[], root: IssueLog): IssuesTree[] { + const result: IssuesTree[] = []; + + if (!root.reason || root.reason.length === 0) { + return [root]; + } + + for (const issueId of root.reason) { + const directChild: IssuesTree | undefined = issues.find((issue) => issue.id === issueId); + if (!directChild) { + continue; + } + const stack: IssuesTree[] = [directChild]; + + const directChildType = directChild.type; + + while (stack.length > 0) { + const currentNode = stack.pop()!; + + if (!currentNode.reason || currentNode.reason.length === 0) { + result.push(currentNode); + continue; + } + + for (const reason of currentNode.reason) { + const child: IssuesTree | undefined = issues.find((issue) => issue.id === reason); + if (!child) { + continue; + } + const extendedChild = { + ...child, + parent: currentNode, + firstParentType: directChildType, + }; + stack.push(extendedChild); + } + } + } + + return result; +} diff --git a/src/store/reducers/tenant/constants.ts b/src/store/reducers/tenant/constants.ts index 2d40249e1..e90ae507e 100644 --- a/src/store/reducers/tenant/constants.ts +++ b/src/store/reducers/tenant/constants.ts @@ -40,5 +40,4 @@ export const TENANT_METRICS_TABS_IDS = { cpu: 'cpu', storage: 'storage', memory: 'memory', - healthcheck: 'healthcheck', } as const; diff --git a/src/store/reducers/tenants/utils.ts b/src/store/reducers/tenants/utils.ts index e596bc3ad..c9ec581ad 100644 --- a/src/store/reducers/tenants/utils.ts +++ b/src/store/reducers/tenants/utils.ts @@ -1,8 +1,10 @@ +import {isNil} from 'lodash'; + import type {PoolName, TPoolStats} from '../../../types/api/nodes'; import type {TTenant} from '../../../types/api/tenant'; import {EType} from '../../../types/api/tenant'; import {DEFAULT_DANGER_THRESHOLD, DEFAULT_WARNING_THRESHOLD} from '../../../utils/constants'; -import {isNumeric} from '../../../utils/utils'; +import {isNumeric, safeParseNumber} from '../../../utils/utils'; import {METRIC_STATUS} from './contants'; import type {PreparedTenant} from './types'; @@ -16,7 +18,7 @@ const getControlPlaneValue = (tenant: TTenant) => { }; export interface TenantMetricStats { - name: T; + name?: T; usage?: number; limit?: number; used?: number; @@ -60,6 +62,8 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { DatabaseQuotas = {}, StorageUsage, QuotaUsage, + NetworkUtilization, + NetworkWriteThroughput, } = tenant; const cpu = Number(CoresUsed) * 1_000_000 || 0; @@ -140,6 +144,18 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { }, ]; + const isNetworkStatsAvailabe = !isNil(NetworkUtilization) && !isNil(NetworkWriteThroughput); + const networkThroughput = safeParseNumber(NetworkWriteThroughput); + const usedNetwork = safeParseNumber(NetworkUtilization) * networkThroughput; + const networkStats: TenantMetricStats[] | undefined = isNetworkStatsAvailabe + ? [ + { + used: usedNetwork, + limit: networkThroughput, + }, + ] + : undefined; + return { memory, blobStorage, @@ -153,6 +169,7 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { memoryStats, blobStorageStats, tabletStorageStats, + networkStats, }; }; diff --git a/src/styles/index.scss b/src/styles/index.scss index b941caa91..7c9b1e3b6 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -4,6 +4,9 @@ @forward './illustrations.scss'; body { + --ydb-drawer-veil-z-index: 0; + + --gn-drawer-veil-z-index: var(--ydb-drawer-veil-z-index); margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', diff --git a/src/types/api/healthcheck.ts b/src/types/api/healthcheck.ts index b0a9e0648..5f55b94f4 100644 --- a/src/types/api/healthcheck.ts +++ b/src/types/api/healthcheck.ts @@ -1,3 +1,4 @@ +// Matches proto3 definition from Ydb.Monitoring package export enum SelfCheckResult { UNSPECIFIED = 'UNSPECIFIED', GOOD = 'GOOD', @@ -28,12 +29,12 @@ interface LocationStoragePDisk { } interface LocationStorageVDisk { - id: string; - pdisk: LocationStoragePDisk; + id: string[]; + pdisk: LocationStoragePDisk[]; } interface LocationStorageGroup { - id: string; + id: string[]; vdisk: LocationStorageVDisk; } @@ -53,30 +54,32 @@ interface LocationComputePool { interface LocationComputeTablet { type: string; - id?: string[]; + id: string[]; count: number; } interface LocationComputeSchema { - type?: string; - path?: string; + type: string; + path: string; } interface LocationCompute { - node?: LocationNode; - pool?: LocationComputePool; + node: LocationNode; + pool: LocationComputePool; tablet: LocationComputeTablet; - schema?: LocationComputeSchema; + schema: LocationComputeSchema; } interface LocationDatabase { name: string; } -interface Location { - storage: LocationStorage; - compute: LocationCompute; - database: LocationDatabase; +export interface Location { + storage?: LocationStorage; + compute?: LocationCompute; + database?: LocationDatabase; + node?: LocationNode; + peer?: LocationNode; } export interface IssueLog { @@ -87,6 +90,80 @@ export interface IssueLog { reason?: string[]; type: string; level: number; + listed?: number; + count?: number; +} + +interface StoragePDiskStatus { + id: string; + overall: StatusFlag; +} + +interface StorageVDiskStatus { + id: string; + overall: StatusFlag; + vdisk_status: StatusFlag; + pdisk: StoragePDiskStatus; +} + +interface StorageGroupStatus { + id: string; + overall: StatusFlag; + vdisks: StorageVDiskStatus[]; +} + +interface StoragePoolStatus { + id: string; + overall: StatusFlag; + groups: StorageGroupStatus[]; +} + +interface StorageStatus { + overall: StatusFlag; + pools: StoragePoolStatus[]; +} + +interface ComputeTabletStatus { + overall: StatusFlag; + type: string; + state: string; + count: number; + id: string[]; +} + +interface ThreadPoolStatus { + overall: StatusFlag; + name: string; + usage: number; +} + +interface LoadAverageStatus { + overall: StatusFlag; + load: number; + cores: number; +} + +interface ComputeNodeStatus { + id: string; + overall: StatusFlag; + tablets: ComputeTabletStatus[]; + pools: ThreadPoolStatus[]; + load: LoadAverageStatus; +} + +interface ComputeStatus { + overall: StatusFlag; + nodes: ComputeNodeStatus[]; + tablets: ComputeTabletStatus[]; + paths_quota_usage: number; + shards_quota_usage: number; +} + +interface DatabaseStatus { + name: string; + overall: StatusFlag; + storage: StorageStatus; + compute: ComputeStatus; } export interface HealthCheckAPIResponse { @@ -94,4 +171,7 @@ export interface HealthCheckAPIResponse { self_check_result: SelfCheckResult; // eslint-disable-next-line camelcase issue_log?: IssueLog[]; + // eslint-disable-next-line camelcase + database_status?: DatabaseStatus[]; + location?: LocationNode; } diff --git a/src/types/api/tenant.ts b/src/types/api/tenant.ts index 4fb38fb2f..a9b6aebd3 100644 --- a/src/types/api/tenant.ts +++ b/src/types/api/tenant.ts @@ -62,6 +62,11 @@ export interface TTenant { StorageUsage?: TStorageUsage[]; QuotaUsage?: TStorageUsage[]; + + /** value is float */ + NetworkUtilization?: number; + /** value is uint64 */ + NetworkWriteThroughput?: string; } export interface THiveDomainStatsStateCount { diff --git a/src/uiFactory/types.ts b/src/uiFactory/types.ts index 366889ce1..98c349c79 100644 --- a/src/uiFactory/types.ts +++ b/src/uiFactory/types.ts @@ -1,3 +1,9 @@ +import type { + CommonIssueType, + GetHealthcheckViewTitles, + GetHealthcheckViewsOrder, +} from '../containers/Tenant/Healthcheck/shared'; +import type {IssuesTree} from '../store/reducers/healthcheckInfo/types'; import type {PreparedTenant} from '../store/reducers/tenants/types'; import type {GetLogsLink} from '../utils/logs'; import type {GetMonitoringClusterLink, GetMonitoringLink} from '../utils/monitoring'; @@ -10,6 +16,14 @@ export interface UIFactory { getLogsLink?: GetLogsLink; getMonitoringLink?: GetMonitoringLink; getMonitoringClusterLink?: GetMonitoringClusterLink; + + healthcheck: { + getHealthckechViewTitles: GetHealthcheckViewTitles; + getHealthcheckViewsOrder: GetHealthcheckViewsOrder; + }; + countHealthcheckIssuesByType: ( + issueTrees: IssuesTree[], + ) => Record & Record; } export type HandleCreateDB = (params: {clusterName: string}) => Promise; diff --git a/src/uiFactory/uiFactory.ts b/src/uiFactory/uiFactory.ts index 6dae3aceb..6894f23f3 100644 --- a/src/uiFactory/uiFactory.ts +++ b/src/uiFactory/uiFactory.ts @@ -1,3 +1,8 @@ +import { + countHealthcheckIssuesByType, + getHealthcheckViewsOrder, + getHealthckechViewTitles, +} from '../containers/Tenant/Healthcheck/shared'; import { getMonitoringClusterLink as getMonitoringClusterLinkDefault, getMonitoringLink as getMonitoringLinkDefault, @@ -8,6 +13,11 @@ import type {UIFactory} from './types'; const uiFactoryBase: UIFactory = { getMonitoringLink: getMonitoringLinkDefault, getMonitoringClusterLink: getMonitoringClusterLinkDefault, + healthcheck: { + getHealthckechViewTitles, + getHealthcheckViewsOrder, + }, + countHealthcheckIssuesByType: countHealthcheckIssuesByType, }; export function configureUIFactory(overrides: UIFactory) { diff --git a/tests/suites/tenant/diagnostics/Diagnostics.ts b/tests/suites/tenant/diagnostics/Diagnostics.ts index f17cda1f5..fdd1e9820 100644 --- a/tests/suites/tenant/diagnostics/Diagnostics.ts +++ b/tests/suites/tenant/diagnostics/Diagnostics.ts @@ -243,7 +243,7 @@ export class Diagnostics { this.cpuCard = page.locator('.metrics-cards__tab:has-text("CPU")'); this.storageCard = page.locator('.metrics-cards__tab:has-text("Storage")'); this.memoryCard = page.locator('.metrics-cards__tab:has-text("Memory")'); - this.healthcheckCard = page.locator('.metrics-cards__tab:has-text("Healthcheck")'); + this.healthcheckCard = page.locator('.ydb-healthcheck-preview'); } async isSchemaViewerVisible() { @@ -334,10 +334,14 @@ export class Diagnostics { } async getHealthcheckStatus() { - const statusElement = this.healthcheckCard.locator( - '.healthcheck__self-check-status-indicator', - ); - return (await statusElement.textContent())?.trim() || ''; + const statusElement = this.healthcheckCard.locator('.ydb-healthcheck-preview__icon'); + return await statusElement.isVisible(); + } + + async hasHealthcheckStatusClass(className: string) { + const statusElement = this.healthcheckCard.locator('.ydb-healthcheck-preview__icon'); + const classList = await statusElement.evaluate((el) => Array.from(el.classList)); + return classList.includes(className); } async selectTopShardsMode(mode: TopShardsMode): Promise { diff --git a/tests/suites/tenant/diagnostics/tabs/info.test.ts b/tests/suites/tenant/diagnostics/tabs/info.test.ts index 75918464d..6c8f79763 100644 --- a/tests/suites/tenant/diagnostics/tabs/info.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/info.test.ts @@ -52,6 +52,11 @@ test.describe('Diagnostics Info tab', async () => { await diagnostics.clickTab(DiagnosticsTab.Info); const status = await diagnostics.getHealthcheckStatus(); - expect(status).toBe('GOOD'); + expect(status).toBeTruthy(); + + const isGood = await diagnostics.hasHealthcheckStatusClass( + 'ydb-healthcheck-preview__icon_good', + ); + expect(isGood).toBe(true); }); });