import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
import { CreateClusterComponent } from './ceph/cluster/create-cluster/create-cluster.component';
import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component';
-import { HostDetailsComponent } from './ceph/cluster/hosts/host-details/host-details.component';
+import { HostSidebarComponent } from './ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component';
import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component';
-import { HostDetailsBreadcrumbResolver } from './ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver';
-import { HostDetailsSectionComponent } from './ceph/cluster/hosts/host-details/host-details-section.component';
+import { HostResourceBreadcrumbResolver } from './ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver';
+import { HostResourcePageComponent } from './ceph/cluster/hosts/host-resource-page/host-resource-page.component';
import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
import { InventoryComponent } from './ceph/cluster/inventory/inventory.component';
import { LogsComponent } from './ceph/cluster/logs/logs.component';
},
{
path: 'hosts/:hostname',
- component: HostDetailsComponent,
- data: { breadcrumbs: HostDetailsBreadcrumbResolver },
+ component: HostSidebarComponent,
+ data: { breadcrumbs: HostResourceBreadcrumbResolver },
children: [
- { path: '', redirectTo: 'devices', pathMatch: 'full' },
+ { path: '', redirectTo: 'overview', pathMatch: 'full' },
{
- path: 'devices',
- component: HostDetailsSectionComponent,
- data: { breadcrumbs: 'Devices', section: 'devices' }
+ path: 'overview',
+ component: HostResourcePageComponent,
+ data: { breadcrumbs: 'Overview', section: 'overview' }
},
{
- path: 'physical-disks',
- component: HostDetailsSectionComponent,
- data: { breadcrumbs: 'Physical Disks', section: 'physical-disks' }
+ path: 'storage-devices',
+ component: HostResourcePageComponent,
+ data: { breadcrumbs: 'Storage Devices', section: 'storage-devices' }
},
{
path: 'daemons',
- component: HostDetailsSectionComponent,
+ component: HostResourcePageComponent,
data: { breadcrumbs: 'Daemons', section: 'daemons' }
},
{
- path: 'performance-details',
- component: HostDetailsSectionComponent,
- data: { breadcrumbs: 'Performance Details', section: 'performance-details' }
- },
- {
- path: 'device-health',
- component: HostDetailsSectionComponent,
- data: { breadcrumbs: 'Device health', section: 'device-health' }
+ path: 'performance',
+ component: HostResourcePageComponent,
+ data: { breadcrumbs: 'Performance', section: 'performance' }
}
]
},
RadioModule,
TilesModule,
LayerModule,
- AccordionModule
+ AccordionModule,
+ MenuButtonModule,
+ ContextMenuModule
} from 'carbon-components-angular';
import Analytics from '@carbon/icons/es/analytics/16';
import CloseFilled from '@carbon/icons/es/close--filled/16';
import { CreateClusterStep4Component } from './create-cluster/create-cluster-step-4/create-cluster-step-4.component';
import { CreateClusterComponent } from './create-cluster/create-cluster.component';
import { CrushmapComponent } from './crushmap/crushmap.component';
-import { HostDetailsComponent } from './hosts/host-details/host-details.component';
-import { HostDetailsSectionComponent } from './hosts/host-details/host-details-section.component';
+import { HostSidebarComponent } from './hosts/host-resource-sidebar/host-resource-sidebar.component';
+import { HostResourcePageComponent } from './hosts/host-resource-page/host-resource-page.component';
import { HostFormComponent } from './hosts/host-form/host-form.component';
import { HostsComponent } from './hosts/hosts.component';
import { InventoryDevicesComponent } from './inventory/inventory-devices/inventory-devices.component';
RadioModule,
TilesModule,
LayerModule,
- AccordionModule
+ AccordionModule,
+ MenuButtonModule,
+ ContextMenuModule
],
declarations: [
MonitorComponent,
OsdDetailsComponent,
OsdScrubModalComponent,
OsdFlagsModalComponent,
- HostDetailsComponent,
- HostDetailsSectionComponent,
+ HostSidebarComponent,
+ HostResourcePageComponent,
ConfigurationDetailsComponent,
ConfigurationFormComponent,
OsdReweightModalComponent,
+++ /dev/null
-import { Injectable } from '@angular/core';
-import { ActivatedRouteSnapshot } from '@angular/router';
-
-import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
-
-@Injectable({
- providedIn: 'root'
-})
-export class HostDetailsBreadcrumbResolver extends BreadcrumbsResolver {
- resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
- const hostname = route.parent?.params?.hostname || route.params?.hostname || '';
- return [
- { text: 'Cluster/Hosts', path: '/hosts' },
- { text: hostname, path: this.getFullPath(route) }
- ];
- }
-}
+++ /dev/null
-@if (hostname) {
- @switch (section) {
- @case ('devices') {
- <cd-device-list [hostname]="hostname"></cd-device-list>
- }
- @case ('physical-disks') {
- <cd-inventory [hostname]="hostname"></cd-inventory>
- }
- @case ('daemons') {
- <cd-service-daemon-list
- [hostname]="hostname"
- flag="hostDetails"
- [hiddenColumns]="['hostname']"
- >
- </cd-service-daemon-list>
- }
- @case ('performance-details') {
- <cd-grafana
- i18n-title
- title="Host details"
- [grafanaPath]="'ceph-host-details?var-ceph_hosts=' + hostname"
- [type]="'metrics'"
- uid="rtOg0AiWz"
- grafanaStyle="five"
- >
- </cd-grafana>
- }
- @case ('device-health') {
- <cd-smart-list [hostname]="hostname"></cd-smart-list>
- }
- }
-} @else {
- <cd-alert-panel
- type="error"
- i18n
- >No hostname found.</cd-alert-panel
- >
-}
+++ /dev/null
-import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute, ParamMap } from '@angular/router';
-
-@Component({
- selector: 'cd-host-details-section',
- templateUrl: './host-details-section.component.html',
- standalone: false
-})
-export class HostDetailsSectionComponent implements OnInit {
- hostname = '';
- section = '';
-
- constructor(private route: ActivatedRoute) {}
-
- ngOnInit(): void {
- this.route.parent?.paramMap.subscribe((pm: ParamMap) => {
- this.hostname = pm.get('hostname') ?? '';
- });
- this.section = this.route.snapshot.data['section'] ?? '';
- }
-}
+++ /dev/null
-<cd-sidebar-layout
- [title]="hostname"
- [items]="sidebarItems"
-></cd-sidebar-layout>
+++ /dev/null
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute, convertToParamMap } from '@angular/router';
-import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import { RouterTestingModule } from '@angular/router/testing';
-import { of } from 'rxjs';
-
-import { CephModule } from '~/app/ceph/ceph.module';
-import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
-import { CoreModule } from '~/app/core/core.module';
-import { Permissions } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { SharedModule } from '~/app/shared/shared.module';
-import { configureTestBed } from '~/testing/unit-test-helper';
-import { HostDetailsComponent } from './host-details.component';
-
-describe('HostDetailsComponent', () => {
- let component: HostDetailsComponent;
- let fixture: ComponentFixture<HostDetailsComponent>;
-
- configureTestBed({
- imports: [
- BrowserAnimationsModule,
- HttpClientTestingModule,
- RouterTestingModule,
- CephModule,
- CoreModule,
- CephSharedModule,
- SharedModule
- ],
- providers: [
- {
- provide: ActivatedRoute,
- useValue: {
- paramMap: of(convertToParamMap({ hostname: 'localhost' }))
- }
- },
- {
- provide: AuthStorageService,
- useValue: {
- getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] })
- }
- }
- ]
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(HostDetailsComponent);
- component = fixture.componentInstance;
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- describe('Host resource layout', () => {
- beforeEach(() => {
- fixture.detectChanges();
- });
-
- it('should render the sidebar layout', () => {
- const layout = fixture.nativeElement.querySelector('cd-sidebar-layout');
- expect(layout).toBeTruthy();
- });
-
- it('should build the sidebar items', () => {
- expect(component.sidebarItems.map((item) => item.label)).toEqual([
- 'Devices',
- 'Physical Disks',
- 'Daemons',
- 'Performance Details',
- 'Device health'
- ]);
- });
-
- it('should set the hostname title', () => {
- expect(component.hostname).toBe('localhost');
- });
- });
-});
+++ /dev/null
-import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
-import { ActivatedRoute, ParamMap } from '@angular/router';
-
-import { Subscription } from 'rxjs';
-
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
-
-@Component({
- selector: 'cd-host-details',
- templateUrl: './host-details.component.html',
- styleUrls: ['./host-details.component.scss'],
- encapsulation: ViewEncapsulation.None,
- standalone: false
-})
-export class HostDetailsComponent implements OnInit, OnDestroy {
- private sub = new Subscription();
- public readonly basePath = '/hosts';
- hostname = '';
- sidebarItems: SidebarItem[] = [];
-
- constructor(
- private route: ActivatedRoute,
- private authStorageService: AuthStorageService
- ) {}
-
- ngOnInit(): void {
- const permissions = this.authStorageService.getPermissions();
- this.sub.add(
- this.route.paramMap.subscribe((pm: ParamMap) => {
- this.hostname = pm.get('hostname') ?? '';
- this.buildSidebarItems(permissions);
- })
- );
- }
-
- ngOnDestroy(): void {
- this.sub.unsubscribe();
- }
-
- private buildSidebarItems(permissions: any): void {
- const items: SidebarItem[] = [
- {
- label: $localize`Devices`,
- route: [this.basePath, this.hostname, 'devices'],
- routerLinkActiveOptions: { exact: true }
- }
- ];
-
- if (permissions.hosts?.read) {
- items.push(
- {
- label: $localize`Physical Disks`,
- route: [this.basePath, this.hostname, 'physical-disks'],
- routerLinkActiveOptions: { exact: true }
- },
- {
- label: $localize`Daemons`,
- route: [this.basePath, this.hostname, 'daemons'],
- routerLinkActiveOptions: { exact: true }
- }
- );
- }
-
- if (permissions.grafana?.read) {
- items.push({
- label: $localize`Performance Details`,
- route: [this.basePath, this.hostname, 'performance-details'],
- routerLinkActiveOptions: { exact: true }
- });
- }
-
- items.push({
- label: $localize`Device health`,
- route: [this.basePath, this.hostname, 'device-health'],
- routerLinkActiveOptions: { exact: true }
- });
-
- this.sidebarItems = items;
- }
-}
--- /dev/null
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class HostResourceBreadcrumbResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+ const hostname = route.parent?.params?.hostname || route.params?.hostname || '';
+ return [
+ { text: 'Cluster/Hosts', path: '/hosts' },
+ { text: hostname, path: this.getFullPath(route) }
+ ];
+ }
+}
--- /dev/null
+@if (hostname) {
+ @switch (section) {
+ @case ('overview') {
+ <cd-resource-overview-card
+ title="Host details"
+ [columns]="4"
+ [fields]="hostOverviewFields"
+ >
+ </cd-resource-overview-card>
+ }
+ @case ('storage-devices') {
+ <section class="host-resource-section cds-mb-6">
+ <h2
+ class="host-resource-section__title cds--type-heading-03"
+ i18n
+ >
+ Ceph Devices
+ </h2>
+ <p
+ class="host-resource-section__description cds--type-body-compact-01"
+ i18n
+ >
+ Storage devices managed by Ceph, including OSD associations, allocation details, and
+ device roles used for cluster storage operations.
+ </p>
+ <cd-device-list [hostname]="hostname"></cd-device-list>
+ </section>
+
+ @if (permissions.hosts.read) {
+ <section class="host-resource-section cds-mb-6">
+ <h2
+ class="host-resource-section__title cds--type-heading-03"
+ i18n
+ >
+ Physical Disks
+ </h2>
+ <p
+ class="host-resource-section__description cds--type-body-compact-01"
+ i18n
+ >
+ View the underlying physical drives attached to the host, including hardware details,
+ capacity, media type, and disk inventory information.
+ </p>
+ <cd-inventory
+ [hostname]="hostname"
+ [showHeading]="false"
+ >
+ </cd-inventory>
+ </section>
+ }
+
+ <section class="host-resource-section cds-mb-6">
+ <h2
+ class="host-resource-section__title cds--type-heading-03"
+ i18n
+ >
+ Health & diagnostics
+ </h2>
+ <cd-smart-list [hostname]="hostname"></cd-smart-list>
+ </section>
+ }
+ @case ('daemons') {
+ @if (permissions.hosts.read) {
+ <cd-service-daemon-list
+ [hostname]="hostname"
+ flag="hostDetails"
+ [hiddenColumns]="['hostname']"
+ >
+ </cd-service-daemon-list>
+ }
+ }
+ @case ('performance') {
+ @if (permissions.grafana.read) {
+ <cd-grafana
+ i18n-title
+ title="Host details"
+ [grafanaPath]="'ceph-host-details?var-ceph_hosts=' + hostname"
+ [type]="'metrics'"
+ uid="rtOg0AiWz"
+ grafanaStyle="five"
+ >
+ </cd-grafana>
+ }
+ }
+ }
+} @else {
+ <cd-alert-panel
+ type="error"
+ i18n
+ >No hostname found.</cd-alert-panel
+ >
+}
--- /dev/null
+import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+import { HttpParams } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+import { BehaviorSubject, of, throwError } from 'rxjs';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HostResourcePageComponent } from './host-resource-page.component';
+
+describe('HostResourcePageComponent', () => {
+ let component: HostResourcePageComponent;
+ let fixture: ComponentFixture<HostResourcePageComponent>;
+
+ const hostServiceSpy = {
+ list: jest.fn(),
+ getTotalMemoryBytes: jest.fn((host?: { memory_total_kb?: number | string }) => {
+ const memoryKb = Number(host?.memory_total_kb);
+ return Number.isFinite(memoryKb) ? memoryKb * 1024 : undefined;
+ }),
+ getRawCapacityBytes: jest.fn(
+ (host?: { hdd_capacity_bytes?: number | string; flash_capacity_bytes?: number | string }) => {
+ const hdd = Number(host?.hdd_capacity_bytes);
+ const flash = Number(host?.flash_capacity_bytes);
+ return Number.isFinite(hdd) && Number.isFinite(flash) ? hdd + flash : undefined;
+ }
+ )
+ };
+ const parentParamMap$ = new BehaviorSubject<ParamMap>(convertToParamMap({ hostname: 'node-1' }));
+
+ configureTestBed({
+ declarations: [HostResourcePageComponent],
+ imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
+ providers: [
+ FormatterService,
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: { paramMap: parentParamMap$.asObservable() },
+ snapshot: { data: { section: 'overview' } }
+ }
+ },
+ {
+ provide: HostService,
+ useValue: hostServiceSpy
+ },
+ {
+ provide: AuthStorageService,
+ useValue: {
+ getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] })
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ /* Create a fresh component instance for each test */
+ fixture = TestBed.createComponent(HostResourcePageComponent);
+ component = fixture.componentInstance;
+
+ /* Clear mock call history between tests */
+ hostServiceSpy.list.mockClear();
+ hostServiceSpy.getTotalMemoryBytes.mockClear();
+ hostServiceSpy.getRawCapacityBytes.mockClear();
+ });
+
+ const getField = (label: string) => {
+ return component.hostOverviewFields?.find((field) => field.label === label);
+ };
+
+ it('should create', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ describe('Overview layout', () => {
+ it('should build overview fields from host inventory details', () => {
+ hostServiceSpy.list.mockReturnValue(
+ of([
+ {
+ hostname: 'node-1',
+ addr: '10.0.0.1',
+ labels: ['_admin', 'storage'],
+ status: 'maintenance',
+ model: 'PowerEdge R750',
+ cpu_count: '2',
+ cpu_cores: 32,
+ memory_total_kb: 1024,
+ hdd_capacity_bytes: 1024,
+ flash_capacity_bytes: 2048,
+ hdd_count: '4',
+ flash_count: 2,
+ nic_count: '6'
+ }
+ ])
+ );
+
+ fixture.detectChanges();
+
+ expect(hostServiceSpy.list).toHaveBeenCalledWith(expect.any(HttpParams), 'true');
+ expect(component.hostname).toBe('node-1');
+ expect(component.section).toBe('overview');
+
+ expect(getField('Hostname')).toEqual(expect.objectContaining({ value: 'node-1 (10.0.0.1)' }));
+ expect(getField('Labels')).toEqual(
+ expect.objectContaining({ values: ['_admin', 'storage'] })
+ );
+ expect(getField('Status')).toEqual(
+ expect.objectContaining({ value: 'Maintenance', status: ICON_TYPE.warning })
+ );
+ expect(getField('Model')).toEqual(expect.objectContaining({ value: 'PowerEdge R750' }));
+ expect(getField('CPUs')).toEqual(expect.objectContaining({ value: '2' }));
+ expect(getField('Cores')).toEqual(expect.objectContaining({ value: 32 }));
+ expect(getField('Total Memory')?.value).toBe('1 MiB'); // 1024 KB transformed
+ expect(getField('Raw Capacity')?.value).toBe('3 KiB'); // 1024 + 2048 Bytes transformed
+ expect(getField('HDDs')).toEqual(expect.objectContaining({ value: '4' }));
+ expect(getField('Flash')).toEqual(expect.objectContaining({ value: 2 }));
+ expect(getField('NICs')).toEqual(expect.objectContaining({ value: '6' }));
+ });
+
+ it('should fall back to placeholder overview values when host loading fails', () => {
+ hostServiceSpy.list.mockReturnValue(throwError(() => new Error('API Error')));
+
+ fixture.detectChanges();
+
+ expect(getField('Hostname')).toEqual(expect.objectContaining({ value: 'node-1' }));
+ expect(getField('Labels')).toEqual(expect.objectContaining({ values: [] }));
+ expect(getField('Status')).toEqual(expect.objectContaining({ value: undefined }));
+ expect(getField('Model')).toEqual(expect.objectContaining({ value: undefined }));
+ expect(getField('Total Memory')).toEqual(expect.objectContaining({ value: '-' }));
+ expect(getField('Raw Capacity')).toEqual(expect.objectContaining({ value: '-' }));
+ });
+
+ it('should keep capacity values empty if standard fact fields are missing', () => {
+ hostServiceSpy.list.mockReturnValue(
+ of([
+ {
+ hostname: 'node-1',
+ memory_total_bytes: 2048,
+ raw_capacity: 4096
+ }
+ ])
+ );
+
+ fixture.detectChanges();
+
+ expect(getField('Total Memory')?.value).toEqual('-');
+ expect(getField('Raw Capacity')?.value).toEqual('-');
+ });
+ });
+
+ describe('Permission-gated template sections', () => {
+ it('should show Physical Disks inventory in storage-devices section when hosts.read is allowed', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'storage-devices';
+ component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-inventory'))).toBeTruthy();
+ });
+
+ it('should hide Physical Disks inventory in storage-devices section when hosts.read is denied', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'storage-devices';
+ component.permissions = new Permissions({ hosts: [], grafana: ['read'] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-inventory'))).toBeFalsy();
+ });
+
+ it('should show daemon list in daemons section when hosts.read is allowed', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'daemons';
+ component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-service-daemon-list'))).toBeTruthy();
+ });
+
+ it('should hide daemon list in daemons section when hosts.read is denied', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'daemons';
+ component.permissions = new Permissions({ hosts: [], grafana: ['read'] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-service-daemon-list'))).toBeFalsy();
+ });
+
+ it('should show grafana panel in performance section when grafana.read is allowed', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'performance';
+ component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-grafana'))).toBeTruthy();
+ });
+
+ it('should hide grafana panel in performance section when grafana.read is denied', () => {
+ hostServiceSpy.list.mockReturnValue(of([]));
+ fixture.detectChanges();
+
+ component.section = 'performance';
+ component.permissions = new Permissions({ hosts: ['read'], grafana: [] });
+ fixture.detectChanges();
+
+ expect(fixture.debugElement.query(By.css('cd-grafana'))).toBeFalsy();
+ });
+ });
+});
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { HttpParams } from '@angular/common/http';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { Subscription } from 'rxjs';
+
+import { HostService } from '~/app/shared/api/host.service';
+import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component';
+import { HostOverviewDetails, STATUS_MAP, getStatus } from '~/app/shared/models/host.interface';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+@Component({
+ selector: 'cd-host-resource-page',
+ templateUrl: './host-resource-page.component.html',
+ standalone: false
+})
+export class HostResourcePageComponent implements OnInit, OnDestroy {
+ private sub = new Subscription();
+ hostname = '';
+ section = '';
+ permissions: Permissions;
+ hostOverviewFields: OverviewField[] = [];
+
+ constructor(
+ private route: ActivatedRoute,
+ private hostService: HostService,
+ private authStorageService: AuthStorageService,
+ private formatter: FormatterService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
+
+ ngOnInit(): void {
+ this.sub.add(
+ this.route.parent?.paramMap.subscribe((pm: ParamMap) => {
+ this.hostname = pm.get('hostname') ?? '';
+ this.loadOverview();
+ })
+ );
+ this.section = this.route.snapshot.data['section'] ?? '';
+ }
+
+ ngOnDestroy(): void {
+ this.sub.unsubscribe();
+ }
+
+ private loadOverview(): void {
+ if (!this.hostname) {
+ this.hostOverviewFields = [];
+ return;
+ }
+
+ let params = new HttpParams();
+ params = params.set('offset', '0');
+ params = params.set('limit', '1');
+ params = params.set('search', this.hostname);
+ params = params.set('sort', '+hostname');
+
+ this.sub.add(
+ this.hostService.list(params, 'true').subscribe({
+ next: (hosts: object[]) => {
+ const hostList = (Array.isArray(hosts) ? hosts : []) as HostOverviewDetails[];
+ const host = hostList.find((item) => item.hostname === this.hostname);
+ this.hostOverviewFields = this.buildOverviewFields(host);
+ },
+ error: () => {
+ this.hostOverviewFields = this.buildOverviewFields();
+ }
+ })
+ );
+ }
+
+ private buildOverviewFields(host: Partial<HostOverviewDetails> = {}): OverviewField[] {
+ const hostStatus = STATUS_MAP[getStatus(host)];
+ const hostnameWithAddr = host?.addr ? `${this.hostname} (${host.addr})` : this.hostname;
+ const totalMemory = this.formatter.formatToBinary(
+ this.hostService.getTotalMemoryBytes(host),
+ false,
+ 1
+ );
+ const rawCapacity = this.formatter.formatToBinary(
+ this.hostService.getRawCapacityBytes(host),
+ false,
+ 1
+ );
+
+ return [
+ {
+ label: $localize`Hostname`,
+ value: hostnameWithAddr,
+ type: 'text'
+ },
+ {
+ label: $localize`Labels`,
+ values: host?.labels ?? [],
+ type: 'tags'
+ },
+ {
+ label: $localize`Status`,
+ value: hostStatus?.label,
+ type: 'status',
+ status: hostStatus?.icon
+ },
+ {
+ label: $localize`Model`,
+ value: host?.model,
+ type: 'text'
+ },
+ {
+ label: $localize`CPUs`,
+ value: host?.cpu_count,
+ type: 'text'
+ },
+ {
+ label: $localize`Cores`,
+ value: host?.cpu_cores,
+ type: 'text'
+ },
+ {
+ label: $localize`Total Memory`,
+ value: totalMemory
+ },
+ {
+ label: $localize`Raw Capacity`,
+ value: rawCapacity
+ },
+ {
+ label: $localize`HDDs`,
+ value: host?.hdd_count,
+ type: 'text'
+ },
+ {
+ label: $localize`Flash`,
+ value: host?.flash_count,
+ type: 'text'
+ },
+ {
+ label: $localize`NICs`,
+ value: host?.nic_count,
+ type: 'text'
+ }
+ ];
+ }
+}
--- /dev/null
+<cd-sidebar-layout
+ class="host-details-layout"
+ [title]="hostname"
+ [items]="sidebarItems"
+>
+ @if (hostStatus || hostLabels.length || hostActions.length) {
+ <div
+ sidebar-header-extra
+ class="host-header-extra cds-mb-3"
+ >
+ <div class="host-header-tags">
+ @if (hostStatus) {
+ <span
+ class="host-status-badge cds-mr-3"
+ [ngClass]="'host-status-badge--' + hostStatusMap?.icon"
+ >
+ <cd-icon [type]="hostStatusMap?.icon"></cd-icon>
+ <span>{{ hostStatusMap?.label ?? hostStatus }}</span>
+ </span>
+ }
+ @for (label of hostLabels; track label) {
+ <cds-tag
+ class="cds-mr-2"
+ type="blue"
+ >{{ label }}</cds-tag
+ >
+ }
+ </div>
+ @if (hostActions.length) {
+ <div class="host-header-actions">
+ <cds-menu-button
+ kind="tertiary"
+ size="lg"
+ menuId="host-actions-menu"
+ menuAlignment="bottom-end"
+ label="Actions"
+ i18n-label
+ >
+ @for (action of getVisibleHostActions(); track action.name) {
+ <cds-menu-item
+ label="{{ action.name }}"
+ [disabled]="isHostActionDisabled(action)"
+ (click)="runHostAction(action)"
+ title="{{ action.name }}"
+ >
+ </cds-menu-item>
+ }
+ </cds-menu-button>
+ </div>
+ }
+ </div>
+ }
+</cd-sidebar-layout>
+
+<ng-template #maintenanceConfirmTpl>
+ @for (msg of errorMessage; track $index; let last = $last) {
+ <div>
+ @if (!last || errorMessage.length === 1) {
+ <ul>
+ <li i18n>{{ msg }}</li>
+ </ul>
+ }
+ </div>
+ }
+ <ng-container i18n>Are you sure you want to continue?</ng-container>
+</ng-template>
--- /dev/null
+@use '@carbon/layout';
+@use '@carbon/colors';
+
+.host-header-extra {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: layout.$spacing-05;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.host-header-tags {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: layout.$spacing-02;
+ min-width: 0;
+}
+
+.host-header-actions {
+ margin-left: auto;
+}
+
+.host-details-layout .sidebar-header-content {
+ flex-wrap: nowrap;
+ width: 100%;
+}
+
+.host-details-layout .sidebar-header-content h2 {
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.host-details-layout .sidebar-header {
+ padding-right: var(--cds-spacing-07);
+}
+
+.host-status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--cds-spacing-02);
+ font-size: var(--cds-body-compact-01-font-size, 0.875rem);
+ font-weight: 600;
+}
+
+.host-status-badge--success {
+ color: var(--cds-support-success);
+}
+
+.host-status-badge--warning {
+ color: var(--cds-support-caution-major);
+}
+
+.host-status-badge--danger {
+ color: var(--cds-support-error);
+}
+
+.host-status-badge--info {
+ color: var(--cds-support-info);
+}
+
+/* Menu is rendered under document.body; scope fixes by menu id. */
+#host-actions-menu.cds--menu {
+ width: max-content !important;
+ min-width: 12rem;
+ background-color: colors.$white !important;
+}
+
+#host-actions-menu.cds--menu > .cds--menu-item {
+ grid-template-columns: minmax(0, 1fr) !important;
+}
+
+#host-actions-menu.cds--menu .cds--menu-item {
+ color: var(--cds-text-primary);
+}
+
+#host-actions-menu.cds--menu .cds--menu-item__label {
+ overflow: visible;
+ text-overflow: unset;
+ white-space: normal;
+}
+
+#host-actions-menu.cds--menu .cds--menu-item:hover,
+#host-actions-menu.cds--menu .cds--menu-item:focus {
+ background-color: colors.$gray-10;
+}
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+import { of } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
+import { CoreModule } from '~/app/core/core.module';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HostSidebarComponent } from './host-resource-sidebar.component';
+
+describe('HostSidebarComponent', () => {
+ let component: HostSidebarComponent;
+ let fixture: ComponentFixture<HostSidebarComponent>;
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ CephModule,
+ CoreModule,
+ CephSharedModule,
+ SharedModule
+ ],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ paramMap: of(convertToParamMap({ hostname: 'localhost' }))
+ }
+ },
+ {
+ provide: AuthStorageService,
+ useValue: {
+ getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] })
+ }
+ },
+ {
+ provide: HostService,
+ useValue: {
+ getDisable: () => false,
+ getAllHosts: () =>
+ of([
+ {
+ hostname: 'localhost',
+ labels: ['_admin'],
+ status: 'available'
+ }
+ ])
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(HostSidebarComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Host resource layout', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should render the sidebar layout', () => {
+ const layout = fixture.nativeElement.querySelector('cd-sidebar-layout');
+ expect(layout).toBeTruthy();
+ });
+
+ it('should build the sidebar items', () => {
+ expect(component.sidebarItems.map((item) => item.label)).toEqual([
+ 'Overview',
+ 'Storage Devices',
+ 'Daemons',
+ 'Performance'
+ ]);
+ });
+
+ it('should set the hostname title', () => {
+ expect(component.hostname).toBe('localhost');
+ });
+ });
+});
--- /dev/null
+import {
+ Component,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewChild,
+ ViewEncapsulation
+} from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+
+import { Subscription } from 'rxjs';
+
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { HostService, HostModalRef } from '~/app/shared/api/host.service';
+import { HostActionService } from '~/app/shared/services/host-action.service';
+import { Host, HostStatusConfig, STATUS_MAP, getStatus } from '~/app/shared/models/host.interface';
+import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
+import { ActionLabels, ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permissions } from '~/app/shared/models/permissions';
+@Component({
+ selector: 'cd-host-sidebar',
+ templateUrl: './host-resource-sidebar.component.html',
+ styleUrls: ['./host-resource-sidebar.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ standalone: false
+})
+export class HostSidebarComponent implements OnInit, OnDestroy {
+ readonly HostStatus = HostStatus;
+
+ @ViewChild('maintenanceConfirmTpl', { static: true })
+ maintenanceConfirmTpl!: TemplateRef<any>;
+
+ private sub = new Subscription();
+ public readonly basePath = '/hosts';
+ hostname = '';
+ hostLabels: string[] = [];
+ hostStatus: HostStatus | string | null = null;
+ hostStatusMap: HostStatusConfig | null = null;
+ hostActions: CdTableAction[] = [];
+ hostSelection = new CdTableSelection();
+ sidebarItems: SidebarItem[] = [];
+ permissions: Permissions;
+ modalRef?: HostModalRef;
+ isExecuting = false;
+ errorMessage: string[] = [];
+ enableMaintenanceBtn = false;
+ draining = false;
+ orchStatus!: OrchestratorStatus;
+
+ messages = {
+ nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.`
+ };
+
+ actionOrchFeatures: Record<string, OrchestratorFeature[]> = {
+ [ActionLabels.EDIT]: [
+ OrchestratorFeature.HOST_LABEL_ADD,
+ OrchestratorFeature.HOST_LABEL_REMOVE
+ ],
+ [ActionLabels.REMOVE]: [OrchestratorFeature.HOST_REMOVE],
+ [ActionLabels.MAINTENANCE]: [
+ OrchestratorFeature.HOST_MAINTENANCE_ENTER,
+ OrchestratorFeature.HOST_MAINTENANCE_EXIT
+ ],
+ [ActionLabels.DRAIN]: [OrchestratorFeature.HOST_DRAIN]
+ };
+
+ constructor(
+ private route: ActivatedRoute,
+ private authStorageService: AuthStorageService,
+ private hostService: HostService,
+ private hostActionService: HostActionService,
+ private actionLabels: ActionLabelsI18n,
+ private orchService: OrchestratorService
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
+
+ ngOnInit(): void {
+ this.sub.add(
+ this.route.paramMap.subscribe((pm: ParamMap) => {
+ this.hostname = pm.get('hostname') ?? '';
+ this.buildSidebarItems(this.permissions);
+ this.loadHostMetadata();
+ })
+ );
+
+ this.sub.add(
+ this.orchService.status().subscribe((orchStatus) => {
+ this.orchStatus = orchStatus;
+ this.buildHostActions();
+ })
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.sub.unsubscribe();
+ }
+
+ private buildSidebarItems(permissions: Permissions): void {
+ const items: SidebarItem[] = [
+ {
+ label: $localize`Overview`,
+ route: [this.basePath, this.hostname, 'overview'],
+ routerLinkActiveOptions: { exact: true }
+ },
+ {
+ label: $localize`Storage Devices`,
+ route: [this.basePath, this.hostname, 'storage-devices'],
+ routerLinkActiveOptions: { exact: true }
+ }
+ ];
+
+ if (permissions.hosts?.read) {
+ items.push({
+ label: $localize`Daemons`,
+ route: [this.basePath, this.hostname, 'daemons'],
+ routerLinkActiveOptions: { exact: true }
+ });
+ }
+
+ if (permissions.grafana?.read) {
+ items.push({
+ label: $localize`Performance`,
+ route: [this.basePath, this.hostname, 'performance'],
+ routerLinkActiveOptions: { exact: true }
+ });
+ }
+
+ this.sidebarItems = items;
+ }
+
+ private loadHostMetadata(): void {
+ if (!this.hostname) {
+ this.hostLabels = [];
+ this.hostStatus = null;
+ this.hostStatusMap = null;
+ return;
+ }
+
+ this.sub.add(
+ this.hostService.getAllHosts().subscribe((hosts: Host[]) => {
+ const host = hosts.find((item) => item.hostname === this.hostname);
+ this.hostLabels = Array.isArray(host?.labels) ? host.labels : [];
+ this.hostStatus = getStatus(host);
+ this.enableMaintenanceBtn = this.hostStatus === HostStatus.MAINTENANCE;
+ this.hostStatusMap = STATUS_MAP[this.hostStatus];
+ this.draining = this.hostLabels.includes('_no_schedule');
+ this.hostSelection.selected = host ? [host] : [];
+ this.buildHostActions();
+ })
+ );
+ }
+
+ private buildHostActions(): void {
+ this.hostActions = [
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.editAction(),
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.EDIT, selection)
+ },
+ {
+ name: this.draining ? this.actionLabels.STOP_DRAIN : this.actionLabels.START_DRAIN,
+ permission: 'update',
+ icon: Icons.exit,
+ click: () => this.hostDrain(this.draining),
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.DRAIN, selection)
+ },
+ {
+ name: this.actionLabels.REMOVE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteAction(),
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.REMOVE, selection)
+ },
+ {
+ name: this.enableMaintenanceBtn
+ ? this.actionLabels.EXIT_MAINTENANCE
+ : this.actionLabels.ENTER_MAINTENANCE,
+ permission: 'update',
+ icon: this.enableMaintenanceBtn ? Icons.exit : Icons.enter,
+ click: () => this.hostMaintenance(),
+ disable: (selection: CdTableSelection) =>
+ this.getDisable(ActionLabels.MAINTENANCE, selection) || this.isExecuting
+ }
+ ];
+ }
+
+ getVisibleHostActions(): CdTableAction[] {
+ return this.hostActions.filter(
+ (action) => !action.visible || action.visible(this.hostSelection)
+ );
+ }
+
+ isHostActionDisabled(action: CdTableAction): boolean {
+ return !!action.disable?.(this.hostSelection);
+ }
+
+ runHostAction(action: CdTableAction): void {
+ if (this.isHostActionDisabled(action)) {
+ return;
+ }
+ action.click?.();
+ }
+
+ getDisable(action: string, selection: CdTableSelection): boolean | string {
+ return this.hostService.getDisable(
+ action,
+ selection,
+ this.orchStatus as OrchestratorStatus,
+ this.actionOrchFeatures,
+ this.messages.nonOrchHost,
+ [ActionLabels.EDIT, ActionLabels.REMOVE, ActionLabels.MAINTENANCE, ActionLabels.DRAIN]
+ );
+ }
+
+ editAction() {
+ const host = this.hostSelection.first();
+ if (!host) {
+ return;
+ }
+
+ this.hostActionService.openEditModal(host, () => {
+ this.loadHostMetadata();
+ });
+ }
+
+ hostMaintenance() {
+ const host = this.hostSelection.first();
+ if (!host) {
+ return;
+ }
+
+ this.hostActionService.hostMaintenance(
+ host,
+ this.maintenanceConfirmTpl,
+ (isExecuting: boolean) => (this.isExecuting = isExecuting),
+ (errorMessage: string[]) => (this.errorMessage = errorMessage),
+ () => this.loadHostMetadata(),
+ () => this.loadHostMetadata(),
+ (modalRef: HostModalRef) => {
+ this.modalRef = modalRef;
+ }
+ );
+ }
+
+ hostDrain(stop = false) {
+ const host = this.hostSelection.first();
+ if (!host) {
+ return;
+ }
+
+ this.hostActionService.hostDrain(host, stop, () => {
+ this.loadHostMetadata();
+ });
+ }
+
+ deleteAction() {
+ const host = this.hostSelection.first();
+ if (!host) {
+ return;
+ }
+
+ this.modalRef = this.hostActionService.deleteAction(host.hostname);
+ }
+}
fixture.detectChanges();
component.getHosts(new CdTableFetchDataContext(() => undefined));
- expect(component.hosts[0]['memory_total_bytes']).toEqual('N/A');
- expect(component.hosts[0]['raw_capacity']).toEqual('N/A');
+ expect(component.hosts[0]['memory_total_bytes']).toEqual('-');
+ expect(component.hosts[0]['raw_capacity']).toEqual('-');
});
it('should show force maintenance modal when it is safe to stop host', () => {
import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import _ from 'lodash';
import { Subscription } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
-import { HostService } from '~/app/shared/api/host.service';
+import { HostService, HostModalRef, HostFactsCapacitySource } from '~/app/shared/api/host.service';
+import { HostActionService } from '~/app/shared/services/host-action.service';
import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { Host } from '~/app/shared/models/host.interface';
import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
-import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
-import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
-import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
-import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { ActionLabels, ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
-import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { FinishedTask } from '~/app/shared/models/finished-task';
import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum';
import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
import { Permissions } from '~/app/shared/models/permissions';
import { EmptyPipe } from '~/app/shared/pipes/empty.pipe';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { CdTableServerSideService } from '~/app/shared/services/cd-table-server-side.service';
-import { NotificationService } from '~/app/shared/services/notification.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { URLBuilderService } from '~/app/shared/services/url-builder.service';
import { HostFormComponent } from './host-form/host-form.component';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
-import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
const BASE_URL = 'hosts';
tableActions: CdTableAction[];
expandClusterActions: CdTableAction[];
selection = new CdTableSelection();
- modalRef: NgbModalRef;
+ modalRef?: HostModalRef;
isExecuting = false;
- errorMessage: string;
+ errorMessage: string[];
enableMaintenanceBtn: boolean;
draining: boolean = false;
- bsModalRef: NgbModalRef;
+ bsModalRef?: HostModalRef;
icons = Icons;
private tableContext: CdTableFetchDataContext = null;
};
orchStatus: OrchestratorStatus;
- actionOrchFeatures = {
- add: [OrchestratorFeature.HOST_ADD],
- edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE],
- remove: [OrchestratorFeature.HOST_REMOVE],
- maintenance: [
+ actionOrchFeatures: Record<string, OrchestratorFeature[]> = {
+ [ActionLabels.ADD]: [OrchestratorFeature.HOST_ADD],
+ [ActionLabels.EDIT]: [
+ OrchestratorFeature.HOST_LABEL_ADD,
+ OrchestratorFeature.HOST_LABEL_REMOVE
+ ],
+ [ActionLabels.REMOVE]: [OrchestratorFeature.HOST_REMOVE],
+ [ActionLabels.MAINTENANCE]: [
OrchestratorFeature.HOST_MAINTENANCE_ENTER,
OrchestratorFeature.HOST_MAINTENANCE_EXIT
],
- drain: [OrchestratorFeature.HOST_DRAIN]
+ [ActionLabels.DRAIN]: [OrchestratorFeature.HOST_DRAIN]
};
constructor(
private authStorageService: AuthStorageService,
private emptyPipe: EmptyPipe,
private hostService: HostService,
+ private hostActionService: HostActionService,
private actionLabels: ActionLabelsI18n,
- private taskWrapper: TaskWrapperService,
private router: Router,
- private notificationService: NotificationService,
private orchService: OrchestratorService,
private cdsModalService: ModalCdsService
) {
: (this.bsModalRef = this.cdsModalService.show(HostFormComponent, {
hideMaintenance: this.hideMaintenance
})),
- disable: (selection: CdTableSelection) => this.getDisable('add', selection)
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.ADD, selection)
},
{
name: this.actionLabels.EDIT,
permission: 'update',
icon: Icons.edit,
click: () => this.editAction(),
- disable: (selection: CdTableSelection) => this.getDisable('edit', selection)
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.EDIT, selection)
},
{
name: this.actionLabels.START_DRAIN,
permission: 'delete',
icon: Icons.destroy,
click: () => this.deleteAction(),
- disable: (selection: CdTableSelection) => this.getDisable('remove', selection)
+ disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.REMOVE, selection)
},
{
name: this.actionLabels.ENTER_MAINTENANCE,
icon: Icons.enter,
click: () => this.hostMaintenance(),
disable: (selection: CdTableSelection) =>
- this.getDisable('maintenance', selection) ||
+ this.getDisable(ActionLabels.MAINTENANCE, selection) ||
this.isExecuting ||
this.enableMaintenanceBtn,
visible: () => !this.showGeneralActionsOnly && !this.enableMaintenanceBtn
icon: Icons.exit,
click: () => this.hostMaintenance(),
disable: (selection: CdTableSelection) =>
- this.getDisable('maintenance', selection) ||
+ this.getDisable(ActionLabels.MAINTENANCE, selection) ||
this.isExecuting ||
!this.enableMaintenanceBtn,
visible: () => !this.showGeneralActionsOnly && this.enableMaintenanceBtn
}
editAction() {
- const host = this.selection.first();
- this.hostService.getLabels().subscribe((resp) => {
- const hostLabels: string[] = Array.isArray(host['labels'])
- ? [...(host['labels'] as string[])]
- : [];
- const labels = new Set(resp.concat(this.hostService.predefinedLabels).concat(hostLabels));
- const allLabels = Array.from(labels).map((label) => {
- return { content: label, selected: hostLabels.includes(label) };
- });
- this.cdsModalService.show(FormModalComponent, {
- titleText: $localize`Edit Host: ${host.hostname}`,
- fields: [
- {
- type: 'select-badges',
- name: 'labels',
- value: hostLabels,
- label: $localize`Labels`,
- typeConfig: {
- customBadges: true,
- options: allLabels,
- messages: new SelectMessages({
- empty: $localize`There are no labels.`,
- filter: $localize`Filter or add labels`,
- add: $localize`Add label`
- })
- }
- }
- ],
- submitButtonText: $localize`Edit Host`,
- onSubmit: (values: any) => {
- this.hostService.update(host['hostname'], true, values.labels).subscribe(() => {
- const selectedHost = this.selection.first();
- if (selectedHost && selectedHost['hostname'] === host.hostname) {
- host['labels'] = values.labels;
- Object.assign(selectedHost, host);
- }
- this.notificationService.show(
- NotificationType.success,
- $localize`Updated Host "${host.hostname}"`
- );
- // Reload the data table content.
- this.table.refreshBtn();
- });
- }
- });
+ const host = this.selection.first() as Host;
+ this.hostActionService.openEditModal(host, (labels: string[]) => {
+ const selectedHost = this.selection.first();
+ if (selectedHost && selectedHost['hostname'] === host.hostname) {
+ host.labels = labels;
+ Object.assign(selectedHost, host);
+ }
+ // Reload the data table content.
+ this.table.refreshBtn();
});
}
hostMaintenance() {
- this.isExecuting = true;
- const host = this.selection.first();
- if (host['status'] !== 'maintenance') {
- this.hostService.update(host['hostname'], false, [], true).subscribe(
- () => {
- this.isExecuting = false;
- this.notificationService.show(
- NotificationType.success,
- $localize`"${host.hostname}" moved to maintenance`
- );
- this.table.refreshBtn();
- },
- (error) => {
- this.isExecuting = false;
- this.errorMessage = error.error['detail'].split(/\n/);
- error.preventDefault();
- if (
- error.error['detail'].includes('WARNING') &&
- !error.error['detail'].includes('It is NOT safe to stop') &&
- !error.error['detail'].includes('ALERT') &&
- !error.error['detail'].includes('unsafe to stop')
- ) {
- const modalVariables = {
- titleText: $localize`Warning`,
- buttonText: $localize`Continue`,
- warning: true,
- bodyTpl: this.maintenanceConfirmTpl,
- showSubmit: true,
- onSubmit: () => {
- this.hostService.update(host['hostname'], false, [], true, true).subscribe(
- () => this.cdsModalService.dismissAll(),
- () => this.cdsModalService.dismissAll()
- );
- }
- };
- this.modalRef = this.cdsModalService.show(ConfirmationModalComponent, modalVariables);
- } else {
- this.notificationService.show(
- NotificationType.error,
- $localize`"${host.hostname}" cannot be put into maintenance`,
- $localize`${error.error['detail']}`
- );
- }
- }
- );
- } else {
- this.hostService.update(host['hostname'], false, [], true).subscribe(() => {
- this.isExecuting = false;
- this.notificationService.show(
- NotificationType.success,
- $localize`"${host.hostname}" has exited maintenance`
- );
- this.table.refreshBtn();
- });
- }
+ const host = this.selection.first() as Host;
+ this.hostActionService.hostMaintenance(
+ host,
+ this.maintenanceConfirmTpl,
+ (isExecuting: boolean) => (this.isExecuting = isExecuting),
+ (errorMessage: string[]) => (this.errorMessage = errorMessage),
+ () => this.table.refreshBtn(),
+ () => undefined,
+ (modalRef: HostModalRef) => {
+ this.modalRef = modalRef;
+ }
+ );
}
hostDrain(stop = false) {
- const host = this.selection.first();
- if (stop) {
- const index = host['labels'].indexOf('_no_schedule', 0);
- host['labels'].splice(index, 1);
- this.hostService.update(host['hostname'], true, host['labels']).subscribe(() => {
- this.notificationService.show(
- NotificationType.info,
- $localize`"${host['hostname']}" stopped draining`
- );
- this.table.refreshBtn();
- });
- } else {
- this.hostService.update(host['hostname'], false, [], false, false, true).subscribe(() => {
- this.notificationService.show(
- NotificationType.info,
- $localize`"${host['hostname']}" started draining`
- );
- this.table.refreshBtn();
- });
- }
+ const host = this.selection.first() as Host;
+ this.hostActionService.hostDrain(host, stop, () => this.table.refreshBtn());
}
- getDisable(
- action: 'add' | 'edit' | 'remove' | 'maintenance' | 'drain',
- selection: CdTableSelection
- ): boolean | string {
- if (
- action === 'remove' ||
- action === 'edit' ||
- action === 'maintenance' ||
- action === 'drain'
- ) {
- if (!selection?.hasSingleSelection) {
- return true;
- }
- if (!_.every(selection.selected, 'sources.orchestrator')) {
- return this.messages.nonOrchHost;
- }
- }
- return this.orchService.getTableActionDisableDesc(
+ getDisable(action: string, selection: CdTableSelection): boolean | string {
+ return this.hostService.getDisable(
+ action,
+ selection,
this.orchStatus,
- this.actionOrchFeatures[action]
+ this.actionOrchFeatures,
+ this.messages.nonOrchHost,
+ [ActionLabels.REMOVE, ActionLabels.EDIT, ActionLabels.MAINTENANCE, ActionLabels.DRAIN]
);
}
deleteAction() {
const hostname = this.selection.first().hostname;
- this.modalRef = this.cdsModalService.show(DeleteConfirmationModalComponent, {
- impact: DeletionImpact.high,
- itemDescription: 'Host',
- itemNames: [hostname],
- actionDescription: 'remove',
- submitActionObservable: () =>
- this.taskWrapper.wrapTaskAroundCall({
- task: new FinishedTask('host/remove', { hostname: hostname }),
- call: this.hostService.delete(hostname)
- })
- });
+ this.modalRef = this.hostActionService.deleteAction(hostname);
}
checkHostsFactsAvailable() {
transformHostsData() {
if (this.checkHostsFactsAvailable()) {
_.forEach(this.hosts, (hostKey) => {
- hostKey['memory_total_bytes'] = this.emptyPipe.transform(hostKey['memory_total_kb'] * 1024);
- hostKey['raw_capacity'] = this.emptyPipe.transform(
- hostKey['hdd_capacity_bytes'] + hostKey['flash_capacity_bytes']
- );
+ const hostFacts = hostKey as HostFactsCapacitySource;
+ const totalMemoryBytes = this.hostService.getTotalMemoryBytes(hostFacts);
+ const rawCapacityBytes = this.hostService.getRawCapacityBytes(hostFacts);
+
+ hostKey['memory_total_bytes'] = this.emptyPipe.transform(totalMemoryBytes);
+ hostKey['raw_capacity'] = this.emptyPipe.transform(rawCapacityBytes);
});
} else {
// mark host facts columns unavailable
<cd-orchestrator-doc-panel *ngIf="showDocPanel"></cd-orchestrator-doc-panel>
<ng-container *ngIf="orchStatus?.available">
- <legend i18n>Physical Disks</legend>
+ @if (showHeading) {
+ <legend i18n>Physical Disks</legend>
+ }
<div class="row">
<div class="col-md-12">
<cd-inventory-devices
export class InventoryComponent implements OnChanges, OnInit, OnDestroy {
// Display inventory page only for this hostname, ignore to display all.
@Input() hostname?: string;
+ @Input() showHeading = true;
private reloadSubscriber: Subscription;
private reloadInterval = 5000;
import { configureTestBed } from '~/testing/unit-test-helper';
import { CdTableFetchDataContext } from '../models/cd-table-fetch-data-context';
+import { OrchestratorService } from './orchestrator.service';
+import { DeviceService } from '../services/device.service';
import { HostService } from './host.service';
describe('HostService', () => {
let service: HostService;
let httpTesting: HttpTestingController;
+ const deviceServiceStub = {
+ prepareDevice: (device: any) => device
+ };
+
+ const orchestratorServiceStub = {
+ getTableActionDisableDesc: jasmine.createSpy('getTableActionDisableDesc').and.returnValue(false)
+ };
+
configureTestBed({
- providers: [HostService],
+ providers: [
+ HostService,
+ { provide: DeviceService, useValue: deviceServiceStub },
+ { provide: OrchestratorService, useValue: orchestratorServiceStub }
+ ],
imports: [HttpClientTestingModule]
});
import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
import { InventoryHost } from '~/app/ceph/cluster/inventory/inventory-host.model';
import { ApiClient } from '~/app/shared/api/api-client';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { Daemon } from '../models/daemon.interface';
import { CdDevice } from '../models/devices';
import { SmartDataResponseV1 } from '../models/smart';
import { DeviceService } from '../services/device.service';
import { Host } from '../models/host.interface';
+import { OrchestratorFeature } from '../models/orchestrator.enum';
import { OrchestratorStatus } from '../models/orchestrator.interface';
+export interface HostModalRef {
+ [key: string]: unknown;
+}
+
+export interface HostFactsCapacitySource {
+ memory_total_kb?: number | string;
+ hdd_capacity_bytes?: number | string;
+ flash_capacity_bytes?: number | string;
+}
+
@Injectable({
providedIn: 'root'
})
constructor(
private http: HttpClient,
- private deviceService: DeviceService
+ private deviceService: DeviceService,
+ private orchService: OrchestratorService
) {
super();
}
return this.http.get<Host[]>(`${this.baseUIURL}/list`);
}
+ /**
+ * Returns total memory in bytes when memory_total_kb is available.
+ */
+ getTotalMemoryBytes(host?: HostFactsCapacitySource): number | undefined {
+ const memoryKb = Number(host?.memory_total_kb);
+ if (!Number.isFinite(memoryKb)) {
+ return undefined;
+ }
+ return memoryKb * 1024;
+ }
+
+ /**
+ * Returns raw capacity in bytes when both HDD and flash capacities are available.
+ */
+ getRawCapacityBytes(host?: HostFactsCapacitySource): number | undefined {
+ const hdd = Number(host?.hdd_capacity_bytes);
+ const flash = Number(host?.flash_capacity_bytes);
+ if (!Number.isFinite(hdd) || !Number.isFinite(flash)) {
+ return undefined;
+ }
+ return hdd + flash;
+ }
+
+ getDisable<TAction extends string>(
+ action: TAction,
+ selection: CdTableSelection,
+ orchStatus: OrchestratorStatus | undefined,
+ actionOrchFeatures: Record<TAction, OrchestratorFeature[]>,
+ nonOrchHostMessage: string,
+ requireSingleSelectionActions: TAction[]
+ ): boolean | string {
+ if (requireSingleSelectionActions.includes(action)) {
+ if (!selection?.hasSingleSelection) {
+ return true;
+ }
+ if (!_.every(selection.selected, 'sources.orchestrator')) {
+ return nonOrchHostMessage;
+ }
+ }
+
+ return this.orchService.getTableActionDisableDesc(
+ orchStatus as OrchestratorStatus,
+ actionOrchFeatures[action]
+ );
+ }
+
+ /**
+ * Returns whether host facts are available from the orchestrator.
+ */
checkHostsFactsAvailable(orchStatus: OrchestratorStatus) {
if (orchStatus?.available) {
return true;
import { PageHeaderComponent } from './page-header/page-header.component';
import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.component';
+import { OverviewComponent } from './resource-overview-card/resource-overview-card.component';
@NgModule({
imports: [
TearsheetStepComponent,
PageHeaderComponent,
SidebarLayoutComponent,
- NumberWithUnitComponent
+ NumberWithUnitComponent,
+ OverviewComponent
],
providers: [provideCharts(withDefaultRegisterables())],
exports: [
PageHeaderComponent,
SidebarLayoutComponent,
NumberWithUnitComponent,
+ OverviewComponent,
ProductiveCardComponent
]
})
--- /dev/null
+<cds-tile class="cd-overview-card">
+ @if (title) {
+ <div class="cds-pl-6">
+ <h3 class="cds--type-heading-03">{{ title }}</h3>
+ </div>
+ }
+
+ <section class="cd-overview-section cds-pt-5">
+ <div
+ cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true"
+ class="cd-overview-grid"
+ >
+ @for (field of fields; track $index) {
+ <div
+ cdsCol
+ class="cd-overview-item"
+ [columnNumbers]="columns | overviewGridColumnNumbers"
+ >
+ <span class="cds--type-label-01 cd-overview-label cds-mb-3">{{ field.label }}</span>
+
+ @if (field.type === 'status') {
+ @let statusMeta = field.status | overviewStatus;
+ <span class="cd-overview-status">
+ <cd-icon [type]="statusMeta.icon"></cd-icon>
+ <span [class]="'cds--type-body-compact-01 ' + statusMeta.textClass">
+ {{ field.value | empty: field.emptyText }}
+ </span>
+ </span>
+ } @else if (field.type === 'tags') {
+ <div class="cd-overview-tags">
+ @for (value of field.values; track $index) {
+ <cds-tag type="blue">{{ value | empty: field.emptyText }}</cds-tag>
+ } @empty {
+ <span class="cds--type-body-compact-01">{{ field.emptyText ?? '-' }}</span>
+ }
+ </div>
+ } @else {
+ <span class="cds--type-body-compact-01 cd-overview-value">
+ {{ field.value | empty: field.emptyText }}
+ </span>
+ }
+ </div>
+ }
+ </div>
+ </section>
+</cds-tile>
--- /dev/null
+@use '@carbon/layout';
+
+.cd-overview-card {
+ display: block;
+}
+
+.cd-overview-section {
+ + .cd-overview-section {
+ border-top: 1px solid var(--cds-border-subtle-01);
+ }
+}
+
+.cd-overview-grid {
+ row-gap: layout.$spacing-05;
+ column-gap: layout.$spacing-05;
+}
+
+.cd-overview-heading {
+ padding-inline-start: layout.$spacing-06;
+}
+
+.cd-overview-item {
+ min-width: 0;
+}
+
+.cd-overview-label {
+ display: block;
+ color: var(--cds-text-secondary);
+}
+
+.cd-overview-value {
+ overflow-wrap: break-word;
+}
+
+.cd-overview-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: layout.$spacing-03;
+}
+
+.cd-overview-status {
+ display: inline-flex;
+ align-items: center;
+ gap: layout.$spacing-02;
+}
+
+.cd-status-text--success {
+ color: var(--cds-support-success);
+}
+
+.cd-status-text--warning {
+ color: var(--cds-support-caution-major);
+}
+
+.cd-status-text--danger {
+ color: var(--cds-support-error);
+}
+
+.cd-status-text--info {
+ color: var(--cds-support-info);
+}
--- /dev/null
+import { beforeEach, describe, expect, it } from '@jest/globals';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+
+import { OverviewComponent } from './resource-overview-card.component';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+
+describe('OverviewComponent', () => {
+ let component: OverviewComponent;
+ let fixture: ComponentFixture<OverviewComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [OverviewComponent],
+ imports: [PipesModule],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(OverviewComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Template Rendering', () => {
+ it('should display the title if provided', () => {
+ component.title = 'Host Overview';
+ fixture.detectChanges();
+
+ const titleEl = fixture.debugElement.query(By.css('.cds--type-heading-03'));
+ expect(titleEl).toBeTruthy();
+ expect(titleEl.nativeElement.textContent.trim()).toBe('Host Overview');
+ });
+
+ it('should render standard text values correctly', () => {
+ component.fields = [{ label: 'CPU', value: 'Intel', type: 'text' }];
+ fixture.detectChanges();
+
+ const labelEl = fixture.debugElement.query(By.css('.cd-overview-label'));
+ const valueEl = fixture.debugElement.query(By.css('.cd-overview-value'));
+
+ expect(labelEl.nativeElement.textContent.trim()).toBe('CPU');
+ expect(valueEl.nativeElement.textContent.trim()).toBe('Intel');
+ });
+
+ it('should render tags correctly', () => {
+ component.fields = [
+ {
+ label: 'Roles',
+ values: ['mon', 'mgr', 'osd'],
+ type: 'tags'
+ }
+ ];
+ fixture.detectChanges();
+
+ const tags = fixture.debugElement.queryAll(By.css('cds-tag'));
+ expect(tags.length).toBe(3);
+ expect(tags[0].nativeElement.textContent.trim()).toBe('mon');
+ expect(tags[1].nativeElement.textContent.trim()).toBe('mgr');
+ expect(tags[2].nativeElement.textContent.trim()).toBe('osd');
+ });
+
+ it('should render fallback emptyText for tags if values array is empty', () => {
+ component.fields = [
+ {
+ label: 'Roles',
+ values: [],
+ type: 'tags',
+ emptyText: 'No roles assigned'
+ }
+ ];
+ fixture.detectChanges();
+
+ const emptyTextEl = fixture.debugElement.query(By.css('.cd-overview-tags span'));
+ expect(emptyTextEl.nativeElement.textContent.trim()).toBe('No roles assigned');
+ });
+
+ it('should render status fields as plain text values', () => {
+ component.fields = [
+ { label: 'State1', value: 'Available', type: 'status', status: 'success' },
+ { label: 'State2', value: 'Maintenance', type: 'status', status: 'warning' },
+ { label: 'State3', value: 'Custom State', type: 'status', status: 'info-circle' }
+ ];
+ fixture.detectChanges();
+
+ const statusContainers = fixture.debugElement.queryAll(By.css('.cd-overview-status'));
+ expect(statusContainers.length).toBe(3);
+
+ expect(statusContainers[0].nativeElement.textContent.trim()).toContain('Available');
+ expect(statusContainers[1].nativeElement.textContent.trim()).toContain('Maintenance');
+ expect(statusContainers[2].nativeElement.textContent.trim()).toContain('Custom State');
+ });
+
+ it('should apply fallback info status class for info statuses', () => {
+ component.fields = [
+ {
+ label: 'State',
+ value: 'Custom State',
+ type: 'status',
+ status: 'info-circle'
+ }
+ ];
+ fixture.detectChanges();
+
+ const statusText = fixture.debugElement.query(
+ By.css('.cd-overview-status .cd-status-text--info')
+ );
+ expect(statusText).toBeTruthy();
+ });
+ });
+});
--- /dev/null
+import { Component, Input } from '@angular/core';
+
+export type OverviewValue = string | number | boolean | null | undefined;
+
+export interface OverviewField {
+ /* Human-readable label shown for the field. */
+ label: string;
+ /* Single value rendered for text/status fields. */
+ value?: OverviewValue;
+ /* Multiple values rendered when the field uses tag display. */
+ values?: OverviewValue[];
+ /* Selects how the field value should be presented in the UI. */
+ type?: 'text' | 'status' | 'tags';
+ /* Visual tone used by status rendering (icon/text styling). */
+ status?: 'success' | 'warning' | 'danger' | 'info-circle';
+ /* Fallback text shown when the value is empty. */
+ emptyText?: string;
+}
+
+@Component({
+ selector: 'cd-resource-overview-card',
+ templateUrl: './resource-overview-card.component.html',
+ styleUrls: ['./resource-overview-card.component.scss'],
+ standalone: false
+})
+export class OverviewComponent {
+ /* Title shown at the top of the overview card. */
+ @Input() title = '';
+ /* Fields rendered in the overview card. */
+ @Input() fields: OverviewField[] = [];
+ /* Number of columns used to layout the fields. */
+ @Input() columns = 3;
+}
@if (title) {
<header class="sidebar-header">
- <h2 class="cds--type-heading-05">{{ title }}</h2>
+ <div class="sidebar-header-content">
+ <h2 class="cds--type-heading-05">{{ title }}</h2>
+ <ng-content select="[sidebar-header-extra]"></ng-content>
+ </div>
</header>
}
<div class="sidebar-layout-container sidebar-layout--full">
.sidebar-header {
padding-left: var(--cds-spacing-05);
}
+
+.sidebar-header-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--cds-spacing-03);
+}
/* Multi-cluster */
CONNECT = 'connect',
RECONNECT = 'reconnect',
- VIEW = 'View'
+ VIEW = 'View',
+
+ /* Hosts */
+ MAINTENANCE = 'Maintenance',
+ DRAIN = 'Drain'
}
@Injectable({
export enum HostStatus {
AVAILABLE = 'Available',
MAINTENANCE = 'Maintenance',
- RUNNING = 'Running'
+ RUNNING = 'Running',
+ OFFLINE = 'Offline'
}
+import { HostStatus } from '../enum/host-status.enum';
+import { ICON_TYPE } from '../enum/icons.enum';
+
+export type OverviewStatusIcon = 'danger' | 'info-circle' | 'success' | 'warning';
export interface Host {
ceph_version: string;
services: Array<{ type: string; id: string }>;
status: any;
service_instances: Array<{ type: string; count: number }>;
}
+
+export interface HostOverviewDetails extends Host {
+ model?: string;
+ cpu_count?: number | string;
+ cpu_cores?: number | string;
+ memory_total_kb?: number | string;
+ hdd_capacity_bytes?: number | string;
+ flash_capacity_bytes?: number | string;
+ hdd_count?: number | string;
+ flash_count?: number | string;
+ nic_count?: number | string;
+}
+
+export interface HostStatusConfig {
+ status: HostStatus | string;
+ icon: OverviewStatusIcon;
+ label: string;
+}
+
+export const STATUS_MAP: Record<string, HostStatusConfig> = {
+ available: {
+ status: HostStatus.AVAILABLE,
+ icon: ICON_TYPE.success,
+ label: $localize`Available`
+ },
+ maintenance: {
+ status: HostStatus.MAINTENANCE,
+ icon: ICON_TYPE.warning,
+ label: $localize`Maintenance`
+ },
+ offline: {
+ status: HostStatus.OFFLINE,
+ icon: ICON_TYPE.danger,
+ label: $localize`Offline`
+ }
+};
+
+export function getStatus(host: { status?: string } = {}): string {
+ const status = host?.status?.trim()?.toLowerCase();
+ if (status === '') return 'available';
+ return status;
+}
standalone: false
})
export class EmptyPipe implements PipeTransform {
- transform(value: any): any {
- if (_.isUndefined(value) || _.isNull(value)) {
- return '-';
+ transform(value: any, emptyText?: string): any {
+ if (_.isUndefined(value) || _.isNull(value) || value === '') {
+ return emptyText ?? '-';
} else if (_.isNaN(value)) {
return 'N/A';
}
--- /dev/null
+import { OverviewGridColumnNumbersPipe } from './overview-grid-column-numbers.pipe';
+
+describe('OverviewGridColumnNumbersPipe', () => {
+ const pipe = new OverviewGridColumnNumbersPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should calculate correct columns for default (3 columns)', () => {
+ expect(pipe.transform(3)).toEqual({ sm: 4, md: 4, lg: 5 });
+ });
+
+ it('should calculate correct columns for 1 column', () => {
+ expect(pipe.transform(1)).toEqual({ sm: 4, md: 8, lg: 16 });
+ });
+
+ it('should calculate correct columns for 4 columns', () => {
+ expect(pipe.transform(4)).toEqual({ sm: 4, md: 4, lg: 4 });
+ });
+
+ it('should clamp column values less than 1 to 1', () => {
+ expect(pipe.transform(0)).toEqual({ sm: 4, md: 8, lg: 16 });
+ expect(pipe.transform(-5)).toEqual({ sm: 4, md: 8, lg: 16 });
+ });
+
+ it('should clamp column values greater than 16 to 16', () => {
+ expect(pipe.transform(20)).toEqual({ sm: 4, md: 4, lg: 1 });
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+export interface OverviewGridColumnNumbers {
+ sm: number;
+ md: number;
+ lg: number;
+}
+
+/* Pipe to transform the number of columns into responsive grid column numbers for overview resource page */
+@Pipe({
+ name: 'overviewGridColumnNumbers',
+ standalone: false
+})
+export class OverviewGridColumnNumbersPipe implements PipeTransform {
+ private readonly maxGridColumns = 16;
+
+ transform(columns: number): OverviewGridColumnNumbers {
+ const roundedColumns = Math.floor(columns);
+ const normalizedColumns = Math.min(Math.max(roundedColumns, 1), this.maxGridColumns);
+
+ return {
+ sm: 4,
+ // md grid has 8 columns - cap at 2 columns per row beyond single-column layouts
+ md: normalizedColumns === 1 ? 8 : 4,
+ lg: Math.max(1, Math.floor(this.maxGridColumns / normalizedColumns))
+ };
+ }
+}
--- /dev/null
+import { describe, expect, it } from '@jest/globals';
+
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { OverviewStatusPipe } from './overview-status.pipe';
+
+describe('OverviewStatusPipe', () => {
+ const pipe = new OverviewStatusPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('maps success status to correct icon and text class', () => {
+ expect(pipe.transform('success')).toEqual({
+ icon: ICON_TYPE.success,
+ textClass: 'cd-status-text--success'
+ });
+ });
+
+ it('maps warning status to correct icon and text class', () => {
+ expect(pipe.transform('warning')).toEqual({
+ icon: ICON_TYPE.warning,
+ textClass: 'cd-status-text--warning'
+ });
+ });
+
+ it('maps danger status to correct icon and text class', () => {
+ expect(pipe.transform('danger')).toEqual({
+ icon: ICON_TYPE.danger,
+ textClass: 'cd-status-text--danger'
+ });
+ });
+
+ it('falls back to info mappings for info, undefined, null, and unknown statuses', () => {
+ const expectedInfoMeta = {
+ icon: ICON_TYPE.infoCircle,
+ textClass: 'cd-status-text--info'
+ };
+
+ expect(pipe.transform('info')).toEqual(expectedInfoMeta);
+ expect(pipe.transform(undefined)).toEqual(expectedInfoMeta);
+ expect(pipe.transform(null)).toEqual(expectedInfoMeta);
+
+ // Using 'as any' here to intentionally test bad inputs that bypass TypeScript
+ expect(pipe.transform('invalid_status' as any)).toEqual(expectedInfoMeta);
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+
+export type OverviewFieldStatusType = 'success' | 'warning' | 'danger' | 'info';
+export type OverviewFieldStatus = OverviewFieldStatusType | null | undefined;
+
+export interface OverviewStatusMeta {
+ icon: string;
+ textClass: string;
+}
+
+const OVERVIEW_STATUS_MAP: Record<OverviewFieldStatusType, OverviewStatusMeta> = {
+ success: {
+ icon: ICON_TYPE.success,
+ textClass: 'cd-status-text--success'
+ },
+ warning: {
+ icon: ICON_TYPE.warning,
+ textClass: 'cd-status-text--warning'
+ },
+ danger: {
+ icon: ICON_TYPE.danger,
+ textClass: 'cd-status-text--danger'
+ },
+ info: {
+ icon: ICON_TYPE.infoCircle,
+ textClass: 'cd-status-text--info'
+ }
+};
+
+/* Pipe to transform the overview field status into corresponding icon or text class */
+@Pipe({
+ name: 'overviewStatus',
+ standalone: false
+})
+export class OverviewStatusPipe implements PipeTransform {
+ transform(status: OverviewFieldStatus): OverviewStatusMeta {
+ const normalizedStatus: OverviewFieldStatusType =
+ status === 'success' || status === 'warning' || status === 'danger' ? status : 'info';
+
+ return OVERVIEW_STATUS_MAP[normalizedStatus];
+ }
+}
import { DimlessBinaryPerMinutePipe } from './dimless-binary-per-minute.pipe';
import { RedirectLinkResolverPipe } from './redirect-link-resolver.pipe';
import { CephVersionPipe } from './ceph-version.pipe';
+import { OverviewStatusPipe } from './overview-status.pipe';
+import { OverviewGridColumnNumbersPipe } from './overview-grid-column-numbers.pipe';
@NgModule({
imports: [CommonModule],
PipeFunctionPipe,
DimlessBinaryPerMinutePipe,
RedirectLinkResolverPipe,
- CephVersionPipe
+ CephVersionPipe,
+ OverviewStatusPipe,
+ OverviewGridColumnNumbersPipe
],
exports: [
ArrayPipe,
PipeFunctionPipe,
DimlessBinaryPerMinutePipe,
RedirectLinkResolverPipe,
- CephVersionPipe
+ CephVersionPipe,
+ OverviewStatusPipe,
+ OverviewGridColumnNumbersPipe
],
providers: [
ArrayPipe,
MbpersecondPipe,
DimlessBinaryPerMinutePipe,
RedirectLinkResolverPipe,
- CephVersionPipe
+ CephVersionPipe,
+ OverviewStatusPipe,
+ OverviewGridColumnNumbersPipe
]
})
export class PipesModule {}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+import { TemplateRef } from '@angular/core';
+import { of, throwError } from 'rxjs';
+
+import { HostActionService } from './host-action.service';
+import { HostService } from '~/app/shared/api/host.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { Host } from '~/app/shared/models/host.interface';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('HostActionService', () => {
+ let service: HostActionService;
+ let hostService: HostService;
+ let notificationService: NotificationService;
+ let cdsModalService: ModalCdsService;
+ let taskWrapper: TaskWrapperService;
+
+ const mockHost: Host = {
+ hostname: 'test-host',
+ labels: ['mon', 'mgr'],
+ status: HostStatus.AVAILABLE
+ } as Host;
+
+ const mockLabels = ['mon', 'mgr', 'osd', 'mds', 'rgw', 'nfs', 'iscsi', 'rbd', 'grafana'];
+
+ configureTestBed({
+ providers: [
+ HostActionService,
+ {
+ provide: HostService,
+ useValue: {
+ getLabels: jasmine.createSpy('getLabels').and.returnValue(of(mockLabels)),
+ update: jasmine.createSpy('update').and.returnValue(of({})),
+ delete: jasmine.createSpy('delete').and.returnValue(of({}))
+ }
+ },
+ {
+ provide: NotificationService,
+ useValue: {
+ show: jasmine.createSpy('show')
+ }
+ },
+ {
+ provide: ModalCdsService,
+ useValue: {
+ show: jasmine.createSpy('show').and.returnValue({ dismiss: () => {} }),
+ dismissAll: jasmine.createSpy('dismissAll')
+ }
+ },
+ {
+ provide: TaskWrapperService,
+ useValue: {
+ wrapTaskAroundCall: jasmine.createSpy('wrapTaskAroundCall').and.returnValue(of({}))
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(HostActionService);
+ hostService = TestBed.inject(HostService);
+ notificationService = TestBed.inject(NotificationService);
+ cdsModalService = TestBed.inject(ModalCdsService);
+ taskWrapper = TestBed.inject(TaskWrapperService);
+ });
+
+ afterEach(() => {
+ (hostService.getLabels as jasmine.Spy).calls.reset();
+ (hostService.update as jasmine.Spy).calls.reset();
+ (hostService.delete as jasmine.Spy).calls.reset();
+ (notificationService.show as jasmine.Spy).calls.reset();
+ (cdsModalService.show as jasmine.Spy).calls.reset();
+ (cdsModalService.dismissAll as jasmine.Spy).calls.reset();
+ (taskWrapper.wrapTaskAroundCall as jasmine.Spy).calls.reset();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('openEditModal', () => {
+ it('should open edit modal and call onSuccess with updated labels', (done) => {
+ const updatedLabels = ['mon', 'mgr', 'new-label'];
+ const onSuccess = jasmine.createSpy('onSuccess');
+
+ service.openEditModal(mockHost, onSuccess);
+
+ setTimeout(() => {
+ expect(hostService.getLabels).toHaveBeenCalled();
+ expect(cdsModalService.show).toHaveBeenCalled();
+
+ // Simulate form submission
+ const showCall = (cdsModalService.show as jasmine.Spy).calls.mostRecent();
+ const modalConfig = showCall.args[1];
+ modalConfig.onSubmit({ labels: updatedLabels });
+
+ setTimeout(() => {
+ expect(hostService.update).toHaveBeenCalledWith(mockHost.hostname, true, updatedLabels);
+ expect(onSuccess).toHaveBeenCalledWith(updatedLabels);
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `Updated Host "${mockHost.hostname}"`
+ );
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should handle empty labels', (done) => {
+ const onSuccess = jasmine.createSpy('onSuccess');
+ (hostService.getLabels as jasmine.Spy).and.returnValue(of([]));
+
+ service.openEditModal(mockHost, onSuccess);
+
+ setTimeout(() => {
+ expect(cdsModalService.show).toHaveBeenCalled();
+ done();
+ }, 100);
+ });
+ });
+
+ describe('hostMaintenance', () => {
+ it('should enter maintenance mode for available host', (done) => {
+ const maintenanceTpl = {} as TemplateRef<any>;
+ const onExecutingChange = jasmine.createSpy('onExecutingChange');
+ const onErrorMessage = jasmine.createSpy('onErrorMessage');
+ const onSuccess = jasmine.createSpy('onSuccess');
+
+ service.hostMaintenance(
+ mockHost,
+ maintenanceTpl,
+ onExecutingChange,
+ onErrorMessage,
+ onSuccess
+ );
+
+ setTimeout(() => {
+ expect(onExecutingChange).toHaveBeenCalledWith(true);
+ expect(hostService.update).toHaveBeenCalledWith(mockHost.hostname, false, [], true);
+
+ // Simulate successful update
+ setTimeout(() => {
+ expect(onExecutingChange).toHaveBeenCalledWith(false);
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `"${mockHost.hostname}" moved to maintenance`
+ );
+ expect(onSuccess).toHaveBeenCalled();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should exit maintenance mode for host in maintenance', (done) => {
+ const maintenanceHost = { ...mockHost, status: HostStatus.MAINTENANCE.toLowerCase() };
+ const maintenanceTpl = {} as TemplateRef<any>;
+ const onExecutingChange = jasmine.createSpy('onExecutingChange');
+ const onErrorMessage = jasmine.createSpy('onErrorMessage');
+ const onSuccess = jasmine.createSpy('onSuccess');
+
+ service.hostMaintenance(
+ maintenanceHost,
+ maintenanceTpl,
+ onExecutingChange,
+ onErrorMessage,
+ onSuccess
+ );
+
+ setTimeout(() => {
+ expect(onExecutingChange).toHaveBeenCalledWith(true);
+ expect(hostService.update).toHaveBeenCalledWith(maintenanceHost.hostname, false, [], true);
+
+ setTimeout(() => {
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.success,
+ `"${maintenanceHost.hostname}" has exited maintenance`
+ );
+ expect(onSuccess).toHaveBeenCalled();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should handle maintenance entry critical error', (done) => {
+ const maintenanceTpl = {} as TemplateRef<any>;
+ const onExecutingChange = jasmine.createSpy('onExecutingChange');
+ const onErrorMessage = jasmine.createSpy('onErrorMessage');
+ const onSuccess = jasmine.createSpy('onSuccess');
+
+ const error = {
+ error: { detail: 'ALERT: Cannot enter maintenance' },
+ preventDefault: jasmine.createSpy('preventDefault')
+ };
+
+ (hostService.update as jasmine.Spy).and.returnValue(throwError(() => error));
+
+ service.hostMaintenance(
+ mockHost,
+ maintenanceTpl,
+ onExecutingChange,
+ onErrorMessage,
+ onSuccess
+ );
+
+ setTimeout(() => {
+ setTimeout(() => {
+ expect(notificationService.show).toHaveBeenCalledWith(
+ NotificationType.error,
+ `"${mockHost.hostname}" cannot be put into maintenance`,
+ jasmine.any(String)
+ );
+ done();
+ }, 100);
+ }, 100);
+ });
+ });
+
+ describe('deleteAction', () => {
+ it('should open delete confirmation modal', () => {
+ const hostname = 'test-host';
+
+ service.deleteAction(hostname);
+
+ expect(cdsModalService.show).toHaveBeenCalled();
+ const showCall = (cdsModalService.show as jasmine.Spy).calls.mostRecent();
+ const config = showCall.args[1];
+
+ expect(config.impact).toBeDefined();
+ expect(config.itemDescription).toBe('Host');
+ expect(config.itemNames).toContain(hostname);
+ expect(config.actionDescription).toBe('remove');
+ });
+
+ it('should call task wrapper on delete action confirmation', () => {
+ const hostname = 'test-host';
+
+ service.deleteAction(hostname);
+
+ const showCall = (cdsModalService.show as jasmine.Spy).calls.mostRecent();
+ const config = showCall.args[1];
+ config.submitActionObservable();
+
+ expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalled();
+ });
+ });
+
+ describe('private helper methods', () => {
+ it('should correctly get host labels', () => {
+ const hostWithLabels = { ...mockHost, labels: ['mon', 'mgr'] };
+ const result = (service as any).getHostLabels(hostWithLabels);
+
+ expect(result).toEqual(['mon', 'mgr']);
+ expect(result).not.toBe(hostWithLabels.labels); // Should be a clone
+ });
+
+ it('should handle undefined labels when getting host labels', () => {
+ const hostNoLabels = { ...mockHost, labels: undefined };
+ const result = (service as any).getHostLabels(hostNoLabels);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should create host labels field with correct structure', () => {
+ const hostLabels = ['mon'];
+ const allLabels = [
+ { content: 'mon', selected: true },
+ { content: 'mgr', selected: false }
+ ];
+
+ const result = (service as any).createHostLabelsField(hostLabels, allLabels);
+
+ expect(result.type).toBe('select-badges');
+ expect(result.name).toBe('labels');
+ expect(result.value).toEqual(hostLabels);
+ expect(result.typeConfig.customBadges).toBe(true);
+ });
+
+ it('should detect host in maintenance mode', () => {
+ const availableHost = { ...mockHost, status: HostStatus.AVAILABLE };
+ const maintenanceHost = { ...mockHost, status: HostStatus.MAINTENANCE.toLowerCase() };
+
+ expect((service as any).isHostInMaintenance(availableHost)).toBe(false);
+ expect((service as any).isHostInMaintenance(maintenanceHost)).toBe(true);
+ });
+
+ it('should determine if maintenance warning should show', () => {
+ const warningMsg = 'WARNING: Some services will be stopped';
+ const unsafeMsg = 'WARNING: It is NOT safe to stop';
+ const alertMsg = 'ALERT: Critical error';
+
+ expect((service as any).shouldShowMaintenanceWarning(warningMsg)).toBe(true);
+ expect((service as any).shouldShowMaintenanceWarning(unsafeMsg)).toBe(false);
+ expect((service as any).shouldShowMaintenanceWarning(alertMsg)).toBe(false);
+ });
+
+ it('should extract maintenance error detail', () => {
+ const error = { error: { detail: 'Test error message' } };
+ const errorNoDetail = { error: {} };
+ const errorNoError = {};
+
+ expect((service as any).getHostMaintenanceErrorDetail(error)).toBe('Test error message');
+ expect((service as any).getHostMaintenanceErrorDetail(errorNoDetail)).toBe('');
+ expect((service as any).getHostMaintenanceErrorDetail(errorNoError)).toBe('');
+ });
+ });
+});
--- /dev/null
+import { Injectable, TemplateRef } from '@angular/core';
+
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { HostService, HostModalRef } from '~/app/shared/api/host.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { Host } from '~/app/shared/models/host.interface';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { FinishedTask } from '../models/finished-task';
+
+interface HostLabelOption {
+ content: string;
+ selected: boolean;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class HostActionService {
+ predefinedLabels = ['mon', 'mgr', 'osd', 'mds', 'rgw', 'nfs', 'iscsi', 'rbd', 'grafana'];
+
+ constructor(
+ private hostService: HostService,
+ private notificationService: NotificationService,
+ private cdsModalService: ModalCdsService,
+ private taskWrapper: TaskWrapperService
+ ) {}
+
+ /**
+ * Opens the host edit modal and updates host labels after submit.
+ */
+ openEditModal(host: Host, onSuccess: (updatedLabels: string[]) => void): void {
+ this.hostService.getLabels().subscribe((resp) => {
+ const hostLabels = this.getHostLabels(host);
+ const allLabels = this.buildHostLabelOptions(resp, hostLabels);
+ const labelsField = this.createHostLabelsField(hostLabels, allLabels);
+
+ this.cdsModalService.show(FormModalComponent, {
+ titleText: $localize`Edit Host: ${host.hostname}`,
+ fields: [labelsField],
+ submitButtonText: $localize`Edit Host`,
+ onSubmit: (values: { labels: string[] }) => {
+ this.submitHostLabelUpdate(host.hostname, values.labels, onSuccess);
+ }
+ });
+ });
+ }
+
+ /**
+ * Moves a host into or out of maintenance mode.
+ */
+ hostMaintenance(
+ host: Host,
+ maintenanceConfirmTpl: TemplateRef<any>,
+ onExecutingChange: (executing: boolean) => void,
+ onErrorMessage: (errorMessage: string[]) => void,
+ onSuccess: () => void,
+ onWarningSuccess: () => void = () => undefined,
+ onWarningModalOpen: (modalRef: HostModalRef) => void = () => undefined
+ ): void {
+ onExecutingChange(true);
+
+ if (this.isHostInMaintenance(host)) {
+ this.exitHostMaintenance(host.hostname, onExecutingChange, onSuccess);
+ return;
+ }
+
+ this.enterHostMaintenance(
+ host.hostname,
+ maintenanceConfirmTpl,
+ onExecutingChange,
+ onErrorMessage,
+ onSuccess,
+ onWarningSuccess,
+ onWarningModalOpen
+ );
+ }
+
+ /**
+ * Starts or stops host drain and updates labels if required.
+ */
+ hostDrain(host: Host, stop: boolean, onSuccess: () => void): void {
+ if (stop) {
+ const labels = Array.isArray(host.labels) ? [...host.labels] : [];
+ const index = labels.indexOf('_no_schedule', 0);
+ if (index > -1) {
+ labels.splice(index, 1);
+ }
+
+ this.hostService.update(host.hostname, true, labels).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`"${host.hostname}" stopped draining`
+ );
+ onSuccess();
+ });
+ return;
+ }
+
+ this.hostService.update(host.hostname, false, [], false, false, true).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.info,
+ $localize`"${host.hostname}" started draining`
+ );
+ onSuccess();
+ });
+ }
+
+ /**
+ * Opens a delete-confirmation modal for removing a host.
+ */
+ deleteAction(hostname: string): HostModalRef {
+ return this.cdsModalService.show(DeleteConfirmationModalComponent, {
+ impact: DeletionImpact.high,
+ itemDescription: $localize`Host`,
+ itemNames: [hostname],
+ actionDescription: $localize`remove`,
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('host/remove', { hostname: hostname }),
+ call: this.hostService.delete(hostname)
+ })
+ });
+ }
+
+ /**
+ * Returns a cloned labels array from a host.
+ */
+ private getHostLabels(host: Host): string[] {
+ return Array.isArray(host.labels) ? [...host.labels] : [];
+ }
+
+ /**
+ * Builds selectable label options from server labels, predefined labels, and host labels.
+ */
+ private buildHostLabelOptions(labels: string[], hostLabels: string[]): HostLabelOption[] {
+ const allLabels = new Set(labels.concat(this.predefinedLabels).concat(hostLabels));
+ return Array.from(allLabels).map((label) => ({
+ content: label,
+ selected: hostLabels.includes(label)
+ }));
+ }
+
+ /**
+ * Creates the labels field config used by the edit-host form modal.
+ */
+ private createHostLabelsField(hostLabels: string[], allLabels: HostLabelOption[]) {
+ return {
+ type: 'select-badges',
+ name: 'labels',
+ value: hostLabels,
+ label: $localize`Labels`,
+ typeConfig: {
+ customBadges: true,
+ options: allLabels,
+ messages: new SelectMessages({
+ empty: $localize`There are no labels.`,
+ filter: $localize`Filter or add labels`,
+ add: $localize`Add label`
+ })
+ }
+ };
+ }
+
+ /**
+ * Submits host label changes and emits success callback/notification.
+ */
+ private submitHostLabelUpdate(
+ hostname: string,
+ labels: string[],
+ onSuccess: (updatedLabels: string[]) => void
+ ): void {
+ this.hostService.update(hostname, true, labels).subscribe(() => {
+ onSuccess(labels);
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Updated Host "${hostname}"`
+ );
+ });
+ }
+
+ /**
+ * Checks whether a host is currently in maintenance mode.
+ */
+ private isHostInMaintenance(host: Host): boolean {
+ return host.status === HostStatus.MAINTENANCE.toLowerCase();
+ }
+
+ /**
+ * Sends the request to enter maintenance and delegates success/error handling.
+ */
+ private enterHostMaintenance(
+ hostname: string,
+ maintenanceConfirmTpl: TemplateRef<any>,
+ onExecutingChange: (executing: boolean) => void,
+ onErrorMessage: (errorMessage: string[]) => void,
+ onSuccess: () => void,
+ onWarningSuccess: () => void,
+ onWarningModalOpen: (modalRef: HostModalRef) => void
+ ): void {
+ this.hostService.update(hostname, false, [], true).subscribe(
+ () => this.handleEnterMaintenanceSuccess(hostname, onExecutingChange, onSuccess),
+ (error) =>
+ this.handleEnterMaintenanceError(
+ hostname,
+ error,
+ maintenanceConfirmTpl,
+ onExecutingChange,
+ onErrorMessage,
+ onWarningSuccess,
+ onWarningModalOpen
+ )
+ );
+ }
+
+ /**
+ * Handles successful transition into maintenance.
+ */
+ private handleEnterMaintenanceSuccess(
+ hostname: string,
+ onExecutingChange: (executing: boolean) => void,
+ onSuccess: () => void
+ ): void {
+ onExecutingChange(false);
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${hostname}" moved to maintenance`
+ );
+ onSuccess();
+ }
+
+ /**
+ * Handles maintenance entry failures and optional warning confirmation flow.
+ */
+ private handleEnterMaintenanceError(
+ hostname: string,
+ error: any,
+ maintenanceConfirmTpl: TemplateRef<any>,
+ onExecutingChange: (executing: boolean) => void,
+ onErrorMessage: (errorMessage: string[]) => void,
+ onWarningSuccess: () => void,
+ onWarningModalOpen: (modalRef: HostModalRef) => void
+ ): void {
+ onExecutingChange(false);
+
+ const errorDetail = this.getHostMaintenanceErrorDetail(error);
+ onErrorMessage(errorDetail.split(/\n/));
+
+ if (typeof error?.preventDefault === 'function') {
+ error.preventDefault();
+ }
+
+ if (this.shouldShowMaintenanceWarning(errorDetail)) {
+ const modalRef = this.openHostMaintenanceWarningModal(
+ hostname,
+ maintenanceConfirmTpl,
+ onWarningSuccess
+ );
+ onWarningModalOpen(modalRef);
+ return;
+ }
+
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`"${hostname}" cannot be put into maintenance`,
+ $localize`${errorDetail}`
+ );
+ }
+
+ /**
+ * Extracts maintenance error detail from backend responses.
+ */
+ private getHostMaintenanceErrorDetail(error: any): string {
+ return error?.error?.detail ?? '';
+ }
+
+ /**
+ * Returns true when warning flow should offer a forced maintenance action.
+ */
+ private shouldShowMaintenanceWarning(errorDetail: string): boolean {
+ return (
+ errorDetail.includes('WARNING') &&
+ !errorDetail.includes('It is NOT safe to stop') &&
+ !errorDetail.includes('ALERT') &&
+ !errorDetail.includes('unsafe to stop')
+ );
+ }
+
+ /**
+ * Opens warning confirmation modal and handles the forced maintenance request.
+ */
+ private openHostMaintenanceWarningModal(
+ hostname: string,
+ maintenanceConfirmTpl: TemplateRef<any>,
+ onWarningSuccess: () => void
+ ): HostModalRef {
+ return this.cdsModalService.show(ConfirmationModalComponent, {
+ titleText: $localize`Warning`,
+ buttonText: $localize`Continue`,
+ warning: true,
+ bodyTpl: maintenanceConfirmTpl,
+ showSubmit: true,
+ onSubmit: () => this.forceHostMaintenance(hostname, onWarningSuccess)
+ });
+ }
+
+ /**
+ * Retries maintenance entry with force and closes any modal afterward.
+ */
+ private forceHostMaintenance(hostname: string, onWarningSuccess: () => void): void {
+ this.hostService.update(hostname, false, [], true, true).subscribe(
+ () => {
+ this.cdsModalService.dismissAll();
+ onWarningSuccess();
+ },
+ () => this.cdsModalService.dismissAll()
+ );
+ }
+
+ /**
+ * Exits maintenance mode and notifies the caller.
+ */
+ private exitHostMaintenance(
+ hostname: string,
+ onExecutingChange: (executing: boolean) => void,
+ onSuccess: () => void
+ ): void {
+ this.hostService.update(hostname, false, [], true).subscribe(() => {
+ onExecutingChange(false);
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`"${hostname}" has exited maintenance`
+ );
+ onSuccess();
+ });
+ }
+}
padding-left: layout.$spacing-07;
}
+.cds-pr-7 {
+ padding-right: layout.$spacing-07;
+}
+
.cds-pt-2px {
padding-top: 2px;
}
padding-top: layout.$spacing-03;
}
+.cds-pt-5 {
+ padding-top: layout.$spacing-05;
+}
+
.cds-pr-4 {
padding-right: layout.$spacing-04;
}