From aa571a226d5c7fc03dfa7245b4a9e6bf4ddf66f0 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Fri, 22 Mar 2019 17:44:00 +0100 Subject: [PATCH] mgr/dashboard: Silence Alertmanager alerts MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Now you can silence alerts through the dashboard. You can now create, recreate, edit and expire a silence. You can create a silence based on a selected alert. The silence form will help you create a silence that silences an alert. It is provided with functionality to check if the silences, that you are about to create, will or will not match an active alert or even a rule. It also provides help choosing the right values for the right chosen matcher attribute name, through the use of type ahead values. The dashboard will now use the Prometheus and the Alertmanager API Fixes: https://tracker.ceph.com/issues/36722 Signed-off-by: Stephan Müller --- doc/mgr/dashboard.rst | 37 +- .../mgr/dashboard/controllers/prometheus.py | 58 +- .../frontend/src/app/app-routing.module.ts | 34 +- .../src/app/ceph/cluster/cluster.module.ts | 22 +- .../alert-list.component.html} | 7 + .../alert-list.component.scss} | 0 .../alert-list/alert-list.component.spec.ts | 125 ++++ .../alert-list.component.ts} | 32 +- .../prometheus-list.component.spec.ts | 30 - .../prometheus-tabs.component.html | 13 + .../prometheus-tabs.component.scss | 0 .../prometheus-tabs.component.spec.ts | 47 ++ .../prometheus-tabs.component.ts | 19 + .../silence-form/silence-form.component.html | 219 +++++++ .../silence-form/silence-form.component.scss | 3 + .../silence-form.component.spec.ts | 595 ++++++++++++++++++ .../silence-form/silence-form.component.ts | 328 ++++++++++ .../silence-list/silence-list.component.html | 29 + .../silence-list/silence-list.component.scss | 0 .../silence-list.component.spec.ts | 320 ++++++++++ .../silence-list/silence-list.component.ts | 197 ++++++ .../silence-matcher-modal.component.html | 98 +++ .../silence-matcher-modal.component.scss | 0 .../silence-matcher-modal.component.spec.ts | 163 +++++ .../silence-matcher-modal.component.ts | 79 +++ .../navigation/navigation.component.html | 6 + .../app/shared/api/prometheus.service.spec.ts | 108 +++- .../src/app/shared/api/prometheus.service.ts | 53 +- .../src/app/shared/constants/app.constants.ts | 24 +- .../src/app/shared/enum/icons.enum.ts | 3 + .../app/shared/models/alertmanager-silence.ts | 23 + .../app/shared/models/prometheus-alerts.ts | 57 +- .../services/prometheus-alert-formatter.ts | 16 +- .../services/prometheus-alert.service.spec.ts | 40 +- .../services/prometheus-alert.service.ts | 30 +- .../prometheus-notification.service.spec.ts | 31 +- .../prometheus-notification.service.ts | 17 +- ...prometheus-silence-matcher.service.spec.ts | 133 ++++ .../prometheus-silence-matcher.service.ts | 82 +++ .../services/url-builder.service.spec.ts | 5 + .../shared/services/url-builder.service.ts | 10 + .../frontend/src/testing/unit-test-helper.ts | 51 +- src/pybind/mgr/dashboard/settings.py | 2 +- .../mgr/dashboard/tests/test_prometheus.py | 64 +- 44 files changed, 3037 insertions(+), 173 deletions(-) rename src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/{prometheus-list/prometheus-list.component.html => alert-list/alert-list.component.html} (79%) rename src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/{prometheus-list/prometheus-list.component.scss => alert-list/alert-list.component.scss} (100%) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.spec.ts rename src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/{prometheus-list/prometheus-list.component.ts => alert-list/alert-list.component.ts} (60%) delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts diff --git a/doc/mgr/dashboard.rst b/doc/mgr/dashboard.rst index 32087211b5b33..e26fb25a06874 100644 --- a/doc/mgr/dashboard.rst +++ b/doc/mgr/dashboard.rst @@ -499,7 +499,8 @@ ways: #. Use both sources simultaneously. All three methods are going to notify you about alerts. You won't be notified -twice if you use both sources. +twice if you use both sources, but you need to consume at least the Alertmanager API +in order to manage silences. #. Use the notification receiver of the dashboard: @@ -525,18 +526,28 @@ twice if you use both sources. configuration checkout the ` documentation `_. -#. Use the API of the Prometheus Alertmanager +#. Use the API of Prometheus and the Alertmanager - This allows you to manage alerts. You will see all alerts, the Alertmanager - currently knows of, in the alerts listing. It can be found in the *Cluster* - submenu as *Alerts*. The alerts can be sorted by name, job, severity, - state and start time. Unfortunately it's not possible to know when an alert + This allows you to manage alerts and silences. You will see all alerts and silences + the Alertmanager currently knows of in the corresponding listing. + Both can be found in the *Cluster* submenu. + + Alerts can be sorted by name, job, severity, state and start time. + Unfortunately it's not possible to know when an alert was sent out through a notification by the Alertmanager based on your configuration, that's why the dashboard will notify the user on any visible change to an alert and will notify the changed alert. - Currently it's not yet possible to silence an alert and expire an silenced - alert, but this is work in progress and will be added in a future release. + Silences can be sorted by id, creator, status, start, updated and end time. + Silences can be created in various ways, it's also possible to expire them. + + #. Create from scratch + + #. Based on a selected alert + + #. Recreate from expired silence + + #. Update a silence (which will recreate and expire it (default Alertmanager behaviour)) To use it, specify the host and port of the Alertmanager server:: @@ -546,6 +557,16 @@ twice if you use both sources. $ ceph dashboard set-alertmanager-api-host 'http://localhost:9093' + To be able to show what a silence will match beforehand, you have to add the host + and port of the Prometheus server:: + + $ ceph dashboard set-prometheus-api-host # default: '' + + For example:: + + $ ceph dashboard set-prometheus-api-host 'http://localhost:9090' + + After setting up the hosts, you have to refresh your the dashboard in your browser window. #. Use both methods diff --git a/src/pybind/mgr/dashboard/controllers/prometheus.py b/src/pybind/mgr/dashboard/controllers/prometheus.py index b80fc05c4bc05..4145fbdfd8ca0 100644 --- a/src/pybind/mgr/dashboard/controllers/prometheus.py +++ b/src/pybind/mgr/dashboard/controllers/prometheus.py @@ -8,6 +8,7 @@ import requests from . import Controller, ApiController, BaseController, RESTController, Endpoint from ..security import Scope from ..settings import Settings +from ..exceptions import DashboardException @Controller('/api/prometheus_receiver', secure=False) @@ -22,20 +23,57 @@ class PrometheusReceiver(BaseController): self.notifications.append(notification) -@ApiController('/prometheus', Scope.PROMETHEUS) -class Prometheus(RESTController): +class PrometheusRESTController(RESTController): + def prometheus_proxy(self, method, path, params=None, payload=None): + return self._proxy(self._get_api_url(Settings.PROMETHEUS_API_HOST), + method, path, params, payload) + + def alert_proxy(self, method, path, params=None, payload=None): + return self._proxy(self._get_api_url(Settings.ALERTMANAGER_API_HOST), + method, path, params, payload) - def _get_api_url(self): - return Settings.ALERTMANAGER_API_HOST.rstrip('/') + '/api/v1' + def _get_api_url(self, host): + return host.rstrip('/') + '/api/v1' - def _api_request(self, url_suffix, params=None): - url = self._get_api_url() + url_suffix - response = requests.request('GET', url, params=params) - payload = json.loads(response.content) - return payload['data'] if 'data' in payload else [] + def _proxy(self, base_url, method, path, params=None, payload=None): + try: + response = requests.request(method, base_url + path, params=params, json=payload) + except Exception: + raise DashboardException('Could not reach external API', http_status_code=404, + component='prometheus') + content = json.loads(response.content) + if content['status'] == 'success': + if 'data' in content: + return content['data'] + return content + raise DashboardException(content, http_status_code=400, component='prometheus') + +@ApiController('/prometheus', Scope.PROMETHEUS) +class Prometheus(PrometheusRESTController): def list(self, **params): - return self._api_request('/alerts', params) + return self.alert_proxy('GET', '/alerts', params) + + @RESTController.Collection(method='GET') + def rules(self, **params): + data = self.prometheus_proxy('GET', '/rules', params) + configs = data['groups'] + rules = [] + for config in configs: + rules += config['rules'] + return rules + + @RESTController.Collection(method='GET', path='/silences') + def get_silences(self, **params): + return self.alert_proxy('GET', '/silences', params) + + @RESTController.Collection(method='POST', path='/silence', status=201) + def create_silence(self, **params): + return self.alert_proxy('POST', '/silences', payload=params) + + @RESTController.Collection(method='DELETE', path='/silence/{s_id}', status=204) + def delete_silence(self, s_id): + return self.alert_proxy('DELETE', '/silence/' + s_id) if s_id else None @ApiController('/prometheus/notifications', Scope.PROMETHEUS) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 12250fddcc52c..73dbc4088f50f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -13,7 +13,9 @@ import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-fo import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component'; import { MonitorComponent } from './ceph/cluster/monitor/monitor.component'; import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component'; -import { PrometheusListComponent } from './ceph/cluster/prometheus/prometheus-list/prometheus-list.component'; +import { AlertListComponent } from './ceph/cluster/prometheus/alert-list/alert-list.component'; +import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component'; +import { SilenceListComponent } from './ceph/cluster/prometheus/silence-list/silence-list.component'; import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component'; import { Nfs501Component } from './ceph/nfs/nfs-501/nfs-501.component'; import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component'; @@ -109,10 +111,38 @@ const routes: Routes = [ }, { path: 'alerts', - component: PrometheusListComponent, + component: AlertListComponent, canActivate: [AuthGuardService], data: { breadcrumbs: 'Cluster/Alerts' } }, + { + path: 'silence', + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Silences' }, + children: [ + { path: '', component: SilenceListComponent }, + { + path: URLVerbs.CREATE, + component: SilenceFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `${URLVerbs.CREATE}/:id`, + component: SilenceFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `${URLVerbs.EDIT}/:id`, + component: SilenceFormComponent, + data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: `${URLVerbs.RECREATE}/:id`, + component: SilenceFormComponent, + data: { breadcrumbs: ActionLabels.RECREATE } + } + ] + }, { path: 'perf_counters/:type/:id', component: PerformanceCounterComponent, 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 4f19f14e8db9d..20f63d2226606 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 @@ -11,6 +11,7 @@ import { ModalModule } from 'ngx-bootstrap/modal'; import { TabsModule } from 'ngx-bootstrap/tabs'; import { TimepickerModule } from 'ngx-bootstrap/timepicker'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; +import { TypeaheadModule } from 'ngx-bootstrap/typeahead'; import { SharedModule } from '../../shared/shared.module'; import { PerformanceCounterModule } from '../performance-counter/performance-counter.module'; @@ -31,7 +32,11 @@ import { OsdPgScrubModalComponent } from './osd/osd-pg-scrub-modal/osd-pg-scrub- import { OsdRecvSpeedModalComponent } from './osd/osd-recv-speed-modal/osd-recv-speed-modal.component'; import { OsdReweightModalComponent } from './osd/osd-reweight-modal/osd-reweight-modal.component'; import { OsdScrubModalComponent } from './osd/osd-scrub-modal/osd-scrub-modal.component'; -import { PrometheusListComponent } from './prometheus/prometheus-list/prometheus-list.component'; +import { AlertListComponent } from './prometheus/alert-list/alert-list.component'; +import { PrometheusTabsComponent } from './prometheus/prometheus-tabs/prometheus-tabs.component'; +import { SilenceFormComponent } from './prometheus/silence-form/silence-form.component'; +import { SilenceListComponent } from './prometheus/silence-list/silence-list.component'; +import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component'; @NgModule({ entryComponents: [ @@ -40,7 +45,9 @@ import { PrometheusListComponent } from './prometheus/prometheus-list/prometheus OsdFlagsModalComponent, OsdRecvSpeedModalComponent, OsdReweightModalComponent, - OsdPgScrubModalComponent + OsdPgScrubModalComponent, + OsdReweightModalComponent, + SilenceMatcherModalComponent ], imports: [ CommonModule, @@ -51,11 +58,13 @@ import { PrometheusListComponent } from './prometheus/prometheus-list/prometheus FormsModule, ReactiveFormsModule, BsDropdownModule.forRoot(), + BsDatepickerModule.forRoot(), ModalModule.forRoot(), AlertModule.forRoot(), TooltipModule.forRoot(), TreeModule, MgrModulesModule, + TypeaheadModule.forRoot(), TimepickerModule.forRoot(), BsDatepickerModule.forRoot() ], @@ -74,9 +83,14 @@ import { PrometheusListComponent } from './prometheus/prometheus-list/prometheus OsdReweightModalComponent, CrushmapComponent, LogsComponent, - PrometheusListComponent, OsdRecvSpeedModalComponent, - OsdPgScrubModalComponent + OsdPgScrubModalComponent, + AlertListComponent, + OsdRecvSpeedModalComponent, + SilenceFormComponent, + SilenceListComponent, + PrometheusTabsComponent, + SilenceMatcherModalComponent ] }) export class ClusterModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.html similarity index 79% rename from src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.html rename to src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.html index 94c5930ce9018..98de28431a99f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.html @@ -1,3 +1,5 @@ + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.scss similarity index 100% rename from src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.scss rename to src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.spec.ts new file mode 100644 index 0000000000000..74c4444555efb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.spec.ts @@ -0,0 +1,125 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; +import { TabsModule } from 'ngx-bootstrap/tabs'; + +import { + configureTestBed, + i18nProviders, + PermissionHelper +} from '../../../../../testing/unit-test-helper'; +import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component'; +import { SharedModule } from '../../../../shared/shared.module'; +import { PrometheusTabsComponent } from '../prometheus-tabs/prometheus-tabs.component'; +import { AlertListComponent } from './alert-list.component'; + +describe('PrometheusListComponent', () => { + let component: AlertListComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + TabsModule.forRoot(), + RouterTestingModule, + ToastModule.forRoot(), + SharedModule + ], + declarations: [AlertListComponent, PrometheusTabsComponent], + providers: [i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AlertListComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('show action buttons and drop down actions depending on permissions', () => { + let tableActions: TableActionsComponent; + let scenario: { fn; empty; single }; + let permissionHelper: PermissionHelper; + let combinations: number[][]; + + const getTableActionComponent = (): TableActionsComponent => { + fixture.detectChanges(); + return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance; + }; + + beforeEach(() => { + permissionHelper = new PermissionHelper(component.permission, () => + getTableActionComponent() + ); + scenario = { + fn: () => tableActions.getCurrentButton().name, + single: 'Create silence', + empty: 'Create silence' + }; + tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 1); + }); + + const permissionSwitch = (combination) => { + tableActions = permissionHelper.setPermissionsAndGetActions( + combination[0], + combination[1], + combination[2] + ); + tableActions.tableActions = component.tableActions; + tableActions.ngOnInit(); + }; + + const testCombinations = (test: Function) => { + combinations.forEach((combination) => { + permissionSwitch(combination); + test(); + }); + }; + + describe('with every permission combination that includes create', () => { + beforeEach(() => { + combinations = [[1, 1, 1], [1, 1, 0], [1, 0, 1], [1, 0, 0]]; + }); + + it(`always shows 'Create silence' as main action`, () => { + testCombinations(() => permissionHelper.testScenarios(scenario)); + }); + + it('shows all actions', () => { + testCombinations(() => { + expect(tableActions.tableActions.length).toBe(1); + expect(tableActions.tableActions).toEqual(component.tableActions); + }); + }); + }); + + describe('with every permission combination that does not include create', () => { + beforeEach(() => { + combinations = [[0, 1, 1], [0, 1, 0], [0, 0, 1], [0, 0, 0]]; + }); + + it(`won't show any action`, () => { + testCombinations(() => { + permissionHelper.testScenarios({ + fn: () => tableActions.getCurrentButton(), + single: undefined, + empty: undefined + }); + }); + }); + + it('shows no actions', () => { + testCombinations(() => { + expect(tableActions.tableActions.length).toBe(0); + expect(tableActions.tableActions).toEqual([]); + }); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.ts similarity index 60% rename from src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.ts rename to src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.ts index a2138d8beabb7..927f3cff4d4be 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.ts @@ -2,20 +2,29 @@ import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { I18n } from '@ngx-translate/i18n-polyfill'; import { CellTemplate } from '../../../../shared/enum/cell-template.enum'; import { Icons } from '../../../../shared/enum/icons.enum'; +import { CdTableAction } from '../../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../../shared/models/cd-table-column'; import { CdTableSelection } from '../../../../shared/models/cd-table-selection'; +import { Permission } from '../../../../shared/models/permissions'; import { CdDatePipe } from '../../../../shared/pipes/cd-date.pipe'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; import { PrometheusAlertService } from '../../../../shared/services/prometheus-alert.service'; +import { URLBuilderService } from '../../../../shared/services/url-builder.service'; + +const BASE_URL = 'silence'; // as only silence actions can be used @Component({ selector: 'cd-prometheus-list', - templateUrl: './prometheus-list.component.html', - styleUrls: ['./prometheus-list.component.scss'] + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }], + templateUrl: './alert-list.component.html', + styleUrls: ['./alert-list.component.scss'] }) -export class PrometheusListComponent implements OnInit { +export class AlertListComponent implements OnInit { @ViewChild('externalLinkTpl') externalLinkTpl: TemplateRef; columns: CdTableColumn[]; + tableActions: CdTableAction[]; + permission: Permission; selection = new CdTableSelection(); icons = Icons; customCss = { @@ -26,10 +35,25 @@ export class PrometheusListComponent implements OnInit { constructor( // NotificationsComponent will refresh all alerts every 5s (No need to do it here as well) + private authStorageService: AuthStorageService, public prometheusAlertService: PrometheusAlertService, + private urlBuilder: URLBuilderService, private i18n: I18n, private cdDatePipe: CdDatePipe - ) {} + ) { + this.permission = this.authStorageService.getPermissions().prometheus; + this.tableActions = [ + { + permission: 'create', + canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection, + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || selection.first().cdExecuting, + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreateFrom(this.selection.first().fingerprint), + name: this.i18n('Create silence') + } + ]; + } ngOnInit() { this.columns = [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.spec.ts deleted file mode 100644 index 7901a05cb33f9..0000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ToastModule } from 'ng2-toastr'; -import { TabsModule } from 'ngx-bootstrap/tabs'; - -import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; -import { SharedModule } from '../../../../shared/shared.module'; -import { PrometheusListComponent } from './prometheus-list.component'; - -describe('PrometheusListComponent', () => { - let component: PrometheusListComponent; - let fixture: ComponentFixture; - - configureTestBed({ - imports: [HttpClientTestingModule, TabsModule.forRoot(), ToastModule.forRoot(), SharedModule], - declarations: [PrometheusListComponent], - providers: [i18nProviders] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PrometheusListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html new file mode 100644 index 0000000000000..b2f530a7e8622 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts new file mode 100644 index 0000000000000..65cc58d235dd5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts @@ -0,0 +1,47 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { TabsModule } from 'ngx-bootstrap/tabs'; + +import { configureTestBed } from '../../../../../testing/unit-test-helper'; +import { PrometheusTabsComponent } from './prometheus-tabs.component'; + +describe('PrometheusTabsComponent', () => { + let component: PrometheusTabsComponent; + let fixture: ComponentFixture; + let router: Router; + + const selectTab = (index) => { + fixture.debugElement.queryAll(By.css('tab'))[index].triggerEventHandler('select', null); + }; + + configureTestBed({ + declarations: [PrometheusTabsComponent], + imports: [RouterTestingModule, HttpClientTestingModule, TabsModule.forRoot()] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PrometheusTabsComponent); + component = fixture.componentInstance; + router = TestBed.get(Router); + spyOn(router, 'navigate').and.stub(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should redirect to alert listing', () => { + selectTab(0); + expect(router.navigate).toHaveBeenCalledWith(['/alerts']); + }); + + it('should redirect to silence listing', () => { + selectTab(1); + expect(router.navigate).toHaveBeenCalledWith(['/silence']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts new file mode 100644 index 0000000000000..5675eb710023e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'cd-prometheus-tabs', + templateUrl: './prometheus-tabs.component.html', + styleUrls: ['./prometheus-tabs.component.scss'] +}) +export class PrometheusTabsComponent { + url: string; + + constructor(private router: Router) { + this.url = this.router.url; + } + + navigateTo(url) { + this.router.navigate([url]); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html new file mode 100644 index 0000000000000..0050c87ec7050 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html @@ -0,0 +1,219 @@ + +
+ + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+

+ + {{ action | titlecase }} {{ resource | upperFirst }} + + Editing a silence will expire the old silence and recreate it as a new silence +

+
+ + +
+
+ +
+ + This field is required! +
+
+ + +
+ +
+ + This field is required! +
+
+ + +
+ +
+ + This field is required! +
+
+ + +
+ +
+ + This field is required! +
+
+ + +
+ +
+ + This field is required! +
+
+ + +
+ Matchers* +
+
+ A silence requires at least one matcher +
+ + + + + + + + +
+
+ + {{ matcherMatch.status }} + +
+
+
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss new file mode 100644 index 0000000000000..fb52450d436cb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss @@ -0,0 +1,3 @@ +textarea { + resize: vertical; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts new file mode 100644 index 0000000000000..7532c553f6868 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts @@ -0,0 +1,595 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import * as _ from 'lodash'; +import { ToastModule } from 'ng2-toastr'; +import { BsDatepickerDirective, BsDatepickerModule } from 'ngx-bootstrap/datepicker'; +import { BsModalService } from 'ngx-bootstrap/modal'; +import { TooltipModule } from 'ngx-bootstrap/tooltip'; +import { of, throwError } from 'rxjs'; + +import { + configureTestBed, + FixtureHelper, + FormHelper, + i18nProviders, + PrometheusHelper +} from '../../../../../testing/unit-test-helper'; +import { NotFoundComponent } from '../../../../core/not-found/not-found.component'; +import { PrometheusService } from '../../../../shared/api/prometheus.service'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { AlertmanagerSilence } from '../../../../shared/models/alertmanager-silence'; +import { Permission } from '../../../../shared/models/permissions'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { SharedModule } from '../../../../shared/shared.module'; +import { SilenceFormComponent } from './silence-form.component'; + +describe('SilenceFormComponent', () => { + // SilenceFormComponent specific + let component: SilenceFormComponent; + let fixture: ComponentFixture; + let form: CdFormGroup; + // Spied on + let prometheusService: PrometheusService; + let authStorageService: AuthStorageService; + let notificationService: NotificationService; + let router: Router; + // Spies + let rulesSpy; + let ifPrometheusSpy; + // Helper + let prometheus: PrometheusHelper; + let formH: FormHelper; + let fixtureH: FixtureHelper; + let params; + // Date mocking related + let originalDate; + const baseTime = new Date('2022-02-22T00:00:00'); + const beginningDate = new Date('2022-02-22T00:00:12.35'); + + const routes: Routes = [{ path: '404', component: NotFoundComponent }]; + configureTestBed({ + declarations: [NotFoundComponent, SilenceFormComponent], + imports: [ + HttpClientTestingModule, + RouterTestingModule.withRoutes(routes), + BsDatepickerModule.forRoot(), + SharedModule, + ToastModule.forRoot(), + TooltipModule.forRoot(), + ReactiveFormsModule + ], + providers: [ + i18nProviders, + { + provide: ActivatedRoute, + useValue: { params: { subscribe: (fn) => fn(params) } } + } + ] + }); + + const createMatcher = (name, value, isRegex) => ({ name, value, isRegex }); + + const addMatcher = (name, value, isRegex) => + component['setMatcher'](createMatcher(name, value, isRegex)); + + const callInit = () => + fixture.ngZone.run(() => { + component['init'](); + }); + + const changeAction = (action: string) => { + const modes = { + add: '/silence/add', + alertAdd: '/silence/add/someAlert', + recreate: '/silence/recreate/someExpiredId', + edit: '/silence/edit/someNotExpiredId' + }; + Object.defineProperty(router, 'url', { value: modes[action] }); + callInit(); + }; + + beforeEach(() => { + params = {}; + + originalDate = Date; + spyOn(global, 'Date').and.callFake((arg) => (arg ? new originalDate(arg) : beginningDate)); + + prometheus = new PrometheusHelper(); + prometheusService = TestBed.get(PrometheusService); + spyOn(prometheusService, 'getAlerts').and.callFake(() => + of([prometheus.createAlert('alert0')]) + ); + ifPrometheusSpy = spyOn(prometheusService, 'ifPrometheusConfigured').and.callFake((fn) => fn()); + rulesSpy = spyOn(prometheusService, 'getRules').and.callFake(() => + of([ + prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]), + prometheus.createRule('alert1', 'someSeverity', []), + prometheus.createRule('alert2', 'someOtherSeverity', [prometheus.createAlert('alert2')]) + ]) + ); + + router = TestBed.get(Router); + + notificationService = TestBed.get(NotificationService); + spyOn(notificationService, 'show').and.stub(); + + authStorageService = TestBed.get(AuthStorageService); + spyOn(authStorageService, 'getUsername').and.returnValue('someUser'); + + fixture = TestBed.createComponent(SilenceFormComponent); + fixtureH = new FixtureHelper(fixture); + component = fixture.componentInstance; + form = component.form; + formH = new FormHelper(form); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(_.isArray(component.rules)).toBeTruthy(); + }); + + it('should have set the logged in user name as creator', () => { + expect(component.form.getValue('createdBy')).toBe('someUser'); + }); + + it('should call disablePrometheusConfig on error calling getRules', () => { + spyOn(prometheusService, 'disablePrometheusConfig'); + rulesSpy.and.callFake(() => throwError({})); + callInit(); + expect(component.rules).toEqual([]); + expect(prometheusService.disablePrometheusConfig).toHaveBeenCalled(); + }); + + it('should remind user if prometheus is not set when it is not configured', () => { + ifPrometheusSpy.and.callFake((_x, fn) => fn()); + callInit(); + expect(component.rules).toEqual([]); + expect(notificationService.show).toHaveBeenCalledWith( + NotificationType.info, + 'Please add your Prometheus host to the dashboard configuration and refresh the page', + undefined, + undefined, + 'Prometheus' + ); + }); + + describe('redirect not allowed users', () => { + let prometheusPermissions: Permission; + let navigateSpy; + + const expectRedirect = (action: string, redirected: boolean) => { + changeAction(action); + expect(router.navigate).toHaveBeenCalledTimes(redirected ? 1 : 0); + if (redirected) { + expect(router.navigate).toHaveBeenCalledWith(['/404']); + } + navigateSpy.calls.reset(); + }; + + beforeEach(() => { + navigateSpy = spyOn(router, 'navigate').and.stub(); + spyOn(authStorageService, 'getPermissions').and.callFake(() => ({ + prometheus: prometheusPermissions + })); + }); + + it('redirects to 404 if not allowed', () => { + prometheusPermissions = new Permission(['delete', 'read']); + expectRedirect('add', true); + expectRedirect('alertAdd', true); + }); + + it('redirects if user does not have minimum permissions to create silences', () => { + prometheusPermissions = new Permission(['update', 'delete', 'read']); + expectRedirect('add', true); + prometheusPermissions = new Permission(['update', 'delete', 'create']); + expectRedirect('recreate', true); + }); + + it('redirects if user does not have minimum permissions to update silences', () => { + prometheusPermissions = new Permission(['create', 'delete', 'read']); + expectRedirect('edit', true); + prometheusPermissions = new Permission(['create', 'delete', 'update']); + expectRedirect('edit', true); + }); + + it('does not redirect if user has minimum permissions to create silences', () => { + prometheusPermissions = new Permission(['create', 'read']); + expectRedirect('add', false); + expectRedirect('alertAdd', false); + expectRedirect('recreate', false); + }); + + it('does not redirect if user has minimum permissions to update silences', () => { + prometheusPermissions = new Permission(['update', 'read']); + expectRedirect('edit', false); + }); + }); + + describe('choose the right action', () => { + const expectMode = (routerMode: string, edit: boolean, recreate: boolean, action: string) => { + changeAction(routerMode); + expect(component.recreate).toBe(recreate); + expect(component.edit).toBe(edit); + expect(component.action).toBe(action); + }; + + beforeEach(() => { + spyOn(prometheusService, 'getSilences').and.callFake((p) => + of([prometheus.createSilence(p.id)]) + ); + }); + + it('should have no special action activate by default', () => { + expectMode('add', false, false, 'Create'); + expect(prometheusService.getSilences).not.toHaveBeenCalled(); + expect(component.form.value).toEqual({ + comment: null, + createdBy: 'someUser', + duration: '2h', + startsAt: baseTime, + endsAt: new Date('2022-02-22T02:00:00') + }); + }); + + it('should be in edit action if route includes edit', () => { + params = { id: 'someNotExpiredId' }; + expectMode('edit', true, false, 'Edit'); + expect(prometheusService.getSilences).toHaveBeenCalledWith(params); + expect(component.form.value).toEqual({ + comment: `A comment for ${params.id}`, + createdBy: `Creator of ${params.id}`, + duration: '1d', + startsAt: new Date('2022-02-22T22:22:00'), + endsAt: new Date('2022-02-23T22:22:00') + }); + expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]); + }); + + it('should be in recreation action if route includes recreate', () => { + params = { id: 'someExpiredId' }; + expectMode('recreate', false, true, 'Recreate'); + expect(prometheusService.getSilences).toHaveBeenCalledWith(params); + expect(component.form.value).toEqual({ + comment: `A comment for ${params.id}`, + createdBy: `Creator of ${params.id}`, + duration: '2h', + startsAt: baseTime, + endsAt: new Date('2022-02-22T02:00:00') + }); + expect(component.matchers).toEqual([createMatcher('job', 'someJob', true)]); + }); + + it('adds matchers based on the label object of the alert with the given id', () => { + params = { id: 'someAlert' }; + expectMode('alertAdd', false, false, 'Create'); + expect(prometheusService.getSilences).not.toHaveBeenCalled(); + expect(prometheusService.getAlerts).toHaveBeenCalled(); + expect(component.matchers).toEqual([ + createMatcher('alertname', 'alert0', false), + createMatcher('instance', 'someInstance', false), + createMatcher('job', 'someJob', false), + createMatcher('severity', 'someSeverity', false) + ]); + expect(component.matcherMatch).toEqual({ + cssClass: 'has-success', + status: 'Matches 1 rule with 1 active alert.' + }); + }); + }); + + describe('time', () => { + // Can't be used to set accurate UTC dates in unit tests as Date uses timezones, + // this means the UTC time changes depending on the timezone you are in. + const changeDatePicker = (el, text) => { + el.triggerEventHandler('change', { target: { value: text } }); + }; + const getDatePicker = (i) => + fixture.debugElement.queryAll(By.directive(BsDatepickerDirective))[i]; + const changeEndDate = (text) => changeDatePicker(getDatePicker(1), text); + const changeStartDate = (text) => changeDatePicker(getDatePicker(0), text); + + it('have all dates set at beginning', () => { + expect(form.getValue('startsAt')).toEqual(baseTime); + expect(form.getValue('duration')).toBe('2h'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00')); + }); + + describe('on start date change', () => { + it('changes end date on start date change if it exceeds it', fakeAsync(() => { + changeStartDate('2022-02-28T 04:05'); + expect(form.getValue('duration')).toEqual('2h'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-28T06:05:00')); + + changeStartDate('2022-12-31T 22:00'); + expect(form.getValue('duration')).toEqual('2h'); + expect(form.getValue('endsAt')).toEqual(new Date('2023-01-01T00:00:00')); + })); + + it('changes duration if start date does not exceed end date ', fakeAsync(() => { + changeStartDate('2022-02-22T 00:45'); + expect(form.getValue('duration')).toEqual('1h 15m'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00')); + })); + + it('should raise invalid start date error', fakeAsync(() => { + changeStartDate('No valid date'); + formH.expectError('startsAt', 'bsDate'); + expect(form.getValue('startsAt').toString()).toBe('Invalid Date'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T02:00:00')); + })); + }); + + describe('on duration change', () => { + it('changes end date if duration is changed', () => { + formH.setValue('duration', '15m'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-22T00:15')); + formH.setValue('duration', '5d 23h'); + expect(form.getValue('endsAt')).toEqual(new Date('2022-02-27T23:00')); + }); + }); + + describe('on end date change', () => { + it('changes duration on end date change if it exceeds start date', fakeAsync(() => { + changeEndDate('2022-02-28T 04:05'); + expect(form.getValue('duration')).toEqual('6d 4h 5m'); + expect(form.getValue('startsAt')).toEqual(baseTime); + })); + + it('changes start date if end date happens before it', fakeAsync(() => { + changeEndDate('2022-02-21T 02:00'); + expect(form.getValue('duration')).toEqual('2h'); + expect(form.getValue('startsAt')).toEqual(new Date('2022-02-21T00:00:00')); + })); + + it('should raise invalid end date error', fakeAsync(() => { + changeEndDate('No valid date'); + formH.expectError('endsAt', 'bsDate'); + expect(form.getValue('endsAt').toString()).toBe('Invalid Date'); + expect(form.getValue('startsAt')).toEqual(baseTime); + })); + }); + }); + + it('should have a creator field', () => { + formH.expectValid('createdBy'); + formH.expectErrorChange('createdBy', '', 'required'); + formH.expectValidChange('createdBy', 'Mighty FSM'); + }); + + it('should have a comment field', () => { + formH.expectError('comment', 'required'); + formH.expectValidChange('comment', 'A pretty long comment'); + }); + + it('should be a valid form if all inputs are filled and at least one matcher was added', () => { + expect(form.valid).toBeFalsy(); + formH.expectValidChange('createdBy', 'Mighty FSM'); + formH.expectValidChange('comment', 'A pretty long comment'); + addMatcher('job', 'someJob', false); + expect(form.valid).toBeTruthy(); + }); + + describe('matchers', () => { + const expectMatch = (helpText) => { + expect(fixtureH.getText('#match-state')).toBe(helpText); + }; + + it('should show the add matcher button', () => { + fixtureH.expectElementVisible('#add-matcher', true); + fixtureH.expectIdElementsVisible( + [ + 'matcher-name-0', + 'matcher-value-0', + 'matcher-isRegex-0', + 'matcher-edit-0', + 'matcher-delete-0' + ], + false + ); + expectMatch(null); + }); + + it('should show added matcher', () => { + addMatcher('job', 'someJob', true); + fixtureH.expectIdElementsVisible( + [ + 'matcher-name-0', + 'matcher-value-0', + 'matcher-isRegex-0', + 'matcher-edit-0', + 'matcher-delete-0' + ], + true + ); + expectMatch(null); + }); + + it('should show multiple matchers', () => { + addMatcher('severity', 'someSeverity', false); + addMatcher('alertname', 'alert0', false); + fixtureH.expectIdElementsVisible( + [ + 'matcher-name-0', + 'matcher-value-0', + 'matcher-isRegex-0', + 'matcher-edit-0', + 'matcher-delete-0', + 'matcher-name-1', + 'matcher-value-1', + 'matcher-isRegex-1', + 'matcher-edit-1', + 'matcher-delete-1' + ], + true + ); + expectMatch('Matches 1 rule with 1 active alert.'); + }); + + it('should show the right matcher values', () => { + addMatcher('alertname', 'alert.*', true); + addMatcher('job', 'someJob', false); + fixture.detectChanges(); + fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname'); + fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert.*'); + fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'true'); + fixtureH.expectFormFieldToBe('#matcher-isRegex-1', 'false'); + expectMatch(null); + }); + + it('should be able to edit a matcher', () => { + addMatcher('alertname', 'alert.*', true); + expectMatch(null); + + const modalService = TestBed.get(BsModalService); + spyOn(modalService, 'show').and.callFake(() => { + return { + content: { + preFillControls: (matcher) => { + expect(matcher).toBe(component.matchers[0]); + }, + submitAction: of({ name: 'alertname', value: 'alert0', isRegex: false }) + } + }; + }); + fixtureH.clickElement('#matcher-edit-0'); + + fixtureH.expectFormFieldToBe('#matcher-name-0', 'alertname'); + fixtureH.expectFormFieldToBe('#matcher-value-0', 'alert0'); + fixtureH.expectFormFieldToBe('#matcher-isRegex-0', 'false'); + expectMatch('Matches 1 rule with 1 active alert.'); + }); + + it('should be able to remove a matcher', () => { + addMatcher('alertname', 'alert0', false); + expectMatch('Matches 1 rule with 1 active alert.'); + fixtureH.clickElement('#matcher-delete-0'); + expect(component.matchers).toEqual([]); + fixtureH.expectIdElementsVisible( + ['matcher-name-0', 'matcher-value-0', 'matcher-isRegex-0'], + false + ); + expectMatch(null); + }); + + it('should be able to remove a matcher and update the matcher text', () => { + addMatcher('alertname', 'alert0', false); + addMatcher('alertname', 'alert1', false); + expectMatch('Your matcher seems to match no currently defined rule or active alert.'); + fixtureH.clickElement('#matcher-delete-1'); + expectMatch('Matches 1 rule with 1 active alert.'); + }); + + it('should show form as invalid if no matcher is set', () => { + expect(form.errors).toEqual({ matcherRequired: true }); + }); + + it('should show form as valid if matcher was added', () => { + addMatcher('some name', 'some value', true); + expect(form.errors).toEqual(null); + }); + }); + + describe('submit tests', () => { + const endsAt = new Date('2022-02-22T02:00:00'); + let silence: AlertmanagerSilence; + const silenceId = '50M3-10N6-1D'; + + const expectSuccessNotification = (titleStartsWith) => + expect(notificationService.show).toHaveBeenCalledWith( + NotificationType.success, + `${titleStartsWith} silence ${silenceId}`, + undefined, + undefined, + 'Prometheus' + ); + + const fillAndSubmit = () => { + ['createdBy', 'comment'].forEach((attr) => { + formH.setValue(attr, silence[attr]); + }); + silence.matchers.forEach((matcher) => + addMatcher(matcher.name, matcher.value, matcher.isRegex) + ); + component.submit(); + }; + + beforeEach(() => { + spyOn(prometheusService, 'setSilence').and.callFake(() => of({ body: { silenceId } })); + spyOn(router, 'navigate').and.stub(); + silence = { + createdBy: 'some creator', + comment: 'some comment', + startsAt: baseTime.toISOString(), + endsAt: endsAt.toISOString(), + matchers: [ + { + name: 'some attribute name', + value: 'some value', + isRegex: false + }, + { + name: 'job', + value: 'node-exporter', + isRegex: false + }, + { + name: 'instance', + value: 'localhost:9100', + isRegex: false + }, + { + name: 'alertname', + value: 'load_0', + isRegex: false + } + ] + }; + }); + + it('should not create a silence if the form is invalid', () => { + component.submit(); + expect(notificationService.show).not.toHaveBeenCalled(); + expect(form.valid).toBeFalsy(); + expect(prometheusService.setSilence).not.toHaveBeenCalledWith(silence); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should route back to "/silence" on success', () => { + fillAndSubmit(); + expect(form.valid).toBeTruthy(); + expect(router.navigate).toHaveBeenCalledWith(['/silence']); + }); + + it('should create a silence', () => { + fillAndSubmit(); + expect(prometheusService.setSilence).toHaveBeenCalledWith(silence); + expectSuccessNotification('Created'); + }); + + it('should recreate a silence', () => { + component.recreate = true; + component.id = 'recreateId'; + fillAndSubmit(); + expect(prometheusService.setSilence).toHaveBeenCalledWith(silence); + expectSuccessNotification('Recreated'); + }); + + it('should edit a silence', () => { + component.edit = true; + component.id = 'editId'; + silence.id = component.id; + fillAndSubmit(); + expect(prometheusService.setSilence).toHaveBeenCalledWith(silence); + expectSuccessNotification('Edited'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts new file mode 100644 index 0000000000000..a9eb0aed0d89f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts @@ -0,0 +1,328 @@ +import { Component } from '@angular/core'; +import { Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { I18n } from '@ngx-translate/i18n-polyfill'; + +import * as _ from 'lodash'; +import { BsModalService } from 'ngx-bootstrap/modal'; + +import { PrometheusService } from '../../../../shared/api/prometheus.service'; +import { + ActionLabelsI18n, + SucceededActionLabelsI18n +} from '../../../../shared/constants/app.constants'; +import { Icons } from '../../../../shared/enum/icons.enum'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../../shared/forms/cd-validators'; +import { + AlertmanagerSilence, + AlertmanagerSilenceMatcher, + AlertmanagerSilenceMatcherMatch +} from '../../../../shared/models/alertmanager-silence'; +import { Permission } from '../../../../shared/models/permissions'; +import { AlertmanagerAlert, PrometheusRule } from '../../../../shared/models/prometheus-alerts'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { PrometheusSilenceMatcherService } from '../../../../shared/services/prometheus-silence-matcher.service'; +import { TimeDiffService } from '../../../../shared/services/time-diff.service'; +import { SilenceMatcherModalComponent } from '../silence-matcher-modal/silence-matcher-modal.component'; + +@Component({ + selector: 'cd-prometheus-form', + templateUrl: './silence-form.component.html', + styleUrls: ['./silence-form.component.scss'] +}) +export class SilenceFormComponent { + icons = Icons; + permission: Permission; + form: CdFormGroup; + rules: PrometheusRule[]; + + // Date formatting rules can be found here: https://momentjs.com/docs/#/displaying/format/ + bsConfig = { dateInputFormat: 'YYYY-MM-DDT HH:mm' }; + + recreate = false; + edit = false; + id: string; + + action: string; + resource = this.i18n('silence'); + + matchers: AlertmanagerSilenceMatcher[] = []; + matcherMatch: AlertmanagerSilenceMatcherMatch = undefined; + matcherConfig = [ + { + tooltip: this.i18n('Attribute name'), + icon: this.icons.paragraph, + attribute: 'name' + }, + { + tooltip: this.i18n('Value'), + icon: this.icons.terminal, + attribute: 'value' + }, + { + tooltip: this.i18n('Regular expression'), + icon: this.icons.magic, + attribute: 'isRegex' + } + ]; + + constructor( + private i18n: I18n, + private router: Router, + private authStorageService: AuthStorageService, + private formBuilder: CdFormBuilder, + private prometheusService: PrometheusService, + private notificationService: NotificationService, + private route: ActivatedRoute, + private timeDiff: TimeDiffService, + private bsModalService: BsModalService, + private silenceMatcher: PrometheusSilenceMatcherService, + private actionLabels: ActionLabelsI18n, + private succeededLabels: SucceededActionLabelsI18n + ) { + this.init(); + } + + private init() { + this.chooseMode(); + this.authenticate(); + this.createForm(); + this.setupDates(); + this.getData(); + } + + private chooseMode() { + this.edit = this.router.url.startsWith('/silence/edit'); + this.recreate = this.router.url.startsWith('/silence/recreate'); + if (this.edit) { + this.action = this.actionLabels.EDIT; + } else if (this.recreate) { + this.action = this.actionLabels.RECREATE; + } else { + this.action = this.actionLabels.CREATE; + } + } + + private authenticate() { + this.permission = this.authStorageService.getPermissions().prometheus; + const allowed = + this.permission.read && (this.edit ? this.permission.update : this.permission.create); + if (!allowed) { + this.router.navigate(['/404']); + } + } + + private createForm() { + this.form = this.formBuilder.group( + { + startsAt: [null, [Validators.required]], + duration: ['2h', [Validators.min(1)]], + endsAt: [null, [Validators.required]], + createdBy: [this.authStorageService.getUsername(), [Validators.required]], + comment: [null, [Validators.required]] + }, + { + validators: CdValidators.custom('matcherRequired', () => this.matchers.length === 0) + } + ); + } + + private setupDates() { + const now = new Date(); + now.setSeconds(0, 0); // Normalizes start date + this.form.silentSet('startsAt', now); + this.updateDate(); + this.subscribeDateChanges(); + } + + private updateDate(updateStartDate?: boolean) { + const next = this.timeDiff.calculateDate( + this.form.getValue(updateStartDate ? 'endsAt' : 'startsAt'), + this.form.getValue('duration'), + updateStartDate + ); + if (next) { + this.form.silentSet(updateStartDate ? 'startsAt' : 'endsAt', next); + } + } + + private subscribeDateChanges() { + this.form.get('startsAt').valueChanges.subscribe(() => { + this.onDateChange(); + }); + this.form.get('duration').valueChanges.subscribe(() => { + this.updateDate(); + }); + this.form.get('endsAt').valueChanges.subscribe(() => { + this.onDateChange(true); + }); + } + + private onDateChange(updateStartDate?: boolean) { + if (this.form.getValue('startsAt') < this.form.getValue('endsAt')) { + this.updateDuration(); + } else { + this.updateDate(updateStartDate); + } + } + + private updateDuration() { + this.form.silentSet( + 'duration', + this.timeDiff.calculateDuration(this.form.getValue('startsAt'), this.form.getValue('endsAt')) + ); + } + + private getData() { + this.getRules(); + this.getModeSpecificData(); + } + + private getRules() { + this.prometheusService.ifPrometheusConfigured( + () => + this.prometheusService.getRules().subscribe( + (rules) => (this.rules = rules), + () => { + this.prometheusService.disablePrometheusConfig(); + this.rules = []; + } + ), + () => { + this.rules = []; + this.notificationService.show( + NotificationType.info, + this.i18n( + 'Please add your Prometheus host to the dashboard configuration and refresh the page' + ), + undefined, + undefined, + 'Prometheus' + ); + } + ); + } + + private getModeSpecificData() { + this.route.params.subscribe((params: { id: string }) => { + if (!params.id) { + return; + } + if (this.edit || this.recreate) { + this.prometheusService.getSilences(params).subscribe((silences) => { + this.fillFormWithSilence(silences[0]); + }); + } else { + this.prometheusService.getAlerts(params).subscribe((alerts) => { + this.fillFormByAlert(alerts[0]); + }); + } + }); + } + + private fillFormWithSilence(silence: AlertmanagerSilence) { + this.id = silence.id; + if (this.edit) { + ['startsAt', 'endsAt'].forEach((attr) => this.form.silentSet(attr, new Date(silence[attr]))); + this.updateDuration(); + } + ['createdBy', 'comment'].forEach((attr) => this.form.silentSet(attr, silence[attr])); + this.matchers = silence.matchers; + this.validateMatchers(); + } + + private validateMatchers() { + if (!this.rules) { + window.setTimeout(() => this.validateMatchers(), 100); + return; + } + this.matcherMatch = this.silenceMatcher.multiMatch(this.matchers, this.rules); + this.form.markAsDirty(); + this.form.updateValueAndValidity(); + } + + private fillFormByAlert(alert: AlertmanagerAlert) { + const labels = alert.labels; + Object.keys(labels).forEach((key) => + this.setMatcher({ + name: key, + value: labels[key], + isRegex: false + }) + ); + } + + private setMatcher(matcher: AlertmanagerSilenceMatcher, index?: number) { + if (_.isNumber(index)) { + this.matchers[index] = matcher; + } else { + this.matchers.push(matcher); + } + this.validateMatchers(); + } + + showMatcherModal(index?: number) { + const modalRef = this.bsModalService.show(SilenceMatcherModalComponent); + const modal = modalRef.content as SilenceMatcherModalComponent; + modal.rules = this.rules; + if (_.isNumber(index)) { + modal.editMode = true; + modal.preFillControls(this.matchers[index]); + } + modalRef.content.submitAction.subscribe((matcher: AlertmanagerSilenceMatcher) => { + this.setMatcher(matcher, index); + }); + } + + deleteMatcher(index: number) { + this.matchers.splice(index, 1); + this.validateMatchers(); + } + + submit() { + if (this.form.invalid) { + return; + } + this.prometheusService.setSilence(this.getSubmitData()).subscribe( + (resp) => { + this.router.navigate(['/silence']); + this.notificationService.show( + NotificationType.success, + this.getNotificationTile(resp.body['silenceId']), + undefined, + undefined, + 'Prometheus' + ); + }, + () => this.form.setErrors({ cdSubmitButton: true }) + ); + } + + private getSubmitData(): AlertmanagerSilence { + const payload = this.form.value; + delete payload.duration; + payload.startsAt = payload.startsAt.toISOString(); + payload.endsAt = payload.endsAt.toISOString(); + payload.matchers = this.matchers; + if (this.edit) { + payload.id = this.id; + } + return payload; + } + + private getNotificationTile(id: string) { + let action; + if (this.edit) { + action = this.succeededLabels.EDITED; + } else if (this.recreate) { + action = this.succeededLabels.RECREATED; + } else { + action = this.succeededLabels.CREATED; + } + return `${action} ${this.resource} ${id}`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html new file mode 100644 index 0000000000000..f67f127cd03cf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts new file mode 100644 index 0000000000000..cb1b68eb340b7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts @@ -0,0 +1,320 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; +import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; +import { BsModalRef, BsModalService, ModalModule } from 'ngx-bootstrap/modal'; +import { TabsModule } from 'ngx-bootstrap/tabs'; +import { of } from 'rxjs'; + +import { + configureTestBed, + i18nProviders, + PermissionHelper +} from '../../../../../testing/unit-test-helper'; +import { PrometheusService } from '../../../../shared/api/prometheus.service'; +import { CriticalConfirmationModalComponent } from '../../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { SharedModule } from '../../../../shared/shared.module'; +import { PrometheusTabsComponent } from '../prometheus-tabs/prometheus-tabs.component'; +import { SilenceListComponent } from './silence-list.component'; + +describe('SilenceListComponent', () => { + let component: SilenceListComponent; + let fixture: ComponentFixture; + let prometheusService: PrometheusService; + + configureTestBed({ + imports: [ + SharedModule, + BsDropdownModule.forRoot(), + TabsModule.forRoot(), + ModalModule.forRoot(), + ToastModule.forRoot(), + RouterTestingModule, + HttpClientTestingModule + ], + declarations: [SilenceListComponent, PrometheusTabsComponent], + providers: [i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SilenceListComponent); + component = fixture.componentInstance; + prometheusService = TestBed.get(PrometheusService); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('show action buttons and drop down actions depending on permissions', () => { + let tableActions: TableActionsComponent; + let scenario: { fn; empty; single }; + let permissionHelper: PermissionHelper; + let silenceState: string; + + const getTableActionComponent = (): TableActionsComponent => { + fixture.detectChanges(); + return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance; + }; + + const setSilenceState = (state) => { + silenceState = state; + }; + + const testNonExpiredSilenceScenario = () => { + setSilenceState('active'); + permissionHelper.testScenarios(scenario); + setSilenceState('pending'); + permissionHelper.testScenarios(scenario); + }; + + beforeEach(() => { + permissionHelper = new PermissionHelper(component.permission, () => + getTableActionComponent() + ); + permissionHelper.createSelection = () => ({ status: { state: silenceState } }); + scenario = { + fn: () => tableActions.getCurrentButton().name, + single: 'Edit', + empty: 'Create' + }; + }); + + describe('with all', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 1); + }); + + it(`shows 'Edit' for single non expired silence else 'Create' as main action`, () => { + scenario.single = 'Edit'; + testNonExpiredSilenceScenario(); + }); + + it(`shows 'Recreate' for single expired silence else 'Create' as main action`, () => { + scenario.single = 'Recreate'; + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it('can use all actions', () => { + expect(tableActions.tableActions.length).toBe(4); + expect(tableActions.tableActions).toEqual(component.tableActions); + }); + }); + + describe('with read, create and update', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 0); + }); + + it(`shows 'Edit' for single non expired silence else 'Create' as main action`, () => { + scenario.single = 'Edit'; + testNonExpiredSilenceScenario(); + }); + + it(`shows 'Recreate' for single expired silence else 'Create' as main action`, () => { + scenario.single = 'Recreate'; + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use all actions except for 'Expire'`, () => { + expect(tableActions.tableActions.length).toBe(3); + expect(tableActions.tableActions).toEqual([ + component.tableActions[0], + component.tableActions[1], + component.tableActions[2] + ]); + }); + }); + + describe('with read, create and delete', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 1); + }); + + it(`shows 'Expire' for single non expired silence else 'Create' as main action`, () => { + scenario.single = 'Expire'; + testNonExpiredSilenceScenario(); + }); + + it(`shows 'Recreate' for single expired silence else 'Create' as main action`, () => { + scenario.single = 'Recreate'; + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use 'Create' and 'Expire' action`, () => { + expect(tableActions.tableActions.length).toBe(3); + expect(tableActions.tableActions).toEqual([ + component.tableActions[0], + component.tableActions[1], + component.tableActions[3] + ]); + }); + }); + + describe('with read, edit and delete', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 1); + }); + + it(`shows always 'Edit' as main action for any state`, () => { + scenario.single = 'Edit'; + scenario.empty = 'Edit'; + testNonExpiredSilenceScenario(); + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use 'Edit' and 'Expire' action`, () => { + expect(tableActions.tableActions.length).toBe(2); + expect(tableActions.tableActions).toEqual([ + component.tableActions[2], + component.tableActions[3] + ]); + }); + }); + + describe('with read and create', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 0); + }); + + it(`shows always 'Create' as main action for single non expired silences`, () => { + scenario.single = 'Create'; + testNonExpiredSilenceScenario(); + }); + + it(`shows 'Recreate' for single expired silence else 'Create' as main action`, () => { + scenario.single = 'Recreate'; + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use 'Create' and 'Recreate' actions`, () => { + expect(tableActions.tableActions.length).toBe(2); + expect(tableActions.tableActions).toEqual([ + component.tableActions[0], + component.tableActions[1] + ]); + }); + }); + + describe('with read and edit', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0); + }); + + it(`shows always 'Edit' as main action for any state`, () => { + scenario.single = 'Edit'; + scenario.empty = 'Edit'; + testNonExpiredSilenceScenario(); + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use 'Edit' action`, () => { + expect(tableActions.tableActions.length).toBe(1); + expect(tableActions.tableActions).toEqual([component.tableActions[2]]); + }); + }); + + describe('with read and delete', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 1); + }); + + it(`shows always 'Expire' as main action for any state`, () => { + scenario.single = 'Expire'; + scenario.empty = 'Expire'; + testNonExpiredSilenceScenario(); + setSilenceState('expired'); + permissionHelper.testScenarios(scenario); + }); + + it(`can use 'Expire' action`, () => { + expect(tableActions.tableActions.length).toBe(1); + expect(tableActions.tableActions).toEqual([component.tableActions[3]]); + }); + }); + + describe('with only read', () => { + beforeEach(() => { + tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0); + }); + + it('shows no main action', () => { + permissionHelper.testScenarios({ + fn: () => tableActions.getCurrentButton(), + single: undefined, + empty: undefined + }); + }); + + it('can use no actions', () => { + expect(tableActions.tableActions.length).toBe(0); + expect(tableActions.tableActions).toEqual([]); + }); + }); + }); + + describe('expire silence', () => { + const setSelectedSilence = (silenceName: string) => { + component.selection.selected = [{ id: silenceName }]; + component.selection.update(); + }; + + const expireSilence = () => { + component.expireSilence(); + const deletion: CriticalConfirmationModalComponent = component.modalRef.content; + deletion.modalRef = new BsModalRef(); + deletion.ngOnInit(); + deletion.callSubmitAction(); + }; + + const expectSilenceToExpire = (silenceId) => { + setSelectedSilence(silenceId); + expireSilence(); + expect(prometheusService.expireSilence).toHaveBeenCalledWith(silenceId); + }; + + beforeEach(() => { + const mockObservable = () => of([]); + spyOn(component, 'refresh').and.callFake(mockObservable); + spyOn(prometheusService, 'expireSilence').and.callFake(mockObservable); + spyOn(TestBed.get(BsModalService), 'show').and.callFake((deletionClass, config) => { + return { + content: Object.assign(new deletionClass(), config.initialState) + }; + }); + }); + + it('should expire a silence', () => { + const notificationService = TestBed.get(NotificationService); + spyOn(notificationService, 'show').and.stub(); + expectSilenceToExpire('someSilenceId'); + expect(notificationService.show).toHaveBeenCalledWith( + NotificationType.success, + 'Expired Silence someSilenceId', + undefined, + undefined, + 'Prometheus' + ); + }); + + it('should refresh after expiring a silence', () => { + expectSilenceToExpire('someId'); + expect(component.refresh).toHaveBeenCalledTimes(1); + expectSilenceToExpire('someOtherId'); + expect(component.refresh).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts new file mode 100644 index 0000000000000..9c8360eb7b0a9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts @@ -0,0 +1,197 @@ +import { Component, OnInit } from '@angular/core'; +import { I18n } from '@ngx-translate/i18n-polyfill'; +import { SortDirection, SortPropDir } from '@swimlane/ngx-datatable'; + +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; +import { Observable, Subscriber } from 'rxjs'; + +import { PrometheusService } from '../../../../shared/api/prometheus.service'; +import { CriticalConfirmationModalComponent } from '../../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { + ActionLabelsI18n, + SucceededActionLabelsI18n +} from '../../../../shared/constants/app.constants'; +import { CellTemplate } from '../../../../shared/enum/cell-template.enum'; +import { Icons } from '../../../../shared/enum/icons.enum'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { AlertmanagerSilence } from '../../../../shared/models/alertmanager-silence'; +import { CdTableAction } from '../../../../shared/models/cd-table-action'; +import { CdTableColumn } from '../../../../shared/models/cd-table-column'; +import { CdTableSelection } from '../../../../shared/models/cd-table-selection'; +import { Permission } from '../../../../shared/models/permissions'; +import { CdDatePipe } from '../../../../shared/pipes/cd-date.pipe'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { URLBuilderService } from '../../../../shared/services/url-builder.service'; + +const BASE_URL = 'silence'; + +@Component({ + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }], + selector: 'cd-silences-list', + templateUrl: './silence-list.component.html', + styleUrls: ['./silence-list.component.scss'] +}) +export class SilenceListComponent implements OnInit { + silences: AlertmanagerSilence[] = []; + columns: CdTableColumn[]; + tableActions: CdTableAction[]; + permission: Permission; + selection = new CdTableSelection(); + modalRef: BsModalRef; + customCss = { + 'label label-danger': 'active', + 'label label-warning': 'pending', + 'label label-default': 'expired' + }; + sorts: SortPropDir[] = [{ prop: 'endsAt', dir: SortDirection.desc }]; + + constructor( + private authStorageService: AuthStorageService, + private i18n: I18n, + private cdDatePipe: CdDatePipe, + private prometheusService: PrometheusService, + private modalService: BsModalService, + private notificationService: NotificationService, + private urlBuilder: URLBuilderService, + private actionLabels: ActionLabelsI18n, + private succeededLabels: SucceededActionLabelsI18n + ) { + this.permission = this.authStorageService.getPermissions().prometheus; + } + + ngOnInit() { + const selectionExpired = (selection: CdTableSelection) => + selection.first() && selection.first().status.state === 'expired'; + this.tableActions = [ + { + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection, + name: this.actionLabels.CREATE + }, + { + permission: 'create', + canBePrimary: (selection: CdTableSelection) => + selection.hasSingleSelection && selectionExpired(selection), + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || + selection.first().cdExecuting || + (selection.first().cdExecuting && selectionExpired(selection)) || + !selectionExpired(selection), + icon: Icons.copy, + routerLink: () => this.urlBuilder.getRecreate(this.selection.first().id), + name: this.actionLabels.RECREATE + }, + { + permission: 'update', + icon: Icons.edit, + canBePrimary: (selection: CdTableSelection) => + selection.hasSingleSelection && !selectionExpired(selection), + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || + selection.first().cdExecuting || + (selection.first().cdExecuting && !selectionExpired(selection)) || + selectionExpired(selection), + routerLink: () => this.urlBuilder.getEdit(this.selection.first().id), + name: this.actionLabels.EDIT + }, + { + permission: 'delete', + icon: Icons.trash, + canBePrimary: (selection: CdTableSelection) => + selection.hasSingleSelection && !selectionExpired(selection), + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || + selection.first().cdExecuting || + selectionExpired(selection), + click: () => this.expireSilence(), + name: this.actionLabels.EXPIRE + } + ]; + this.columns = [ + { + name: this.i18n('ID'), + prop: 'id', + flexGrow: 3 + }, + { + name: this.i18n('Created by'), + prop: 'createdBy', + flexGrow: 2 + }, + { + name: this.i18n('Started'), + prop: 'startsAt', + pipe: this.cdDatePipe + }, + { + name: this.i18n('Updated'), + prop: 'updatedAt', + pipe: this.cdDatePipe + }, + { + name: this.i18n('Ends'), + prop: 'endsAt', + pipe: this.cdDatePipe + }, + { + name: this.i18n('Status'), + prop: 'status.state', + cellTransformation: CellTemplate.classAdding + } + ]; + } + + refresh() { + this.prometheusService.ifAlertmanagerConfigured(() => { + this.prometheusService.getSilences().subscribe( + (silences) => { + this.silences = silences; + }, + () => { + this.prometheusService.disableAlertmanagerConfig(); + } + ); + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + expireSilence() { + const id = this.selection.first().id; + const i18nSilence = this.i18n('Silence'); + const applicationName = 'Prometheus'; + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: i18nSilence, + actionDescription: this.actionLabels.EXPIRE, + submitActionObservable: () => + new Observable((observer: Subscriber) => { + this.prometheusService.expireSilence(id).subscribe( + () => { + this.notificationService.show( + NotificationType.success, + `${this.succeededLabels.EXPIRED} ${i18nSilence} ${id}`, + undefined, + undefined, + applicationName + ); + }, + (resp) => { + resp['application'] = applicationName; + observer.error(resp); + }, + () => { + observer.complete(); + this.refresh(); + } + ); + }) + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html new file mode 100644 index 0000000000000..c0ad6ac65ebbf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html @@ -0,0 +1,98 @@ + + +
+ + + +
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts new file mode 100644 index 0000000000000..cd8fa2802117a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts @@ -0,0 +1,163 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { TypeaheadModule } from 'ngx-bootstrap/typeahead'; + +import { + configureTestBed, + FixtureHelper, + FormHelper, + i18nProviders, + PrometheusHelper +} from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { SilenceMatcherModalComponent } from './silence-matcher-modal.component'; + +describe('SilenceMatcherModalComponent', () => { + let component: SilenceMatcherModalComponent; + let fixture: ComponentFixture; + + let formH: FormHelper; + let fixtureH: FixtureHelper; + let prometheus: PrometheusHelper; + + configureTestBed({ + declarations: [SilenceMatcherModalComponent], + imports: [ + HttpClientTestingModule, + SharedModule, + TypeaheadModule.forRoot(), + RouterTestingModule, + ReactiveFormsModule + ], + providers: [BsModalRef, i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SilenceMatcherModalComponent); + component = fixture.componentInstance; + + fixtureH = new FixtureHelper(fixture); + formH = new FormHelper(component.form); + prometheus = new PrometheusHelper(); + + component.rules = [ + prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]), + prometheus.createRule('alert1', 'someSeverity', []) + ]; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a name field', () => { + formH.expectError('name', 'required'); + formH.expectValidChange('name', 'alertname'); + }); + + it('should only allow a specific set of name attributes', () => { + expect(component.nameAttributes).toEqual(['alertname', 'instance', 'job', 'severity']); + }); + + it('should autocomplete a list based on the set name', () => { + const expectations = { + alertname: ['alert0', 'alert1'], + instance: ['someInstance'], + job: ['someJob'], + severity: ['someSeverity'] + }; + Object.keys(expectations).forEach((key) => { + formH.setValue('name', key); + expect(component.possibleValues).toEqual(expectations[key]); + }); + }); + + describe('test rule matching', () => { + const expectMatch = (name, value, helpText) => { + component.preFillControls({ + name: name, + value: value, + isRegex: false + }); + expect(fixtureH.getText('#match-state')).toBe(helpText); + }; + + it('should match no rule and no alert', () => { + expectMatch( + 'alertname', + 'alert', + 'Your matcher seems to match no currently defined rule or active alert.' + ); + }); + + it('should match a rule with no alert', () => { + expectMatch('alertname', 'alert1', 'Matches 1 rule with no active alerts.'); + }); + + it('should match a rule and an alert', () => { + expectMatch('alertname', 'alert0', 'Matches 1 rule with 1 active alert.'); + }); + + it('should match multiple rules and an alert', () => { + expectMatch('severity', 'someSeverity', 'Matches 2 rules with 1 active alert.'); + }); + + it('should match multiple rules and multiple alerts', () => { + component.rules[1].alerts.push(null); + expectMatch('severity', 'someSeverity', 'Matches 2 rules with 2 active alerts.'); + }); + + it('should not show match-state if regex is checked', () => { + fixtureH.expectElementVisible('#match-state', false); + formH.setValue('name', 'severity'); + formH.setValue('value', 'someSeverity'); + fixtureH.expectElementVisible('#match-state', true); + formH.setValue('isRegex', true); + fixtureH.expectElementVisible('#match-state', false); + }); + }); + + it('should only enable value field if name was set', () => { + const value = component.form.get('value'); + expect(value.disabled).toBeTruthy(); + formH.setValue('name', component.nameAttributes[0]); + expect(value.enabled).toBeTruthy(); + formH.setValue('name', null); + expect(value.disabled).toBeTruthy(); + }); + + it('should have a value field', () => { + formH.setValue('name', component.nameAttributes[0]); + formH.expectError('value', 'required'); + formH.expectValidChange('value', 'alert0'); + }); + + it('should test preFillControls', () => { + const controlValues = { + name: 'alertname', + value: 'alert0', + isRegex: false + }; + component.preFillControls(controlValues); + expect(component.form.value).toEqual(controlValues); + }); + + it('should test submit', (done) => { + const controlValues = { + name: 'alertname', + value: 'alert0', + isRegex: false + }; + component.preFillControls(controlValues); + component.submitAction.subscribe((resp) => { + expect(resp).toEqual(controlValues); + done(); + }); + component.onSubmit(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts new file mode 100644 index 0000000000000..d04537fca1e36 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts @@ -0,0 +1,79 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; + +import * as _ from 'lodash'; +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { + AlertmanagerSilenceMatcher, + AlertmanagerSilenceMatcherMatch +} from '../../../../shared/models/alertmanager-silence'; +import { PrometheusRule } from '../../../../shared/models/prometheus-alerts'; +import { PrometheusSilenceMatcherService } from '../../../../shared/services/prometheus-silence-matcher.service'; + +@Component({ + selector: 'cd-silence-matcher-modal', + templateUrl: './silence-matcher-modal.component.html', + styleUrls: ['./silence-matcher-modal.component.scss'] +}) +export class SilenceMatcherModalComponent { + @Output() + submitAction = new EventEmitter(); + + form: CdFormGroup; + editMode = false; + rules: PrometheusRule[]; + nameAttributes = ['alertname', 'instance', 'job', 'severity']; + possibleValues: string[] = []; + matcherMatch: AlertmanagerSilenceMatcherMatch = undefined; + + constructor( + private formBuilder: CdFormBuilder, + private silenceMatcher: PrometheusSilenceMatcherService, + public bsModalRef: BsModalRef + ) { + this.createForm(); + this.subscribeToChanges(); + } + + private createForm() { + this.form = this.formBuilder.group({ + name: [null, [Validators.required]], + value: [{ value: null, disabled: true }, [Validators.required]], + isRegex: new FormControl(false) + }); + } + + private subscribeToChanges() { + this.form.get('name').valueChanges.subscribe((name) => { + if (name === null) { + this.form.get('value').disable(); + return; + } + this.setPossibleValues(name); + this.form.get('value').enable(); + }); + this.form.get('value').valueChanges.subscribe((value) => { + const values = this.form.value; + values.value = value; // Isn't the current value at this stage + this.matcherMatch = this.silenceMatcher.singleMatch(values, this.rules); + }); + } + + private setPossibleValues(name) { + this.possibleValues = _.sortedUniq( + this.rules.map((r) => _.get(r, this.silenceMatcher.getAttributePath(name))).filter((x) => x) + ); + } + + preFillControls(matcher: AlertmanagerSilenceMatcher) { + this.form.setValue(matcher); + } + + onSubmit() { + this.submitAction.emit(this.form.value); + this.bsModalRef.hide(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 5ee7508d3712a..f2dbbca4ef2aa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -104,6 +104,12 @@ Alerts +
  • + Silences +
  • diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts index ed3edc9fe013d..db14f4679c112 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts @@ -2,7 +2,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { TestBed } from '@angular/core/testing'; import { configureTestBed } from '../../../testing/unit-test-helper'; -import { PrometheusNotification } from '../models/prometheus-alerts'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; import { PrometheusService } from './prometheus.service'; import { SettingsService } from './settings.service'; @@ -28,12 +28,45 @@ describe('PrometheusService', () => { expect(service).toBeTruthy(); }); - it('should call list', () => { - service.list().subscribe(); + it('should get alerts', () => { + service.getAlerts().subscribe(); const req = httpTesting.expectOne('api/prometheus'); expect(req.request.method).toBe('GET'); }); + it('should get silences', () => { + service.getSilences().subscribe(); + const req = httpTesting.expectOne('api/prometheus/silences'); + expect(req.request.method).toBe('GET'); + }); + + it('should set a silence', () => { + const silence = { + id: 'someId', + matchers: [ + { + name: 'getZero', + value: 0, + isRegex: false + } + ], + startsAt: '2019-01-25T14:32:46.646300974Z', + endsAt: '2019-01-25T18:32:46.646300974Z', + createdBy: 'someCreator', + comment: 'for testing purpose' + }; + service.setSilence(silence).subscribe(); + const req = httpTesting.expectOne('api/prometheus/silence'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(silence); + }); + + it('should expire a silence', () => { + service.expireSilence('someId').subscribe(); + const req = httpTesting.expectOne('api/prometheus/silence/someId'); + expect(req.request.method).toBe('DELETE'); + }); + it('should call getNotificationSince without a notification', () => { service.getNotifications().subscribe(); const req = httpTesting.expectOne('api/prometheus/notifications?from=last'); @@ -41,36 +74,89 @@ describe('PrometheusService', () => { }); it('should call getNotificationSince with notification', () => { - service.getNotifications({ id: '42' } as PrometheusNotification).subscribe(); + service.getNotifications({ id: '42' } as AlertmanagerNotification).subscribe(); const req = httpTesting.expectOne('api/prometheus/notifications?from=42'); expect(req.request.method).toBe('GET'); }); + it('should get prometheus rules', () => { + service.getRules({}).subscribe(); + const req = httpTesting.expectOne('api/prometheus/rules'); + expect(req.request.method).toBe('GET'); + }); + describe('ifAlertmanagerConfigured', () => { let x: any; + let host; - const receiveConfig = (value) => { + const receiveConfig = () => { const req = httpTesting.expectOne('api/settings/alertmanager-api-host'); expect(req.request.method).toBe('GET'); - req.flush({ value }); + req.flush({ value: host }); }; beforeEach(() => { x = false; TestBed.get(SettingsService)['settings'] = {}; + service.ifAlertmanagerConfigured((v) => (x = v), () => (x = [])); + host = 'http://localhost:9093'; }); it('changes x in a valid case', () => { - service.ifAlertmanagerConfigured((v) => (x = v)); expect(x).toBe(false); - const host = 'http://localhost:9093'; - receiveConfig(host); + receiveConfig(); expect(x).toBe(host); }); - it('does not change x in a invalid case', () => { + it('does changes x an empty array in a invalid case', () => { + host = ''; + receiveConfig(); + expect(x).toEqual([]); + }); + + it('disables the set setting', () => { + receiveConfig(); + service.disableAlertmanagerConfig(); + x = false; service.ifAlertmanagerConfigured((v) => (x = v)); - receiveConfig(''); + expect(x).toBe(false); + }); + }); + + describe('ifPrometheusConfigured', () => { + let x: any; + let host; + + const receiveConfig = () => { + const req = httpTesting.expectOne('api/settings/prometheus-api-host'); + expect(req.request.method).toBe('GET'); + req.flush({ value: host }); + }; + + beforeEach(() => { + x = false; + TestBed.get(SettingsService)['settings'] = {}; + service.ifPrometheusConfigured((v) => (x = v), () => (x = [])); + host = 'http://localhost:9090'; + }); + + it('changes x in a valid case', () => { + expect(x).toBe(false); + receiveConfig(); + expect(x).toBe(host); + }); + + it('does changes x an empty array in a invalid case', () => { + host = ''; + receiveConfig(); + expect(x).toEqual([]); + }); + + it('disables the set setting', () => { + receiveConfig(); + service.disablePrometheusConfig(); + x = false; + service.ifPrometheusConfigured((v) => (x = v)); expect(x).toBe(false); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts index e17c5cf84f538..bdcd7fd35f526 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts @@ -3,7 +3,12 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { PrometheusAlert, PrometheusNotification } from '../models/prometheus-alerts'; +import { AlertmanagerSilence } from '../models/alertmanager-silence'; +import { + AlertmanagerAlert, + AlertmanagerNotification, + PrometheusRule +} from '../models/prometheus-alerts'; import { ApiModule } from './api.module'; import { SettingsService } from './settings.service'; @@ -12,21 +17,55 @@ import { SettingsService } from './settings.service'; }) export class PrometheusService { private baseURL = 'api/prometheus'; + private settingsKey = { + alertmanager: 'api/settings/alertmanager-api-host', + prometheus: 'api/settings/prometheus-api-host' + }; constructor(private http: HttpClient, private settingsService: SettingsService) {} - ifAlertmanagerConfigured(fn): void { - this.settingsService.ifSettingConfigured('api/settings/alertmanager-api-host', fn); + ifAlertmanagerConfigured(fn, elseFn?): void { + this.settingsService.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn); } - list(params = {}): Observable { - return this.http.get(this.baseURL, { params }); + disableAlertmanagerConfig(): void { + this.settingsService.disableSetting(this.settingsKey.alertmanager); } - getNotifications(notification?: PrometheusNotification): Observable { + ifPrometheusConfigured(fn, elseFn?): void { + this.settingsService.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn); + } + + disablePrometheusConfig(): void { + this.settingsService.disableSetting(this.settingsKey.prometheus); + } + + getAlerts(params = {}): Observable { + return this.http.get(this.baseURL, { params }); + } + + getSilences(params = {}): Observable { + return this.http.get(`${this.baseURL}/silences`, { params }); + } + + getRules(params = {}): Observable { + return this.http.get(`${this.baseURL}/rules`, { params }); + } + + setSilence(silence: AlertmanagerSilence) { + return this.http.post(`${this.baseURL}/silence`, silence, { observe: 'response' }); + } + + expireSilence(silenceId: string) { + return this.http.delete(`${this.baseURL}/silence/${silenceId}`, { observe: 'response' }); + } + + getNotifications( + notification?: AlertmanagerNotification + ): Observable { const url = `${this.baseURL}/notifications?from=${ notification && notification.id ? notification.id : 'last' }`; - return this.http.get(url); + return this.http.get(url); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index b8762e15ac354..e3ca443d26144 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -29,7 +29,11 @@ export enum URLVerbs { /* Non-standard verbs */ COPY = 'copy', - CLONE = 'clone' + CLONE = 'clone', + + /* Prometheus wording */ + RECREATE = 'recreate', + EXPIRE = 'expire' } export enum ActionLabels { @@ -56,7 +60,11 @@ export enum ActionLabels { CLONE = 'Clone', /* Read-only */ - SHOW = 'Show' + SHOW = 'Show', + + /* Prometheus wording */ + RECREATE = 'Recreate', + EXPIRE = 'Expire' } @Injectable({ @@ -91,6 +99,8 @@ export class ActionLabelsI18n { SHOW: string; TRASH: string; UNPROTECT: string; + RECREATE: string; + EXPIRE: string; constructor(private i18n: I18n) { /* Create a new item */ @@ -129,6 +139,10 @@ export class ActionLabelsI18n { this.SHOW = this.i18n('Show'); this.TRASH = this.i18n('Move to Trash'); this.UNPROTECT = this.i18n('Unprotect'); + + /* Prometheus wording */ + this.RECREATE = this.i18n('Recreate'); + this.EXPIRE = this.i18n('Expire'); } } @@ -164,6 +178,8 @@ export class SucceededActionLabelsI18n { SHOWED: string; TRASHED: string; UNPROTECTED: string; + RECREATED: string; + EXPIRED: string; constructor(private i18n: I18n) { /* Create a new item */ @@ -202,5 +218,9 @@ export class SucceededActionLabelsI18n { this.SHOWED = this.i18n('Showed'); this.TRASHED = this.i18n('Moved to Trash'); this.UNPROTECTED = this.i18n('Unprotected'); + + /* Prometheus wording */ + this.RECREATED = this.i18n('Recreated'); + this.EXPIRED = this.i18n('Expired'); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index cd4d5e69cda07..50578b8d0958a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -34,6 +34,9 @@ export enum Icons { questionCircle = 'fa fa-question-circle-o', check = 'fa fa-check', // Notification check show = 'fa fa-eye', // Show + paragraph = 'fa fa-paragraph', // Silence Matcher - Attribute name + terminal = 'fa fa-terminal', // Silence Matcher - Value + magic = 'fa fa-magic', // Silence Matcher - Regex checkbox hourglass = 'fa fa-hourglass-o', // Task filledHourglass = 'fa fa-hourglass', // Task table = 'fa fa-table', // Table, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts new file mode 100644 index 0000000000000..b7b8862954bae --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts @@ -0,0 +1,23 @@ +export class AlertmanagerSilenceMatcher { + name: string; + value: any; + isRegex: boolean; +} + +export class AlertmanagerSilenceMatcherMatch { + status: string; + cssClass: string; +} + +export class AlertmanagerSilence { + id?: string; + matchers: AlertmanagerSilenceMatcher[]; + startsAt: string; // DateStr + endsAt: string; // DateStr + updatedAt?: string; // DateStr + createdBy: string; + comment: string; + status?: { + state: 'expired' | 'active' | 'pending'; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts index b45147623507b..56a0eb7f605d3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts @@ -1,20 +1,45 @@ -class CommonAlert { +export class PrometheusAlertLabels { + alertname: string; + instance: string; + job: string; + severity: string; +} + +class Annotations { + description: string; + summary: string; +} + +class CommonAlertmanagerAlert { + labels: PrometheusAlertLabels; + annotations: Annotations; + startsAt: string; // Date string + endsAt: string; // Date string + generatorURL: string; +} + +class PrometheusAlert { + labels: PrometheusAlertLabels; + annotations: Annotations; + state: 'pending' | 'firing'; + activeAt: string; // Date string + value: number; +} + +export class PrometheusRule { + name: string; // => PrometheusAlertLabels.alertname + query: string; + duration: 10; labels: { - alertname: string; - instance: string; - job: string; - severity: string; - }; - annotations: { - description: string; - summary: string; + severity: string; // => PrometheusAlertLabels.severity }; - startsAt: string; - endsAt: string; - generatorURL: string; + annotations: Annotations; + alerts: PrometheusAlert[]; // Shows only active alerts + health: string; + type: string; } -export class PrometheusAlert extends CommonAlert { +export class AlertmanagerAlert extends CommonAlertmanagerAlert { status: { state: 'unprocessed' | 'active' | 'suppressed'; silencedBy: null | string[]; @@ -24,18 +49,18 @@ export class PrometheusAlert extends CommonAlert { fingerprint: string; } -export class PrometheusNotificationAlert extends CommonAlert { +export class AlertmanagerNotificationAlert extends CommonAlertmanagerAlert { status: 'firing' | 'resolved'; } -export class PrometheusNotification { +export class AlertmanagerNotification { status: 'firing' | 'resolved'; groupLabels: object; commonAnnotations: object; groupKey: string; notified: string; id: string; - alerts: PrometheusNotificationAlert[]; + alerts: AlertmanagerNotificationAlert[]; version: string; receiver: string; externalURL: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts index 2ae0e8f10b5fe..9f1e7f4c45434 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts @@ -6,9 +6,9 @@ import { Icons } from '../../shared/enum/icons.enum'; import { NotificationType } from '../enum/notification-type.enum'; import { CdNotificationConfig } from '../models/cd-notification'; import { - PrometheusAlert, - PrometheusCustomAlert, - PrometheusNotificationAlert + AlertmanagerAlert, + AlertmanagerNotificationAlert, + PrometheusCustomAlert } from '../models/prometheus-alerts'; import { NotificationService } from './notification.service'; @@ -23,18 +23,18 @@ export class PrometheusAlertFormatter { } convertToCustomAlerts( - alerts: (PrometheusNotificationAlert | PrometheusAlert)[] + alerts: (AlertmanagerNotificationAlert | AlertmanagerAlert)[] ): PrometheusCustomAlert[] { return _.uniqWith( alerts.map((alert) => { return { status: _.isObject(alert.status) - ? (alert as PrometheusAlert).status.state - : this.getPrometheusNotificationStatus(alert as PrometheusNotificationAlert), + ? (alert as AlertmanagerAlert).status.state + : this.getPrometheusNotificationStatus(alert as AlertmanagerNotificationAlert), name: alert.labels.alertname, url: alert.generatorURL, summary: alert.annotations.summary, - fingerprint: _.isObject(alert.status) && (alert as PrometheusAlert).fingerprint + fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint }; }), _.isEqual @@ -44,7 +44,7 @@ export class PrometheusAlertFormatter { /* * This is needed because NotificationAlerts don't use 'active' */ - private getPrometheusNotificationStatus(alert: PrometheusNotificationAlert): string { + private getPrometheusNotificationStatus(alert: AlertmanagerNotificationAlert): string { const state = alert.status; return state === 'firing' ? 'active' : state; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts index 294ac37f884be..902a9dbbf32de 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts @@ -12,7 +12,7 @@ import { import { PrometheusService } from '../api/prometheus.service'; import { NotificationType } from '../enum/notification-type.enum'; import { CdNotificationConfig } from '../models/cd-notification'; -import { PrometheusAlert } from '../models/prometheus-alerts'; +import { AlertmanagerAlert } from '../models/prometheus-alerts'; import { SharedModule } from '../shared.module'; import { NotificationService } from './notification.service'; import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; @@ -21,7 +21,7 @@ import { PrometheusAlertService } from './prometheus-alert.service'; describe('PrometheusAlertService', () => { let service: PrometheusAlertService; let notificationService: NotificationService; - let alerts: PrometheusAlert[]; + let alerts: AlertmanagerAlert[]; let prometheusService: PrometheusService; let prometheus: PrometheusHelper; @@ -38,20 +38,30 @@ describe('PrometheusAlertService', () => { expect(TestBed.get(PrometheusAlertService)).toBeTruthy(); }); - it('tests error case ', () => { - const resp = { status: 500, error: {} }; - service = new PrometheusAlertService(null, { - ifAlertmanagerConfigured: (fn) => fn(), - list: () => ({ subscribe: (_fn, err) => err(resp) }) + describe('test error cases', () => { + const expectDisabling = (status, expectation) => { + let disabledSetting = false; + const resp = { status: status, error: {} }; + service = new PrometheusAlertService(null, ({ + ifAlertmanagerConfigured: (fn) => fn(), + getAlerts: () => ({ subscribe: (_fn, err) => err(resp) }), + disableAlertmanagerConfig: () => (disabledSetting = true) + } as object) as PrometheusService); + service.refresh(); + expect(disabledSetting).toBe(expectation); + }; + + it('disables on 504 error which is thrown if the mgr failed', () => { + expectDisabling(504, true); }); - expect(service['connected']).toBe(true); - service.refresh(); - expect(service['connected']).toBe(false); - expect(resp['application']).toBe('Prometheus'); - expect(resp.error['detail']).toBe( - 'Please check if Prometheus Alertmanager is still running' - ); + it('disables on 404 error which is thrown if the external api cannot be reached', () => { + expectDisabling(404, true); + }); + + it('does not disable on 400 error which is thrown if the external api receives unexpected data', () => { + expectDisabling(400, false); + }); }); describe('refresh', () => { @@ -67,7 +77,7 @@ describe('PrometheusAlertService', () => { prometheusService = TestBed.get(PrometheusService); spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); - spyOn(prometheusService, 'list').and.callFake(() => of(alerts)); + spyOn(prometheusService, 'getAlerts').and.callFake(() => of(alerts)); alerts = [prometheus.createAlert('alert0')]; service.refresh(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts index c7bdb9b1cbe9b..24d26a2cd8290 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import * as _ from 'lodash'; import { PrometheusService } from '../api/prometheus.service'; -import { PrometheusAlert, PrometheusCustomAlert } from '../models/prometheus-alerts'; +import { AlertmanagerAlert, PrometheusCustomAlert } from '../models/prometheus-alerts'; import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; @Injectable({ @@ -11,8 +11,7 @@ import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; }) export class PrometheusAlertService { private canAlertsBeNotified = false; - private connected = true; - alerts: PrometheusAlert[] = []; + alerts: AlertmanagerAlert[] = []; constructor( private alertFormatter: PrometheusAlertFormatter, @@ -20,24 +19,19 @@ export class PrometheusAlertService { ) {} refresh() { - this.prometheusService.ifAlertmanagerConfigured((url) => { - if (this.connected) { - this.prometheusService.list().subscribe( - (alerts) => this.handleAlerts(alerts), - (resp) => { - const errorMsg = `Please check if Prometheus Alertmanager is still running`; - resp['application'] = 'Prometheus'; - if (resp.status === 500) { - this.connected = false; - resp.error.detail = errorMsg; - } + this.prometheusService.ifAlertmanagerConfigured(() => { + this.prometheusService.getAlerts().subscribe( + (alerts) => this.handleAlerts(alerts), + (resp) => { + if ([404, 504].includes(resp.status)) { + this.prometheusService.disableAlertmanagerConfig(); } - ); - } + } + ); }); } - private handleAlerts(alerts: PrometheusAlert[]) { + private handleAlerts(alerts: AlertmanagerAlert[]) { if (this.canAlertsBeNotified) { this.notifyOnAlertChanges(alerts, this.alerts); } @@ -45,7 +39,7 @@ export class PrometheusAlertService { this.canAlertsBeNotified = true; } - private notifyOnAlertChanges(alerts: PrometheusAlert[], oldAlerts: PrometheusAlert[]) { + private notifyOnAlertChanges(alerts: AlertmanagerAlert[], oldAlerts: AlertmanagerAlert[]) { const changedAlerts = this.getChangedAlerts( this.alertFormatter.convertToCustomAlerts(alerts), this.alertFormatter.convertToCustomAlerts(oldAlerts) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts index c5ced809559df..1bc468b2792f7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts @@ -2,7 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ToastModule, ToastsManager } from 'ng2-toastr'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { configureTestBed, @@ -12,7 +12,7 @@ import { import { PrometheusService } from '../api/prometheus.service'; import { NotificationType } from '../enum/notification-type.enum'; import { CdNotificationConfig } from '../models/cd-notification'; -import { PrometheusNotification } from '../models/prometheus-alerts'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; import { SharedModule } from '../shared.module'; import { NotificationService } from './notification.service'; import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; @@ -21,10 +21,11 @@ import { PrometheusNotificationService } from './prometheus-notification.service describe('PrometheusNotificationService', () => { let service: PrometheusNotificationService; let notificationService: NotificationService; - let notifications: PrometheusNotification[]; + let notifications: AlertmanagerNotification[]; let prometheusService: PrometheusService; let prometheus: PrometheusHelper; let shown: CdNotificationConfig[]; + let getNotificationSinceMock: Function; const toastFakeService = { error: () => true, @@ -56,7 +57,8 @@ describe('PrometheusNotificationService', () => { spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn()); prometheusService = TestBed.get(PrometheusService); - spyOn(prometheusService, 'getNotifications').and.callFake(() => of(notifications)); + getNotificationSinceMock = () => of(notifications); + spyOn(prometheusService, 'getNotifications').and.callFake(() => getNotificationSinceMock()); notifications = [prometheus.createNotification()]; }); @@ -90,7 +92,17 @@ describe('PrometheusNotificationService', () => { it('notifies not on the first call', () => { service.refresh(); - expect(notificationService.show).not.toHaveBeenCalled(); + expect(notificationService.save).not.toHaveBeenCalled(); + }); + + it('notifies should not call the api again if it failed once', () => { + getNotificationSinceMock = () => throwError(new Error('Test error')); + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1); + expect(service['backendFailure']).toBe(true); + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1); + service['backendFailure'] = false; }); describe('looks of fired notifications', () => { @@ -183,19 +195,20 @@ describe('PrometheusNotificationService', () => { it('only shows toasties if it got new data', () => { service.refresh(); - expect(notificationService.show).toHaveBeenCalledTimes(1); + expect(notificationService.save).toHaveBeenCalledTimes(1); notifications = []; service.refresh(); service.refresh(); - expect(notificationService.show).toHaveBeenCalledTimes(1); + expect(notificationService.save).toHaveBeenCalledTimes(1); notifications = [prometheus.createNotification()]; service.refresh(); - expect(notificationService.show).toHaveBeenCalledTimes(2); + expect(notificationService.save).toHaveBeenCalledTimes(2); service.refresh(); - expect(notificationService.show).toHaveBeenCalledTimes(3); + expect(notificationService.save).toHaveBeenCalledTimes(3); }); it('filters out duplicated and non user visible changes in notifications', fakeAsync(() => { + asyncRefresh(); // Return 2 notifications with 3 duplicated alerts and 1 non visible changed alert const secondAlert = prometheus.createNotificationAlert('alert0'); secondAlert.endsAt = new Date().toString(); // Should be ignored as it's not visible diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts index 5b4f795bb5f5f..d2b35b2599bd0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts @@ -4,14 +4,15 @@ import * as _ from 'lodash'; import { PrometheusService } from '../api/prometheus.service'; import { CdNotificationConfig } from '../models/cd-notification'; -import { PrometheusNotification } from '../models/prometheus-alerts'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; @Injectable({ providedIn: 'root' }) export class PrometheusNotificationService { - private notifications: PrometheusNotification[]; + private notifications: AlertmanagerNotification[]; + private backendFailure = false; constructor( private alertFormatter: PrometheusAlertFormatter, @@ -21,12 +22,18 @@ export class PrometheusNotificationService { } refresh() { + if (this.backendFailure) { + return; + } this.prometheusService .getNotifications(_.last(this.notifications)) - .subscribe((notifications) => this.handleNotifications(notifications)); + .subscribe( + (notifications) => this.handleNotifications(notifications), + () => (this.backendFailure = true) + ); } - private handleNotifications(notifications: PrometheusNotification[]) { + private handleNotifications(notifications: AlertmanagerNotification[]) { if (notifications.length === 0) { return; } @@ -38,7 +45,7 @@ export class PrometheusNotificationService { this.notifications = this.notifications.concat(notifications); } - private formatNotification(notification: PrometheusNotification): CdNotificationConfig[] { + private formatNotification(notification: AlertmanagerNotification): CdNotificationConfig[] { return this.alertFormatter .convertToCustomAlerts(notification.alerts) .map((alert) => this.alertFormatter.convertAlertToNotification(alert)); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts new file mode 100644 index 0000000000000..684a7daa193d8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts @@ -0,0 +1,133 @@ +import { TestBed } from '@angular/core/testing'; + +import { + configureTestBed, + i18nProviders, + PrometheusHelper +} from '../../../testing/unit-test-helper'; +import { PrometheusRule } from '../models/prometheus-alerts'; +import { SharedModule } from '../shared.module'; +import { PrometheusSilenceMatcherService } from './prometheus-silence-matcher.service'; + +describe('PrometheusSilenceMatcherService', () => { + let service: PrometheusSilenceMatcherService; + let prometheus: PrometheusHelper; + let rules: PrometheusRule[]; + + configureTestBed({ + imports: [SharedModule], + providers: [i18nProviders] + }); + + const addMatcher = (name, value) => ({ + name: name, + value: value, + isRegex: false + }); + + beforeEach(() => { + prometheus = new PrometheusHelper(); + service = TestBed.get(PrometheusSilenceMatcherService); + rules = [ + prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]), + prometheus.createRule('alert1', 'someSeverity', []), + prometheus.createRule('alert2', 'someOtherSeverity', [prometheus.createAlert('alert2')]) + ]; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('test rule matching with one matcher', () => { + const expectSingleMatch = (name, value, helpText, successClass: boolean) => { + const match = service.singleMatch(addMatcher(name, value), rules); + expect(match.status).toBe(helpText); + expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning'); + }; + + it('should match no rule and no alert', () => { + expectSingleMatch( + 'alertname', + 'alert', + 'Your matcher seems to match no currently defined rule or active alert.', + false + ); + }); + + it('should match a rule with no alert', () => { + expectSingleMatch('alertname', 'alert1', 'Matches 1 rule with no active alerts.', false); + }); + + it('should match a rule and an alert', () => { + expectSingleMatch('alertname', 'alert0', 'Matches 1 rule with 1 active alert.', true); + }); + + it('should match multiple rules and an alert', () => { + expectSingleMatch('severity', 'someSeverity', 'Matches 2 rules with 1 active alert.', true); + }); + + it('should match multiple rules and multiple alerts', () => { + expectSingleMatch('job', 'someJob', 'Matches 2 rules with 2 active alerts.', true); + }); + + it('should return any match if regex is checked', () => { + const match = service.singleMatch( + { + name: 'severity', + value: 'someSeverity', + isRegex: true + }, + rules + ); + expect(match).toBeFalsy(); + }); + }); + + describe('test rule matching with multiple matcher', () => { + const expectMultiMatch = (matchers, helpText, successClass: boolean) => { + const match = service.multiMatch(matchers, rules); + expect(match.status).toBe(helpText); + expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning'); + }; + + it('should match no rule and no alert', () => { + expectMultiMatch( + [addMatcher('alertname', 'alert0'), addMatcher('job', 'ceph')], + 'Your matcher seems to match no currently defined rule or active alert.', + false + ); + }); + + it('should match a rule with no alert', () => { + expectMultiMatch( + [addMatcher('severity', 'someSeverity'), addMatcher('alertname', 'alert1')], + 'Matches 1 rule with no active alerts.', + false + ); + }); + + it('should match a rule and an alert', () => { + expectMultiMatch( + [addMatcher('instance', 'someInstance'), addMatcher('alertname', 'alert0')], + 'Matches 1 rule with 1 active alert.', + true + ); + }); + + it('should return any match if regex is checked', () => { + const match = service.multiMatch( + [ + addMatcher('instance', 'someInstance'), + { + name: 'severity', + value: 'someSeverity', + isRegex: true + } + ], + rules + ); + expect(match).toBeFalsy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts new file mode 100644 index 0000000000000..c9bb9729dd8e0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; + +import * as _ from 'lodash'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import { + AlertmanagerSilenceMatcher, + AlertmanagerSilenceMatcherMatch +} from '../models/alertmanager-silence'; +import { PrometheusRule } from '../models/prometheus-alerts'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusSilenceMatcherService { + private valueAttributePath = { + alertname: 'name', + instance: 'alerts.0.labels.instance', + job: 'alerts.0.labels.job', + severity: 'labels.severity' + }; + + constructor(private i18n: I18n) {} + + singleMatch( + matcher: AlertmanagerSilenceMatcher, + rules: PrometheusRule[] + ): AlertmanagerSilenceMatcherMatch { + return this.multiMatch([matcher], rules); + } + + multiMatch( + matchers: AlertmanagerSilenceMatcher[], + rules: PrometheusRule[] + ): AlertmanagerSilenceMatcherMatch { + if (matchers.some((matcher) => matcher.isRegex)) { + return; + } + matchers.forEach((matcher) => { + rules = this.getMatchedRules(matcher, rules); + }); + return this.describeMatch(rules); + } + + private getMatchedRules( + matcher: AlertmanagerSilenceMatcher, + rules: PrometheusRule[] + ): PrometheusRule[] { + const attributePath = this.getAttributePath(matcher.name); + return rules.filter((r) => _.get(r, attributePath) === matcher.value); + } + + private describeMatch(rules: PrometheusRule[]): AlertmanagerSilenceMatcherMatch { + let alerts = 0; + rules.forEach((r) => (alerts += r.alerts.length)); + return { + status: this.getMatchText(rules.length, alerts), + cssClass: alerts ? 'has-success' : 'has-warning' + }; + } + + getAttributePath(name: string): string { + return this.valueAttributePath[name]; + } + + private getMatchText(rules: number, alerts: number): string { + const msg = { + noRule: this.i18n('Your matcher seems to match no currently defined rule or active alert.'), + noAlerts: this.i18n('no active alerts'), + alert: this.i18n('1 active alert'), + alerts: this.i18n('{{n}} active alerts', { n: alerts }), + rule: this.i18n('Matches 1 rule'), + rules: this.i18n('Matches {{n}} rules', { n: rules }) + }; + return rules + ? this.i18n('{{rules}} with {{alerts}}.', { + rules: rules > 1 ? msg.rules : msg.rule, + alerts: alerts ? (alerts > 1 ? msg.alerts : msg.alert) : msg.noAlerts + }) + : msg.noRule; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts index fa8bde77910ef..bc8b54ca38148 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts @@ -25,6 +25,11 @@ describe('URLBuilderService', () => { expect(urlBuilder.getCreate()).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}`); }); + it('get Create From URL', () => { + const id = 'someId'; + expect(urlBuilder.getCreateFrom(id)).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}/${id}`); + }); + it('get Edit URL with item', () => { const item = 'test_pool'; expect(urlBuilder.getEdit(item)).toBe(`/${urlBuilder.base}/${URLVerbs.EDIT}/${item}`); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts index 15684597e5a4e..b06f307ad2e07 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts @@ -20,6 +20,11 @@ export class URLBuilderService { getCreate(absolute = true): string { return this.getURL(URLVerbs.CREATE, absolute); } + + getCreateFrom(item: string, absolute = true): string { + return this.getURL(URLVerbs.CREATE, absolute, item); + } + getDelete(absolute = true): string { return this.getURL(URLVerbs.DELETE, absolute); } @@ -37,4 +42,9 @@ export class URLBuilderService { getRemove(absolute = true): string { return this.getURL(URLVerbs.REMOVE, absolute); } + + // Prometheus wording + getRecreate(item: string, absolute = true): string { + return this.getURL(URLVerbs.RECREATE, absolute, item); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts index 9e73ff094128c..18888b48aa887 100644 --- a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts +++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts @@ -11,9 +11,10 @@ import { Icons } from '../app/shared/enum/icons.enum'; import { CdFormGroup } from '../app/shared/forms/cd-form-group'; import { Permission } from '../app/shared/models/permissions'; import { - PrometheusAlert, - PrometheusNotification, - PrometheusNotificationAlert + AlertmanagerAlert, + AlertmanagerNotification, + AlertmanagerNotificationAlert, + PrometheusRule } from '../app/shared/models/prometheus-alerts'; import { _DEV_ } from '../unit-test-configuration'; @@ -217,22 +218,52 @@ export class FixtureHelper { } export class PrometheusHelper { - createAlert(name, state = 'active', timeMultiplier = 1) { + createSilence(id) { + return { + id: id, + createdBy: `Creator of ${id}`, + comment: `A comment for ${id}`, + startsAt: new Date('2022-02-22T22:22:00').toISOString(), + endsAt: new Date('2022-02-23T22:22:00').toISOString(), + matchers: [ + { + name: 'job', + value: 'someJob', + isRegex: true + } + ] + }; + } + + createRule(name, severity, alerts: any[]): PrometheusRule { + return { + name: name, + labels: { + severity: severity + }, + alerts: alerts + } as PrometheusRule; + } + + createAlert(name, state = 'active', timeMultiplier = 1): AlertmanagerAlert { return { fingerprint: name, status: { state }, labels: { - alertname: name + alertname: name, + instance: 'someInstance', + job: 'someJob', + severity: 'someSeverity' }, annotations: { summary: `${name} is ${state}` }, generatorURL: `http://${name}`, startsAt: new Date(new Date('2022-02-22').getTime() * timeMultiplier).toString() - } as PrometheusAlert; + } as AlertmanagerAlert; } - createNotificationAlert(name, status = 'firing') { + createNotificationAlert(name, status = 'firing'): AlertmanagerNotificationAlert { return { status: status, labels: { @@ -242,15 +273,15 @@ export class PrometheusHelper { summary: `${name} is ${status}` }, generatorURL: `http://${name}` - } as PrometheusNotificationAlert; + } as AlertmanagerNotificationAlert; } - createNotification(alertNumber = 1, status = 'firing') { + createNotification(alertNumber = 1, status = 'firing'): AlertmanagerNotification { const alerts = []; for (let i = 0; i < alertNumber; i++) { alerts.push(this.createNotificationAlert('alert' + i, status)); } - return { alerts, status } as PrometheusNotification; + return { alerts, status } as AlertmanagerNotification; } createLink(url) { diff --git a/src/pybind/mgr/dashboard/settings.py b/src/pybind/mgr/dashboard/settings.py index 6b47ff731d580..8b57d1bed4c17 100644 --- a/src/pybind/mgr/dashboard/settings.py +++ b/src/pybind/mgr/dashboard/settings.py @@ -45,7 +45,7 @@ class Options(object): GANESHA_CLUSTERS_RADOS_POOL_NAMESPACE = ('', str) # Prometheus settings - PROMETHEUS_API_HOST = ('', str) # Not in use ATM + PROMETHEUS_API_HOST = ('', str) ALERTMANAGER_API_HOST = ('', str) # iSCSI management settings diff --git a/src/pybind/mgr/dashboard/tests/test_prometheus.py b/src/pybind/mgr/dashboard/tests/test_prometheus.py index 1f9d79588b2db..73dedbab843e1 100644 --- a/src/pybind/mgr/dashboard/tests/test_prometheus.py +++ b/src/pybind/mgr/dashboard/tests/test_prometheus.py @@ -1,32 +1,70 @@ # -*- coding: utf-8 -*- # pylint: disable=protected-access +from mock import patch + from . import ControllerTestCase from .. import mgr -from ..controllers import BaseController, Controller from ..controllers.prometheus import Prometheus, PrometheusReceiver, PrometheusNotifications -@Controller('alertmanager/mocked/api/v1/alerts', secure=False) -class AlertManagerMockInstance(BaseController): - def __call__(self, path, **params): - return 'Some Api {}'.format(path) +class PrometheusControllerTest(ControllerTestCase): + alert_host = 'http://alertmanager:9093/mock' + alert_host_api = alert_host + '/api/v1' + prometheus_host = 'http://prometheus:9090/mock' + prometheus_host_api = prometheus_host + '/api/v1' -class PrometheusControllerTest(ControllerTestCase): @classmethod def setup_server(cls): settings = { - 'ALERTMANAGER_API_HOST': 'http://localhost:{}/alertmanager/mocked/'.format(54583) + 'ALERTMANAGER_API_HOST': cls.alert_host, + 'PROMETHEUS_API_HOST': cls.prometheus_host } mgr.get_module_option.side_effect = settings.get Prometheus._cp_config['tools.authenticate.on'] = False PrometheusNotifications._cp_config['tools.authenticate.on'] = False - cls.setup_controllers([AlertManagerMockInstance, Prometheus, - PrometheusNotifications, PrometheusReceiver]) + cls.setup_controllers([Prometheus, PrometheusNotifications, PrometheusReceiver]) + + def test_rules(self): + with patch('requests.request') as mock_request: + self._get('/api/prometheus/rules') + mock_request.assert_called_with('GET', self.prometheus_host_api + '/rules', + json=None, params={}) def test_list(self): - self._get('/api/prometheus') - self.assertStatus(200) + with patch('requests.request') as mock_request: + self._get('/api/prometheus') + mock_request.assert_called_with('GET', self.alert_host_api + '/alerts', + json=None, params={}) + + def test_get_silences(self): + with patch('requests.request') as mock_request: + self._get('/api/prometheus/silences') + mock_request.assert_called_with('GET', self.alert_host_api + '/silences', + json=None, params={}) + + def test_add_silence(self): + with patch('requests.request') as mock_request: + self._post('/api/prometheus/silence', {'id': 'new-silence'}) + mock_request.assert_called_with('POST', self.alert_host_api + '/silences', + params=None, json={'id': 'new-silence'}) + + def test_update_silence(self): + with patch('requests.request') as mock_request: + self._post('/api/prometheus/silence', {'id': 'update-silence'}) + mock_request.assert_called_with('POST', self.alert_host_api + '/silences', + params=None, json={'id': 'update-silence'}) + + def test_expire_silence(self): + with patch('requests.request') as mock_request: + self._delete('/api/prometheus/silence/0') + mock_request.assert_called_with('DELETE', self.alert_host_api + '/silence/0', + json=None, params=None) + + def test_silences_empty_delete(self): + with patch('requests.request') as mock_request: + self._delete('/api/prometheus/silence') + mock_request.assert_not_called() def test_post_on_receiver(self): PrometheusReceiver.notifications = [] @@ -85,6 +123,6 @@ class PrometheusControllerTest(ControllerTestCase): self._post('/api/prometheus_receiver', {'name': 'foo'}) self._post('/api/prometheus_receiver', {'name': 'bar'}) self._get('/api/prometheus/notifications?from=' + next_to_last['id']) - foreLast = PrometheusReceiver.notifications[1] + forelast = PrometheusReceiver.notifications[1] last = PrometheusReceiver.notifications[2] - self.assertEqual(self.jsonBody(), [foreLast, last]) + self.assertEqual(self.jsonBody(), [forelast, last]) -- 2.39.5