]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: revamped hosts overview resource page 69468/head
authorSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Sun, 12 Jul 2026 13:39:00 +0000 (19:09 +0530)
committerSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Sun, 12 Jul 2026 13:39:00 +0000 (19:09 +0530)
- Added a new shared overview component for the resource page

- Added a new host-action service

- Fixes: https://tracker.ceph.com/issues/76712

Signed-off-by: Syed Ali Ul Hasan <syedaliulhasan19@gmail.com>
42 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/host.interface.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss

index 95f1449aa9bc12964c5ab6a478fdf5ff2b5167d3..3f0df6b42563443b3ac90c1e2db77297c48e1da3 100644 (file)
@@ -8,10 +8,10 @@ import { ConfigurationFormComponent } from './ceph/cluster/configuration/configu
 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';
@@ -158,34 +158,29 @@ const routes: Routes = [
       },
       {
         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' }
           }
         ]
       },
index ac33390869ad1a36873babc83b8dcda7e4621717..6be23e612fa4f18155087cd7954054548175f7e0 100644 (file)
@@ -28,7 +28,9 @@ import {
   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';
@@ -60,8 +62,8 @@ import { CreateClusterStep3Component } from './create-cluster/create-cluster-ste
 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';
@@ -148,7 +150,9 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/
     RadioModule,
     TilesModule,
     LayerModule,
-    AccordionModule
+    AccordionModule,
+    MenuButtonModule,
+    ContextMenuModule
   ],
   declarations: [
     MonitorComponent,
@@ -157,8 +161,8 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/
     OsdDetailsComponent,
     OsdScrubModalComponent,
     OsdFlagsModalComponent,
-    HostDetailsComponent,
-    HostDetailsSectionComponent,
+    HostSidebarComponent,
+    HostResourcePageComponent,
     ConfigurationDetailsComponent,
     ConfigurationFormComponent,
     OsdReweightModalComponent,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts
deleted file mode 100644 (file)
index fe27747..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-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) }
-    ];
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html
deleted file mode 100644 (file)
index a9a70e3..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-@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
-  >
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts
deleted file mode 100644 (file)
index c4e92dd..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-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'] ?? '';
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
deleted file mode 100644 (file)
index 35343d1..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<cd-sidebar-layout
-  [title]="hostname"
-  [items]="sidebarItems"
-></cd-sidebar-layout>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
deleted file mode 100644 (file)
index f0086af..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-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');
-    });
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts
deleted file mode 100644 (file)
index 28a7602..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-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;
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts
new file mode 100644 (file)
index 0000000..e2a9c1b
--- /dev/null
@@ -0,0 +1,17 @@
+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) }
+    ];
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html
new file mode 100644 (file)
index 0000000..55bdb20
--- /dev/null
@@ -0,0 +1,92 @@
+@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
+  >
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts
new file mode 100644 (file)
index 0000000..6747d22
--- /dev/null
@@ -0,0 +1,228 @@
+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();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts
new file mode 100644 (file)
index 0000000..626a794
--- /dev/null
@@ -0,0 +1,145 @@
+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'
+      }
+    ];
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html
new file mode 100644 (file)
index 0000000..fe3cc5f
--- /dev/null
@@ -0,0 +1,66 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss
new file mode 100644 (file)
index 0000000..46a4f0c
--- /dev/null
@@ -0,0 +1,87 @@
+@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;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts
new file mode 100644 (file)
index 0000000..20dc3f4
--- /dev/null
@@ -0,0 +1,94 @@
+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');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts
new file mode 100644 (file)
index 0000000..7267771
--- /dev/null
@@ -0,0 +1,274 @@
+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);
+  }
+}
index c1ce3f7efab3454f3f057433008b29aff27a1d86..21375beb0ba3951e4db65a9a5d9453b4102765ed 100644 (file)
@@ -239,8 +239,8 @@ describe('HostsComponent', () => {
     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', () => {
index 07d29d635360537b2ef6440133a97373dab58cb9..26ff22f284f203c73d8b8d54f9908b5836c891d4 100644 (file)
@@ -1,40 +1,32 @@
 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';
 
@@ -94,12 +86,12 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   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;
@@ -111,25 +103,27 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   };
 
   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
   ) {
@@ -160,14 +154,14 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
             : (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,
@@ -188,7 +182,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
         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,
@@ -196,7 +190,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
         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
@@ -207,7 +201,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
         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
@@ -315,171 +309,52 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   }
 
   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() {
@@ -496,10 +371,12 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   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
index 14b1c4ef4ee26c985941d9d4266b4e575d06243e..5302fde889e8b02ef09c4cba6f5e50fce839d9a6 100644 (file)
@@ -1,6 +1,8 @@
 <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
index d252eda5ce97b8a5059d6aee83b64d714674997e..5736899f0cf6433f7513c382cc00a155abbcab42 100644 (file)
@@ -17,6 +17,7 @@ import { InventoryDevice } from './inventory-devices/inventory-device.model';
 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;
index ce13a1011ffe7ceecc8522be5185ebe15360e700..d6103d3878877d77939b507049f9ef63941898da 100644 (file)
@@ -3,14 +3,28 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 
 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]
   });
 
index 57a10ba729607bc52a7172373c9038626e28ae4c..e1d408a70ee519bd8b40c5653316f75fb9e15507 100644 (file)
@@ -8,14 +8,27 @@ import { map, mergeMap, toArray } from 'rxjs/operators';
 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'
 })
@@ -27,7 +40,8 @@ export class HostService extends ApiClient {
 
   constructor(
     private http: HttpClient,
-    private deviceService: DeviceService
+    private deviceService: DeviceService,
+    private orchService: OrchestratorService
   ) {
     super();
   }
@@ -173,6 +187,55 @@ export class HostService extends ApiClient {
     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;
index fd81525f009e420028e10f59a2b06d6cba532843..b56b4da806b487007cc3d22d169db0bf56ac05f0 100644 (file)
@@ -138,6 +138,7 @@ import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.componen
 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: [
@@ -235,7 +236,8 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com
     TearsheetStepComponent,
     PageHeaderComponent,
     SidebarLayoutComponent,
-    NumberWithUnitComponent
+    NumberWithUnitComponent,
+    OverviewComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   exports: [
@@ -282,6 +284,7 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com
     PageHeaderComponent,
     SidebarLayoutComponent,
     NumberWithUnitComponent,
+    OverviewComponent,
     ProductiveCardComponent
   ]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html
new file mode 100644 (file)
index 0000000..2bb10ce
--- /dev/null
@@ -0,0 +1,49 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss
new file mode 100644 (file)
index 0000000..d5dec8c
--- /dev/null
@@ -0,0 +1,61 @@
+@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);
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts
new file mode 100644 (file)
index 0000000..7a74ea9
--- /dev/null
@@ -0,0 +1,115 @@
+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();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts
new file mode 100644 (file)
index 0000000..b0cc64d
--- /dev/null
@@ -0,0 +1,33 @@
+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;
+}
index cdacb9dd73879e59de182a0aa1008e8c4958aad9..86daa75b1cafb4e591a2a3a71e532669d2b3d709 100644 (file)
@@ -1,6 +1,9 @@
 @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">
index 5e7c58b68b2ddf2d3ffbc089bae1b8215ab61f01..744f95ac09532d0b090f3a6c516e276befd39d0b 100644 (file)
 .sidebar-header {
   padding-left: var(--cds-spacing-05);
 }
+
+.sidebar-header-content {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--cds-spacing-03);
+}
index d5352edea15696e047ecb528d716982c6d235a01..41891d9b8432bce73fcd8865df9a90934366ee77 100644 (file)
@@ -89,7 +89,11 @@ export enum ActionLabels {
   /* Multi-cluster */
   CONNECT = 'connect',
   RECONNECT = 'reconnect',
-  VIEW = 'View'
+  VIEW = 'View',
+
+  /* Hosts */
+  MAINTENANCE = 'Maintenance',
+  DRAIN = 'Drain'
 }
 
 @Injectable({
index 89e26422c97d4cb363d957efec4b91ac71be906e..2f9348ff196959868148a7c420d632533dbaa011 100644 (file)
@@ -1,5 +1,6 @@
 export enum HostStatus {
   AVAILABLE = 'Available',
   MAINTENANCE = 'Maintenance',
-  RUNNING = 'Running'
+  RUNNING = 'Running',
+  OFFLINE = 'Offline'
 }
index bff2aea8862826a81db78ad4fc68ee50da5cde00..7d5c8463cb7dc0af269adbe9d1694ce5079cd430 100644 (file)
@@ -1,3 +1,7 @@
+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 }>;
@@ -11,3 +15,45 @@ export interface Host {
   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;
+}
index c172feda8514751813f01bcc0de8e50664c14265..4c2793e6a1fc5d3feba7b0fc9ed6111940fcd771 100644 (file)
@@ -7,9 +7,9 @@ import _ from 'lodash';
   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';
     }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.spec.ts
new file mode 100644 (file)
index 0000000..c73578c
--- /dev/null
@@ -0,0 +1,30 @@
+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 });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-grid-column-numbers.pipe.ts
new file mode 100644 (file)
index 0000000..17c99df
--- /dev/null
@@ -0,0 +1,28 @@
+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))
+    };
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.spec.ts
new file mode 100644 (file)
index 0000000..c9d2055
--- /dev/null
@@ -0,0 +1,47 @@
+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);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/overview-status.pipe.ts
new file mode 100644 (file)
index 0000000..7a2852f
--- /dev/null
@@ -0,0 +1,44 @@
+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];
+  }
+}
index 458d0d5a311aae82abea7fbbacbfcbe593c7aff9..55ebfc7d35f4927574f5d4e2acad50ea83c2f1f6 100755 (executable)
@@ -42,6 +42,8 @@ import { PipeFunctionPipe } from './pipe-function.pipe';
 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],
@@ -86,7 +88,9 @@ import { CephVersionPipe } from './ceph-version.pipe';
     PipeFunctionPipe,
     DimlessBinaryPerMinutePipe,
     RedirectLinkResolverPipe,
-    CephVersionPipe
+    CephVersionPipe,
+    OverviewStatusPipe,
+    OverviewGridColumnNumbersPipe
   ],
   exports: [
     ArrayPipe,
@@ -129,7 +133,9 @@ import { CephVersionPipe } from './ceph-version.pipe';
     PipeFunctionPipe,
     DimlessBinaryPerMinutePipe,
     RedirectLinkResolverPipe,
-    CephVersionPipe
+    CephVersionPipe,
+    OverviewStatusPipe,
+    OverviewGridColumnNumbersPipe
   ],
   providers: [
     ArrayPipe,
@@ -163,7 +169,9 @@ import { CephVersionPipe } from './ceph-version.pipe';
     MbpersecondPipe,
     DimlessBinaryPerMinutePipe,
     RedirectLinkResolverPipe,
-    CephVersionPipe
+    CephVersionPipe,
+    OverviewStatusPipe,
+    OverviewGridColumnNumbersPipe
   ]
 })
 export class PipesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.spec.ts
new file mode 100644 (file)
index 0000000..a8654f9
--- /dev/null
@@ -0,0 +1,310 @@
+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('');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/host-action.service.ts
new file mode 100644 (file)
index 0000000..f2ae91d
--- /dev/null
@@ -0,0 +1,343 @@
+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();
+    });
+  }
+}
index ac3a20cea999241dde26a177ab8fa5ed14239c3a..c34f3a94f6526ee7c122335b16d3295245d6136c 100644 (file)
   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;
 }