From: Abhishek Desai Date: Mon, 19 Jan 2026 08:47:54 +0000 (+0530) Subject: mgr/dashboard : Add Certificate tab under service details X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=f6f8626a43ac516b954a54ec5f6a6f1e67a13279;p=ceph.git mgr/dashboard : Add Certificate tab under service details fixes : https://tracker.ceph.com/issues/74429 Signed-off-by: Abhishek Desai --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts index 72eeb37a2ef..fa21762af46 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/services.po.ts @@ -32,7 +32,7 @@ export class ServicesPageHelper extends PageHelper { clickServiceTab(serviceName: string, tabName: string) { this.getExpandCollapseElement(serviceName).click(); cy.get('cd-service-details').within(() => { - this.getTab(tabName).click(); + this.getCdsTab(tabName).click(); }); } diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts index 77d972de465..99f00bc0640 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts @@ -95,6 +95,10 @@ export abstract class PageHelper { return cy.contains('.nav.nav-tabs a', tabName); } + getCdsTab(tabName: string) { + return cy.contains('cds-tab-headers button[role="tab"]', tabName); + } + getTabText(index: number) { return this.getTabs().its(index).text(); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index b05b726e5f6..2285de4d3d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -21,7 +21,8 @@ import { SelectModule, LayoutModule, NumberModule, - FileUploaderModule + FileUploaderModule, + TabsModule } from 'carbon-components-angular'; import Analytics from '@carbon/icons/es/analytics/16'; import CloseFilled from '@carbon/icons/es/close--filled/16'; @@ -77,6 +78,7 @@ import { SilenceListComponent } from './prometheus/silence-list/silence-list.com import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component'; import { PlacementPipe } from './services/placement.pipe'; import { ServiceDaemonListComponent } from './services/service-daemon-list/service-daemon-list.component'; +import { ServiceCertificateDetailsComponent } from './services/service-cert-details/service-certificate-details.component'; import { ServiceDetailsComponent } from './services/service-details/service-details.component'; import { ServiceFormComponent } from './services/service-form/service-form.component'; import { ServicesComponent } from './services/services.component'; @@ -124,6 +126,7 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ ToggletipModule, IconModule, TagModule, + TabsModule, TextLabelListComponent, SelectModule, LayoutModule, @@ -161,6 +164,7 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ ActiveAlertListComponent, ServiceDetailsComponent, ServiceDaemonListComponent, + ServiceCertificateDetailsComponent, TelemetryComponent, PrometheusTabsComponent, ServiceFormComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.html new file mode 100644 index 00000000000..b04f2ed1ad3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.html @@ -0,0 +1,108 @@ + + +
+
+
+ + Certificate name + + + {{ certificate?.cert_name || '-' }} + +
+
+ + Valid until + + + {{ formatDate(certificate?.expiry_date) || '-' }} + +
+
+ +
+
+
+ + Status + + +
+ + + + + + +
+
+ + Days remaining + + + {{ certificate?.days_to_expiration ? certificate.days_to_expiration : '-' }} + +
+
+ +
+
+ + Issuer + + + {{ certificate?.issuer || certificate?.signed_by || '-' }} + +
+
+ + Common name + + + {{ certificate?.common_name || '-' }} + +
+
+
+ + +
+ + + {{ formatCertificateStatus(certificate) }} + +
+
+ +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.spec.ts new file mode 100644 index 00000000000..6aea2e317a3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.spec.ts @@ -0,0 +1,122 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { expect as jestExpect } from '@jest/globals'; + +import { IconComponent } from '~/app/shared/components/icon/icon.component'; +import { + CephCertificateStatus, + CephServiceCertificate +} from '~/app/shared/models/service.interface'; +import { ServiceCertificateDetailsComponent } from './service-certificate-details.component'; + +describe('ServiceCertificateDetailsComponent', () => { + let component: ServiceCertificateDetailsComponent; + let fixture: ComponentFixture; + const baseCert: CephServiceCertificate = { + cert_name: 'cert', + scope: 'SERVICE', + requires_certificate: true, + status: CephCertificateStatus.valid, + days_to_expiration: 0, + signed_by: 'user', + has_certificate: true, + certificate_source: 'user', + expiry_date: '2025-01-01', + issuer: 'issuer', + common_name: 'cn' + }; + const makeCert = (override: Partial): CephServiceCertificate => ({ + ...baseCert, + ...override + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ServiceCertificateDetailsComponent, IconComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(ServiceCertificateDetailsComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + jestExpect(component).toBeTruthy(); + }); + + it('should format certificate status with expiry date', () => { + const cert = makeCert({ status: CephCertificateStatus.valid, expiry_date: '2025-01-01' }); + + jestExpect(component.formatCertificateStatus(cert)).toBe('Valid - 01 Jan 2025'); + }); + + it('should return dash when certificate not required', () => { + const cert = makeCert({ requires_certificate: false, has_certificate: false }); + jestExpect(component.formatCertificateStatus(cert)).toBe('-'); + }); + + it('should emit editService with service identifiers', () => { + component.serviceName = 'svc-name'; + component.serviceType = 'svc-type'; + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.editService, 'emit'); + const button = fixture.debugElement.query(By.css('cds-icon-button')); + + button.triggerEventHandler('click', {}); + + jestExpect(emitSpy).toHaveBeenCalledWith({ serviceName: 'svc-name', serviceType: 'svc-type' }); + }); + + it('should show success icon and text for valid status', () => { + component.certificate = makeCert({ + status: CephCertificateStatus.valid, + expiry_date: '2025-01-01' + }); + fixture.detectChanges(); + + const statusIcon = fixture.debugElement.query(By.css('.status-row cd-icon')); + const statusText = fixture.debugElement + .query(By.css('.status-row span')) + .nativeElement.textContent.trim(); + + jestExpect(statusIcon.componentInstance.type).toBe('success'); + jestExpect(statusText).toBe('Valid - 01 Jan 2025'); + }); + + it('should fall back to warning icon for invalid status', () => { + component.certificate = makeCert({ status: 'invalid_status', expiry_date: '2025-01-01' }); + fixture.detectChanges(); + + const statusIcon = fixture.debugElement.query(By.css('.status-row cd-icon')); + jestExpect(statusIcon.componentInstance.type).toBe('warning'); + }); + + it('should use warning icon for warning status', () => { + component.certificate = makeCert({ + status: CephCertificateStatus.expiringSoon, + expiry_date: '2025-01-01' + }); + fixture.detectChanges(); + + const statusIcon = fixture.debugElement.query(By.css('.status-row cd-icon')); + const statusText = fixture.debugElement + .query(By.css('.status-row span')) + .nativeElement.textContent.trim(); + + jestExpect(statusIcon.componentInstance.type).toBe('warning'); + jestExpect(statusText).toBe('Expiring soon - 01 Jan 2025'); + }); + + it('should display dash when service has no certificate', () => { + component.certificate = makeCert({ requires_certificate: false, has_certificate: false }); + fixture.detectChanges(); + + const statusText = fixture.debugElement + .query(By.css('.status-row span')) + .nativeElement.textContent.trim(); + jestExpect(statusText).toBe('-'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.ts new file mode 100644 index 00000000000..05605a73853 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-cert-details/service-certificate-details.component.ts @@ -0,0 +1,63 @@ +import { + CephCertificateStatus, + CephServiceCertificate +} from '~/app/shared/models/service.interface'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; + +@Component({ + selector: 'cd-service-certificate-details', + templateUrl: './service-certificate-details.component.html', + styleUrls: ['./service-certificate-details.component.scss'], + providers: [CdDatePipe], + standalone: false +}) +export class ServiceCertificateDetailsComponent { + @Input() certificate: CephServiceCertificate; + @Input() serviceName?: string; + @Input() serviceType?: string; + + @Output() editService = new EventEmitter<{ serviceName?: string; serviceType?: string }>(); + + readonly statusIconMap: Record = { + valid: 'success', + expiring: 'warning', + expiring_soon: 'warning', + expired: 'danger', + default: 'warning' + }; + + constructor(private cdDatePipe: CdDatePipe) {} + + formatCertificateStatus(cert: CephServiceCertificate): string { + if (!cert || !cert.requires_certificate || !cert.status) { + return '-'; + } + + const formattedDate = this.formatDate(cert.expiry_date); + switch (cert.status) { + case CephCertificateStatus.valid: + return formattedDate ? `Valid - ${formattedDate}` : 'Valid'; + case CephCertificateStatus.expiringSoon: + return formattedDate ? `Expiring soon - ${formattedDate}` : 'Expiring soon'; + case CephCertificateStatus.expired: + return formattedDate ? `Expired - ${formattedDate}` : 'Expired'; + case CephCertificateStatus.notConfigured: + return 'Not configured'; + default: + return formattedDate ? `${cert.status} - ${formattedDate}` : cert.status; + } + } + + formatDate(dateValue: string | Date | null | undefined): string | null { + if (!dateValue) { + return null; + } + return this.cdDatePipe.transform(dateValue, 'DD MMM y'); + } + + onEdit(): void { + this.editService.emit({ serviceName: this.serviceName, serviceType: this.serviceType }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html index dc252a42cda..6e215916745 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-daemon-list/service-daemon-list.component.html @@ -5,34 +5,22 @@ - - -
-
+@switch (mode) { + @case ('daemons') { + + } + @case ('events') { + @if (hasOrchestrator) { + + + } + } +}
(); + hasOrchestrator = false; showDocPanel = false; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html index 704f0f98e0e..104e7e21fc0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.html @@ -1,4 +1,31 @@ - - - - +@if (service) { + + + + + + + @if (hasCertificate) { + + + + + } + + + + + + +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts index eca7c7dc60b..49f8c2c51a4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-details/service-details.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { Permissions } from '~/app/shared/models/permissions'; @@ -15,4 +15,24 @@ export class ServiceDetailsComponent { @Input() selection: CdTableSelection; + + @Output() + editService = new EventEmitter<{ serviceName?: string; serviceType?: string }>(); + + get service() { + return this.selection as any; + } + + get certificate() { + return this.service?.certificate; + } + + get hasCertificate() { + const cert = this.certificate; + return cert?.has_certificate; + } + + onEditService(payload: { serviceName?: string; serviceType?: string }) { + this.editService.emit(payload); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html index 07380d6a169..b864fb163eb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html @@ -20,7 +20,8 @@ + [selection]="expandedRow" + (editService)="openModal(true, $event)"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts index 782650c8eaf..0aa51e340ab 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts @@ -1,4 +1,3 @@ -import { DatePipe } from '@angular/common'; import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; @@ -24,6 +23,7 @@ import { CephServiceCertificate, CephServiceSpec } from '~/app/shared/models/service.interface'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -40,7 +40,10 @@ const BASE_URL = 'services'; selector: 'cd-services', templateUrl: './services.component.html', styleUrls: ['./services.component.scss'], - providers: [DatePipe, { provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }], + providers: [ + CdDatePipe, + { provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) } + ], standalone: false }) export class ServicesComponent extends ListWithDetails implements OnChanges, OnInit { @@ -94,7 +97,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI private router: Router, private settingsService: SettingsService, private cdsModalService: ModalCdsService, - private datePipe: DatePipe + private cdDatePipe: CdDatePipe ) { super(); this.permissions = this.authStorageService.getPermissions(); @@ -124,18 +127,20 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI ]; } - openModal(edit = false) { + openModal(edit = false, payload?: { serviceName?: string; serviceType?: string } | string) { + const serviceNameFromPayload = typeof payload === 'string' ? payload : payload?.serviceName; + const serviceTypeFromPayload = typeof payload === 'string' ? undefined : payload?.serviceType; + + const targetServiceName = serviceNameFromPayload ?? this.selection.first()?.service_name; + const targetServiceType = serviceTypeFromPayload ?? this.selection.first()?.service_type; + if (this.routedModal) { edit ? this.router.navigate([ BASE_URL, { outlets: { - modal: [ - URLVerbs.EDIT, - this.selection.first().service_type, - this.selection.first().service_name - ] + modal: [URLVerbs.EDIT, targetServiceType, targetServiceName] } } ]) @@ -144,8 +149,8 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI let initialState = {}; edit ? (initialState = { - serviceName: this.selection.first()?.service_name, - serviceType: this.selection?.first()?.service_type, + serviceName: targetServiceName, + serviceType: targetServiceType, hiddenServices: this.hiddenServices, editing: edit }) @@ -321,7 +326,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI } const formattedDate = cert.expiry_date - ? this.datePipe.transform(cert.expiry_date, 'dd MMM y') + ? this.cdDatePipe.transform(cert.expiry_date, 'DD MMM y') : null; switch (cert.status) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts index 6ee238e76fe..303590a814a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts @@ -26,8 +26,9 @@ export interface CephServiceCertificate { has_certificate: boolean; certificate_source: string; expiry_date: string; + issuer: string; + common_name: string; } - // This will become handy when creating arbitrary services export interface CephServiceSpec { service_name: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts index 0ff248ebcc2..2fdc1c3063b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts @@ -7,24 +7,21 @@ import moment from 'moment'; standalone: false }) export class CdDatePipe implements PipeTransform { + private static readonly DEFAULT_FORMAT = 'D/M/YY hh:mm A'; + constructor() {} - transform(value: any): any { + transform(value: any, format: string = CdDatePipe.DEFAULT_FORMAT): any { if (value === null || value === '') { return ''; } let date: string; const offset = moment().utcOffset(); if (_.isNumber(value)) { - date = moment - .parseZone(moment.unix(value)) - .utc() - .utcOffset(offset) - .local() - .format('D/M/YY hh:mm A'); + date = moment.parseZone(moment.unix(value)).utc().utcOffset(offset).local().format(format); } else { value = value?.replace?.('Z', ''); - date = moment.parseZone(value).utc().utcOffset(offset).local().format('D/M/YY hh:mm A'); + date = moment.parseZone(value).utc().utcOffset(offset).local().format(format); } return date; }