clickServiceTab(serviceName: string, tabName: string) {
this.getExpandCollapseElement(serviceName).click();
cy.get('cd-service-details').within(() => {
- this.getTab(tabName).click();
+ this.getCdsTab(tabName).click();
});
}
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();
}
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';
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';
ToggletipModule,
IconModule,
TagModule,
+ TabsModule,
TextLabelListComponent,
SelectModule,
LayoutModule,
ActiveAlertListComponent,
ServiceDetailsComponent,
ServiceDaemonListComponent,
+ ServiceCertificateDetailsComponent,
TelemetryComponent,
PrometheusTabsComponent,
ServiceFormComponent,
--- /dev/null
+<cd-details-card>
+ <ng-container
+ class="details-body-content">
+ <div cdsStack="horizontal"
+ [gap]="6">
+ <div cdsStack="vertical"
+ [gap]="3">
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Certificate name
+ </span>
+ <span class="cds--type-body-compact-01"
+ [title]="certificate?.cert_name || '-'">
+ {{ certificate?.cert_name || '-' }}
+ </span>
+ </div>
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Valid until
+ </span>
+ <span class="cds--type-body-compact-01"
+ [title]="formatDate(certificate?.expiry_date) || '-'">
+ {{ formatDate(certificate?.expiry_date) || '-' }}
+ </span>
+ </div>
+ </div>
+
+ <div cdsStack="vertical"
+ [gap]="3">
+ <div cdsStack="horizontal"
+ [gap]="1">
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Status
+ </span>
+ <ng-container *ngTemplateOutlet="statusTemplate; context: { status: certificate?.status }"></ng-container>
+ </div>
+ <!-- icon -->
+ <cds-icon-button kind="ghost"
+ size="sm"
+ (click)="onEdit()"
+ class="cds-mt-5">
+ <cd-icon type="edit">
+ </cd-icon>
+ </cds-icon-button>
+
+ </div>
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Days remaining
+ </span>
+ <span class="cds--type-body-compact-01"
+ [title]="certificate?.days_to_expiration ? certificate.days_to_expiration : '-'">
+ {{ certificate?.days_to_expiration ? certificate.days_to_expiration : '-' }}
+ </span>
+ </div>
+ </div>
+
+ <div cdsStack="vertical"
+ [gap]="3">
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Issuer
+ </span>
+ <span class="cds--type-body-compact-01"
+ [title]="certificate?.issuer || certificate?.signed_by || '-'">
+ {{ certificate?.issuer || certificate?.signed_by || '-' }}
+ </span>
+ </div>
+ <div cdsStack="vertical"
+ [gap]="1">
+ <span class="cds--type-label-01"
+ i18n>
+ Common name
+ </span>
+ <span class="cds--type-body-compact-01"
+ [title]="certificate?.common_name || '-'">
+ {{ certificate?.common_name || '-' }}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <ng-template #statusTemplate
+ let-status="status">
+ <div cdsStack="horizontal"
+ [gap]="2"
+ class="status-row">
+ <cd-icon
+ [type]="statusIconMap[status] || statusIconMap['default']">
+ </cd-icon>
+ <span class="cds--type-body-compact-01">{{ formatCertificateStatus(certificate) }}</span>
+
+ </div>
+ </ng-template>
+
+ </ng-container>
+</cd-details-card>
--- /dev/null
+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<ServiceCertificateDetailsComponent>;
+ 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>): 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('-');
+ });
+});
--- /dev/null
+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<string, keyof typeof ICON_TYPE> = {
+ 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 });
+ }
+}
</div>
<ng-template #serviceDetailsTpl>
- <ng-container>
- <nav ngbNav
- #nav="ngbNav"
- class="nav-tabs"
- cdStatefulTab="service-details">
- <ng-container ngbNavItem="details">
- <a ngbNavLink
- i18n>Daemons</a>
- <ng-template ngbNavContent>
- <ng-container *ngTemplateOutlet="serviceDaemonDetailsTpl"></ng-container>
- </ng-template>
- </ng-container>
- <ng-container ngbNavItem="service_events">
- <a ngbNavLink
- i18n>Service Events</a>
- <ng-template ngbNavContent>
- <cd-table *ngIf="hasOrchestrator"
- #serviceTable
- [data]="services"
- [columns]="serviceColumns"
- columnMode="flex"
- (fetchData)="getServices($event)">
- </cd-table>
- </ng-template>
- </ng-container>
- </nav>
- <div [ngbNavOutlet]="nav"></div>
- </ng-container>
+@switch (mode) {
+ @case ('daemons') {
+ <ng-container *ngTemplateOutlet="serviceDaemonDetailsTpl"></ng-container>
+ }
+ @case ('events') {
+ @if (hasOrchestrator) {
+ <cd-table
+ #serviceTable
+ [data]="services"
+ [columns]="serviceColumns"
+ columnMode="flex"
+ (fetchData)="getServices($event)">
+ </cd-table>
+ }
+ }
+}
</ng-template>
<ng-template #statusTpl
OnChanges,
OnDestroy,
OnInit,
+ Output,
+ EventEmitter,
QueryList,
TemplateRef,
ViewChild,
@Input()
flag?: string;
+ @Input()
+ mode: 'daemons' | 'events' = 'daemons';
+
total = 100;
warningThreshold = 0.8;
selection = new CdTableSelection();
permissions: Permissions;
+ @Output()
+ editService = new EventEmitter<{ serviceName?: string; serviceType?: string }>();
+
hasOrchestrator = false;
showDocPanel = false;
-<ng-container *ngIf="selection">
- <cd-service-daemon-list [serviceName]="selection['service_name']">
- </cd-service-daemon-list>
-</ng-container>
+@if (service) {
+ <cds-tabs [cacheActive]="false">
+ <cds-tab heading="Daemons"
+ i18n-heading>
+ <cd-service-daemon-list
+ [serviceName]="service?.service_name"
+ mode="daemons">
+ </cd-service-daemon-list>
+ </cds-tab>
+
+ @if (hasCertificate) {
+ <cds-tab heading="Certificate"
+ i18n-heading>
+ <cd-service-certificate-details
+ [certificate]="certificate"
+ [serviceName]="service?.service_name"
+ [serviceType]="service?.service_type"
+ (editService)="onEditService($event)">
+ </cd-service-certificate-details>
+ </cds-tab>
+ }
+
+ <cds-tab heading="Service Events"
+ i18n-heading>
+ <cd-service-daemon-list
+ [serviceName]="service?.service_name"
+ mode="events">
+ </cd-service-daemon-list>
+ </cds-tab>
+ </cds-tabs>
+}
-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';
@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);
+ }
}
</cd-table-actions>
<cd-service-details *cdTableDetail
[permissions]="permissions"
- [selection]="expandedRow">
+ [selection]="expandedRow"
+ (editService)="openModal(true, $event)">
</cd-service-details>
</cd-table>
</ng-container>
-import { DatePipe } from '@angular/common';
import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
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';
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 {
private router: Router,
private settingsService: SettingsService,
private cdsModalService: ModalCdsService,
- private datePipe: DatePipe
+ private cdDatePipe: CdDatePipe
) {
super();
this.permissions = this.authStorageService.getPermissions();
];
}
- 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]
}
}
])
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
})
}
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) {
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;
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;
}