Unverified Commit 46f61083 authored by Boris Vasilenko's avatar Boris Vasilenko Committed by GitHub
Browse files

NAS-116916 / 22.12 / NAS-116916: 'status' column at pools table (#6861)


* NAS-116916: Creating new pools table details

* NAS-116916: Improvements to the code

* NAS-116916: tests for disk-node

* NAS-116916: use VDevStatus enum

* NAS-116916: redeclare DeviceNestedDataNode type

* NAS-116916: smart translation placeholder

* NAS-116916: fix translation (plural literal for 'one')

* NAS-116916: add translation support for root nodes

* NAS-116916: Adding table captions and other improvements

* NAS-116916: fix filtering

* NAS-116916: Fixing status caption position

* NAS-116916: Filtering tree nodes

* NAS-116916: Small adjustments

* NAS-116916: Collapse on click on the VDevGroup line
Co-authored-by: default avatarBoris Vasilenko <bvasilenko@ixsystems.com>
parent 8b8353af
Showing with 622 additions and 72 deletions
+622 -72
......@@ -16,6 +16,10 @@ export const customSvgIcons = {
ix_full_logo_rgb: 'assets/customicons/ix_full_logo_rgb.svg',
ix_logomark_rgb: 'assets/customicons/ix_logomark_rgb.svg',
ha_reconnecting: 'assets/customicons/ha_reconnecting.svg',
'ix-hdd': 'assets/customicons/ix-hdd.svg',
'ix-ssd': 'assets/customicons/ix-ssd.svg',
'ix-hdd-mirror': 'assets/customicons/ix-hdd-mirror.svg',
'ix-ssd-mirror': 'assets/customicons/ix-ssd-mirror.svg',
'iscsi-share': 'assets/customicons/iscsi-share.svg',
'nfs-share': 'assets/customicons/nfs-share.svg',
'smb-share': 'assets/customicons/smb-share.svg',
......
import { VDev } from 'app/interfaces/storage.interface';
export interface VDevGroup {
disk: string;
guid: string;
children: VDev[];
}
export type DeviceNestedDataNode = VDev | VDevGroup;
export function isVDev(obj: DeviceNestedDataNode): obj is VDev {
return 'stats' in obj;
}
export function flattenTreeWithFilter<T extends { children?: T[] }>(
items: T[],
filterFunction: (item: T) => boolean,
foundItems: T[] = [],
): T[] {
if (!items?.length) {
return;
}
for (const item of items) {
if (filterFunction(item)) {
foundItems.push(item);
}
flattenTreeWithFilter(item.children, filterFunction, foundItems);
}
return foundItems;
}
......@@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { filter, pluck } from 'rxjs/operators';
import { IxNestedTreeDataSource } from 'app/modules/ix-tree/ix-nested-tree-datasource';
import { findInTree } from 'app/modules/ix-tree/utils/find-in-tree.utils';
import { flattenTreeWithFilter } from 'app/modules/ix-tree/utils/flattern-tree-with-filter';
import { DatasetInTree } from 'app/pages/datasets/store/dataset-in-tree.interface';
import { DatasetTreeStore } from 'app/pages/datasets/store/dataset-store.service';
import { WebSocketService } from 'app/services';
......@@ -89,9 +89,9 @@ export class DatasetsManagementComponent implements OnInit {
private createDataSource(datasets: DatasetInTree[]): void {
this.dataSource = new IxNestedTreeDataSource<DatasetInTree>(datasets);
this.dataSource.filterPredicate = (datasets, query = '') => {
return datasets.map((datasetRoot) => {
return findInTree([datasetRoot], (dataset) => dataset.id.toLowerCase().includes(query.toLowerCase()));
}).filter(Boolean);
return flattenTreeWithFilter(datasets, (dataset: DatasetInTree) => {
return dataset.id.toLowerCase().includes(query.toLowerCase());
});
};
}
}
......@@ -14,52 +14,87 @@
</div>
<div class="disk-tree-wrapper">
<ix-tree class="disk-tree" [dataSource]="dataSource" [treeControl]="treeControl">
<ix-tree-node
*ixTreeNodeDef="let disk; dataSource: dataSource"
ixTreeNodeToggle
[ixTreeNodeToggleRecursive]="false"
[ixTreeNodeDefDataSource]="dataSource"
(click)="onRowSelected(disk, $event)"
[class.selected]="disk.guid === selectedItem?.guid"
>
<button mat-icon-button disabled></button>
{{ disk.disk || disk.guid }}
</ix-tree-node>
<div class="disk-tree-inner">
<div class="disk-tree-header">
<div>
<span class="disk-name-header">{{ 'Device Name' | translate }}</span>
</div>
<div class="disk-status-header">{{ 'Status' | translate }}</div>
<div>{{ 'Capacity' | translate }}</div>
<div>{{ 'Errors' | translate }}</div>
</div>
<ix-tree class="disk-tree" [dataSource]="dataSource" [treeControl]="treeControl">
<ix-tree-node
*ixTreeNodeDef="let vdev; dataSource: dataSource"
ixTreeNodeToggle
[ixTreeNodeToggleRecursive]="false"
[ixTreeNodeDefDataSource]="dataSource"
(click)="onRowSelected(vdev, $event)"
[class.selected]="vdev.guid === selectedItem?.guid"
>
<button mat-icon-button disabled></button>
<ix-disk-node [vdev]="vdev | cast" [disk]="diskDictionary[vdev.disk]"></ix-disk-node>
</ix-tree-node>
<ix-nested-tree-node
*ixTreeNodeDef="let disk; dataSource: dataSource, when: hasNestedChild"
[ixTreeNodeDefDataSource]="dataSource"
>
<div
class="disk-nested-tree-root-node"
(click)="onRowSelected(disk, $event)"
[class.selected]="disk.guid === selectedItem?.guid"
<ix-nested-tree-node
*ixTreeNodeDef="let vdev; dataSource: dataSource, when: isVdevGroup"
[ixTreeNodeDefDataSource]="dataSource"
>
<button
mat-icon-button
ixTreeNodeToggle
(click)="$event.preventDefault()"
[attr.aria-label]="'Toggle {row}' | translate: { row: disk.guid }"
<div
class="disk-nested-tree-root-node"
(click)="onRowGroupSelected(vdev, $event)"
>
<mat-icon class="mat-icon-rtl-mirror">
{{ treeControl.isExpanded(disk) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
<ix-vdev-group-node [vdev]="vdev | cast">
<button
mat-icon-button
ixTreeNodeToggle
(click)="$event.preventDefault()"
[attr.aria-label]="'Toggle {row}' | translate: { row: vdev.guid }"
class="vdev-group-toggle"
>
<mat-icon>
{{ treeControl.isExpanded(vdev) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
</ix-vdev-group-node>
</div>
{{ disk.disk || disk.guid }}
</div>
<ng-container *ngIf="treeControl.isExpanded(vdev)" ixTreeNodeOutlet></ng-container>
</ix-nested-tree-node>
<ix-nested-tree-node
*ixTreeNodeDef="let vdev; dataSource: dataSource, when: hasNestedChild"
[ixTreeNodeDefDataSource]="dataSource"
>
<div
class="disk-nested-tree-root-node"
(click)="onRowSelected(vdev, $event)"
[class.selected]="vdev.guid === selectedItem?.guid"
>
<button
mat-icon-button
ixTreeNodeToggle
(click)="$event.preventDefault()"
[attr.aria-label]="'Toggle {row}' | translate: { row: vdev.guid }"
>
<mat-icon>
{{ treeControl.isExpanded(vdev) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
<ix-disk-node [vdev]="vdev | cast" [disk]="diskDictionary[vdev.children[0].disk]"></ix-disk-node>
</div>
<ng-container *ngIf="treeControl.isExpanded(disk)" ixTreeNodeOutlet></ng-container>
</ix-nested-tree-node>
</ix-tree>
<ng-container *ngIf="treeControl.isExpanded(vdev)" ixTreeNodeOutlet></ng-container>
</ix-nested-tree-node>
</ix-tree>
</div>
</div>
</div>
<div class="details-container">
<ix-disk-details-panel
*ngIf="selectedItem && isDiskSelected && diskDictionary[selectedItem.disk]"
[topologyItem]="selectedItem"
[topologyParentItem]="selectedParentItem"
[topologyItem]="selectedItem | cast"
[topologyParentItem]="selectedParentItem | cast"
[disk]="diskDictionary[selectedItem.disk]"
></ix-disk-details-panel>
</div>
......
@import '~assets/styles/mixins/grid';
:host {
$page-header-height: 81px;
display: block;
......@@ -55,10 +57,47 @@
overflow: auto;
}
.disk-tree-inner {
display: flex;
flex: 1;
flex-direction: column;
min-width: fit-content;
}
.disk-tree {
border-top: 2px solid var(--lines);
}
.disk-tree-header {
@include grid-row();
align-items: center;
color: var(--fg2);
min-height: 48px;
padding-left: 48px;
> div {
font-weight: bold;
padding: 4px 0;
&:first-child {
left: 0;
position: sticky;
}
}
.disk-name-header {
background: linear-gradient(90deg, var(--bg1) 0%, var(--bg1) calc(100% - 13px), transparent 100%);
padding-left: 8px;
padding-right: 15px;
}
.disk-status-header {
display: flex;
justify-content: center;
width: 70%;
}
}
.disk-nested-tree-root-node {
align-items: center;
border-bottom: 1px solid var(--lines);
......@@ -87,3 +126,10 @@
}
}
}
.vdev-group-toggle {
height: 25px;
line-height: 0;
margin-right: 10px;
width: 25px;
}
......@@ -4,14 +4,17 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import _ from 'lodash';
import { EMPTY } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { VDevType } from 'app/enums/v-dev-type.enum';
import { DeviceNestedDataNode, isVDev } from 'app/interfaces/device-nested-data-node.interface';
import { PoolTopology } from 'app/interfaces/pool.interface';
import { Disk, VDev } from 'app/interfaces/storage.interface';
import { Disk } from 'app/interfaces/storage.interface';
import { IxNestedTreeDataSource } from 'app/modules/ix-tree/ix-nested-tree-datasource';
import { findInTree } from 'app/modules/ix-tree/utils/find-in-tree.utils';
import { flattenTreeWithFilter } from 'app/modules/ix-tree/utils/flattern-tree-with-filter';
import { DevicesStore } from 'app/pages/storage2/modules/devices/stores/devices-store.service';
import { WebSocketService } from 'app/services';
......@@ -23,26 +26,28 @@ import { WebSocketService } from 'app/services';
})
export class DevicesComponent implements OnInit {
topology: PoolTopology;
selectedItem: VDev;
selectedParentItem: VDev | undefined;
dataSource: IxNestedTreeDataSource<VDev>;
treeControl = new NestedTreeControl<VDev, string>((vdev) => vdev.children, {
selectedItem: DeviceNestedDataNode;
selectedParentItem: DeviceNestedDataNode | undefined;
dataSource: IxNestedTreeDataSource<DeviceNestedDataNode>;
treeControl = new NestedTreeControl<DeviceNestedDataNode, string>((vdev) => vdev.children, {
trackBy: (vdev) => vdev.guid,
});
diskDictionary: { [key: string]: Disk } = {};
isLoading = false;
readonly hasNestedChild = (_: number, vdev: VDev): boolean => Boolean(vdev.children?.length);
readonly hasNestedChild = (_: number, vdev: DeviceNestedDataNode): boolean => Boolean(vdev.children?.length);
readonly isVdevGroup = (_: number, vdev: DeviceNestedDataNode): boolean => !isVDev(vdev);
constructor(
private ws: WebSocketService,
private cdr: ChangeDetectorRef,
private route: ActivatedRoute,
private devicesStore: DevicesStore,
private translate: TranslateService,
) { }
get isDiskSelected(): boolean {
return this.selectedItem.type === VDevType.Disk;
return isVDev(this.selectedItem) && this.selectedItem.type === VDevType.Disk;
}
ngOnInit(): void {
......@@ -53,43 +58,68 @@ export class DevicesComponent implements OnInit {
.subscribe(() => this.loadTopologyAndDisks());
}
private createDataSource(disks: VDev[]): void {
this.dataSource = new IxNestedTreeDataSource(disks);
this.dataSource.filterPredicate = (disks, query = '') => {
return disks.map((disk) => {
return findInTree([disk], (vdev) => {
switch (vdev.type) {
private createDataSource(dataNodes: DeviceNestedDataNode[]): void {
this.dataSource = new IxNestedTreeDataSource(dataNodes);
this.dataSource.filterPredicate = (dataNodes, query = '') => {
return flattenTreeWithFilter(dataNodes, (dataNode) => {
if (isVDev(dataNode)) {
switch (dataNode.type) {
case VDevType.Disk:
return vdev.disk.toLowerCase().includes(query.toLowerCase());
return dataNode.disk?.toLowerCase().includes(query.toLowerCase());
case VDevType.Mirror:
return vdev.name.toLowerCase().includes(query.toLowerCase());
return dataNode.name?.toLowerCase().includes(query.toLowerCase());
}
});
}).filter(Boolean);
} else {
return false;
}
});
};
}
private selectFirstNode(): void {
if (!this.treeControl?.dataNodes?.length) {
return;
private createDataNodes(topology: PoolTopology): DeviceNestedDataNode[] {
const dataNodes: DeviceNestedDataNode[] = [];
if (topology.data.length) {
dataNodes.push({ children: topology.data, disk: this.translate.instant('Data VDEVs'), guid: 'data' } as DeviceNestedDataNode);
}
if (topology.cache.length) {
dataNodes.push({ children: topology.cache, disk: this.translate.instant('Cache'), guid: 'cache' } as DeviceNestedDataNode);
}
if (topology.log.length) {
dataNodes.push({ children: topology.log, disk: this.translate.instant('Log'), guid: 'log' } as DeviceNestedDataNode);
}
if (topology.spare.length) {
dataNodes.push({ children: topology.spare, disk: this.translate.instant('Spare'), guid: 'spare' } as DeviceNestedDataNode);
}
if (topology.special.length) {
dataNodes.push({ children: topology.special, disk: this.translate.instant('Metadata'), guid: 'special' } as DeviceNestedDataNode);
}
if (topology.dedup.length) {
dataNodes.push({ children: topology.dedup, disk: this.translate.instant('Dedup'), guid: 'dedup' } as DeviceNestedDataNode);
}
return dataNodes;
}
const disk = this.treeControl.dataNodes[0];
this.treeControl.expand(disk);
this.selectedItem = disk;
private selectVdevGroupNode(): void {
this.treeControl?.dataNodes?.forEach((node) => this.treeControl.expand(node));
this.selectedParentItem = undefined;
}
onRowSelected(vdev: VDev, event: MouseEvent): void {
onRowSelected(dataNodeSelected: DeviceNestedDataNode, event: MouseEvent): void {
event.stopPropagation();
this.selectedItem = vdev;
this.selectedParentItem = [...Object.values(this.topology.data)].find((group: VDev) => {
return group?.children.find((child) => {
return child.guid === vdev.guid;
});
this.selectedItem = dataNodeSelected;
this.selectedParentItem = findInTree(this.treeControl.dataNodes, (dataNode: DeviceNestedDataNode) => {
return dataNode.guid === dataNodeSelected.guid;
});
}
onRowGroupSelected(dataNodeSelected: DeviceNestedDataNode, _: MouseEvent): void {
if (this.treeControl.isExpanded(dataNodeSelected)) {
this.treeControl.collapse(dataNodeSelected);
} else {
this.treeControl.expand(dataNodeSelected);
}
}
onSearch(query: string): void {
this.dataSource.filter(query);
}
......@@ -105,9 +135,10 @@ export class DevicesComponent implements OnInit {
tap((disks) => {
this.diskDictionary = _.keyBy(disks, (disk) => disk.devname);
this.topology = pools[0].topology;
this.treeControl.dataNodes = this.topology.data;
this.createDataSource(this.topology.data);
this.selectFirstNode();
const dataNodes = this.createDataNodes(pools[0].topology);
this.treeControl.dataNodes = dataNodes;
this.createDataSource(dataNodes);
this.selectVdevGroupNode();
this.isLoading = false;
this.cdr.markForCheck();
}),
......
<mat-icon *ngIf="diskIcon" [svgIcon]="diskIcon"></mat-icon>
\ No newline at end of file
:host {
margin-right: 6px;
}
.mat-icon {
align-items: center;
display: inline-flex;
vertical-align: bottom;
}
import { MatIcon } from '@angular/material/icon';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { DiskType } from 'app/enums/disk-type.enum';
import { Disk, VDev } from 'app/interfaces/storage.interface';
import { DiskIconComponent } from 'app/pages/storage2/modules/devices/components/disk-icon/disk-icon.component';
describe('DiskIconComponent', () => {
let spectator: Spectator<DiskIconComponent>;
const diskSsd = { type: DiskType.Ssd } as Disk;
const diskHdd = { type: DiskType.Hdd } as Disk;
const vdevDisk = { children: [] } as VDev;
const vdevMirror = { children: [{}] } as VDev;
const createComponent = createComponentFactory({
component: DiskIconComponent,
});
it('shows hdd disk icon', () => {
spectator = createComponent({
props: { disk: diskHdd, vdev: vdevDisk },
});
expect(spectator.query(MatIcon).svgIcon).toBe('ix-hdd');
});
it('shows ssd disk icon', () => {
spectator = createComponent({
props: { disk: diskSsd, vdev: vdevDisk },
});
expect(spectator.query(MatIcon).svgIcon).toBe('ix-ssd');
});
it('shows hdd mirror icon', () => {
spectator = createComponent({
props: { disk: diskHdd, vdev: vdevMirror },
});
expect(spectator.query(MatIcon).svgIcon).toBe('ix-hdd-mirror');
});
it('shows ssd mirror icon', () => {
spectator = createComponent({
props: { disk: diskSsd, vdev: vdevMirror },
});
expect(spectator.query(MatIcon).svgIcon).toBe('ix-ssd-mirror');
});
});
import {
ChangeDetectionStrategy, Component, Input,
} from '@angular/core';
import { DiskType } from 'app/enums/disk-type.enum';
import { Disk, VDev } from 'app/interfaces/storage.interface';
@Component({
selector: 'ix-disk-icon',
templateUrl: './disk-icon.component.html',
styleUrls: ['./disk-icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiskIconComponent {
@Input() vdev: VDev;
@Input() disk: Disk;
get diskIcon(): string {
if (this.vdev.children.length) {
if (this.disk.type === DiskType.Hdd) {
return 'ix-hdd-mirror';
}
if (this.disk.type === DiskType.Ssd) {
return 'ix-ssd-mirror';
}
} else {
if (this.disk.type === DiskType.Hdd) {
return 'ix-hdd';
}
if (this.disk.type === DiskType.Ssd) {
return 'ix-ssd';
}
}
return '';
}
}
<div class="tree-node-grid">
<div class="cell cell-name">
<div class="icon-container">
<ix-disk-icon [vdev]="vdev" [disk]="disk"></ix-disk-icon>
</div>
<span class="name">{{ diskName }}</span>
</div>
<div class="cell cell-status">
<span [style.background-color]="statusColor">{{ diskStatus }}</span>
</div>
<div class="cell cell-capacity">
<span>{{ diskCapacity }}</span>
</div>
<div class="cell cell-errors">
<span>{{ diskErrors }}</span>
</div>
</div>
@import '~assets/styles/mixins/grid';
:host {
align-items: center;
display: flex;
flex: 1;
font-weight: 500;
min-height: 48px;
padding: 0;
}
.tree-node-grid {
@include grid-row();
align-items: center;
.cell {
align-items: center;
display: inline-flex;
min-height: 48px;
&:first-child {
left: 0;
position: sticky;
}
}
.cell-status {
justify-content: center;
width: 70%;
span {
padding: 10px 20px;
}
}
.cell-name {
align-items: stretch;
font-weight: bold;
.icon-container {
align-items: center;
background-color: var(--bg2);
display: flex;
padding: 5px;
}
.name {
align-items: center;
background: linear-gradient(90deg, var(--bg2) 0%, var(--bg2) calc(100% - 11px), transparent 100%);
display: inline-flex;
padding-right: 15px;
}
}
}
// TODO: Fragile (at least with css selectors)
:host-context(ix-tree-node:hover),
:host-context(ix-tree-node.selected),
:host-context(.disk-nested-tree-root-node:hover),
:host-context(.disk-nested-tree-root-node.selected) {
.tree-node-grid .cell-name .name {
background: linear-gradient(90deg, var(--hover-bg) 0%, var(--hover-bg) calc(100% - 11px), transparent 100%);
}
.tree-node-grid .cell-name .icon-container {
background-color: var(--hover-bg);
}
}
$level-offset: 25px;
$padding-left: 8px;
::ng-deep {
@for $i from 2 through 10 {
ix-tree-node[aria-level="#{$i}"],
ix-nested-tree-node[aria-level="#{$i}"] {
.mat-icon-button:first-child {
left: $padding-left + $level-offset * ($i - 2);
}
ix-disk-node {
padding-left: $padding-left + $level-offset * ($i - 2);
}
}
}
}
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import { DiskType } from 'app/enums/disk-type.enum';
import { VDevStatus } from 'app/enums/vdev-status.enum';
import { Disk, VDev } from 'app/interfaces/storage.interface';
import { DiskIconComponent } from 'app/pages/storage2/modules/devices/components/disk-icon/disk-icon.component';
import { DiskNodeComponent } from 'app/pages/storage2/modules/devices/components/disk-node/disk-node.component';
describe('DiskNodeComponent', () => {
let spectator: Spectator<DiskNodeComponent>;
const vdev = {
type: 'DISK',
path: null,
guid: '123',
status: VDevStatus.Offline,
stats: {
read_errors: 1,
write_errors: 2,
checksum_errors: 3,
},
children: [],
disk: 'sdf',
} as VDev;
const disk = {
type: DiskType.Hdd,
size: 1024 * 1024 * 16,
} as Disk;
const createComponent = createComponentFactory({
component: DiskNodeComponent,
declarations: [
MockComponent(DiskIconComponent),
],
});
beforeEach(() => {
spectator = createComponent({
props: { disk, vdev },
});
});
it('shows "Device Name"', () => {
expect(spectator.query('.name')).toHaveText(vdev.disk);
expect(spectator.query(DiskIconComponent).disk).toBe(disk);
expect(spectator.query(DiskIconComponent).vdev).toBe(vdev);
});
it('shows "Status"', () => {
expect(spectator.query('.cell-status span')).toHaveText(vdev.status);
expect(spectator.component.statusColor).toEqual('var(--magenta)');
});
it('shows "Capacity"', () => {
expect(spectator.query('.cell-capacity')).toHaveText('16.00MiB');
});
it('shows "Errors"', () => {
expect(spectator.query('.cell-errors')).toHaveText('6 Errors');
});
});
import {
ChangeDetectionStrategy, Component, Input,
} from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { PoolStatus } from 'app/enums/pool-status.enum';
import { VDevStatus } from 'app/enums/vdev-status.enum';
import { Disk, VDev } from 'app/interfaces/storage.interface';
import { WidgetUtils } from 'app/pages/dashboard/utils/widget-utils';
@UntilDestroy()
@Component({
selector: 'ix-disk-node',
templateUrl: './disk-node.component.html',
styleUrls: ['./disk-node.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiskNodeComponent {
@Input() vdev: VDev;
@Input() disk: Disk;
private utils: WidgetUtils;
constructor(
protected translate: TranslateService,
) {
this.utils = new WidgetUtils();
}
get diskName(): string {
return this.vdev.disk || this.vdev.type;
}
get diskStatus(): string {
return this.vdev?.status ? this.vdev.status : '';
}
get diskCapacity(): string {
return this.disk && this.disk?.size ? this.utils.convert(this.disk.size).value
+ this.utils.convert(this.disk.size).units : '';
}
get diskErrors(): string {
if (this.vdev.stats) {
const errors = this.vdev.stats?.checksum_errors + this.vdev.stats?.read_errors + this.vdev.stats?.write_errors;
return this.translate.instant('{n, plural, =0 {No Errors} one {# Error} other {# Errors}}', { n: errors });
}
return '';
}
get statusColor(): string {
switch (this.vdev.status as PoolStatus | VDevStatus) {
case PoolStatus.Faulted:
return 'var(--red)';
case PoolStatus.Offline:
return 'var(--magenta)';
default:
return '';
}
}
}
<div class="row">
<div class="caption-container">
<span class="caption-name">{{ this.vdev.disk | translate }}</span>
</div>
<div class="toggle-container">
<ng-content></ng-content>
</div>
</div>
:host {
width: 100%;
}
.row {
background: var(--bg1);
color: var(--fg2);
display: flex;
justify-content: space-between;
min-height: 26px;
padding-left: 48px;
.caption-container {
font-weight: bold;
padding: 4px 0;
&:first-child {
left: 0;
position: sticky;
}
}
.caption-name {
background: linear-gradient(90deg, var(--bg1) 0%, var(--bg1) calc(100% - 13px), transparent 100%);
padding-left: 8px;
padding-right: 15px;
}
.toggle-container {
display: flex;
justify-content: flex-end;
position: sticky;
right: 0;
}
}
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { VDevGroup } from 'app/interfaces/device-nested-data-node.interface';
import { VDevGroupNodeComponent } from 'app/pages/storage2/modules/devices/components/vdev-group-node/vdev-group-node.component';
describe('VDevGroupNodeComponent', () => {
let spectator: Spectator<VDevGroupNodeComponent>;
const vdev = {
disk: 'Data VDEVs',
guid: 'data',
children: [],
} as VDevGroup;
const createComponent = createComponentFactory({
component: VDevGroupNodeComponent,
});
beforeEach(() => {
spectator = createComponent({
props: { vdev },
});
});
it('shows caption', () => {
expect(spectator.query('.caption-name')).toHaveText(vdev.disk);
});
});
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { VDevGroup } from 'app/interfaces/device-nested-data-node.interface';
@UntilDestroy()
@Component({
selector: 'ix-vdev-group-node',
templateUrl: './vdev-group-node.component.html',
styleUrls: ['./vdev-group-node.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VDevGroupNodeComponent {
@Input() vdev: VDevGroup;
}
......@@ -10,6 +10,7 @@ import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxFilesizeModule } from 'ngx-filesize';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { CastModule } from 'app/modules/cast/cast.module';
import { AppCommonModule } from 'app/modules/common/app-common.module';
import { EntityModule } from 'app/modules/entity/entity.module';
import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module';
......@@ -19,13 +20,16 @@ import { DevicesComponent } from 'app/pages/storage2/modules/devices/components/
import {
DiskDetailsPanelComponent,
} from 'app/pages/storage2/modules/devices/components/disk-details-panel/disk-details-panel.component';
import { DiskIconComponent } from 'app/pages/storage2/modules/devices/components/disk-icon/disk-icon.component';
import { DiskInfoCardComponent } from 'app/pages/storage2/modules/devices/components/disk-info-card/disk-info-card.component';
import { DiskNodeComponent } from 'app/pages/storage2/modules/devices/components/disk-node/disk-node.component';
import {
HardwareDiskEncryptionComponent,
} from 'app/pages/storage2/modules/devices/components/hardware-disk-encryption/hardware-disk-encryption.component';
import {
ManageDiskSedDialogComponent,
} from 'app/pages/storage2/modules/devices/components/hardware-disk-encryption/manage-disk-sed-dialog/manage-disk-sed-dialog.component';
import { VDevGroupNodeComponent } from 'app/pages/storage2/modules/devices/components/vdev-group-node/vdev-group-node.component';
import { ZfsInfoCardComponent } from 'app/pages/storage2/modules/devices/components/zfs-info-card/zfs-info-card.component';
import { routes } from 'app/pages/storage2/modules/devices/devices.routing';
import { DevicesStore } from 'app/pages/storage2/modules/devices/stores/devices-store.service';
......@@ -53,6 +57,7 @@ import { SmartInfoCardComponent } from './components/smart-info-card/smart-info-
ReactiveFormsModule,
RouterModule.forChild(routes),
TranslateModule,
CastModule,
AppLoaderModule,
],
declarations: [
......@@ -62,7 +67,10 @@ import { SmartInfoCardComponent } from './components/smart-info-card/smart-info-
HardwareDiskEncryptionComponent,
ManageDiskSedDialogComponent,
SmartInfoCardComponent,
DiskNodeComponent,
ZfsInfoCardComponent,
VDevGroupNodeComponent,
DiskIconComponent,
],
providers: [
DevicesStore,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment