#. 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:
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::
$ 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
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)
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)
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';
},
{
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,
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';
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: [
OsdFlagsModalComponent,
OsdRecvSpeedModalComponent,
OsdReweightModalComponent,
- OsdPgScrubModalComponent
+ OsdPgScrubModalComponent,
+ OsdReweightModalComponent,
+ SilenceMatcherModalComponent
],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
BsDropdownModule.forRoot(),
+ BsDatepickerModule.forRoot(),
ModalModule.forRoot(),
AlertModule.forRoot(),
TooltipModule.forRoot(),
TreeModule,
MgrModulesModule,
+ TypeaheadModule.forRoot(),
TimepickerModule.forRoot(),
BsDatepickerModule.forRoot()
],
OsdReweightModalComponent,
CrushmapComponent,
LogsComponent,
- PrometheusListComponent,
OsdRecvSpeedModalComponent,
- OsdPgScrubModalComponent
+ OsdPgScrubModalComponent,
+ AlertListComponent,
+ OsdRecvSpeedModalComponent,
+ SilenceFormComponent,
+ SilenceListComponent,
+ PrometheusTabsComponent,
+ SilenceMatcherModalComponent
]
})
export class ClusterModule {}
--- /dev/null
+<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>
--- /dev/null
+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([]);
+ });
+ });
+ });
+ });
+});
--- /dev/null
+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;
+ }
+}
+++ /dev/null
-<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>
+++ /dev/null
-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();
- });
-});
+++ /dev/null
-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;
- }
-}
--- /dev/null
+<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>
+
--- /dev/null
+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']);
+ });
+});
--- /dev/null
+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]);
+ }
+}
--- /dev/null
+<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>
--- /dev/null
+textarea {
+ resize: vertical;
+}
--- /dev/null
+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');
+ });
+ });
+});
--- /dev/null
+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}`;
+ }
+}
--- /dev/null
+<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>
+
--- /dev/null
+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);
+ });
+ });
+});
--- /dev/null
+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();
+ }
+ );
+ })
+ }
+ });
+ }
+}
--- /dev/null
+<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">×</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>
+
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+ }
+}
<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>
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';
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');
});
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);
});
});
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';
})
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);
}
}
/* Non-standard verbs */
COPY = 'copy',
- CLONE = 'clone'
+ CLONE = 'clone',
+
+ /* Prometheus wording */
+ RECREATE = 'recreate',
+ EXPIRE = 'expire'
}
export enum ActionLabels {
CLONE = 'Clone',
/* Read-only */
- SHOW = 'Show'
+ SHOW = 'Show',
+
+ /* Prometheus wording */
+ RECREATE = 'Recreate',
+ EXPIRE = 'Expire'
}
@Injectable({
SHOW: string;
TRASH: string;
UNPROTECT: string;
+ RECREATE: string;
+ EXPIRE: string;
constructor(private i18n: I18n) {
/* Create a new item */
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');
}
}
SHOWED: string;
TRASHED: string;
UNPROTECTED: string;
+ RECREATED: string;
+ EXPIRED: string;
constructor(private i18n: I18n) {
/* Create a new item */
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');
}
}
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,
--- /dev/null
+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';
+ };
+}
-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[];
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;
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';
}
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
/*
* 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;
}
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';
describe('PrometheusAlertService', () => {
let service: PrometheusAlertService;
let notificationService: NotificationService;
- let alerts: PrometheusAlert[];
+ let alerts: AlertmanagerAlert[];
let prometheusService: PrometheusService;
let prometheus: PrometheusHelper;
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', () => {
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();
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({
})
export class PrometheusAlertService {
private canAlertsBeNotified = false;
- private connected = true;
- alerts: PrometheusAlert[] = [];
+ alerts: AlertmanagerAlert[] = [];
constructor(
private alertFormatter: PrometheusAlertFormatter,
) {}
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);
}
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)
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,
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';
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,
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()];
});
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', () => {
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
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,
}
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;
}
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));
--- /dev/null
+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();
+ });
+ });
+});
--- /dev/null
+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;
+ }
+}
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}`);
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);
}
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);
+ }
}
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';
}
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: {
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) {
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
# -*- 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 = []
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])