]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Silence Alertmanager alerts
authorStephan Müller <smueller@suse.com>
Fri, 22 Mar 2019 16:44:00 +0000 (17:44 +0100)
committerStephan Müller <smueller@suse.com>
Thu, 4 Jul 2019 14:19:26 +0000 (16:19 +0200)
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 <smueller@suse.com>
47 files changed:
doc/mgr/dashboard.rst
src/pybind/mgr/dashboard/controllers/prometheus.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-tabs/prometheus-tabs.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-form/silence-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-list/silence-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/silence-matcher-modal/silence-matcher-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts
src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
src/pybind/mgr/dashboard/settings.py
src/pybind/mgr/dashboard/tests/test_prometheus.py

index 32087211b5b331f0b3178125b4399c307960a555..e26fb25a06874519bdd2da20bb6623c184a09303 100644 (file)
@@ -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 `<http_config> documentation
    <https://prometheus.io/docs/alerting/configuration/#%3Chttp_config%3E>`_.
 
-#. 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 <prometheus-host:port>  # 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
 
index b80fc05c4bc057d71f96d543ff3dfdca8f03938a..4145fbdfd8ca0518e7559276efa9469e90c47364 100644 (file)
@@ -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)
index 12250fddcc52c3f845b6f2920fda82a57a8a59ec..73dbc4088f50f11d144a6c982c3000a7486ccf30 100644 (file)
@@ -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,
index 4f19f14e8db9d56ef39703318188f15f0ff5d16a..20f63d2226606f55508832ea212fe7e5dde39a65 100644 (file)
@@ -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/alert-list/alert-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.html
new file mode 100644 (file)
index 0000000..98de284
--- /dev/null
@@ -0,0 +1,33 @@
+<cd-prometheus-tabs></cd-prometheus-tabs>
+
+<cd-table [data]="prometheusAlertService.alerts"
+          [columns]="columns"
+          identifier="fingerprint"
+          [forceIdentifier]="true"
+          [customCss]="customCss"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
+  <tabset cdTableDetail *ngIf="selection.hasSingleSelection">
+    <tab i18n-heading
+         heading="Details">
+      <cd-table-key-value [renderObjects]="true"
+                          [hideEmpty]="true"
+                          [appendParentKey]="false"
+                          [data]="selection.first()"
+                          [customCss]="customCss"
+                          [autoReload]="false">
+      </cd-table-key-value>
+    </tab>
+  </tabset>
+</cd-table>
+
+<ng-template #externalLinkTpl
+             let-row="row"
+             let-value="value">
+  <a [href]="value" target="_blank"><i [ngClass]="[icons.lineChart]"></i> Source</a>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
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 (file)
index 0000000..74c4444
--- /dev/null
@@ -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<AlertListComponent>;
+
+  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/alert-list/alert-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/alert-list/alert-list.component.ts
new file mode 100644 (file)
index 0000000..927f3cf
--- /dev/null
@@ -0,0 +1,96 @@
+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',
+  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
+  templateUrl: './alert-list.component.html',
+  styleUrls: ['./alert-list.component.scss']
+})
+export class AlertListComponent implements OnInit {
+  @ViewChild('externalLinkTpl')
+  externalLinkTpl: TemplateRef<any>;
+  columns: CdTableColumn[];
+  tableActions: CdTableAction[];
+  permission: Permission;
+  selection = new CdTableSelection();
+  icons = Icons;
+  customCss = {
+    'label label-danger': 'active',
+    'label label-warning': 'unprocessed',
+    'label label-info': 'suppressed'
+  };
+
+  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 = [
+      {
+        name: this.i18n('Name'),
+        prop: 'labels.alertname',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Job'),
+        prop: 'labels.job',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Severity'),
+        prop: 'labels.severity'
+      },
+      {
+        name: this.i18n('State'),
+        prop: 'status.state',
+        cellTransformation: CellTemplate.classAdding
+      },
+      {
+        name: this.i18n('Started'),
+        prop: 'startsAt',
+        pipe: this.cdDatePipe
+      },
+      {
+        name: this.i18n('URL'),
+        prop: 'generatorURL',
+        sortable: false,
+        cellTemplate: this.externalLinkTpl
+      }
+    ];
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+}
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/prometheus-list/prometheus-list.component.html
deleted file mode 100644 (file)
index 94c5930..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<cd-table [data]="prometheusAlertService.alerts"
-          [columns]="columns"
-          identifier="fingerprint"
-          [forceIdentifier]="true"
-          [customCss]="customCss"
-          selectionType="single"
-          (updateSelection)="updateSelection($event)">
-  <tabset cdTableDetail *ngIf="selection.hasSingleSelection">
-    <tab i18n-heading
-         heading="Details">
-      <cd-table-key-value [renderObjects]="true"
-                          [hideEmpty]="true"
-                          [appendParentKey]="false"
-                          [data]="selection.first()"
-                          [customCss]="customCss"
-                          [autoReload]="false">
-      </cd-table-key-value>
-    </tab>
-  </tabset>
-</cd-table>
-
-<ng-template #externalLinkTpl
-             let-row="row"
-             let-value="value">
-  <a [href]="value" target="_blank"><i [ngClass]="[icons.lineChart]"></i> Source</a>
-</ng-template>
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/prometheus-list/prometheus-list.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
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 (file)
index 7901a05..0000000
+++ /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<PrometheusListComponent>;
-
-  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-list/prometheus-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/prometheus-list/prometheus-list.component.ts
deleted file mode 100644 (file)
index a2138d8..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-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 { CdTableColumn } from '../../../../shared/models/cd-table-column';
-import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
-import { CdDatePipe } from '../../../../shared/pipes/cd-date.pipe';
-import { PrometheusAlertService } from '../../../../shared/services/prometheus-alert.service';
-
-@Component({
-  selector: 'cd-prometheus-list',
-  templateUrl: './prometheus-list.component.html',
-  styleUrls: ['./prometheus-list.component.scss']
-})
-export class PrometheusListComponent implements OnInit {
-  @ViewChild('externalLinkTpl')
-  externalLinkTpl: TemplateRef<any>;
-  columns: CdTableColumn[];
-  selection = new CdTableSelection();
-  icons = Icons;
-  customCss = {
-    'label label-danger': 'active',
-    'label label-warning': 'unprocessed',
-    'label label-info': 'suppressed'
-  };
-
-  constructor(
-    // NotificationsComponent will refresh all alerts every 5s (No need to do it here as well)
-    public prometheusAlertService: PrometheusAlertService,
-    private i18n: I18n,
-    private cdDatePipe: CdDatePipe
-  ) {}
-
-  ngOnInit() {
-    this.columns = [
-      {
-        name: this.i18n('Name'),
-        prop: 'labels.alertname',
-        flexGrow: 2
-      },
-      {
-        name: this.i18n('Job'),
-        prop: 'labels.job',
-        flexGrow: 2
-      },
-      {
-        name: this.i18n('Severity'),
-        prop: 'labels.severity'
-      },
-      {
-        name: this.i18n('State'),
-        prop: 'status.state',
-        cellTransformation: CellTemplate.classAdding
-      },
-      {
-        name: this.i18n('Started'),
-        prop: 'startsAt',
-        pipe: this.cdDatePipe
-      },
-      {
-        name: this.i18n('URL'),
-        prop: 'generatorURL',
-        sortable: false,
-        cellTemplate: this.externalLinkTpl
-      }
-    ];
-  }
-
-  updateSelection(selection: CdTableSelection) {
-    this.selection = selection;
-  }
-}
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 (file)
index 0000000..b2f530a
--- /dev/null
@@ -0,0 +1,13 @@
+<tabset>
+  <tab heading="Alerts"
+       i18n-heading
+       [active]="url === '/alerts'"
+       (select)="navigateTo('/alerts')">
+  </tab>
+  <tab heading="Silences"
+       i18n-heading
+       [active]="url === '/silence'"
+       (select)="navigateTo('/silence')">
+  </tab>
+</tabset>
+
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..65cc58d
--- /dev/null
@@ -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<PrometheusTabsComponent>;
+  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 (file)
index 0000000..5675eb7
--- /dev/null
@@ -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 (file)
index 0000000..0050c87
--- /dev/null
@@ -0,0 +1,219 @@
+<ng-template #matcherTpl let-matcher="matcher" let-index="index">
+  <div class="input-group">
+    <ng-container *ngFor="let config of matcherConfig">
+      <span class="input-group-addon"
+            [tooltip]=config.tooltip>
+        <i class="icon-prepend" [ngClass]="[config.icon]"></i>
+      </span>
+      <ng-container *ngIf="config.attribute !== 'isRegex'">
+        <input type="text"
+               id="matcher-{{config.attribute}}-{{index}}"
+               class="form-control"
+               [value]="matcher[config.attribute]"
+               disabled
+               readonly>
+      </ng-container>
+      <ng-container *ngIf="config.attribute === 'isRegex'">
+        <span class="input-group-addon">
+          <input type="checkbox"
+                 id="matcher-{{config.attribute}}-{{index}}"
+                 [checked]="matcher[config.attribute]"
+                 disabled
+                 readonly>
+        </span>
+      </ng-container>
+    </ng-container>
+    <!-- Matcher actions -->
+    <span class="input-group-btn">
+      <button type="button"
+              class="btn btn-default"
+              id="matcher-edit-{{index}}"
+              i18n-tooltip
+              tooltip="Edit"
+              (click)="showMatcherModal(index)">
+        <i [ngClass]="[icons.edit]"></i>
+      </button>
+      <button type="button"
+              class="btn btn-default"
+              id="matcher-delete-{{index}}"
+              i18n-tooltip
+              tooltip="Delete"
+              (click)="deleteMatcher(index)">
+        <i [ngClass]="[icons.trash]"></i>
+      </button>
+    </span>
+  </div>
+  <span class="help-block"></span>
+</ng-template>
+
+<div class="col-sm-12 col-lg-6">
+  <form #formDir="ngForm"
+        [formGroup]="form"
+        class="form-horizontal"
+        name="form"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          <span i18n="@@formTitle">
+            {{ action | titlecase }} {{ resource | upperFirst }}
+          </span>
+          <cd-helper *ngIf="edit"
+                     i18n>Editing a silence will expire the old silence and recreate it as a new silence</cd-helper>
+        </h3>
+      </div>
+
+      <!-- Creator -->
+      <div class="panel-body">
+        <div [ngClass]="{'has-error': form.showError('createdBy', formDir)}"
+             class="form-group">
+          <label class="control-label col-sm-3"
+                 for="created-by">
+            <ng-container i18n>Creator</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input class="form-control"
+                   formControlName="createdBy"
+                   id="created-by"
+                   name="created-by"
+                   type="text">
+            <span *ngIf="form.showError('createdBy', formDir, 'required')"
+                  class="help-block"
+                  i18n>This field is required!</span>
+          </div>
+        </div>
+
+        <!-- Comment -->
+        <div [ngClass]="{'has-error': form.showError('comment', formDir)}"
+             class="form-group">
+          <label class="control-label col-sm-3"
+                 for="comment">
+            <ng-container i18n>Comment</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <textarea class="form-control"
+                      formControlName="comment"
+                      id="comment"
+                      name="comment"
+                      type="text">
+            </textarea>
+            <span *ngIf="form.showError('comment', formDir, 'required')"
+                  class="help-block"
+                  i18n>This field is required!</span>
+          </div>
+        </div>
+
+        <!-- Start time -->
+        <div [ngClass]="{'has-error': form.showError('startsAt', formDir)}"
+             class="form-group">
+          <label class="control-label col-sm-3"
+                 for="starts-at">
+            <ng-container i18n>Start time</ng-container>
+            <cd-helper i18n>If the start time lies in the past the creation time will be used</cd-helper>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input [bsConfig]="bsConfig"
+                   bsDatepicker
+                   class="form-control"
+                   formControlName="startsAt"
+                   id="starts-at"
+                   name="starts-at">
+            <span *ngIf="form.showError('startsAt', formDir, 'required')"
+                  class="help-block"
+                  i18n>This field is required!</span>
+          </div>
+        </div>
+
+        <!-- Duration -->
+        <div [ngClass]="{'has-error': form.showError('duration', formDir)}"
+             class="form-group">
+          <label class="control-label col-sm-3"
+                 for="duration">
+            <ng-container i18n>Duration</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input class="form-control"
+                   formControlName="duration"
+                   id="duration"
+                   name="duration"
+                   type="text">
+            <span *ngIf="form.showError('duration', formDir, 'required')"
+                  class="help-block"
+                  i18n>This field is required!</span>
+          </div>
+        </div>
+
+        <!-- End time -->
+        <div [ngClass]="{'has-error': form.showError('endsAt', formDir)}"
+             class="form-group">
+          <label class="control-label col-sm-3"
+                 for="ends-at">
+            <ng-container i18n>End time</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input [bsConfig]="bsConfig"
+                   bsDatepicker
+                   class="form-control"
+                   formControlName="endsAt"
+                   id="ends-at"
+                   name="ends-at">
+            <span *ngIf="form.showError('endsAt', formDir, 'required')"
+                  class="help-block"
+                  i18n>This field is required!</span>
+          </div>
+        </div>
+
+        <!-- Matchers -->
+        <fieldset>
+          <legend i18n>Matchers<span class="required">*</span></legend>
+          <div class="col-sm-offset-3 col-sm-9">
+            <h5 *ngIf="matchers.length === 0"
+                [ngClass]="{'text-warning': !formDir.submitted, 'text-danger': formDir.submitted}">
+              <strong i18n>A silence requires at least one matcher</strong>
+            </h5>
+
+            <span *ngFor="let matcher of matchers; let i=index;">
+              <ng-container *ngTemplateOutlet="matcherTpl; context:{index: i, matcher: matcher}"></ng-container>
+            </span>
+
+            <span class="form-control no-border">
+              <button type="button"
+                      id="add-matcher"
+                      class="btn btn-sm btn-default btn-label pull-right"
+                      [ngClass]="{'btn-warning': formDir.submitted && matchers.length === 0 }"
+                      (click)="showMatcherModal()">
+                <i [ngClass]="[icons.width, icons.add]"></i>
+                <ng-container i18n>Add matcher</ng-container>
+              </button>
+            </span>
+          </div>
+          <div *ngIf="matchers.length && matcherMatch"
+               class="col-sm-offset-3 col-sm-9 {{matcherMatch.cssClass}}"
+               id="match-state">
+            <span class="help-block {{matcherMatch.cssClass}}">
+              {{ matcherMatch.status }}
+            </span>
+          </div>
+        </fieldset>
+      </div>
+
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button (submitAction)="submit()"
+                            [form]="formDir"
+                            id="submit"
+                            i18n="@@formTitle"
+                            type="button">
+            {{ action | titlecase }} {{ resource | upperFirst }}
+          </cd-submit-button>
+          <cd-back-button></cd-back-button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
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 (file)
index 0000000..fb52450
--- /dev/null
@@ -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 (file)
index 0000000..7532c55
--- /dev/null
@@ -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<SilenceFormComponent>;
+  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 (file)
index 0000000..a9eb0ae
--- /dev/null
@@ -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 (file)
index 0000000..f67f127
--- /dev/null
@@ -0,0 +1,29 @@
+<cd-prometheus-tabs></cd-prometheus-tabs>
+
+<cd-table [data]="silences"
+          [columns]="columns"
+          [forceIdentifier]="true"
+          [customCss]="customCss"
+          [sorts]="sorts"
+          selectionType="single"
+          (fetchData)="refresh()"
+          (updateSelection)="updateSelection($event)">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
+  <tabset cdTableDetail *ngIf="selection.hasSingleSelection">
+    <tab i18n-heading
+         heading="Details">
+      <cd-table-key-value [renderObjects]="true"
+                          [hideEmpty]="true"
+                          [appendParentKey]="false"
+                          [data]="selection.first()"
+                          [customCss]="customCss"
+                          [autoReload]="false">
+      </cd-table-key-value>
+    </tab>
+  </tabset>
+</cd-table>
+
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..cb1b68e
--- /dev/null
@@ -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<SilenceListComponent>;
+  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 (file)
index 0000000..9c8360e
--- /dev/null
@@ -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<any>) => {
+            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 (file)
index 0000000..c0ad6ac
--- /dev/null
@@ -0,0 +1,98 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>Matcher</h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+
+<form class="form-horizontal"
+      #formDir="ngForm"
+      [formGroup]="form"
+      novalidate>
+  <div class="modal-body">
+    <!-- Name -->
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('name', formDir)}">
+      <label class="control-label col-sm-3"
+             for="name">
+        <ng-container i18n>Name</ng-container>
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="name"
+                formControlName="name"
+                name="name">
+          <option [ngValue]="null"
+                  i18n>-- Select an attribute to match against --</option>
+          <option *ngFor="let attribute of nameAttributes"
+                  [value]="attribute">
+            {{ attribute }}
+          </option>
+        </select>
+        <span class="help-block"
+              *ngIf="form.showError('name', formDir, 'required')"
+              i18n>This field is required!</span>
+      </div>
+    </div>
+
+    <!-- Value -->
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('value', formDir)}">
+      <label class="control-label col-sm-3"
+             for="value">
+        <ng-container i18n>Value</ng-container>
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <input id="value"
+               class="form-control"
+               type="text"
+               [typeahead]="possibleValues"
+               [typeaheadMinLength]="0"
+               formControlName="value">
+        <span *ngIf="form.showError('value', formDir, 'required')"
+              class="help-block"
+              i18n>This field is required!</span>
+      </div>
+      <div *ngIf="form.getValue('value') && !form.getValue('isRegex') && matcherMatch"
+           class="col-sm-offset-3 col-sm-9 {{matcherMatch.cssClass}}"
+           id="match-state">
+        <span class="help-block {{matcherMatch.cssClass}}">
+          {{matcherMatch.status}}
+        </span>
+      </div>
+    </div>
+
+    <!-- isRegex -->
+    <div class="form-group">
+      <div class="col-sm-offset-3 col-sm-9">
+        <div class="input-group">
+          <div class="checkbox checkbox-primary">
+            <input id="is-regex"
+                   type="checkbox"
+                   formControlName="isRegex">
+            <label for="is-regex"
+                   i18n>Use regular expression</label>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="modal-footer">
+    <cd-submit-button (submitAction)="onSubmit()"
+                      [form]="form">
+      <ng-container i18n>{editMode, select, 1 {Update} other {Add}}</ng-container>
+    </cd-submit-button>
+    <cd-back-button [back]="bsModalRef.hide"
+                    name="Close"
+                    i18n-name>
+    </cd-back-button>
+  </div>
+</form>
+
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..cd8fa28
--- /dev/null
@@ -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<SilenceMatcherModalComponent>;
+
+  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 (file)
index 0000000..d04537f
--- /dev/null
@@ -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();
+  }
+}
index 5ee7508d3712a409af9066e230aa1d11d083f8ac..f2dbbca4ef2aa611f1a913db2821c4998beda8d1 100644 (file)
             <a i18n
                routerLink="/alerts">Alerts</a>
           </li>
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_prometheus"
+              *ngIf="prometheusConfigured && permissions.prometheus.read">
+            <a i18n
+               routerLink="/silence">Silences</a>
+          </li>
         </ul>
       </li>
 
index ed3edc9fe013d4d21fa48741dec54b5f915b13eb..db14f4679c1128cc7bf862625c73397092e76005 100644 (file)
@@ -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);
     });
   });
index e17c5cf84f538bb12b06946bdc3c47de9b1f6567..bdcd7fd35f526e109c9741d20ccdee872ac54b41 100644 (file)
@@ -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<PrometheusAlert[]> {
-    return this.http.get<PrometheusAlert[]>(this.baseURL, { params });
+  disableAlertmanagerConfig(): void {
+    this.settingsService.disableSetting(this.settingsKey.alertmanager);
   }
 
-  getNotifications(notification?: PrometheusNotification): Observable<PrometheusNotification[]> {
+  ifPrometheusConfigured(fn, elseFn?): void {
+    this.settingsService.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn);
+  }
+
+  disablePrometheusConfig(): void {
+    this.settingsService.disableSetting(this.settingsKey.prometheus);
+  }
+
+  getAlerts(params = {}): Observable<AlertmanagerAlert[]> {
+    return this.http.get<AlertmanagerAlert[]>(this.baseURL, { params });
+  }
+
+  getSilences(params = {}): Observable<AlertmanagerSilence[]> {
+    return this.http.get<AlertmanagerSilence[]>(`${this.baseURL}/silences`, { params });
+  }
+
+  getRules(params = {}): Observable<PrometheusRule[]> {
+    return this.http.get<PrometheusRule[]>(`${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<AlertmanagerNotification[]> {
     const url = `${this.baseURL}/notifications?from=${
       notification && notification.id ? notification.id : 'last'
     }`;
-    return this.http.get<PrometheusNotification[]>(url);
+    return this.http.get<AlertmanagerNotification[]>(url);
   }
 }
index b8762e15ac35456a991ad33311ea85b93afe8545..e3ca443d26144a7f7e3cbabb1183c3d043ec4de5 100644 (file)
@@ -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');
   }
 }
index cd4d5e69cda07cc0c72032df1ba343cdeb028f9c..50578b8d0958a73ca777cdac9a49aa8d6f1162c1 100644 (file)
@@ -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 (file)
index 0000000..b7b8862
--- /dev/null
@@ -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';
+  };
+}
index b45147623507be97115ab318307a1ec12e715ad5..56a0eb7f605d35d6a2f55f820cf8f55284eba2f4 100644 (file)
@@ -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;
index 2ae0e8f10b5fea18bc7ffa6ecf3fd2bc64f9b4ce..9f1e7f4c454349dfe0d5c45740e17dfc839ce178 100644 (file)
@@ -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;
   }
index 294ac37f884bee591468f07248d3c71238e2822d..902a9dbbf32de3515b1b5360430b7166206c4428 100644 (file)
@@ -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, <PrometheusService>{
-      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 <a target="_blank" href="undefined">Prometheus Alertmanager</a> 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();
index c7bdb9b1cbe9b9f4431eae09025cfc38384a8a7f..24d26a2cd8290c0bb5a3dd6058486950d95604c4 100644 (file)
@@ -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 <a target="_blank" href="${url}">Prometheus Alertmanager</a> 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)
index c5ced809559df47a75f73b864044f5c3b808dff7..1bc468b2792f765d6b8519bfa81d75ab45f7ec06 100644 (file)
@@ -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
index 5b4f795bb5f5f80ba22c116905bb8df68be14403..d2b35b2599bd0c9058f3b6e0d30b665cf3b8d5d8 100644 (file)
@@ -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 (file)
index 0000000..684a7da
--- /dev/null
@@ -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 (file)
index 0000000..c9bb972
--- /dev/null
@@ -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;
+  }
+}
index fa8bde77910ef20286c2cbd2eecc0231bd56a1b6..bc8b54ca38148a6e861b0333a3b05584bd287c3e 100644 (file)
@@ -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}`);
index 15684597e5a4ef6c75d3a65c1f7775168e971aa9..b06f307ad2e077045875e3f55985514a208461c5 100644 (file)
@@ -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);
+  }
 }
index 9e73ff094128cab935206933355464a766edd9f3..18888b48aa887274dc127770fff9beab000d7533 100644 (file)
@@ -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) {
index 6b47ff731d5802940482392acac443706f6e1378..8b57d1bed4c17d1bff634338a57ffd614a2912e9 100644 (file)
@@ -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
index 1f9d79588b2db010c278a7ab983cd4b8fc729a62..73dedbab843e1cd5bcf8c40e65d3411fa7f23b50 100644 (file)
@@ -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])