Merge pull request #5 from prometheus/refactor/editable-silences

Implement silence create/read/update/delete API and UI workflow.
This commit is contained in:
juliusv 2013-07-26 07:08:31 -07:00
commit 3f9cc9e3e3
14 changed files with 459 additions and 128 deletions

View File

@ -34,12 +34,19 @@ func main() {
defer aggregator.Close()
webService := &web.WebService{
// REST API Service.
AlertManagerService: &api.AlertManagerService{
Aggregator: aggregator,
Suppressor: suppressor,
},
// Template-based page handlers.
AlertsHandler: &web.AlertsHandler{
Aggregator: aggregator,
},
SilencesHandler: &web.SilencesHandler{
Suppressor: suppressor,
},
}
go webService.ServeForever()

View File

@ -19,15 +19,25 @@ import (
"sort"
)
const eventNameLabel = "name"
type EventFingerprint uint64
type EventLabels map[string]string
type EventPayload map[string]string
// Event models an action triggered by Prometheus.
type Event struct {
// Label value pairs for purpose of aggregation, matching, and disposition
// dispatching. This must minimally include a "name" label.
Labels map[string]string
Labels EventLabels
// Extra key/value information which is not used for aggregation.
Payload map[string]string
Payload EventPayload
}
func (e Event) Name() string {
// BUG: ensure in a proper place that all events have a name?
return e.Labels[eventNameLabel]
}
func (e Event) Fingerprint() EventFingerprint {

View File

@ -14,6 +14,7 @@
package manager
import (
"encoding/json"
"fmt"
"log"
"sync"
@ -21,6 +22,7 @@ import (
)
type SuppressionId uint
type Suppressions []*Suppression
type Suppression struct {
// The numeric ID of the suppression.
@ -40,7 +42,53 @@ type Suppression struct {
expiryTimer *time.Timer
}
type Suppressions []*Suppression
type ApiSilence struct {
CreatedBy string
CreatedAtSeconds int64
EndsAtSeconds int64
Comment string
Filters map[string]string
}
func (s *Suppression) MarshalJSON() ([]byte, error) {
filters := map[string]string{}
for _, f := range s.Filters {
name := f.Name.String()[1 : len(f.Name.String())-1]
value := f.Value.String()[1 : len(f.Value.String())-1]
filters[name] = value
}
return json.Marshal(&ApiSilence{
CreatedBy: s.CreatedBy,
CreatedAtSeconds: s.CreatedAt.Unix(),
EndsAtSeconds: s.EndsAt.Unix(),
Comment: s.Comment,
Filters: filters,
})
}
func (s *Suppression) UnmarshalJSON(data []byte) error {
sc := &ApiSilence{}
json.Unmarshal(data, sc)
filters := make(Filters, 0, len(sc.Filters))
for label, value := range sc.Filters {
filters = append(filters, NewFilter(label, value))
}
if sc.EndsAtSeconds == 0 {
sc.EndsAtSeconds = time.Now().Add(time.Hour).Unix()
}
*s = Suppression{
CreatedBy: sc.CreatedBy,
CreatedAt: time.Now().UTC(),
EndsAt: time.Unix(sc.EndsAtSeconds, 0).UTC(),
Comment: sc.Comment,
Filters: filters,
}
return nil
}
type Suppressor struct {
// Suppressions managed by this Suppressor.

View File

@ -14,8 +14,9 @@
package web
import (
"github.com/prometheus/alert_manager/manager"
"net/http"
"github.com/prometheus/alert_manager/manager"
)
type AlertStatus struct {

View File

@ -22,7 +22,13 @@ import (
type AlertManagerService struct {
gorest.RestService `root:"/api/" consumes:"application/json" produces:"application/json"`
addEvents gorest.EndPoint `method:"POST" path:"/event" postdata:"Events"`
addEvents gorest.EndPoint `method:"POST" path:"/events" postdata:"Events"`
addSilence gorest.EndPoint `method:"POST" path:"/silences" postdata:"Suppression"`
getSilence gorest.EndPoint `method:"GET" path:"/silences/{id:int}" output:"string"`
updateSilence gorest.EndPoint `method:"POST" path:"/silences/{id:int}" postdata:"Suppression"`
delSilence gorest.EndPoint `method:"DELETE" path:"/silences/{id:int}"`
silenceSummary gorest.EndPoint `method:"GET" path:"/silences" output:"string"`
Aggregator *manager.Aggregator
Suppressor *manager.Suppressor
}

93
web/api/silence.go Normal file
View File

@ -0,0 +1,93 @@
// Copyright 2013 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"code.google.com/p/gorest"
"github.com/prometheus/alert_manager/manager"
)
type Silence struct {
CreatedBy string
CreatedAtSeconds int64
EndsAtSeconds int64
Comment string
Filters map[string]string
}
func (s AlertManagerService) AddSilence(sc manager.Suppression) {
// BUG: add server-side form validation.
id := s.Suppressor.AddSuppression(&sc)
rb := s.ResponseBuilder()
rb.SetResponseCode(http.StatusCreated)
rb.Location(fmt.Sprintf("/api/silences/%d", id))
}
func (s AlertManagerService) GetSilence(id int) string {
rb := s.ResponseBuilder()
rb.SetContentType(gorest.Application_Json)
silence, err := s.Suppressor.GetSuppression(manager.SuppressionId(id))
if err != nil {
log.Printf("Error getting silence: %s", err)
rb.SetResponseCode(http.StatusNotFound)
return err.Error()
}
resultBytes, err := json.Marshal(&silence)
if err != nil {
log.Printf("Error marshalling silence: %s", err)
rb.SetResponseCode(http.StatusInternalServerError)
return err.Error()
}
return string(resultBytes)
}
func (s AlertManagerService) UpdateSilence(sc manager.Suppression, id int) {
// BUG: add server-side form validation.
sc.Id = manager.SuppressionId(id)
if err := s.Suppressor.UpdateSuppression(&sc); err != nil {
log.Printf("Error updating silence: %s", err)
rb := s.ResponseBuilder()
rb.SetResponseCode(http.StatusNotFound)
}
}
func (s AlertManagerService) DelSilence(id int) {
if err := s.Suppressor.DelSuppression(manager.SuppressionId(id)); err != nil {
log.Printf("Error deleting silence: %s", err)
rb := s.ResponseBuilder()
rb.SetResponseCode(http.StatusNotFound)
}
}
func (s AlertManagerService) SilenceSummary() string {
rb := s.ResponseBuilder()
rb.SetContentType(gorest.Application_Json)
silenceSummary := s.Suppressor.SuppressionSummary()
resultBytes, err := json.Marshal(silenceSummary)
if err != nil {
log.Printf("Error marshalling silences: %s", err)
rb.SetResponseCode(http.StatusInternalServerError)
return err.Error()
}
return string(resultBytes)
}

View File

@ -15,10 +15,21 @@ package web
import (
"net/http"
"github.com/prometheus/alert_manager/manager"
)
type SilencesHandler struct{}
type SilenceStatus struct {
Silences manager.Suppressions
}
type SilencesHandler struct {
Suppressor *manager.Suppressor
}
func (h *SilencesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
executeTemplate(w, "silences", nil)
silenceStatus := &SilenceStatus{
Silences: h.Suppressor.SuppressionSummary(),
}
executeTemplate(w, "silences", silenceStatus)
}

View File

@ -2,14 +2,10 @@ body {
padding-top: 60px;
}
#create_silence_modal th {
#edit_silence_modal th {
text-align: left;
}
.add_silence_form {
display: inline;
}
.del_label_button {
margin-bottom: 10px;
}

View File

@ -1,5 +1,8 @@
var silenceRow = null;
var silenceId = null;
function clearSilenceLabels() {
$("#silence_label_table").empty();
$("#silence_filters_table").empty();
}
function addSilenceLabel(label, value) {
@ -9,11 +12,11 @@ function addSilenceLabel(label, value) {
if (!value) {
value = "";
}
$("#silence_label_table").append(
$("#silence_filters_table").append(
'<tr>' +
' <td><input class="input-large" type="text" placeholder="label regex" value="' + label + '"></td>' +
' <td><input class="input-large" type="text" placeholder="value regex" value="' + value + '"></td>' +
' <td><button class="btn del_label_button"><i class="icon-minus"></i></button></td>' +
' <td><input class="input-large" name="silence_filter_label[]" type="text" placeholder="label regex" value="' + label + '" required></td>' +
' <td><input class="input-large" name="silence_filter_value[]" type="text" placeholder="value regex" value="' + value + '" required></td>' +
' <td><button type="button" class="btn del_label_button"><i class="icon-minus"></i></button></td>' +
'</tr>');
bindDelLabel();
}
@ -25,25 +28,173 @@ function bindDelLabel() {
});
}
function init() {
$("#new_silence_btn").click(function() {
clearSilenceLabels();
function silenceJsonFromForm() {
var filters = {};
var labels = $('input[name="silence_filter_label[]"]');
var values = $('input[name="silence_filter_value[]"]');
for (var i = 0; i < labels.length; i++) {
filters[labels.get(i).value] = values.get(i).value;
}
var endsAt = 0;
if ($("#silence_ends_at").val() != "") {
var picker = $("#ends_at_datetimepicker").data("datetimepicker");
endsAt = Math.round(picker.getLocalDate().getTime() / 1000);
}
return JSON.stringify({
CreatedBy: $("#silence_created_by").val(),
EndsAtSeconds: endsAt,
Comment: $("#silence_comment").val(),
Filters: filters
});
}
$(".add_silence_btn").click(function() {
clearSilenceLabels();
var form = $(this).parents("form");
var labels = form.children('input[name="label[]"]');
var values = form.children('input[name="value[]"]');
for (var i = 0; i < labels.length; i++) {
addSilenceLabel(labels.get(i).value, values.get(i).value);
function createSilence() {
$.ajax({
type: "POST",
url: "/api/silences",
data: silenceJsonFromForm(),
dataType: "text",
success: function(data, textStatus, jqXHR) {
location.reload();
},
error: function(data, textStatus, jqXHR) {
alert("Creating silence failed: " + textStatus);
}
});
}
$("#add_label_button").click(function() {
function updateSilence() {
$.ajax({
type: "POST",
url: "/api/silences/" + silenceId,
data: silenceJsonFromForm(),
dataType: "text",
success: function(data, textStatus, jqXHR) {
location.reload();
},
error: function(data, textStatus, jqXHR) {
alert("Updating silence failed: " + textStatus);
}
});
}
function getSilence(silenceId, successFn) {
$.ajax({
type: "GET",
url: "/api/silences/" + silenceId,
async: false,
success: successFn,
error: function(data, textStatus, jqXHR) {
alert("Creating silence failed: " + textStatus);
}
});
}
function deleteSilence(silenceId, silenceRow) {
$.ajax({
type: "DELETE",
url: "/api/silences/" + silenceId,
success: function(data, textStatus, jqXHR) {
silenceRow.remove();
$("#del_silence_modal").modal("hide");
},
error: function(data, textStatus, jqXHR) {
alert("Removing silence failed: " + textStatus);
}
});
}
function initNewSilence() {
silenceId = null;
$("#edit_silence_header, #edit_silence_btn").html("Create Silence");
$("#edit_silence_form")[0].reset();
}
function populateSilenceLabels(form) {
var labels = form.children('input[name="label[]"]');
var values = form.children('input[name="value[]"]');
for (var i = 0; i < labels.length; i++) {
addSilenceLabel(labels.get(i).value, values.get(i).value);
}
}
function init() {
$.ajaxSetup({
cache: false
});
$("#ends_at_datetimepicker").datetimepicker({
language: "en",
pickSeconds: false
});
$("#edit_silence_modal").on("shown", function(e) {
$("#silence_created_by").focus();
});
$("#edit_silence_modal").on("hidden", function(e) {
clearSilenceLabels();
});
// This is the "Silences" page button to open the dialog for creating a new
// silence.
$("#new_silence_btn").click(function() {
initNewSilence();
});
// These are the "Alerts" page buttons to open the dialog for creating a new
// silence for the alert / alert instance.
$(".add_silence_btn").click(function() {
initNewSilence();
populateSilenceLabels($(this).parents("form"));
});
$("#add_filter_button").click(function() {
addSilenceLabel("", "");
});
$("#edit_silence_form").submit(function() {
if (silenceId != null) {
updateSilence();
} else {
createSilence();
}
return false;
});
$(".edit_silence_btn").click(function() {
$("#edit_silence_header, #edit_silence_btn").html("Update Silence");
silenceRow = $(this).parents("tr");
silenceId = silenceRow.find("input[name='silence_id']").val();
$("#edit_silence_form input[name='silence_id']").val(silenceId);
getSilence(silenceId, function(silence) {
var picker = $("#ends_at_datetimepicker").data('datetimepicker');
var endsAt = new Date(silence.EndsAtSeconds * 1000);
picker.setLocalDate(endsAt);
$("#silence_created_by").val(silence.CreatedBy);
$("#silence_comment").val(silence.Comment);
for (var f in silence.Filters) {
addSilenceLabel(f, silence.Filters[f]);
}
});
});
// When clicking the "Remove Silence" button in the Silences table, save the
// table row and silence ID to global variables (ugh) so they can be used
// from the modal dialog to remove that silence.
$(".del_silence_modal_btn").click(function() {
silenceRow = $(this).parents("tr");
silenceId = silenceRow.find("input[name='silence_id']").val();
});
// Deletion confirmation button action.
$(".del_silence_btn").click(function() {
deleteSilence(silenceId, silenceRow);
});
}
$(init);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,9 @@
<link href="/static/vendor/bootstrap/css/bootstrap.min.css" media="all" rel="stylesheet" type="text/css" />
<script src="/static/vendor/bootstrap/js/bootstrap.js" type="text/javascript"></script>
<link href="/static/vendor/tarruda_bootstrap_datetimepicker/css/bootstrap-datetimepicker.min.css" media="all" rel="stylesheet" type="text/css" />
<script src="/static/vendor/tarruda_bootstrap_datetimepicker/js/bootstrap-datetimepicker.min.js" type="text/javascript"></script>
<link href="/static/css/default.css" media="all" rel="stylesheet" type="text/css" />
{{template "head" .}}
@ -45,43 +48,49 @@
</div>
</body>
</html>
{{define "createSilenceModal"}}
{{define "editSilenceModal"}}
<!-- Modal -->
<div id="create_silence_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="create_silence_header" aria-hidden="true">
<div id="edit_silence_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="edit_silence_header" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3 id="create_silence_header">Create Silence</h3>
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3 id="edit_silence_header">Create Silence</h3>
</div>
<div class="modal-body">
<form class="form-horizontal">
<form class="form-horizontal" id="edit_silence_form">
<div class="control-group">
<label class="control-label" for="silence_creator">Creator</label>
<label class="control-label" for="silence_created_by">Creator</label>
<div class="controls">
<input type="text" id="silence_creator" placeholder="Creator">
<input type="text" id="silence_created_by" placeholder="Creator" required>
</div>
</div>
<div class="control-group">
<label class="control-label" for="silence_expiry">Expiry</label>
<label class="control-label" for="silence_ends_at">Ends At</label>
<div class="controls">
<input type="text" id="silence_expiry" placeholder="Expiry">
<div id="ends_at_datetimepicker" class="input-append date">
<input type="text" id="silence_ends_at" placeholder="Ends At (default 1h from now)" data-format="dd/MM/yyyy hh:mm:ss">
<span class="add-on">
<i data-time-icon="icon-time" data-date-icon="icon-calendar"></i>
</span>
</div>
</div>
</div>
<div class="control-group">
<label class="control-label" for="silence_creator">Comment</label>
<label class="control-label" for="silence_created_by">Comment</label>
<div class="controls">
<input type="text" id="silence_comment" placeholder="Comment">
</div>
</div>
</form>
<label>Labels:</label>
<table id="silence_label_table">
</table>
<button class="btn" id="add_label_button"><i class="icon-plus"></i> Add Label Filter</button>
<button class="btn">Preview Silence</button>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">Cancel</button>
<button class="btn btn-primary">Create Silence</button>
</div>
<label>Label filters:</label>
<table id="silence_filters_table">
</table>
<br/>
<button type="button" class="btn" id="add_filter_button"><i class="icon-plus"></i> Add Label Filter</button>
<button type="button" class="btn">Preview Silence</button>
</div>
<div class="modal-footer">
<button type="button" class="btn" data-dismiss="modal" aria-hidden="true">Cancel</button>
<button type="submit" class="btn btn-primary" id="edit_silence_btn">Create Silence</button>
</div>
</form>
</div>
{{end}}

View File

@ -24,23 +24,23 @@
{{range .AlertAggregates}}
<tr>
<td>
{{index .Event.Name}}
<span class="label label-important">{{index .Event.Name}}</span>
<form class="add_silence_form">
<input type="hidden" name="label[]" value="name">
<input type="hidden" name="value[]" value="{{.Event.Labels.name}}">
<a href="#create_silence_modal" role="button" class="btn btn-mini add_silence_btn" data-toggle="modal">Silence Alert</a>
<a href="#edit_silence_modal" role="button" class="btn btn-mini add_silence_btn" data-toggle="modal">Silence Alert</a>
</form>
</td>
<td>
{{range $label, $value := .Event.Labels}}
<b>{{$label}}</b>="{{$value}}",
<span class="label label-info"><b>{{$label}}</b>="{{$value}}"</span>
{{end}}
<form class="add_silence_form">
{{range $label, $value := .Event.Labels}}
<input type="hidden" name="label[]" value="{{$label}}">
<input type="hidden" name="value[]" value="{{$value}}">
{{end}}
<a href="#create_silence_modal" role="button" class="btn btn-mini add_silence_btn" data-toggle="modal">Silence Instance</a>
<a href="#edit_silence_modal" role="button" class="btn btn-mini add_silence_btn" data-toggle="modal">Silence Instance</a>
</form>
</td>
<td>{{timeSince .Created}} ago</td>
@ -50,5 +50,5 @@
{{end}}
</tbody>
</table>
{{template "createSilenceModal" .}}
{{template "editSilenceModal" .}}
{{end}}

View File

@ -6,89 +6,54 @@
{{define "content"}}
<h2>Silences</h2>
<p><a href="#create_silence_modal" role="button" class="btn btn-primary" id="new_silence_btn" data-toggle="modal">New Silence</a></p>
<p><a href="#edit_silence_modal" role="button" class="btn btn-primary" id="new_silence_btn" data-toggle="modal">New Silence</a></p>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th>ID</th>
<th>Labels</th>
<th>Creator</th>
<th>Expires At</th>
<th>Created At</th>
<th>Ends At</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
<tr>
<td>{service="Taxes",country="*"}</td>
<td>your government</td>
<td>2050-01-01 10:23:00</td>
<td>Taxes are here to stay. Sorry.</td>
<td>
<button class="btn btn-mini btn-primary">Edit Silence</button>
<button class="btn btn-mini btn-danger">Remove Silence</button>
</td>
</tr>
{{range .Silences}}
<tr>
<td>{{.Id}}</td>
<td>
{{range .Filters}}
<b>{{.Name}}</b>="{{.Value}}",
{{end}}
</td>
<td>{{.CreatedBy}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.EndsAt}}</td>
<td>{{.Comment}}</td>
<td>
<input type="hidden" name="silence_id" value="{{.Id}}">
<a href="#edit_silence_modal" role="button" class="btn btn-mini btn-primary edit_silence_btn" id="new_silence_btn" data-toggle="modal">Edit Silence</a>
<a href="#del_silence_modal" role="button" class="btn btn-mini btn-danger del_silence_modal_btn" data-toggle="modal">Remove Silence</a>
</td>
</tr>
{{end}}
</tbody>
</table>
{{template "createSilenceModal" .}}
{{template "editSilenceModal" .}}
<div id="del_silence_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="del_silence_header">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3 id="del_silence_header">Deletion Confirmation</h3>
</div>
<div class="modal-body">
<p>Do you really want to delete this silence?</p>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">Cancel</a>
<button class="btn btn-primary btn-danger del_silence_btn">Delete</a>
</div>
</div>
{{end}}